diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index 841459b..6102eef 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -176,6 +176,35 @@ Source 负责连接和健康状态,channel 负责稳定的测量身份,bindi 所有 migration 必须在空库和带历史数据的升级副本上通过,迁移前后逐表对账,不 drop/truncate 任何 业务数据。SQLite batch migration 后显式验证 FK、索引和唯一约束。 +### M8 Alembic revision 计划与仓库级不变量 + +M8 的 schema 变更沿现有单一 `alembic_app` 链按下表串行落地;不得合并成一次不可审计的大迁移, +也不得创建并行 head: + +| Task / revision | `down_revision` | Schema 变更 | +| --- | --- | --- | +| T01 / `20260822_15_meter_sources` | `20260625_14_meter_uuid` | 新建 `meter_source`、`meter_source_channel`、`meter_source_binding`;给 `energy_cost_period` 增加 nullable `source_binding_id` | +| T03 / `20260822_16_dsmr_source_adoption` | `20260822_15_meter_sources` | `dsmr_reading.source_id` 政名为 `telegram_id`,新增 non-null `meter_source_id`,唯一键改为 `(meter_source_id, recorded_at)`,并执行 DSMR/source/binding/cost 历史回填 | +| T08 / `20260822_17_warmtelink_readings` | `20260822_16_dsmr_source_adoption` | 新建 `warmtelink_reading` 标量历史表 | +| T12 / `20260822_18_contract_scopes` | `20260822_17_warmtelink_readings` | 给 `energy_contract` 增加 non-null `scope`,旧行回填 `electricity` | +| T14 / `20260822_19_meter_cost_periods` | `20260822_18_contract_scopes` | 新建 `meter_cost_period` 审计账本 | + +每张新增 revision 的任务还必须同时遵守以下仓库级契约: + +1. 新模型模块在首次引入时显式 import 到 `alembic_app/env.py`,保证 `Base.metadata` 完整;后续只在 + 已注册模块中新增模型时无需重复改 env。 +2. 同一任务把 `scripts/app_db_adopt.py::APP_BASELINE_REVISION` 更新为该任务的新 head。现有 + fail-closed 启动校验和多组部署/模型回归测试都要求该常量与唯一 Alembic head 完全相等;不得把 + 常量临时留在旧 revision 等后续任务补救。 +3. `scripts/run_migrations.py` 继续只调用 app DB adoption/upgrade,不新增第二条迁移链;每个任务验证 + 旧 head→新 head、空库→head 和重复运行幂等。 +4. 实现、review 和 T20 收尾只使用 pytest `tmp_path`、`/tmp` 下明确命名的临时 SQLite 库以及合成的 + 历史 fixture。不得挂载、复制、打开或修改运行中 production 的 DB 路径、容器或 volume;所谓 + “历史升级副本”在本轮指结构和边界场景等价的隔离合成副本,不含真实生产数据。 +5. 每次升级对账至少记录 migration 前后业务表行数、Alembic revision、孤儿 FK、关键唯一键/索引; + 任何不一致必须在同一事务中失败并回滚。downgrade 仅用于临时测试库的 schema 可逆性验证,绝不 + 对生产或用户备份执行。 + ### DSMR 回填算法 1. 读取旧 runtime config,创建一个 `dsmr_mqtt` source 和稳定 electricity channel;无旧 config 时也 @@ -262,7 +291,9 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - `create app/models/meter_source.py` - `modify app/models/__init__.py` - `modify app/models/energy.py` +- `modify alembic_app/env.py` - `create alembic_app/versions/20260822_15_meter_sources.py` +- `modify scripts/app_db_adopt.py` - `create tests/test_meter_sources.py` - `modify tests/test_energy_models.py` @@ -273,9 +304,12 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 半开区间相交,数据库 FK 使用 `RESTRICT`,relationship 不配置 delete cascade。 3. 给 `EnergyCostPeriod` 增加 nullable `source_binding_id` FK 和 relationship;迁移此时只加列, 不回填 DSMR 历史。 -4. migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何 +4. 在 `alembic_app/env.py` 注册新模型模块,并把 `APP_BASELINE_REVISION` 同步到 revision 15; + 不改变 `scripts/run_migrations.py` 的单库行为。 +5. migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何 外部数据库文件。 -5. 测试 Alembic 空库升级、模型默认值、唯一约束、FK RESTRICT 和无 delete cascade。 +6. 测试 Alembic 空库升级、revision 14→15、重复运行、baseline=head、模型默认值、唯一约束、 + FK RESTRICT 和无 delete cascade;数据库只使用 `tmp_path` 临时文件。 **Out of scope / 不要碰** - 不迁移 `dsmr_reading`,不读取旧 config,不实现 API、worker 或 source profile。 @@ -284,11 +318,12 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 **Acceptance criteria** - [ ] 三张新表、UUID/唯一键/FK/索引与 §5 一致,`energy_cost_period` 新列可空。 - [ ] 删除被 channel/binding/成本引用的行会被数据库拒绝,不级联丢历史。 -- [ ] 空库与从 revision 14 升级都到达 head,测试覆盖约束。 +- [ ] 空库与从 revision 14 升级都到达 head,`APP_BASELINE_REVISION` 等于唯一 head,测试覆盖约束。 - [ ] `pytest`、`ruff check .` 全绿。 **Reviewer checklist** - migration 是否完全无 drop/truncate/用户文件操作;SQLite 重建后 FK 是否仍启用。 +- `alembic_app/env.py` 是否已注册新模型,baseline 常量是否与 revision 15 完全一致。 - 时间窗是否统一为 `[started_at, ended_at)`,没有把 source 生命周期混进 Meter epoch。 ### M8-T02 — Source profile registry 与 binding service @@ -335,6 +370,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 **Files** - `modify app/models/energy.py` - `create alembic_app/versions/20260822_16_dsmr_source_adoption.py` +- `modify scripts/app_db_adopt.py` - `create tests/test_dsmr_source_migration.py` - `modify tests/test_energy_models.py` @@ -347,6 +383,8 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 binding 保持 nullable;migration 注释和测试必须明确这是历史边界,而非当前 binding。 4. 每一阶段在 migration 内核对 source/readings/cost 行数与 orphan FK;不一致立即抛错回滚。 5. 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。 +6. 把 `APP_BASELINE_REVISION` 同步到 revision 16;所有升级 fixture 仅在 `tmp_path` 中构造,不读取 + 真实 app DB 或 volume。 **Out of scope / 不要碰** - 不删除旧 DSMR `app_config` 行,不改变 MQTT subscription,不改 API 响应。 @@ -356,10 +394,12 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - [ ] 迁移前后 DSMR 与成本行数不减少,orphan FK 为 0,正常可解析周期有 binding。 - [ ] 两个 source 可在同一 timestamp 各存一条 DSMR reading;同 source 重复 timestamp 被拒绝。 - [ ] `telegram_id` 不参与幂等唯一键,旧 telegram id 完整保留。 +- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 16 head,升级重复运行幂等。 - [ ] 历史升级 fixture、空库升级、`pytest`、`ruff check .` 全绿。 **Reviewer checklist** - 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。 +- baseline 常量是否随 revision 16 同步,fixture 是否完全隔离于真实生产路径。 - 是否存在“把所有历史强绑当前 Meter/source”的静默错误或任何 destructive cleanup。 ### M8-T04 — DSMR runtime 改为多 Source 配置 [structural] @@ -528,6 +568,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 **Files** - `modify app/models/meter_source.py` - `create alembic_app/versions/20260822_17_warmtelink_readings.py` +- `modify scripts/app_db_adopt.py` - `create tests/test_warmtelink_models.py` **Steps** @@ -538,6 +579,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 3. FK 使用 `RESTRICT`,不配置 orphan/delete cascade;选择足以容纳长期累计量与 0.001 精度的 Numeric precision/scale,并测试 Decimal round-trip。 4. migration 覆盖空库/升级、唯一键、FK 和索引;downgrade 不触碰任何外部文件。 +5. 把 `APP_BASELINE_REVISION` 同步到 revision 17;所有 migration 测试只使用 `tmp_path` 临时库。 **Out of scope / 不要碰** - 不实现接纳、sampling、worker、API 或成本。 @@ -547,11 +589,13 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - [ ] schema 与 §5 一致,Decimal 无 float 转换,source/channel 删除受限。 - [ ] 同 channel/timestamp 幂等隔离,不同 channel 同时刻允许。 - [ ] 模型和 migration 中不存在 raw telegram/equipment identifier 列。 +- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 17 head。 - [ ] `pytest`、`ruff check .` 全绿。 **Reviewer checklist** - Numeric precision 是否覆盖合理长期累计值;timezone 与唯一键是否使用设备时间。 - migration 是否数据安全、约束命名稳定且可在 SQLite 正确执行。 +- baseline 常量是否随 revision 17 同步,测试是否未打开任何真实数据库。 ### M8-T09 — WarmteLink 质量接纳、发现与分钟采样 [structural] @@ -677,6 +721,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - `modify app/schemas/energy_contract.py` - `modify app/api/routes/api/energy_contracts.py` - `create alembic_app/versions/20260822_18_contract_scopes.py` +- `modify scripts/app_db_adopt.py` - `modify tests/test_api_energy_contracts.py` - `modify tests/test_energy_models.py` - `modify openapi/openapi.json` @@ -692,6 +737,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 list 默认兼容 electricity 行为,新增可选 scope filter。 4. migration 对账合同/version/cost 行数,不改 versions/pricing values,不删除任何合同。 5. 覆盖两 scope 同时 active、同 scope 互斥、kind/scope mismatch、旧 payload 与升级 fixture;重导契约。 +6. 把 `APP_BASELINE_REVISION` 同步到 revision 18;升级 fixture 仅使用隔离临时库。 **Out of scope / 不要碰** - 不新增真实 thermal profile/费率,不改 electricity 成本公式。 @@ -701,10 +747,12 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - [ ] 旧合同全部回填 electricity 且行数/version FK 不变。 - [ ] service 对预置的 electricity/thermal rows 可同时保持 active;每 scope 最多一份 active。 - [ ] 旧 manual/tibber API 请求仍成功并得到 electricity scope。 +- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 18 head。 - [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿。 **Reviewer checklist** - 并发/事务失败是否可能留下同 scope 双 active 或误停用另一 scope。 +- baseline 常量是否随 revision 18 同步,历史对账是否在临时副本内执行。 - migration 是否在 active 旧数据上安全,API 默认值是否真正向后兼容。 ### M8-T13 — District-heating 定价 Profile @@ -752,6 +800,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 **Files** - `modify app/models/energy.py` - `create alembic_app/versions/20260822_19_meter_cost_periods.py` +- `modify scripts/app_db_adopt.py` - `create tests/test_meter_cost_models.py` **Steps** @@ -763,6 +812,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 3. quantity/cost 使用定点 Numeric,ORM 不经 float;JSON 中的金额/数量统一序列化为十进制字符串。 4. migration 只创建新表,不迁移/删除 `energy_cost_period`;测试空库/升级、Decimal round-trip、 唯一键、nullable degraded 和 FK RESTRICT。 +5. 把 `APP_BASELINE_REVISION` 同步到 revision 19;升级/降级测试只使用 `tmp_path` 临时库。 **Out of scope / 不要碰** - 不计算或回填热力成本,不泛化/删除现有 electricity 表。 @@ -772,10 +822,12 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - [ ] 表结构可表达正常和降级 period,`(commodity, period_start)` 幂等。 - [ ] Decimal 与 JSON snapshot 可精确 round-trip,无 binary float。 - [ ] 现有 electricity 账本及行数完全不受 migration 影响。 +- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 19 head。 - [ ] `pytest`、`ruff check .` 全绿。 **Reviewer checklist** - 降级 nullable 不能放松正常写入的 service invariant;FK 删除策略必须保护审计。 +- baseline 常量是否随 revision 19 同步,测试路径是否与生产 DB/volume 完全隔离。 - period timezone/半开区间、唯一键和 Numeric scale 是否足以重算。 ### M8-T15 — Thermal 15 分钟成本引擎与调度 [structural] @@ -1022,7 +1074,8 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 overlay 合并、device path、非 root、COPY source 存在。 3. 写运行文档:识别稳定 by-id、查 GID、启停 overlay、创建 source、discover/bind、质量含义、 reconnect/权限排障、backup/migration、合同录入、HA toggle 与安全回滚。明确不删除旧 config/data。 -4. 在备份数据库副本演练 Alembic 14→head 并对账;新空库迁移到 head。 +4. 在 `/tmp` 下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。 + 不挂载、复制或打开运行中 production 的数据库、容器或 volume。 5. 跑全量后端/前端/OpenAPI/codegen 闸门和真实 `docker build`;启动迁移后的 app,确认关键路由 不 500。 6. 按 §12 人工 walkthrough 走完整链;通过后把 Roadmap/设计索引/M8 状态与 T01~T20 Status