diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 1a4e70c..174aadf 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -1230,7 +1230,24 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session], if metric == "water_tax": return result["breakdown"]["hot_water_tax"] if metric == "hot_water_total": - return result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"] + # Keep the existing component entities variable-only, while making + # the two consumer-facing totals partition the all-in summary. + # Standing costs are assigned by their contractual commodity: + # hot-water network belongs to hot water; the remaining named + # standing fees belong to heating. + return ( + result["breakdown"]["hot_water_heating"] + + result["breakdown"]["hot_water"] + + result["breakdown"]["hot_water_tax"] + + result["fixed_breakdown"]["hot_water_network"] + ) + if metric == "heating": + return result["breakdown"]["heating"] + sum( + (result["fixed_breakdown"][key] for key in ( + "heating_network", "metering", "delivery_set", "other" + )), + 0, + ) return result["breakdown"][metric] return _getter diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index b9ad1f0..1d0a320 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -1222,7 +1222,17 @@ Assistant Energy Water 所要求的 `water`;保留 `m³` 与 `total_increasing ### M8-R15 — HA Discovery identity/topic repair -修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`,金额为 `hot_water_heating + hot_water`,不含 `hot_water_tax`。 +修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`;其后由 R17 明确为 hot-water 的 all-in 分配值。 + +### M8-R17 — Thermal HA standing-cost allocation + +保持全部 thermal HA entity key、unique_id 与名称不变,只调整 `Thermal Heating Total/Today` 与 +`Thermal Hot Water Total/Today` 的数值语义。Heating 为 `heating` variable 加上 +`heating_network`、`metering`、`delivery_set`、`other` 四项 fixed breakdown;Hot Water 为 +`hot_water_heating + hot_water + hot_water_tax` 加上 `hot_water_network`。因此两者之和精确等于 +`Thermal All-in Total/Today`,每项 standing 只分配一次。component 实体(hot-water heating、water、 +water tax)继续只显示 variable,`Fixed` 继续显示全部 standing;provider 只读取 `meter_cost.summarize` +在 01:05 结算后的结果,不改 15 分钟 ledger、API/schema 或数据库。 M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md`。 它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然 diff --git a/docs/warmtelink-energy.md b/docs/warmtelink-energy.md index 0c0534a..89f05ef 100644 --- a/docs/warmtelink-energy.md +++ b/docs/warmtelink-energy.md @@ -77,7 +77,13 @@ docker compose -f docker-compose.yml run --rm migration 成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdown;fixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。 -在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。核对 unit、state class、availability、today reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity 或合同金额放进 HA entity 名称、日志或截图。 +在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。`Thermal Heating Total/Today` +显示 heating variable 加上 heating network、metering、delivery set 与 other 固定费;`Thermal Hot Water +Total/Today` 显示 hot-water heating、water、water tax variable 加上 hot-water network 固定费。两条合计 +恰好等于 `Thermal All-in Total/Today`;component entities 仍仅显示各自 variable,`Fixed` 仍显示全部 +standing,避免重复计费。数值仍来自在 01:05 后结算的 summary。核对 unit、state class、availability、today +reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity +或合同金额放进 HA entity 名称、日志或截图。 ## 安全回滚 diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index a610021..b8197dc 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -3002,6 +3002,11 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None: "heating": Decimal("0"), "hot_water_heating": Decimal("1.25"), "hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"), }, + "fixed_breakdown": { + "heating_network": Decimal("0"), "metering": Decimal("0"), + "delivery_set": Decimal("0"), "hot_water_network": Decimal("0"), + "other": Decimal("0"), + }, } def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict: @@ -3023,5 +3028,53 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None: assert entity.value_getter(session) == Decimal("0") hot_water_total = next(item.entity for item in build_catalog(session) if item.entity.key.endswith(".hot_water_total_today")) - assert hot_water_total.value_getter(session) == Decimal("4.00") + assert hot_water_total.value_getter(session) == Decimal("13.99") assert captured["end"] == now + + +@pytest.mark.parametrize("suffix", ("total", "today")) +def test_m8_thermal_totals_allocate_standing_costs_and_match_all_in(energy_db, suffix: str) -> None: + """Heating and hot water each receive their assigned costs, without overlap.""" + from app.integrations.expose import build_catalog + from app.services import timezone as tz + + now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc) + summary = { + "period_count": 2, + "fixed_cost": Decimal("35"), + "total_cost": Decimal("54"), + "breakdown": { + "heating": Decimal("10"), "hot_water_heating": Decimal("2"), + "hot_water": Decimal("3"), "hot_water_tax": Decimal("4"), + }, + "fixed_breakdown": { + "heating_network": Decimal("5"), "metering": Decimal("6"), + "delivery_set": Decimal("7"), "hot_water_network": Decimal("9"), + "other": Decimal("8"), + }, + } + + with Session(energy_db) as session: + _make_thermal_source_and_meter(session, "heating", now) + _make_thermal_source_and_meter(session, "hot_water", now) + session.commit() + with ( + patch("app.integrations.expose._utc_now", return_value=now), + patch.object(tz, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")), + patch.object(tz, "local_now", return_value=now.astimezone(ZoneInfo("Europe/Amsterdam"))), + patch("app.services.meter_cost.summarize", return_value=summary), + ): + entities = {item.entity.key.rsplit(".", 1)[-1]: item.entity + for item in build_catalog(session) if item.entity.key.startswith("thermal_cost.")} + heating = entities[f"heating_{suffix}"].value_getter(session) + hot_water = entities[f"hot_water_total_{suffix}"].value_getter(session) + + assert heating == Decimal("36") # 10 + heating_network + metering + delivery_set + other + assert hot_water == Decimal("18") # 2 + 3 + 4 + hot_water_network + assert entities[f"fixed_{suffix}"].value_getter(session) == Decimal("35") + assert entities[f"all_in_{suffix}"].value_getter(session) == Decimal("54") + assert heating + hot_water == entities[f"all_in_{suffix}"].value_getter(session) + # Component entities deliberately remain variable-only. + assert entities[f"hot_water_heating_{suffix}"].value_getter(session) == Decimal("2") + assert entities[f"water_{suffix}"].value_getter(session) == Decimal("3") + assert entities[f"water_tax_{suffix}"].value_getter(session) == Decimal("4")