# M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算（含 Tibber 动态 / 固定合同两种 profile）

> 阅读前提：先读 [`README.md`](./README.md)（协作模型、任务卡格式、校验闸门、数据安全红线）。本里程碑建立在 **M5（MQTT + HA Discovery + expose 框架 + Energy 视图 + Recharts）** 之上，复用其大部分基建，并把 M5 的"两层模型"（YAML profile 定结构 + DB 行存部署值）从设备采集复制到**电价合同**上。
> **状态**：架构与原子任务已拆到可开工。少数价格常数（卖价残差、能源税年值、双费率寄存器映射）等真实 Tibber token / 账单确认，已落成 §13 的可调值 + TODO，不阻塞实现。

## 1. 目标

把"电价合同"与"DSMR 实时电表数据"接起来，按 **15 分钟**周期算出**实际买电支出 / 卖电收入**，自己落库留底（不可变、可审计），并通过 MQTT + HA Discovery 反哺 Home Assistant 的 Energy 仪表盘：

1. **通用电价层（两层模型）**：仓库内**只读 YAML profile 定义合同结构**（`manual` 固定/可变费率、`tibber` 动态电价各一个结构）；DB 的 **`EnergyContract`（+ 版本）存可改的数值**（UI 填）；**price strategy** 按 `kind` 出价。一次激活一个合同。
2. **DSMR 实时数据接入**：订阅 DSMR Reader 的 `dsmr/json`（每秒一条完整 telegram），**整帧存为 JSON blob、按 10 秒降采样**落库（电、气、各相全存，未来加供暖不改表）。
3. **实时买卖电费计算（两层）**：每 15 分钟用**电表累计寄存器差值** × 当时合同的买/卖价算计量电费（不可变、快照价）；日/月/年**汇总**再加固定费、减能源税抵扣。**不做净计量**（saldering 2027 取消）。
4. **反哺 Home Assistant**：复用 M5 expose/Discovery，把当前买/卖价、累计买电支出、累计卖电收入发成 HA 实体（累计用 `total_increasing`）。
5. **前端**：Energy 视图下新增合同管理（按 profile 结构生成表单）+ 价格曲线 + 费用走势/明细 + Tibber 测试。

> **命名分层（延续 M5）**：存储/采集/计算层中性命名（`dsmr_*` / `tibber_price` / `energy_cost_period` / `energy_contract`）；面向用户并入既有 **Energy** 视图。

## 2. 现状（实现者可据此工作，不必通读全仓库）

**单库 + Alembic（M5 完成态）**
- `app/db.py`：`class Base(DeclarativeBase)`，cached engine（已开 `journal_mode=WAL` + `foreign_keys=ON`）。
- 单 Alembic 链 `alembic_app/`，**当前 head = `20260622_10_exposed_entities`**；`scripts/app_db_adopt.py` 的 `APP_BASELINE_REVISION` 同。迁移命名 `YYYYMMDD_NN_<desc>.py`，串 `down_revision`；`alembic_app/env.py` 逐个 import 模型。
- `modbus_reading` 已确立**通用 JSON payload 遥测**范式（`recorded_at` 真实索引列 + `payload` JSON）——`dsmr_reading` 直接沿用。
- M5 两层模型（`app/integrations/modbus/profiles/*.yaml` 定结构 + `modbus_device` 存部署值 + pydantic 启动期校验 + `decode`）是本里程碑电价层的范本。

**配置系统（扁平 KV，自动渲染）**
- `app/config.py` `Settings(BaseSettings)`；`app/services/config_page.py` 的 `CONFIG_FIELDS` 注册表 + `build_runtime_settings(session, bootstrap)`（DB override 合并，**消费方都用它**）+ `_settings_payload`（新字段补一行）。前端 `ConfigPage.tsx` 按 section/`input_type`/`secret` 通用渲染，bool 渲染成开关。
- ⚠️ 扁平 KV **装不下"合同清单 + 逐字段数值 + 版本"**——这些走专用表 + 专用 API + 自定义 UI（仿 M5 `modbus_device`）。

**MQTT（M5，关键：当前只发不收）**
- `app/integrations/mqtt.py` `MqttManager`：`is_configured/is_connected/connect/disconnect/reconnect/publish`，paho `loop_start()` 后台线程，VERSION2 回调。**无 `subscribe`/`on_message`**——M6-T06 在此扩订阅端。
- `app/integrations/expose.py`：`ExposableEntity`（dataclass，`value_getter: Callable[[Session],Any]`）、`EntityProvider = Protocol(session)->list[ExposableEntity]`、`register_provider` + `build_catalog`。已有 `_modbus_provider`。**M6 加 `_energy_cost_provider` 复用整条 Discovery 链**。
- `app/services/ha_discovery.py`：`build_discovery_payload`/`publish_discovery`/`publish_states`/`publish_device_state`。`app/models/expose.py` `ExposedEntityToggle`（默认未勾不暴露）。

**后台调度（APScheduler，UTC）**
- `app/main.py` lifespan：`BackgroundScheduler(timezone="UTC")`，`add_job(..., max_instances=1, coalesce=True)`；同步 wrapper 自管 session、吞异常不崩 job。已有 public-ip / modbus-poll / discovery-state 等 job——**新增 job/订阅不得破坏它们**。

**依赖**：`httpx`（Tibber GraphQL）、`paho-mqtt`、`pyyaml`、`apscheduler` 均已在 `requirements.in/.txt`。**M6 不新增 Python 依赖**。前端已有 Recharts / TanStack Query / `openapi-fetch` 类型化 client / Mantine。

## 3. 目标架构

### 3.0 数据流总览

```
 EnergyContract(active, 版本带生效日期)         DSMR Reader
   │  kind=manual → 常数双费率                    │ MQTT dsmr/json (1s)
   │  kind=tibber → API 15min                     ▼
   │                                    订阅 + 10s 降采样 → dsmr_reading(整帧 JSON blob)
   │ (kind=tibber 时)                              │
   ▼                                               │ 累计寄存器=真值
 Tibber GraphQL → tibber_price(15min, 不可变)      │
   │                                               ▼
   └──────────────► price strategy ◄────── 计费引擎（每 15min 边界）
                   (manual/tibber 出价)            │ 寄存器差 × 价，快照
                                                   ▼
                                       energy_cost_period(每15min 计量电费, 不可变)
                                                   │
                          ┌────────────────────────┼───────────────────────────┐
                          ▼                         ▼                            ▼
              日/月/年汇总(+固定费 −抵扣)   expose provider + HA Discovery   /api/energy/* + 前端
```

三条核心原则：
1. **算钱用寄存器差值，不用功率积分**：每 15 分钟能量 = 累计寄存器@末 − @初，电表真值、与采样频率无关。瞬时功率只用于显示（且 HA 里已有，后端不专门用它算钱）。
2. **结构固定（YAML）、数值可改（UI/DB）、历史不可变（快照 + 版本）**：profile YAML 定合同长什么样；UI 填数值、改价存新版本；算过的周期快照当时的价，永不被回改。
3. **两层费用**：每 15 分钟只算**计量电费**；按天/年的**固定费、能源税抵扣**在**汇总层**加，不污染每周期引擎。三相 / 双费率 / 燃气都靠"通用 blob + profile 结构"容纳，不改表。

### 3.1 两层电价模型（本里程碑地基，仿 M5）

```
┌──────────────────────────────────────────────────────────────┐
│ 知识层（固定，随代码走）          部署层（可改，落 DB，UI 填）  │
│ ── pricing profile YAML ──        ── EnergyContract 行 ──       │
│ • kind=manual / tibber            • name（UI 自由填）           │
│ • 有哪些字段、单位、计费语义      • kind（UI 选）               │
│ • 哪些分档 / 是否动态源           • active（一次一个）          │
│ • UI 不可编辑                     • currency                    │
│                                   ── EnergyContractVersion ──   │
│                                   • effective_from / to         │
│                                   • values(JSON，符合 profile)  │
│                                   • 改价=加新版本，旧版本保留    │
└──────────────────────────────────────────────────────────────┘
         │ kind 决定 strategy
         ▼
   price strategy（代码）: manual=常数双费率出价; tibber=读 tibber_price 出价
```

- **profile YAML（仓库内只读，定结构）**：`app/integrations/pricing/profiles/manual.yaml`、`tibber.yaml`，pydantic 启动期校验。声明该 kind **有哪些字段、单位、计费语义、哪些分档**——**不放具体数值**。
- **`EnergyContract` 行（部署，UI 填值）**：`name`（自由填，不 hardcode "fixed"）、`kind`（UI 选）、`active`、`currency`。
- **`EnergyContractVersion`（版本/时段，可审计）**：`effective_from`/`effective_to`(null=开口) + `values`(JSON，符合 profile 结构)。**改价 = 加一个新版本行，旧版本保留**；"2026 上半年一个价、下半年另一个价" = 同合同两个版本。引擎算某周期时挑**覆盖该时刻的版本**。
- **price strategy（代码，按 kind）**：`manual` 用版本里的双费率常数出价；`tibber` 读 `tibber_price` + 版本里的 energy_tax/sell_adjust 出价。计费引擎对两者一视同仁。

**`manual.yaml` 结构（块状、可读；值在 UI 填）**

```yaml
kind: manual
label: 固定 / 可变费率（NL，双费率）
energy:
  dual_tariff: true              # 用 delivered_1/2、returned_1/2 分高低谷
  buy:
    normal: { unit: EUR/kWh }    # 高价档（_2）
    dal:    { unit: EUR/kWh }    # 低价档（_1）
  sell:
    normal: { unit: EUR/kWh }    # 回送高价
    dal:    { unit: EUR/kWh }    # 回送低价（现两档同价，仍分开填）
  energy_tax: { unit: EUR/kWh }  # 能源税，加到买价上（含 VAT）
  ode:        { unit: EUR/kWh, default: 0 }   # 现并入能源税
standing:                        # 固定费：UI 按月填，内部摊到每天
  network_fee:    { unit: EUR/month }
  management_fee: { unit: EUR/month }
credits:
  heffingskorting: { unit: EUR/year }   # 能源税抵扣，年度，汇总时扣
```

**`tibber.yaml` 结构**

```yaml
kind: tibber
label: Tibber 动态电价（15 分钟）
energy:
  source: tibber_api             # buy = total; sell = total − energy_tax − sell_adjust
  energy_tax:   { unit: EUR/kWh }
  sell_adjust:  { unit: EUR/kWh, default: 0 }
standing:
  management_fee: { unit: EUR/month, default: 5.99 }
  network_fee:    { unit: EUR/month }
credits:
  heffingskorting: { unit: EUR/year }
```

> 所有金额含 VAT（用户口径）。**Tibber token** 不在合同里、走 secret config（凭据归 config）。

### 3.2 DSMR 接入

- **数据源**：DSMR Reader 的 **Telegram JSON**（单 topic `dsmr/json`，一条 = 一帧完整 telegram，已是解析后的表寄存器值）。**每秒一条**。
- **整帧存 JSON blob**（沿用 `modbus_reading` payload 哲学）：**不做字段 allowlist**，整帧存下来（电、气 `extra_device_*`、各相全要）。理由：现有燃气、未来供暖、三相——blob 全装，**不改表**；读取时按 key 取。
- **实测 JSON 的坑（已确认）**：数值是 **JSON 字符串**（`"20915.154"`）→ 计费读取时转 **Decimal**；缺测相位是 **`null`** → 视为缺测；`id`（telegram 自增）做幂等去重。
- **降采样**：仅当 `timestamp` 秒数落在 `dsmr_sample_interval_s`（默认 10）整数倍时落盘，其余丢弃（量从 60 行/分降到 ~6 行/分；秒=00 在网格上，每个 15 分钟边界都抓得到）。
- **订阅**：扩 `MqttManager` 订阅端（`on_connect` 里 subscribe、`topic→handler`、`on_message` 分发，handler 在网络线程内只做"解析+降采样+一次短事务"、吞异常不崩连接）。`dsmr_ingest_enabled`（默认 false，opt-in）。
- **进口/出口总量** = `delivered_1+delivered_2` / `returned_1+returned_2`；**双费率分档** = `_1`(dal/低) vs `_2`(normal/高)（NL 惯例，§13 确认别接反）。
- **三相 / 燃气 / 供暖**：blob 天然容纳，单相→三相只是多几个 key，不改表（详 §10）。

### 3.3 Tibber 价格接入（仅 `kind=tibber` 时）

- **客户端**：`app/integrations/tibber/client.py`，httpx POST `https://api.tibber.com/v1-beta/gql`，`Bearer <token>`。
- **15 分钟查询**（introspection + demo 实证）：
  ```graphql
  { viewer { homes { id currentSubscription {
      priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
        nodes { startsAt total energy tax currency level }
  } } } } }
  ```
  - `Price` 字段：`total`（=energy+tax，**全包价**，demo 已证 `total==energy+tax`）、`energy`（纯 spot ex-VAT）、`tax`、`startsAt`（含时区偏移）、`currency`、`level`。
  - 枚举 **`PriceInfoRangeResolution`** 值 `DAILY/HOURLY/QUARTER_HOURLY`（不是 `PriceResolution`）；老的 `priceInfo.range` 已废弃。
  - **已验证（demo key）**：返回 96 节点、间隔 15 分钟、`total==energy+tax`；老数据无真 15min 价时 API 把小时价**重复成 4 个刻钟**，当前 NL 数据则是真 15 分钟。**解析不假设固定间隔/节点数**，按 `startsAt` 逐点存。
- **抓取调度**：启动 + 每日 job 拉「今天+明天」，按 `starts_at` **upsert** `tibber_price`（幂等）。次日价约 13:00 CET 发布 → 每日 14:00 CET（12:00 UTC）抓明天，缺失每小时重试。**仅当 active 合同 kind=tibber 且 token 存在时跑**。
- **连接测试** `POST /api/energy/tibber/test`：拉一次 current price，三态（success 带当前价 / config-error / failed），仿 M5 `mqtt/test`。

### 3.4 计费引擎（两层）

**第一层 — 每 15 分钟计量电费（`energy_cost_period`，不可变、快照价）**
- 触发：APScheduler 1 分钟 tick，找"已闭合未算"的 15 分钟周期算（也支持按区间手动重算）。
- 单周期 `[t0,t1)`（`t1` 已过）：
  1. 取各寄存器在 `t0`/`t1` 的值（`recorded_at ≤ 边界` 的最后一行，Decimal），算 **per-register 差**：`Δd1,Δd2,Δr1,Δr2`。
  2. 取 active 合同**在 t0 生效的版本** + 其 strategy：
     - `manual`：`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`（`buy_x = energy_buy_x + energy_tax + ode`）；`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
     - `tibber`：取覆盖 t0 的 `tibber_price`（`starts_at ≤ t0` 最近一条）；`buy = total`、`sell = total − energy_tax − sell_adjust`；`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
  3. `net_cost = import_cost − export_revenue`；**upsert** `energy_cost_period`，**快照**当时用的价 + `contract_version_id`。
- 缺价/缺数据：跳过或标 `degraded`，留待重算。**不做净计量**（进出口分开累加）。

**第二层 — 汇总（日/月/年，读时计算，不存表）**
- `Σ 周期 net_cost`（区间内）**＋ 固定费**（`network_fee + management_fee` 按月→按天 × 天数）**− 能源税抵扣**（`heffingskorting` 按年→按天 × 天数）= **实际应付**。
- 固定费/抵扣取 active 合同版本的值；UI 按月/按年填，引擎内部摊到每天。

### 3.5 数据模型（新增 5 张表，单库 app 链）

> 单一 P1 智能电表，`dsmr_reading` 不设 device 表；将来第二块表加 `source` 列即可。

| 表 | 关键列 | 说明 |
| --- | --- | --- |
| **`dsmr_reading`** | `recorded_at`(idx)、`source_id`(unique，=telegram id)、`payload`(JSON) | 整帧 telegram blob，10s 降采样 |
| **`energy_contract`** | `name`、`kind`(manual/tibber)、`active`、`currency`、时间戳 | 合同头；一次一个 active |
| **`energy_contract_version`** | `contract_id`(FK)、`effective_from`、`effective_to`(null)、`values`(JSON)、`created_at` | 版本/时段；改价加新版本、旧版本保留（审计）|
| **`tibber_price`** | `starts_at`(unique)、`resolution`、`energy`、`tax`、`total`、`level`、`currency`、`fetched_at` | 15min 价缓存，**不可变**；仅 tibber kind |
| **`energy_cost_period`** | `period_start`(unique)、`d1/d2/r1/r2_kwh`(周期差)、`import_cost`、`export_revenue`、`net_cost`、`currency`、`pricing`(JSON 快照)、`contract_version_id`(FK)、`degraded`、`computed_at` | 每15min 计量电费，**不可变**（快照价+来源版本）|

- `energy_cost_period` 存 per-register 差 + 快照价 → 完全可审计、source-agnostic、可重算覆盖。
- FK：`energy_contract_version.contract_id → energy_contract.id`（ON DELETE RESTRICT）；`energy_cost_period.contract_version_id → energy_contract_version.id`（RESTRICT，保审计链）。

### 3.6 不可变 / 审计（贯穿）

1. **算账快照**：`energy_cost_period` 存死当时实际用的价 + `contract_version_id`；改合同 / 改价**不回改**已算周期——"以前的价就是以前的价"。重算是**显式 opt-in**（`POST /recompute`），不自动覆盖历史。
2. **合同版本只增不改**：改价 = 新 `energy_contract_version`（带生效日期），旧版本保留，全程审计链。
3. **Tibber 价不可变**：过去某 15 分钟的价不会变。
4. 三者叠加 → manual / tibber 都满足审计与不可变。

### 3.7 MQTT / HA expose（复用 M5）

加 `app/integrations/expose.py::_energy_cost_provider`（`register_provider`），归一个 HA device（`identifiers="energy-cost"`）：

| key | component | 取值 | 用途 |
| --- | --- | --- | --- |
| `buy_price_now` | sensor | 当前周期/当前档买价（€/kWh，monetary） | HA 当前单价 |
| `sell_price_now` | sensor | 当前卖价（€/kWh） | |
| `import_cost_total` | sensor | 累计 import_cost（`total_increasing`，monetary，currency） | HA Energy "总成本实体" |
| `export_revenue_total` | sensor | 累计 export_revenue（`total_increasing`） | HA Energy 回送补偿 |

- `value_getter(session)` 从 `energy_cost_period`（累计/当前）取值。沿用 M5 勾选（默认未勾）、`publish_discovery`、`publish_states`（计费 job 后顺带推 + 周期兜底）。

### 3.8 前端（并入 Energy 视图）

- **合同管理**：列表 + 新建/编辑（**表单按选中 profile 结构渲染字段**：manual 双费率/能源税/固定费/抵扣；tibber token 提示走 Config）+ 激活（一次一个 active）+ 改价=新版本（带生效日期）+ 版本历史只读展示。
- **价格曲线**：今天+明天 15min 买/卖价（tibber）或固定档位（manual）折线（复用 `EnergyCharts`），取 `/api/energy/prices`。
- **费用走势 + 明细 + 汇总**：当日/月 import_cost/export_revenue/net 走势 + 每 15min 明细 + 汇总卡片（含固定费/抵扣），取 `/api/energy/costs` + `/costs/summary`。
- **Tibber 测试**：Config 页一块「Tibber」，三态（仿 MQTT 测试）。
- 全走类型化 client；空/加载/错误态齐全；缺 key 容错。

## 4. API 契约（M6 要落地的端点）

> 全部 `/api` 前缀、session + CSRF（写）保护、JSON 进出；schema 经 `scripts/export_openapi.py` 固化入库。

| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 合同 | `GET /api/energy/contracts` | 列出合同 + active 标记 |
| 合同 | `POST /api/energy/contracts` | 新建合同（kind + 首版本值，按 profile 校验）|
| 合同 | `GET /api/energy/contracts/{id}` | 单个合同 + 版本历史 |
| 合同 | `PATCH /api/energy/contracts/{id}` | 改名 / 激活（一次一个 active）|
| 合同 | `POST /api/energy/contracts/{id}/versions` | 加新版本（改价，带生效日期）|
| 合同 | `GET /api/energy/profiles` | 列出 pricing profile 结构（前端按它渲染表单）|
| 价格 | `GET /api/energy/prices?start&end` | 区间价格点（曲线）|
| 费用 | `GET /api/energy/costs?start&end` | 区间 `energy_cost_period`（走势/明细）|
| 费用 | `GET /api/energy/costs/summary?start&end` | 区间汇总（计量电费 + 固定费 − 抵扣）|
| 费用 | `POST /api/energy/costs/recompute?start&end` | 幂等重算（补数据/改价后，显式）|
| DSMR | `GET /api/energy/dsmr/latest` | 最新 `dsmr_reading` |
| 测试 | `POST /api/energy/tibber/test` | 试连 Tibber + 拉当前价，三态 |

> DSMR/Tibber 的**标量配置 + token** 复用现有 `GET/PUT /api/config`（只加 CONFIG_FIELDS）。**价格数值全在合同里**，不进 flat config。

## 5. 已锁定决策（讨论后拍板）

1. **两层电价模型**：profile YAML 定结构（仓库、固定、UI 不可编辑）+ `EnergyContract`(+版本) 存数值（UI 填、版本化）+ strategy 按 kind 出价。仿 M5。
2. **kind 不叫 "fixed"**：`manual`（人工填、可双费率、可带时段）/ `tibber`（API 动态）；合同 `name` UI 自由填。
3. **买价**：tibber = API `total`（全包，已证 total=energy+tax）；manual = `energy_buy_档 + energy_tax`。**卖价**：tibber = `total − energy_tax − sell_adjust`；manual = `sell_档`（回送价，无能源税）。均含 VAT。
4. **双费率**：manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价（`_1`=dal/低、`_2`=normal/高）；tibber 求和、15min 价不分档。
5. **两层费用**：每 15min `energy_cost_period` 只算计量电费（不可变、快照价）；日/月/年汇总再加固定费（按月→天）− heffingskorting（按年→天）。
6. **回送阶梯罚金（terugleverkosten）不做**：按自然年累计、用户住不到年底算不准——不算、不记、不加功能（留痕见 §10）。
7. **DSMR 整帧存 JSON blob**（不做字段 allowlist），10s 降采样；数值转 Decimal、null 容错、`id` 幂等。扩 `MqttManager` 订阅端。
8. **5 张表**：`dsmr_reading`、`energy_contract`、`energy_contract_version`、`tibber_price`、`energy_cost_period`。单电表不设 device 表。
9. **不可变 / 审计**：算账快照价 + 合同版本只增不改 + tibber 价天然不可变；重算显式 opt-in。
10. **Tibber 用 httpx + `priceInfoRange(QUARTER_HOURLY)`**，仅 active=tibber 时抓；token 走 secret config。
11. **复用 M5 expose/Discovery**：加 `_energy_cost_provider`；累计成本/收入用 `total_increasing` 喂 HA Energy。
12. **前端并入 Energy 视图**：合同管理表单按 profile 结构渲染；Recharts 画价/费。
13. **opt-in 开关**：`dsmr_ingest_enabled` 默认 false；Tibber 抓取/计费由 active 合同存在性驱动。
14. **周期边界用 UTC 刻钟**（NL 偏移整小时，本地/UTC 重合）。**不新增依赖**。

> 项目定位：个人自用、家庭特化、不开源——按单用户场景简化，不过度抽象。

## 6. 任务依赖图

```
Phase A（地基）
  M6-T01 [schema] 5 张表 + 模型 + 迁移
  M6-T02 flat 配置（dsmr_* + tibber token/home_id）        ← 与 T01 并行
  M6-T03 pricing profile 框架（manual.yaml/tibber.yaml + pydantic + strategy 注册表）  ← 纯模块，可早做

Phase B（合同 + 数据接入）
  M6-T04 EnergyContract CRUD + 版本 + 按 profile 校验（API）   (Depends T01,T03)
  M6-T05 Tibber 客户端 + 抓价 service + 调度 + tibber/test     (Depends T01,T02,T03)
  M6-T06 MqttManager 扩订阅 + DSMR ingest（blob/10s）          (Depends T01,T02)

Phase C（计算 + 反哺）
  M6-T07 计费引擎（每15min 计量 + 汇总）+ 周期 job + 重算       (Depends T01,T03,T04,T05,T06)
   └─► M6-T08 energy_cost expose provider + state 发布          (Depends T07)

Phase D（API + 前端）
  M6-T09 Energy 数据 API（prices/costs/summary/dsmr-latest/recompute）  (Depends T05,T07)
   └─► M6-T10 前端：合同管理 + 价格/费用视图 + Tibber 测试      (Depends T04,T09)

收尾
  M6-T11 文档 + OpenAPI + roadmap（Depends 全部）
```

`T01`、`T02`、`T03` 无前置可先开。

---

## 7. 原子任务（任务卡）

> 后端任务沿用校验闸门（`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库）；前端闸门见 §8。**不新增依赖**。纯新增、不删/移文件；改 `app/main.py` lifespan 时不得破坏既有 job/连接。

### M6-T01 — 5 张表与模型 `[schema]`
- **Status**: `done` · **Depends**: none
- **Context**: 单库 app 链新增 5 张表（§3.5）。只建 schema + 模型，不写采集/计算/接口。
- **Files**: `create app/models/energy.py`（`DsmrReading`/`EnergyContract`/`EnergyContractVersion`/`TibberPrice`/`EnergyCostPeriod`）；`create alembic_app/versions/20260623_11_energy_tables.py`；`modify alembic_app/env.py`、`scripts/app_db_adopt.py`（`APP_BASELINE_REVISION`→新 head）；`create tests/test_energy_models.py`
- **Steps**:
  1. 按 §3.5 定义模型（`Mapped[...]`）：`DsmrReading`（`recorded_at` idx、`source_id` unique nullable、`payload` JSON）；`EnergyContract`（`name`/`kind`/`active`/`currency`/时间戳）；`EnergyContractVersion`（`contract_id` FK RESTRICT、`effective_from`/`effective_to` nullable、`values` JSON、`created_at`）；`TibberPrice`（`starts_at` unique、`resolution`、`energy/tax/total` float、`level` nullable、`currency`、`fetched_at`）；`EnergyCostPeriod`（`period_start` unique、`d1/d2/r1/r2_kwh` float、`import_cost/export_revenue/net_cost` float、`currency`、`pricing` JSON、`contract_version_id` FK RESTRICT nullable、`degraded` bool、`computed_at`）。
  2. 新 revision：`down_revision="20260622_10_exposed_entities"`；`upgrade()` 建 5 表 + 索引/唯一/FK；`downgrade()` 反向 drop。更新 `APP_BASELINE_REVISION`。
- **Out of scope**: 不写客户端/采集/计费/接口；不动其它模型。
- **Acceptance criteria**:
  - [ ] 全新临时库 upgrade 到 head 含 5 表及约束；`downgrade -1` 干净回滚；链上单 head。
  - [ ] FK 为 RESTRICT；唯一/索引正确；`payload`/`values`/`pricing` 为 JSON 列。
  - [ ] `APP_BASELINE_REVISION` == 新 head。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 列/约束/FK 与 §3.5 一致；`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列；`env.py` 已 import 新模型；迁移 `down_revision` 指当前真实 head；无破坏性操作。

### M6-T02 — flat 配置（DSMR + Tibber 凭据）
- **Status**: `done` · **Depends**: none
- **Context**: DSMR 订阅设置 + Tibber 凭据接入扁平配置（前端自动渲染）。**价格数值不在这里**（在合同里）。
- **Files**: `modify app/config.py`、`app/services/config_page.py`（CONFIG_FIELDS + `_settings_payload`）；`modify .env.example`、`tests/test_api_config.py`
- **Steps**: `Settings` 加 `dsmr_ingest_enabled: bool=False`、`dsmr_mqtt_topic: str="dsmr/json"`、`dsmr_sample_interval_s: int=10`、`tibber_api_token: str=""`、`tibber_home_id: str=""`；CONFIG_FIELDS 归「DSMR」「Tibber」section，`tibber_api_token` 为 secret，bool=checkbox、数值=number；`_settings_payload` 补齐；`.env.example` 加注释样例（默认 off）。
- **Out of scope**: 不建客户端/采集；不动既有字段；不放任何价格常数。
- **Acceptance criteria**:
  - [ ] 新项在 `GET /api/config` 分 section；token 为 secret（回空/留空保留）；非法值 422 不写库。
  - [ ] 校验闸门全绿（OpenAPI 变化则重导出）。
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例；`_settings_payload` 无遗漏；默认 off。

### M6-T03 — pricing profile 框架 + strategy 注册表
- **Status**: `done` · **Depends**: none
- **Context**: 仓库内 `manual.yaml`/`tibber.yaml` 定结构（pydantic 校验），加 price strategy 注册表（manual/tibber 出价）。纯模块，单测。
- **Files**: `create app/integrations/pricing/__init__.py`、`pricing/profiles.py`（pydantic 模型 + `load_profile`/`list_profiles`/`validate_values`）、`pricing/profiles/manual.yaml`、`pricing/profiles/tibber.yaml`、`pricing/strategies.py`（`register_strategy`/`get_strategy`、`manual`/`tibber` 实现）；`create tests/test_pricing_profiles.py`、`tests/test_pricing_strategies.py`
- **Steps**:
  1. `profiles.py`：pydantic 模型描述 §3.1 结构；`load_profile(kind)`（读 YAML，校验失败抛错）；`validate_values(kind, values)`（合同数值是否符合结构）。
  2. `profiles/*.yaml`：按 §3.1 写 manual / tibber 结构。
  3. `strategies.py`：策略接口 `price_period(deltas, t0, values, session) -> {import_cost, export_revenue, net, pricing_snapshot}`（Decimal）：
     - `manual`：双费率公式（§3.4），`buy_x = energy_buy_x + energy_tax + ode`、`sell_x` 直接用。
     - `tibber`：取覆盖 t0 的 `tibber_price`，`buy=total`、`sell=total−energy_tax−sell_adjust`，进出口求和。
- **Out of scope**: 不连真实 API（tibber 策略只读 `tibber_price` 表，由 T05 填）；不写采集/调度/接口。
- **Acceptance criteria**:
  - [ ] 单测：`load_profile("manual"/"tibber")` 校验通过；缺字段/类型错抛可识别错；`validate_values` 正确判合规。
  - [ ] 单测：manual 策略双费率出价正确（`import = Δd1×buy_dal + Δd2×buy_normal` 等）；tibber 策略 `buy=total`、`sell=total−energy_tax−sell_adjust`，负 total 出口得负值。
  - [ ] Decimal 计算无 float 误差。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: profile 纯结构、无具体数值；策略用 Decimal；进出口分开（不相抵）；tibber 价匹配用 `starts_at ≤ t0` 最近一条；无 OOP 过度抽象（数据+函数，仿 M5 profiles.py）。

### M6-T04 — EnergyContract CRUD + 版本 + 校验（API）
- **Status**: `done` · **Depends**: M6-T01, M6-T03
- **Context**: 合同增删改、版本（改价加新版本、生效日期）、激活（一次一个）、按 profile 校验数值。
- **Files**: `create app/api/routes/api/energy_contracts.py`、`app/schemas/energy_contract.py`、`app/services/contracts.py`；`modify app/main.py`（注册路由）；`create tests/test_api_energy_contracts.py`
- **Steps**:
  1. `contracts.py` service：建合同（含首版本，`validate_values` 校验）、加版本（带 `effective_from`，自动闭合上一版本 `effective_to`）、激活（把其它 active 置 false）、`active_contract_version_at(session, ts)`（取 active 合同在 ts 生效的版本）。
  2. 路由：`GET/POST/GET{id}/PATCH{id}`、`POST {id}/versions`、`GET /profiles`（返回结构供前端渲染表单）；session+CSRF；数值不合规 422。
- **Out of scope**: 不算费用（T07）；不发 MQTT；不写采集。
- **Acceptance criteria**:
  - [ ] CRUD + 版本 + 激活行为正确：新建按 profile 校验（不合规 422）；加版本自动闭合上一段；激活互斥（同时只一个 active）；`GET /profiles` 返回结构。
  - [ ] 未登录 401、缺 CSRF 403；schema 经 OpenAPI 固化入库。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 版本只增不改（不覆盖旧版本）；激活互斥；删除受 FK RESTRICT（有版本/有费用记录不可裸删）；数值校验走 T03 `validate_values`；路径键用合同 id。

### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
- **Status**: `done` · **Depends**: M6-T01, M6-T02, M6-T03
- **Context**: httpx 拉 15min 价 upsert `tibber_price`；启动+每日抓（仅 active=tibber）；连接测试。
- **Files**: `create app/integrations/tibber/__init__.py`、`tibber/client.py`、`app/services/tibber_prices.py`；`modify app/main.py`（抓价 job）；`create tests/test_tibber_client.py`、`tests/test_tibber_prices.py`
- **Steps**:
  1. `client.py`：`fetch_price_range(token, home_id|None) -> list[PricePoint]`（§3.3 query，httpx 超时/认证失败抛错，不假设固定节点数）；`fetch_current_price(...)`（供 test）。
  2. `tibber_prices.py`：`refresh_prices(session, settings)`（拉今天+明天 upsert，幂等）；仅当 active 合同 kind=tibber 且 token 存在时执行。
  3. `main.py`：同步 wrapper + `IntervalTrigger`，`max_instances=1, coalesce=True`，吞异常不崩。
- **Out of scope**: 不算费用（T07）；不发 MQTT；test 路由在 T09（本任务备 service）。
- **Acceptance criteria**:
  - [ ] 单测（httpx mock）：解析返回为 PricePoint；认证失败/超时抛可识别异常。
  - [ ] 单测：`refresh_prices` 幂等（按 `starts_at` upsert，不重复插）；非 tibber active 时 no-op。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: token 不进日志；httpx 超时；upsert 幂等；时间存 UTC；无对 `tibber_price` 的破坏性批删。

### M6-T06 — MqttManager 扩订阅 + DSMR ingest
- **Status**: `done` · **Depends**: M6-T01, M6-T02
- **Context**: 给只发不收的 `MqttManager` 扩订阅；DSMR ingest 整帧 blob + 10s 降采样落库。
- **Files**: `modify app/integrations/mqtt.py`（订阅注册 + on_message 分发）；`create app/services/dsmr_ingest.py`；`modify app/main.py`（启用时注册订阅）；`create tests/test_mqtt_subscribe.py`、`tests/test_dsmr_ingest.py`
- **Steps**:
  1. `mqtt.py`：`subscribe(topic, handler)`（`on_connect` 里对已注册 topic subscribe，`on_message` 按 topic 分发，handler 异常吞掉不崩连接）；保持既有 publish 不回归。
  2. `dsmr_ingest.py`：`handle_message(payload_bytes, settings)`：parse JSON → 取 `timestamp`(UTC) → 降采样（`second % dsmr_sample_interval_s != 0` 丢弃）→ **整帧存 payload**（值原样/Decimal 友好）→ 短 session 写 `dsmr_reading`（`source_id`=`id`，存在则跳过）。
  3. `main.py`：`dsmr_ingest_enabled` 时把 handler 注册到 `dsmr_mqtt_topic`。
- **Out of scope**: 不算费用（T07）；不改既有 publish/discovery 语义；不做字段 allowlist（整帧存）。
- **Acceptance criteria**:
  - [ ] 单测：`subscribe` 注册后 `on_message` 分发到 handler；既有 publish 无回归。
  - [ ] 单测：喂样本 JSON（字符串数值、null 相位），秒=00 整帧落盘、秒≠10 整数倍丢弃；同 `id` 不重插。
  - [ ] `dsmr_ingest_enabled=false` 不订阅/不落盘。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: on_message 网络线程内只做短事务、吞异常不崩连接；整帧存（含 gas/各相）；null 容错；`source_id` 幂等；10s 降采样正确。

### M6-T07 — 计费引擎 + 周期 job + 汇总 + 重算
- **Status**: `done` · **Depends**: M6-T01, M6-T03, M6-T04, M6-T05, M6-T06
- **Context**: 每 15min 用寄存器差 × active 合同 strategy 算计量电费（快照、不可变）；汇总加固定费 − 抵扣；周期 job + 重算。
- **Files**: `create app/services/energy_cost.py`；`modify app/main.py`（周期 job）；`create tests/test_energy_cost.py`
- **Steps**:
  1. `energy_cost.py`：`register_at(session, boundary)`（取 ≤boundary 最后一行的 d1/d2/r1/r2，Decimal）；`compute_period(session, t0)`（取 active 版本 + strategy（T03/T04）→ 算 → upsert + 快照 `pricing` + `contract_version_id`，缺价/缺数据标 `degraded` 或跳过）；`compute_closed_periods(session)`；`recompute_range(session, start, end)`（幂等覆盖）；`summarize(session, start, end)`（Σnet + 固定费按天 − heffingskorting 按天，取 active 版本值）。
  2. `main.py`：1 分钟 tick job（`max_instances=1, coalesce=True`），有 active 合同 + DSMR 数据时算；吞异常不崩。
- **Out of scope**: 不发 MQTT（T08）；不加 HTTP（T09，备 service）；不改采集/价格抓取。
- **Acceptance criteria**:
  - [ ] 单测：构造 `dsmr_reading`+合同版本（manual 双费率 / tibber + `tibber_price`），`compute_period` 算出正确 cost/revenue/net，快照价 + `contract_version_id` 落库；upsert 幂等。
  - [ ] 单测：跨价格版本时按 t0 选对版本；缺价跳过、缺读数标 `degraded`。
  - [ ] 单测：`summarize` = Σnet + 固定费（月→天×天数）− heffingskorting（年→天×天数）。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: Decimal 算钱；寄存器差为"末−初"；进出口分开；周期边界 UTC 刻钟；快照不可变（重算才覆盖，且显式）；版本选择按 `effective_from≤t0<effective_to`；job 不崩。

### M6-T08 — energy_cost expose provider + state 发布
- **Status**: `done` · **Depends**: M6-T07
- **Context**: 复用 M5 expose/Discovery，发当前买/卖价 + 累计成本/收入。
- **Files**: `modify app/integrations/expose.py`（`_energy_cost_provider` + 注册）；`modify app/services/energy_cost.py` 或 `app/main.py`（算完顺带 `publish_states`）；`create tests/test_energy_expose.py`
- **Steps**: provider 产出 §3.7 实体（`buy_price_now`/`sell_price_now`/`import_cost_total`/`export_revenue_total`），累计项 `total_increasing`+`monetary`+currency；`value_getter` 从 `energy_cost_period` 取；计费 job 后 `publish_states`。
- **Out of scope**: 不改 Discovery 机制本身；不改采集/计费。
- **Acceptance criteria**:
  - [ ] 单测：`build_catalog` 含 energy_cost 实体；累计项 `total_increasing`；勾选默认未勾。
  - [ ] 单测：`value_getter` 取到正确当前价/累计值；MQTT 未启用整链 no-op。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: `key` 稳定（固定字符串）；复用既有 provider 协议、未改其它 provider；累计 `total_increasing` 语义正确；不阻塞计费 job。

### M6-T09 — Energy 数据 API
- **Status**: `done` · **Depends**: M6-T05, M6-T07
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点（合同 CRUD 在 T04）。
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`；`modify app/main.py`（注册）；`create tests/test_api_energy.py`
- **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`（调 T07）、`POST /tibber/test`（调 T05 client，三态）；session+CSRF；查询走索引 + 窗口/上限。
- **Out of scope**: 不写采集/计费/发布逻辑（复用 service）。
- **Acceptance criteria**:
  - [ ] 各端点行为正确：窗口+limit 上限生效；recompute 幂等；tibber/test 三态；401/403 正确；schema 入库（`git diff --exit-code openapi/` 无差异）。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 查询有时间窗+上限；recompute 复用 T07、无破坏删；test 不泄 token。

### M6-T10 — 前端：合同管理 + 价格/费用视图 + Tibber 测试
- **Status**: `done` · **Depends**: M6-T04, M6-T09
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`；`create frontend/src/energy/ContractManager.tsx`、`ContractForm.tsx`、`TibberPrices.tsx`、`CostView.tsx`、扩 `hooks.ts`；`modify` Config 页 Tibber 测试入口；`create` 对应 `*.test.tsx`
- **Steps**: 合同列表/新建/编辑/激活/加版本（**表单字段按 `/profiles` 结构渲染**，manual 双费率/税/固定费/抵扣）+ 版本历史只读；价格曲线 + 费用走势/明细 + 汇总卡片；Tibber 测试三态；全走类型化 client；空/加载/错误态 + 缺 key 容错。
- **Out of scope**: 不做服务端降采样；不改后端。
- **Acceptance criteria**:
  - [ ] 合同 CRUD/激活/版本可用，表单按 profile 渲染；价格/费用/汇总渲染正确；区间只取窗口；币种/单位来自 API。
  - [ ] Tibber 测试三态可用。
  - [ ] 前端闸门全绿（`lint`/`typecheck`/`test`/`build`）。
- **Reviewer checklist**: 全走类型化 client；图表组件隔离复用；表单按结构渲染、不 hardcode 字段；窗口/上限；空/错/加载/缺 key 容错。

### M6-T11 — 文档 + OpenAPI + roadmap 收尾
- **Status**: `done` · **Depends**: 全部
- **Files**: `modify README.md`、`docs/roadmap.md`（M6 行 + 毕业）、`docs/architecture-overview.md`、`docs/design/README.md`（列入 m6）；`run python scripts/export_openapi.py` 并提交 `openapi/`
- **Acceptance criteria**:
  - [ ] 文档反映新链路；`git diff --exit-code openapi/` 无未提交差异；校验闸门全绿。
- **Reviewer checklist**: 无残留过时描述；OpenAPI 入库；§13 已敲定项同步回文档。

---

## 8. 前端校验闸门

在 `frontend/` 下：`npm ci && npm run lint && npm run typecheck && npm run test && npm run build`（留意 chunk 体积告警）。后端同任务改路由/schema 仍需根目录 `export_openapi.py` 并提交 `openapi/`。**不新增前端依赖**（Recharts 已在）。

## 9. 构建上下文完整性（M1 教训）

- **纯新增、不删/移文件**，**不新增依赖**（httpx/paho/pyyaml 已在）。新增源文件都在 `app/`、`scripts/`、`frontend/` 既有 `COPY` 范围内，无需改 `Dockerfile`；**`app/integrations/pricing/profiles/*.yaml` 是非 .py 资源**——确认随 `COPY app` 进镜像、运行期按 `__file__`/`importlib.resources` 定位（别用 CWD 相对路径）。`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
- 改 `app/main.py` lifespan 注册新 job/订阅时**不得破坏**既有 public-ip / modbus-poll / discovery job 与 MQTT 连接。
- 发版前置走查（CLAUDE.md）：真起 app → 订到 `dsmr/json`、（合同生效后）拉一次 Tibber 价、看费用落库、前端合同/价格/费用视图人工瞄一眼，再打 tag。

## 10. 后续杠杆（本里程碑不做，文档留痕）

- **回送阶梯罚金（terugleverkosten）**：按自然年累计、阶梯式；本期不做。将来做就把阶梯表存进合同结构，在**年度汇总**层按 YTD 累计回送量套阶梯（不进每周期引擎）。
- **燃气 / 区域供暖计费**：数据已在 blob 里（gas `extra_device_*`）；将来在合同结构加"商品价"+ `energy_cost_period` 加 `commodity` 维度即可，不改主链。
- **能源税分档**：年用电跨 10000 kWh 档时税率变；现按单档配置。
- **降采样 / 保留**：`dsmr_reading` 10s 长期行数大，加定期降采样/保留窗口任务（SQL 端 `AVG(json_extract(...))`）。
- **价格 level 配色 / 便宜时段提示**：用 Tibber `level` 驱动前端配色与用电建议。
- **HA 让 HA 算 vs 后端算**：当前后端算累计；也可只发买/卖价 €/kWh 让 HA Energy 自乘（实体都已具备）。

## 11. 人工验收 walkthrough（实现完成后）

> 受控手工验证（依赖家里 DSMR Reader + broker；Tibber 部分待合同生效），不进自动化。

1. **合同（先用 manual，立即可用）**：Energy 页新建一个 `manual` 合同，按表单填双费率/能源税/固定费/抵扣，激活。
2. **DSMR 落库**：开 `dsmr_ingest_enabled`、确认 topic；MQTT Explorer 看 `dsmr/json` 有消息；等若干 10 秒 → `GET /api/energy/dsmr/latest` 有最新整帧；`dsmr_reading` 约每 10 秒一行（整帧 blob）。
3. **费用计算**：跨过一个整刻钟边界 → `GET /api/energy/costs` 出现该周期 import/export/cost/revenue/net（manual 双费率分档对得上手算）；`GET /costs/summary` 含固定费/抵扣；改价加新版本后旧周期不变、`POST /recompute` 才覆盖。
4. **Tibber（合同生效后）**：建 `tibber` 合同、Config 填 token、点「Tibber 测试」三态成功；激活后抓价 → `GET /prices` 有 15min 价、费用切到动态。
5. **反哺 HA**：Expose 勾选 energy_cost 实体、开 Discovery、重发 → HA 出现当前买/卖价 + 累计成本/收入；HA Energy 挂累计实体。

## 12. 里程碑完成定义（DoD）

- 后端能：订 `dsmr/json` 按 10s 整帧落库；按 active 合同（manual 双费率 / tibber 动态）每 15min 用寄存器差 × 价算计量电费（不可变、快照）；日/月/年汇总加固定费 − 抵扣；不做净计量；改价走版本、历史不回改。
- 复用 M5 Discovery 把当前买/卖价 + 累计成本/收入（`total_increasing`）发成 HA 实体。
- 前端 Energy 视图能管理合同（表单按 profile 渲染、激活、版本）、看价格曲线与费用走势/明细/汇总、测 Tibber。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 入库。
- README / architecture / roadmap / design 索引反映 M6。

## 13. 待确认 / TODO（拿到真实 token + 账单后钉死，均已落成配置/默认值，不阻塞实现）

1. **买价**：✅ tibber = API `total`（demo 已证 total=energy+tax）；manual = energy_buy_档 + energy_tax。无待办。
2. **卖价残差（tibber）**：`sell = total − energy_tax − sell_adjust`，`sell_adjust` 默认 0（买卖费相等抵消）。真实账单确认后若有残差再调。
3. **双费率寄存器映射**：`_1`=dal/低、`_2`=normal/高（NL 惯例）——接价前用真实数据确认别接反（差价小但要对）。
4. **能源税年值**：manual/tibber 的 `energy_tax` 默认 ~0.1108（2026 第一档含 VAT），按当年实际值核。
5. **Tibber 15min + 币种**：✅ 查询/分辨率已 demo 证实；仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
6. **manual 现行数值**（你的固定合同）：normal 0.133 / dal 0.127 / 回送两档（现同价）/ 管理费 ≈€0.329/天（≈€9.87/月）/ 电网费 + heffingskorting 待填。
</content>
