From 0fb51d338c9352d567ce5b4149d2ebc72d07afd2 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 23 Aug 2026 09:21:48 +0200 Subject: [PATCH] M8-T13: add district heating pricing profile --- app/integrations/pricing/profiles.py | 161 +++++++++++++++++- .../pricing/profiles/district_heating.yaml | 56 ++++++ app/services/contracts.py | 7 +- docs/design/m8-warmtelink-energy.md | 2 +- tests/test_api_energy_contracts.py | 114 ++++++++++++- tests/test_pricing_profiles.py | 130 +++++++++++++- 6 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 app/integrations/pricing/profiles/district_heating.yaml diff --git a/app/integrations/pricing/profiles.py b/app/integrations/pricing/profiles.py index 76b63f6..f250e93 100644 --- a/app/integrations/pricing/profiles.py +++ b/app/integrations/pricing/profiles.py @@ -25,6 +25,7 @@ Design notes from __future__ import annotations import logging +from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any, Optional @@ -152,13 +153,86 @@ class TibberProfile(BaseModel): return self +# --------------------------------------------------------------------------- +# DistrictHeatingProfile — user-entered thermal contract structure +# --------------------------------------------------------------------------- + + +class DistrictHeatingFieldSpec(BaseModel): + """A Decimal-safe thermal tariff field displayed to the user.""" + + model_config = {"extra": "forbid"} + + unit: str + label: str + help: str + minimum: Decimal = Decimal("0") + default: Decimal | None = None + + +class DistrictHeatingVariableSpec(BaseModel): + model_config = {"extra": "forbid"} + + heating: DistrictHeatingFieldSpec + hot_water_heating: DistrictHeatingFieldSpec + hot_water: DistrictHeatingFieldSpec + hot_water_tax: DistrictHeatingFieldSpec + + +class DistrictHeatingStandingSpec(BaseModel): + model_config = {"extra": "forbid"} + + heating_network: DistrictHeatingFieldSpec + metering: DistrictHeatingFieldSpec + delivery_set: DistrictHeatingFieldSpec + hot_water_network: DistrictHeatingFieldSpec + other: DistrictHeatingFieldSpec + + +class DistrictHeatingProfile(BaseModel): + """Complete structure description for a ``district_heating`` contract.""" + + model_config = {"extra": "forbid"} + + kind: str + label: str + variable: DistrictHeatingVariableSpec + standing: DistrictHeatingStandingSpec + + @model_validator(mode="after") + def _check_kind(self) -> "DistrictHeatingProfile": + if self.kind != "district_heating": + raise ValueError( + "DistrictHeatingProfile requires kind='district_heating', " + f"got {self.kind!r}" + ) + units = { + "heating": "EUR/GJ", + "hot_water_heating": "EUR/m³", + "hot_water": "EUR/m³", + "hot_water_tax": "EUR/m³", + } + for key, unit in units.items(): + field = getattr(self.variable, key) + if field.unit != unit or field.minimum != 0 or field.default is not None: + raise ValueError(f"district_heating.variable.{key} must be required {unit} with minimum 0") + for key in DistrictHeatingStandingSpec.model_fields: + field = getattr(self.standing, key) + if field.unit != "EUR/year" or field.minimum != 0 or field.default != 0: + raise ValueError( + f"district_heating.standing.{key} must be EUR/year with default and minimum 0" + ) + return self + + # A union type for type hints where either profile is acceptable. -AnyProfile = ManualProfile | TibberProfile +AnyProfile = ManualProfile | TibberProfile | DistrictHeatingProfile # Map kind → Pydantic model class used for validation. -_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile]] = { +_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile] | type[DistrictHeatingProfile]] = { "manual": ManualProfile, "tibber": TibberProfile, + "district_heating": DistrictHeatingProfile, } @@ -204,6 +278,11 @@ def load_profile(kind: str) -> AnyProfile: f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}" ) + # YAML's implicit float conversion must never contaminate the thermal profile. + # Existing electricity profiles intentionally retain their established defaults. + if raw.get("kind", kind) == "district_heating": + _reject_yaml_floats(raw, path) + # Choose the right Pydantic model based on the ``kind`` field in the YAML. yaml_kind = raw.get("kind", kind) model_cls = _PROFILE_MODELS.get(yaml_kind) @@ -362,6 +441,82 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d return filled +def _reject_yaml_floats(value: Any, path: Path) -> None: + """Reject implicit YAML floats for district-heating profile metadata.""" + if isinstance(value, float): + raise ProfileValidationError( + f"Profile '{path.stem}' must not contain YAML float values; use integer 0 or strings." + ) + if isinstance(value, dict): + for child in value.values(): + _reject_yaml_floats(child, path) + elif isinstance(value, list): + for child in value: + _reject_yaml_floats(child, path) + + +def _decimal_value(section: str, key: str, value: Any) -> str: + """Validate and normalise one thermal amount without passing through float.""" + if isinstance(value, bool) or isinstance(value, float) or not isinstance(value, (str, int, Decimal)): + raise ProfileValidationError( + f"Contract values field '{section}.{key}' must be a Decimal-compatible string or integer, " + f"got {type(value).__name__!r}" + ) + try: + amount = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise ProfileValidationError( + f"Contract values field '{section}.{key}' must be a Decimal-compatible value" + ) from exc + if not amount.is_finite() or amount < 0: + raise ProfileValidationError( + f"Contract values field '{section}.{key}' must be a non-negative finite Decimal" + ) + return format(amount, "f") + + +def _validate_district_heating_values( + values: dict[str, Any], profile: DistrictHeatingProfile +) -> dict[str, Any]: + """Validate thermal values and return a complete JSON-safe Decimal snapshot.""" + if not isinstance(values, dict): + raise ProfileValidationError("District-heating contract values must be a mapping") + expected_sections = {"variable", "standing"} + unknown_sections = set(values) - expected_sections + if unknown_sections: + raise ProfileValidationError( + f"District-heating contract values contain unknown section(s): {sorted(unknown_sections)}" + ) + + def normalise_section( + section: str, specs: Any, *, defaults_allowed: bool + ) -> dict[str, str]: + supplied = values.get(section, {}) + if not isinstance(supplied, dict): + raise ProfileValidationError(f"Contract values section '{section}' must be a mapping") + expected = set(type(specs).model_fields) + unknown = set(supplied) - expected + if unknown: + raise ProfileValidationError( + f"Contract values section '{section}' contains unknown field(s): {sorted(unknown)}" + ) + normalised: dict[str, str] = {} + for key in type(specs).model_fields: + if key not in supplied: + field = getattr(specs, key) + if not defaults_allowed or field.default is None: + raise ProfileValidationError(f"Contract values missing required field '{section}.{key}'") + normalised[key] = format(field.default, "f") + else: + normalised[key] = _decimal_value(section, key, supplied[key]) + return normalised + + return { + "variable": normalise_section("variable", profile.variable, defaults_allowed=False), + "standing": normalise_section("standing", profile.standing, defaults_allowed=True), + } + + def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]: """Validate a contract-values dict against the named profile structure. @@ -395,5 +550,7 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]: return _validate_manual_values(values, profile) if isinstance(profile, TibberProfile): return _validate_tibber_values(values, profile) + if isinstance(profile, DistrictHeatingProfile): + return _validate_district_heating_values(values, profile) # Unreachable with current kinds, but guard for future extensions. raise ProfileValidationError(f"No validator implemented for kind={kind!r}") diff --git a/app/integrations/pricing/profiles/district_heating.yaml b/app/integrations/pricing/profiles/district_heating.yaml new file mode 100644 index 0000000..1b1fe9c --- /dev/null +++ b/app/integrations/pricing/profiles/district_heating.yaml @@ -0,0 +1,56 @@ +kind: district_heating +label: 区域供热 + +variable: + heating: + unit: EUR/GJ + label: 供暖热量 + help: 按供暖用热量计收;请录入合同中的实际金额。 + minimum: 0 + hot_water_heating: + unit: EUR/m³ + label: 热水加热 + help: 按热水体积计收的加热部分;请录入合同中的实际金额。 + minimum: 0 + hot_water: + unit: EUR/m³ + label: 热水用量 + help: 按热水体积计收的用量部分;请录入合同中的实际金额。 + minimum: 0 + hot_water_tax: + unit: EUR/m³ + label: 热水税费 + help: 按热水体积计收的税费部分;请录入合同中的实际金额。 + minimum: 0 + +standing: + heating_network: + unit: EUR/year + label: 供暖网络费 + help: 年度固定费用;默认零,按合同实际金额录入。 + minimum: 0 + default: 0 + metering: + unit: EUR/year + label: 计量费 + help: 年度固定费用;默认零,按合同实际金额录入。 + minimum: 0 + default: 0 + delivery_set: + unit: EUR/year + label: 交付装置费 + help: 年度固定费用;默认零,按合同实际金额录入。 + minimum: 0 + default: 0 + hot_water_network: + unit: EUR/year + label: 热水网络费 + help: 年度固定费用;默认零,按合同实际金额录入。 + minimum: 0 + default: 0 + other: + unit: EUR/year + label: 其他固定费 + help: 年度固定费用;默认零,按合同实际金额录入。 + minimum: 0 + default: 0 diff --git a/app/services/contracts.py b/app/services/contracts.py index aca7d4b..d5f35d6 100644 --- a/app/services/contracts.py +++ b/app/services/contracts.py @@ -160,11 +160,14 @@ def create_contract( name: Human-readable label for the contract. kind: - Pricing strategy identifier (``"manual"`` or ``"tibber"``). + Pricing strategy identifier (``"manual"``, ``"tibber"``, or + ``"district_heating"``). currency: ISO 4217 currency code (default ``"EUR"``). values: - Pricing values dict conforming to the named profile's structure. + Pricing values dict conforming to the named profile's structure. The + district-heating profile normalises its Decimal-safe values to strings + before the JSON snapshot is stored. Validated via ``validate_values(kind, values)`` before any writes. effective_from: UTC datetime at which the first pricing version takes effect. diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index 487b5ee..5d4eb9d 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -774,7 +774,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 ### M8-T13 — District-heating 定价 Profile -- **Status**: `todo` +- **Status**: `done` - **Depends**: M8-T12 - **Context**: 用 profile 固定热力字段、单位和验证,不把用户的实际 Vattenfall 金额写进仓库。 diff --git a/tests/test_api_energy_contracts.py b/tests/test_api_energy_contracts.py index e33af6d..fe13cc9 100644 --- a/tests/test_api_energy_contracts.py +++ b/tests/test_api_energy_contracts.py @@ -104,6 +104,15 @@ _TIBBER_VALUES: dict[str, Any] = { }, } +_DISTRICT_HEATING_VALUES: dict[str, Any] = { + "variable": { + "heating": "12.34", + "hot_water_heating": "1.20", + "hot_water": "2.30", + "hot_water_tax": "0.10", + }, +} + def _manual_payload(**overrides) -> dict[str, Any]: base: dict[str, Any] = { @@ -127,6 +136,17 @@ def _tibber_payload(**overrides) -> dict[str, Any]: return base +def _district_heating_payload(**overrides) -> dict[str, Any]: + base: dict[str, Any] = { + "name": "District Heating", + "kind": "district_heating", + "currency": "EUR", + "values": _DISTRICT_HEATING_VALUES, + } + base.update(overrides) + return base + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -156,7 +176,7 @@ def test_profiles_unauthenticated_returns_401(contracts_client): assert resp.status_code == 401 -def test_profiles_returns_both_kinds(contracts_client): +def test_profiles_returns_all_kinds(contracts_client): client, _ = contracts_client _login(client) resp = client.get("/api/energy/profiles") @@ -166,6 +186,7 @@ def test_profiles_returns_both_kinds(contracts_client): kinds = {p["kind"] for p in body["profiles"]} assert "manual" in kinds assert "tibber" in kinds + assert "district_heating" in kinds def test_profiles_contain_structure(contracts_client): @@ -177,9 +198,12 @@ def test_profiles_contain_structure(contracts_client): for profile in body["profiles"]: assert "kind" in profile assert "label" in profile - assert "energy" in profile assert "standing" in profile - assert "credits" in profile + if profile["kind"] == "district_heating": + assert "variable" in profile + else: + assert "energy" in profile + assert "credits" in profile # --------------------------------------------------------------------------- @@ -346,6 +370,57 @@ def test_create_tibber_contract_success(contracts_client): assert len(body["versions"]) == 1 +def test_create_district_heating_contract_normalises_snapshot_and_scope(contracts_client): + client, _ = contracts_client + _login(client) + response = client.post( + "/api/energy/contracts", + json=_district_heating_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + assert response.status_code == 201 + body = response.json() + assert body["kind"] == "district_heating" + assert body["scope"] == "thermal" + assert body["versions"][0]["values"] == { + "variable": _DISTRICT_HEATING_VALUES["variable"], + "standing": { + "heating_network": "0", "metering": "0", "delivery_set": "0", + "hot_water_network": "0", "other": "0", + }, + } + assert client.get("/api/energy/contracts").json()["items"] == [] + assert client.get("/api/energy/contracts?scope=thermal").json()["total"] == 1 + mismatch = client.post( + "/api/energy/contracts", + json=_district_heating_payload(scope="electricity"), + headers={"X-CSRF-Token": _CSRF}, + ) + assert mismatch.status_code == 422 + + +@pytest.mark.parametrize( + "values", + [ + {"variable": {"heating": "1"}}, + {"variable": {**_DISTRICT_HEATING_VALUES["variable"], "heating": -1}}, + {"variable": {**_DISTRICT_HEATING_VALUES["variable"], "heating": 1.5}}, + {"variable": {**_DISTRICT_HEATING_VALUES["variable"], "extra": "1"}}, + ], +) +def test_create_district_heating_rejects_invalid_decimal_values(contracts_client, values): + client, engine = contracts_client + _login(client) + response = client.post( + "/api/energy/contracts", + json=_district_heating_payload(values=values), + headers={"X-CSRF-Token": _CSRF}, + ) + assert response.status_code == 422 + with Session(engine) as session: + assert session.execute(select(EnergyContract)).scalars().all() == [] + + def test_create_contract_defaults_effective_from(contracts_client): """When effective_from is omitted, the version is created with a recent timestamp. @@ -815,3 +890,36 @@ def test_add_version_closes_previous_and_appends(contracts_client): .all() ) assert len(all_versions) == 2 + + +def test_district_heating_version_timeline_keeps_normalised_snapshots(contracts_client): + client, _ = contracts_client + _login(client) + t0 = datetime(2026, 1, 1, tzinfo=UTC) + created = client.post( + "/api/energy/contracts", + json=_district_heating_payload(effective_from=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + assert created.status_code == 201 + contract_id = created.json()["id"] + t1 = datetime(2026, 6, 1, tzinfo=UTC) + updated_values = { + "variable": {**_DISTRICT_HEATING_VALUES["variable"], "heating": "13.500"}, + "standing": {"metering": "10.00"}, + } + response = client.post( + f"/api/energy/contracts/{contract_id}/versions", + json={"effective_from": t1.isoformat(), "values": updated_values}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert response.status_code == 201 + old, new = response.json()["versions"] + assert old["effective_to"] is not None + assert old["values"]["variable"]["heating"] == "12.34" + assert new["effective_to"] is None + assert new["values"]["variable"]["heating"] == "13.500" + assert new["values"]["standing"] == { + "heating_network": "0", "metering": "10.00", "delivery_set": "0", + "hot_water_network": "0", "other": "0", + } diff --git a/tests/test_pricing_profiles.py b/tests/test_pricing_profiles.py index 02e9dce..33d5389 100644 --- a/tests/test_pricing_profiles.py +++ b/tests/test_pricing_profiles.py @@ -26,6 +26,7 @@ import pytest import yaml from app.integrations.pricing.profiles import ( + DistrictHeatingProfile, ManualProfile, ProfileNotFoundError, ProfileValidationError, @@ -156,6 +157,64 @@ class TestLoadProfileTibber: assert profile.credits.heffingskorting.unit == "EUR/year" +class TestLoadProfileDistrictHeating: + """The thermal profile contains only field structure and zero defaults.""" + + def test_d11_fields_units_and_zero_defaults(self) -> None: + profile = load_profile("district_heating") + assert isinstance(profile, DistrictHeatingProfile) + assert profile.kind == "district_heating" + assert list(type(profile.variable).model_fields) == [ + "heating", "hot_water_heating", "hot_water", "hot_water_tax" + ] + assert profile.variable.heating.unit == "EUR/GJ" + assert all( + getattr(profile.variable, key).unit == "EUR/m³" + for key in ("hot_water_heating", "hot_water", "hot_water_tax") + ) + assert list(type(profile.standing).model_fields) == [ + "heating_network", "metering", "delivery_set", "hot_water_network", "other" + ] + assert all( + getattr(profile.standing, key).unit == "EUR/year" + for key in type(profile.standing).model_fields + ) + assert all( + getattr(profile.standing, key).default == 0 for key in type(profile.standing).model_fields + ) + assert all( + getattr(profile.variable, key).minimum == 0 for key in type(profile.variable).model_fields + ) + assert all( + getattr(profile.standing, key).minimum == 0 for key in type(profile.standing).model_fields + ) + + def test_profile_has_no_nonzero_tariff_defaults(self) -> None: + profile_path = Path(__file__).parent.parent / "app/integrations/pricing/profiles/district_heating.yaml" + raw = yaml.safe_load(profile_path.read_text()) + defaults = [ + field.get("default") + for section in (raw["variable"], raw["standing"]) + for field in section.values() + if "default" in field + ] + assert defaults == [0, 0, 0, 0, 0] + + def test_profile_contains_no_nonzero_numeric_tariff_reference(self) -> None: + raw = yaml.safe_load( + (Path(__file__).parent.parent / "app/integrations/pricing/profiles/district_heating.yaml").read_text() + ) + + def numeric_values(value): + if isinstance(value, dict): + return [number for child in value.values() for number in numeric_values(child)] + if isinstance(value, (int, float)) and not isinstance(value, bool): + return [value] + return [] + + assert numeric_values(raw) == [0] * 14 + + # --------------------------------------------------------------------------- # 3-5: load_profile error cases # --------------------------------------------------------------------------- @@ -220,6 +279,28 @@ class TestLoadProfileErrors: with pytest.raises(ProfileValidationError, match="unknown_kind"): load_profile("unknown_kind") + def test_district_heating_yaml_float_raises_validation_error(self, tmp_path: Path) -> None: + path = tmp_path / "district_heating.yaml" + path.write_text( + """kind: district_heating +label: Bad thermal profile +variable: + heating: {unit: EUR/GJ, label: Heating, help: Enter it, minimum: 0.1} + hot_water_heating: {unit: EUR/m³, label: Heating, help: Enter it, minimum: 0} + hot_water: {unit: EUR/m³, label: Water, help: Enter it, minimum: 0} + hot_water_tax: {unit: EUR/m³, label: Tax, help: Enter it, minimum: 0} +standing: + heating_network: {unit: EUR/year, label: Network, help: Enter it, minimum: 0, default: 0} + metering: {unit: EUR/year, label: Metering, help: Enter it, minimum: 0, default: 0} + delivery_set: {unit: EUR/year, label: Set, help: Enter it, minimum: 0, default: 0} + hot_water_network: {unit: EUR/year, label: Water network, help: Enter it, minimum: 0, default: 0} + other: {unit: EUR/year, label: Other, help: Enter it, minimum: 0, default: 0} +""" + ) + with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path): + with pytest.raises(ProfileValidationError, match="float"): + load_profile("district_heating") + # --------------------------------------------------------------------------- # 6-9: validate_values @@ -409,6 +490,52 @@ class TestValidateValuesTibber: validate_values("tibber", values) +_VALID_DISTRICT_HEATING_VALUES = { + "variable": { + "heating": "12.3400", + "hot_water_heating": "1.20", + "hot_water": "2.30", + "hot_water_tax": "0.10", + }, + "standing": { + "heating_network": "11", + "metering": "12", + "delivery_set": "13", + "hot_water_network": "14", + "other": "15", + }, +} + + +class TestValidateValuesDistrictHeating: + def test_complete_values_are_decimal_string_snapshots(self) -> None: + filled = validate_values("district_heating", _VALID_DISTRICT_HEATING_VALUES) + assert filled == _VALID_DISTRICT_HEATING_VALUES + assert filled["variable"]["heating"] == "12.3400" + + def test_minimal_values_fill_all_zero_standing_fields(self) -> None: + values = {"variable": _VALID_DISTRICT_HEATING_VALUES["variable"]} + filled = validate_values("district_heating", values) + assert filled["standing"] == { + "heating_network": "0", "metering": "0", "delivery_set": "0", + "hot_water_network": "0", "other": "0", + } + + @pytest.mark.parametrize( + ("values", "match"), + [ + ({"variable": {"heating": "1"}}, "hot_water_heating"), + ({**_VALID_DISTRICT_HEATING_VALUES, "unexpected": {}}, "unknown section"), + ({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "extra": "1"}}, "unknown field"), + ({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "heating": "-1"}}, "non-negative"), + ({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "heating": 1.5}}, "Decimal-compatible"), + ], + ) + def test_rejects_missing_unknown_negative_and_float(self, values, match: str) -> None: + with pytest.raises(ProfileValidationError, match=match): + validate_values("district_heating", values) + + # --------------------------------------------------------------------------- # 10: list_profiles # --------------------------------------------------------------------------- @@ -421,11 +548,12 @@ class TestListProfiles: for item in profiles: assert isinstance(item, dict) - def test_contains_manual_and_tibber(self) -> None: + def test_contains_all_pricing_kinds(self) -> None: profiles = list_profiles() kinds = {p["kind"] for p in profiles} assert "manual" in kinds assert "tibber" in kinds + assert "district_heating" in kinds def test_each_entry_has_label(self) -> None: profiles = list_profiles()