Compare commits
2
Commits
v1.6.3
...
7d46cb96d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d46cb96d3 | ||
|
|
5e1545efad |
@@ -1230,7 +1230,24 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session],
|
|||||||
if metric == "water_tax":
|
if metric == "water_tax":
|
||||||
return result["breakdown"]["hot_water_tax"]
|
return result["breakdown"]["hot_water_tax"]
|
||||||
if metric == "hot_water_total":
|
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 result["breakdown"][metric]
|
||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
|
|||||||
@@ -1222,7 +1222,17 @@ Assistant Energy Water 所要求的 `water`;保留 `m³` 与 `total_increasing
|
|||||||
|
|
||||||
### M8-R15 — HA Discovery identity/topic repair
|
### 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`。
|
M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md`。
|
||||||
它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然
|
它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然
|
||||||
|
|||||||
@@ -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 合同和数字不变。
|
成本页的 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 名称、日志或截图。
|
||||||
|
|
||||||
## 安全回滚
|
## 安全回滚
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { useMantineEnv } from '@mantine/core'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderWithProviders } from './test-utils'
|
||||||
|
|
||||||
|
function MantineEnvironmentProbe() {
|
||||||
|
return <output>{useMantineEnv()}</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderWithProviders', () => {
|
||||||
|
it('uses Mantine test environment', () => {
|
||||||
|
renderWithProviders(<MantineEnvironmentProbe />)
|
||||||
|
|
||||||
|
expect(screen.getByText('test')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,7 +51,7 @@ export function renderWithProviders(ui: ReactNode, options: RenderOptions = {})
|
|||||||
|
|
||||||
function Wrapper() {
|
function Wrapper() {
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider env="test">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={entries}>
|
<MemoryRouter initialEntries={entries}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
// Mantine-heavy UI tests allocate substantial jsdom resources. Keep the
|
||||||
|
// worker pool bounded so concurrent CI jobs remain reliable.
|
||||||
|
maxWorkers: 2,
|
||||||
setupFiles: ['./src/test-setup.ts'],
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
env: {
|
env: {
|
||||||
// Lock the test timezone to UTC so that date-formatting assertions
|
// Lock the test timezone to UTC so that date-formatting assertions
|
||||||
|
|||||||
@@ -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"),
|
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
|
||||||
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
|
"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:
|
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")
|
assert entity.value_getter(session) == Decimal("0")
|
||||||
hot_water_total = next(item.entity for item in build_catalog(session)
|
hot_water_total = next(item.entity for item in build_catalog(session)
|
||||||
if item.entity.key.endswith(".hot_water_total_today"))
|
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
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user