diff --git a/app/api/routes/api/energy.py b/app/api/routes/api/energy.py index 9cdf79a..83ddc4f 100644 --- a/app/api/routes/api/energy.py +++ b/app/api/routes/api/energy.py @@ -44,7 +44,7 @@ from __future__ import annotations import logging from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Query, status from fastapi.responses import JSONResponse @@ -133,13 +133,23 @@ def _manual_tariff_from_values(values: dict[str, Any]) -> ManualTariffSchema: ) +def _electricity_prices_response(response: PricesResponse) -> JSONResponse: + """Preserve the exact pre-scope electricity response body.""" + return JSONResponse( + content=response.model_dump( + mode="json", include={"kind", "currency", "points", "tariff"} + ) + ) + + # --------------------------------------------------------------------------- # GET /api/energy/prices # --------------------------------------------------------------------------- -@router.get("/prices", response_model=PricesResponse) +@router.get("/prices", response_model=PricesResponse, response_model_exclude_none=True) def get_prices( + scope: Literal["electricity", "thermal"] = Query("electricity"), start: datetime | None = Query( default=None, description="Inclusive start of the time window (ISO 8601). " @@ -186,6 +196,17 @@ def get_prices( """ now = datetime.now(UTC) + if scope == "thermal": + version = active_contract_version_at(db, now, scope="thermal") + if version is None: + return PricesResponse(kind=None, currency="EUR", points=[], tariff=None) + return PricesResponse( + kind="district_heating", currency=version.contract.currency, + contract_version_id=version.id, effective_from=_as_utc(version.effective_from), + effective_to=_as_utc(version.effective_to) if version.effective_to else None, + values=version.values, points=[], tariff=None, + ) + # Default window: today + tomorrow. if start is None: start = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -199,12 +220,12 @@ def get_prices( version = active_contract_version_at(db, start_utc) if version is None: - return PricesResponse( + return _electricity_prices_response(PricesResponse( kind=None, currency="EUR", points=[], tariff=None, - ) + )) contract = version.contract currency = contract.currency @@ -247,30 +268,30 @@ def get_prices( ) ) - return PricesResponse( + return _electricity_prices_response(PricesResponse( kind="tibber", currency=currency, points=points, tariff=None, - ) + )) elif contract.kind == "manual": tariff = _manual_tariff_from_values(version.values or {}) - return PricesResponse( + return _electricity_prices_response(PricesResponse( kind="manual", currency=currency, points=[], tariff=tariff, - ) + )) else: # Unknown kind — return empty response gracefully. - return PricesResponse( + return _electricity_prices_response(PricesResponse( kind=contract.kind, currency=currency, points=[], tariff=None, - ) + )) # --------------------------------------------------------------------------- diff --git a/app/api/routes/api/meter_costs.py b/app/api/routes/api/meter_costs.py new file mode 100644 index 0000000..de5425e --- /dev/null +++ b/app/api/routes/api/meter_costs.py @@ -0,0 +1,150 @@ +"""Authenticated API for the thermal 15-minute cost ledger.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.dependencies import get_db +from app.models.energy import MeterCostPeriod +from app.schemas.meter_cost import ( + MeterCostPeriodSchema, + MeterCostRecomputeResponse, + MeterCostsResponse, + ThermalCostSummaryResponse, +) +from app.services.auth import AuthenticatedSession +from app.services.meter_cost import recompute_range, summarize +from app.services.timezone import local_midnight_utc, local_now + +router = APIRouter(prefix="/api/energy/meter-costs", tags=["api-energy"]) + +_LIMIT_MAX = 5000 +_RECOMPUTE_MAX_DAYS = 31 +_QUARTER = timedelta(minutes=15) + + +def _utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _decimal_strings(value: object) -> object: + if isinstance(value, dict): + return {str(key): _decimal_strings(item) for key, item in value.items()} + if isinstance(value, Decimal): + return format(value, "f") + return str(value) if isinstance(value, (int, float)) else value + + +def _row_schema(row: MeterCostPeriod) -> MeterCostPeriodSchema: + return MeterCostPeriodSchema( + commodity=row.commodity, + period_start=_utc(row.period_start), period_end=_utc(row.period_end), + meter_id=row.meter_id, source_binding_id=row.source_binding_id, + contract_version_id=row.contract_version_id, quantity=format(row.quantity, "f"), + cost=format(row.cost, "f"), currency=row.currency, + cost_breakdown=_decimal_strings(row.cost_breakdown), + pricing_snapshot=_decimal_strings(row.pricing_snapshot), quality=row.quality, + degraded=row.degraded, degraded_reason=row.degraded_reason, + ) + + +@router.get("", response_model=MeterCostsResponse) +def get_meter_costs( + scope: Literal["thermal"] = Query("thermal"), + commodity: Literal["heating", "hot_water"] | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int = Query(500, ge=1, le=_LIMIT_MAX), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> MeterCostsResponse: + """List thermal rows in a half-open time window with stable pagination.""" + del scope + if start is not None and end is not None and _utc(end) <= _utc(start): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "'end' must be after 'start'.") + clauses = [] + if commodity is not None: + clauses.append(MeterCostPeriod.commodity == commodity) + if start is not None: + clauses.append(MeterCostPeriod.period_start >= _utc(start)) + if end is not None: + clauses.append(MeterCostPeriod.period_start < _utc(end)) + total = db.scalar(select(func.count()).select_from(MeterCostPeriod).where(*clauses)) or 0 + rows = db.execute( + select(MeterCostPeriod).where(*clauses).order_by(MeterCostPeriod.period_start, MeterCostPeriod.id) + .offset(offset).limit(limit) + ).scalars().all() + return MeterCostsResponse(items=[_row_schema(row) for row in rows], total=total) + + +@router.get("/summary", response_model=ThermalCostSummaryResponse) +def get_meter_cost_summary( + scope: Literal["thermal"] = Query("thermal"), + start: datetime | None = None, + end: datetime | None = None, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> ThermalCostSummaryResponse: + """Summarize thermal variable and once-per-contract daily fixed costs.""" + del scope + if start is None or end is None: + today = local_now().date() + start = start or local_midnight_utc(today) + end = end or local_midnight_utc(today + timedelta(days=1)) + start, end = _utc(start), _utc(end) + if end <= start: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "'end' must be after 'start'.") + result = summarize(db, start, end) + breakdown = result["breakdown"] + fixed_breakdown = result["fixed_breakdown"] + fixed = result["fixed_cost"] + return ThermalCostSummaryResponse( + currency=result["currency"], heating=format(breakdown["heating"], "f"), + hot_water_heating=format(breakdown["hot_water_heating"], "f"), + hot_water=format(breakdown["hot_water"], "f"), hot_water_tax=format(breakdown["hot_water_tax"], "f"), + variable_subtotal=format(result["variable_cost"], "f"), + fixed_breakdown={key: format(value, "f") for key, value in fixed_breakdown.items()}, + fixed_subtotal=format(fixed, "f"), + all_in=format(result["total_cost"], "f"), period_count=result["period_count"], + degraded_count=result["degraded_count"], + ) + + +@router.post("/recompute", response_model=MeterCostRecomputeResponse) +def post_meter_cost_recompute( + scope: Literal["thermal"] = Query("thermal"), + start: datetime = Query(...), end: datetime = Query(...), + db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> MeterCostRecomputeResponse: + """Atomically overwrite closed, UTC-quarter thermal rows in a bounded window.""" + del scope + start, end = _utc(start), _utc(end) + if end <= start or end - start > timedelta(days=_RECOMPUTE_MAX_DAYS): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid or overlarge recompute window") + if start.minute % 15 or start.second or start.microsecond or end.minute % 15 or end.second or end.microsecond: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "start and end must align to UTC quarters") + if end > datetime.now(UTC): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "recompute window must be closed") + try: + processed = recompute_range(db, start, end, commit=False) + # Ensure pending upserts participate in this transaction before the + # counts are read; a flush/query failure must still roll everything back. + db.flush() + rows = db.execute(select(MeterCostPeriod.degraded).where( + MeterCostPeriod.period_start >= start, MeterCostPeriod.period_start < end + )).scalars().all() + degraded = sum(bool(value) for value in rows) + db.commit() + except Exception: + db.rollback() + raise + return MeterCostRecomputeResponse(processed=processed, normal=len(rows) - degraded, degraded=degraded) diff --git a/app/main.py b/app/main.py index aeb6edd..801d882 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from app.api.routes.api.config import router as api_config_router from app.api.routes.api.data import router as api_data_router from app.api.routes.api.energy import router as api_energy_router from app.api.routes.api.energy_contracts import router as api_energy_contracts_router +from app.api.routes.api.meter_costs import router as api_meter_costs_router from app.api.routes.api.expose import router as api_expose_router from app.api.routes.api.meters import router as api_meters_router from app.api.routes.api.meter_sources import router as api_meter_sources_router @@ -343,6 +344,7 @@ def create_app() -> FastAPI: app.include_router(api_data_router) app.include_router(api_energy_router) app.include_router(api_energy_contracts_router) + app.include_router(api_meter_costs_router) app.include_router(api_meters_router) app.include_router(api_meter_sources_router) app.include_router(api_expose_router) diff --git a/app/schemas/energy.py b/app/schemas/energy.py index c8775db..8a371d8 100644 --- a/app/schemas/energy.py +++ b/app/schemas/energy.py @@ -76,6 +76,24 @@ class PricesResponse(BaseModel): "Null for tibber contracts and when no active contract exists." ), ) + contract_version_id: int | None = Field( + default=None, + description="Thermal active contract version identifier; omitted for electricity.", + ) + effective_from: datetime | None = Field( + default=None, + description="Thermal contract version start; omitted for electricity.", + ) + effective_to: datetime | None = Field( + default=None, + description="Thermal contract version end; omitted for electricity.", + ) + values: dict[str, dict[str, str]] | None = Field( + default=None, + description="Thermal normalized Decimal-string contract values; omitted for electricity.", + ) + + model_config = {"ser_json_exclude_none": True} # --------------------------------------------------------------------------- diff --git a/app/schemas/meter_cost.py b/app/schemas/meter_cost.py new file mode 100644 index 0000000..af07d68 --- /dev/null +++ b/app/schemas/meter_cost.py @@ -0,0 +1,62 @@ +"""Schemas for the commodity-scoped thermal cost ledger.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +class MeterCostPeriodSchema(BaseModel): + """One auditable thermal ledger row; all Decimal values are JSON strings.""" + + commodity: Literal["heating", "hot_water"] + period_start: datetime + period_end: datetime + meter_id: int | None + source_binding_id: int | None + contract_version_id: int | None + quantity: str + cost: str + currency: str + cost_breakdown: dict[str, str] + pricing_snapshot: dict[str, dict[str, str]] + quality: str + degraded: bool + degraded_reason: str | None + + +class MeterCostsResponse(BaseModel): + items: list[MeterCostPeriodSchema] + total: int = Field(description="Total matching rows before pagination.") + + +class ThermalFixedBreakdown(BaseModel): + """D11 annual-standing charges accrued per settled local day, as Decimal strings.""" + + heating_network: str + metering: str + delivery_set: str + hot_water_network: str + other: str + + +class ThermalCostSummaryResponse(BaseModel): + currency: str + heating: str + hot_water_heating: str + hot_water: str + hot_water_tax: str + variable_subtotal: str + fixed_breakdown: ThermalFixedBreakdown + fixed_subtotal: str + all_in: str + period_count: int + degraded_count: int + + +class MeterCostRecomputeResponse(BaseModel): + processed: int + normal: int + degraded: int diff --git a/app/services/meter_cost.py b/app/services/meter_cost.py index 010ce87..ddddcb0 100644 --- a/app/services/meter_cost.py +++ b/app/services/meter_cost.py @@ -220,7 +220,14 @@ def compute_closed_periods(session: Session, *, now: datetime | None = None) -> return written -def recompute_range(session: Session, start: datetime, end: datetime) -> int: +def recompute_range(session: Session, start: datetime, end: datetime, *, commit: bool = True) -> int: + """Recompute a thermal range. + + The historical service entry point remains self-committing for the scheduler + and direct callers. HTTP callers pass ``commit=False`` so validation, + recomputation, response statistics, and the single commit share one + transaction owned by the route. + """ cursor, end = floor_to_quarter(_utc(start)), _utc(end) now, written = datetime.now(UTC), 0 while cursor < end: @@ -229,7 +236,8 @@ def recompute_range(session: Session, start: datetime, end: datetime) -> int: if compute_period(session, commodity, cursor, overwrite=True): written += 1 cursor += _PERIOD - session.commit() + if commit: + session.commit() return written @@ -257,7 +265,9 @@ def summarize(session: Session, start: datetime, end: datetime, *, now: datetime final_day = timezone_service.local_date(end - timedelta(microseconds=1)) final_day = min(final_day, _settled_end_date(now or datetime.now(UTC))) day = timezone_service.local_date(start) - fixed = Decimal("0") + fixed_breakdown: dict[str, Decimal] = {key: Decimal("0") for key in ( + "heating_network", "metering", "delivery_set", "hot_water_network", "other" + )} versions = active_contract_versions(session, scope="thermal") while start < end and day <= final_day: day_start = datetime.combine(day, time.min, tzinfo=timezone_service.local_tz()).astimezone(UTC) @@ -275,14 +285,14 @@ def summarize(session: Session, start: datetime, end: datetime, *, now: datetime if segment_start >= segment_end: continue values = version.values["standing"] - annual = sum((_decimal(values.get(key, "0")) for key in ( - "heating_network", "metering", "delivery_set", "hot_water_network", "other" - )), Decimal("0")) fraction = Decimal(str((segment_end - segment_start).total_seconds())) / local_day_seconds - fixed += annual / Decimal("365") * fraction + for key in fixed_breakdown: + fixed_breakdown[key] += _decimal(values.get(key, "0")) / Decimal("365") * fraction day += timedelta(days=1) + fixed = sum(fixed_breakdown.values(), Decimal("0")) return { "currency": good[0].currency if good else "EUR", "variable_cost": variable, - "fixed_cost": fixed, "total_cost": variable + fixed, "breakdown": breakdown, + "fixed_cost": fixed, "fixed_breakdown": fixed_breakdown, + "total_cost": variable + fixed, "breakdown": breakdown, "period_count": len(good), "degraded_count": len(rows) - len(good), } diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index afbdc5c..6de386f 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -891,7 +891,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 ### M8-T16 — Thermal Prices / Costs / Summary / Recompute API -- **Status**: `todo` +- **Status**: `done` - **Depends**: M8-T15 - **Context**: 对外提供 scope-aware 价格和新的热力账本,不破坏既有 electricity 路由响应。 @@ -900,6 +900,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - `create app/api/routes/api/meter_costs.py` - `modify app/schemas/energy.py` - `modify app/api/routes/api/energy.py` +- `modify app/services/meter_cost.py` - `modify app/main.py` - `create tests/test_meter_cost_api.py` - `modify tests/test_api_energy.py` diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 0990a24..7c8afa6 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -559,6 +559,66 @@ export interface paths { patch?: never; trace?: never; }; + "/api/energy/meter-costs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Meter Costs + * @description List thermal rows in a half-open time window with stable pagination. + */ + get: operations["get_meter_costs_api_energy_meter_costs_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/meter-costs/summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Meter Cost Summary + * @description Summarize thermal variable and once-per-contract daily fixed costs. + */ + get: operations["get_meter_cost_summary_api_energy_meter_costs_summary_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/meter-costs/recompute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Meter Cost Recompute + * @description Atomically overwrite closed, UTC-quarter thermal rows in a bounded window. + */ + post: operations["post_meter_cost_recompute_api_energy_meter_costs_recompute_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/energy/meters": { parameters: { query?: never; @@ -1962,6 +2022,74 @@ export interface components { /** Ended At */ ended_at: string | null; }; + /** + * MeterCostPeriodSchema + * @description One auditable thermal ledger row; all Decimal values are JSON strings. + */ + MeterCostPeriodSchema: { + /** + * Commodity + * @enum {string} + */ + commodity: "heating" | "hot_water"; + /** + * Period Start + * Format: date-time + */ + period_start: string; + /** + * Period End + * Format: date-time + */ + period_end: string; + /** Meter Id */ + meter_id: number | null; + /** Source Binding Id */ + source_binding_id: number | null; + /** Contract Version Id */ + contract_version_id: number | null; + /** Quantity */ + quantity: string; + /** Cost */ + cost: string; + /** Currency */ + currency: string; + /** Cost Breakdown */ + cost_breakdown: { + [key: string]: string; + }; + /** Pricing Snapshot */ + pricing_snapshot: { + [key: string]: { + [key: string]: string; + }; + }; + /** Quality */ + quality: string; + /** Degraded */ + degraded: boolean; + /** Degraded Reason */ + degraded_reason: string | null; + }; + /** MeterCostRecomputeResponse */ + MeterCostRecomputeResponse: { + /** Processed */ + processed: number; + /** Normal */ + normal: number; + /** Degraded */ + degraded: number; + }; + /** MeterCostsResponse */ + MeterCostsResponse: { + /** Items */ + items: components["schemas"]["MeterCostPeriodSchema"][]; + /** + * Total + * @description Total matching rows before pagination. + */ + total: number; + }; /** * MeterDeclareRequest * @description Request body for POST /api/energy/meters. @@ -2493,6 +2621,30 @@ export interface components { points: components["schemas"]["PricePointSchema"][]; /** @description Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists. */ tariff?: components["schemas"]["ManualTariffSchema"] | null; + /** + * Contract Version Id + * @description Thermal active contract version identifier; omitted for electricity. + */ + contract_version_id?: number | null; + /** + * Effective From + * @description Thermal contract version start; omitted for electricity. + */ + effective_from?: string | null; + /** + * Effective To + * @description Thermal contract version end; omitted for electricity. + */ + effective_to?: string | null; + /** + * Values + * @description Thermal normalized Decimal-string contract values; omitted for electricity. + */ + values?: { + [key: string]: { + [key: string]: string; + }; + } | null; }; /** * ProfileSummary @@ -2737,6 +2889,46 @@ export interface components { */ days: number; }; + /** ThermalCostSummaryResponse */ + ThermalCostSummaryResponse: { + /** Currency */ + currency: string; + /** Heating */ + heating: string; + /** Hot Water Heating */ + hot_water_heating: string; + /** Hot Water */ + hot_water: string; + /** Hot Water Tax */ + hot_water_tax: string; + /** Variable Subtotal */ + variable_subtotal: string; + fixed_breakdown: components["schemas"]["ThermalFixedBreakdown"]; + /** Fixed Subtotal */ + fixed_subtotal: string; + /** All In */ + all_in: string; + /** Period Count */ + period_count: number; + /** Degraded Count */ + degraded_count: number; + }; + /** + * ThermalFixedBreakdown + * @description D11 annual-standing charges accrued per settled local day, as Decimal strings. + */ + ThermalFixedBreakdown: { + /** Heating Network */ + heating_network: string; + /** Metering */ + metering: string; + /** Delivery Set */ + delivery_set: string; + /** Hot Water Network */ + hot_water_network: string; + /** Other */ + other: string; + }; /** * TibberTestPriceSchema * @description Current Tibber price point returned on a successful test. @@ -3272,6 +3464,7 @@ export interface operations { get_prices_api_energy_prices_get: { parameters: { query?: { + scope?: "electricity" | "thermal"; /** @description Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted. */ start?: string | null; /** @description Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted. */ @@ -3669,6 +3862,110 @@ export interface operations { }; }; }; + get_meter_costs_api_energy_meter_costs_get: { + parameters: { + query?: { + scope?: "thermal"; + commodity?: ("heating" | "hot_water") | null; + start?: string | null; + end?: string | null; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterCostsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_meter_cost_summary_api_energy_meter_costs_summary_get: { + parameters: { + query?: { + scope?: "thermal"; + start?: string | null; + end?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ThermalCostSummaryResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_meter_cost_recompute_api_energy_meter_costs_recompute_post: { + parameters: { + query: { + scope?: "thermal"; + start: string; + end: string; + }; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterCostRecomputeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_energy_meters_api_energy_meters_get: { parameters: { query?: never; diff --git a/openapi/openapi.json b/openapi/openapi.json index 2705bc6..4b41874 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -704,6 +704,20 @@ "description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).", "operationId": "get_prices_api_energy_prices_get", "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "enum": [ + "electricity", + "thermal" + ], + "type": "string", + "default": "electricity", + "title": "Scope" + } + }, { "name": "start", "in": "query", @@ -1401,6 +1415,288 @@ } } }, + "/api/energy/meter-costs": { + "get": { + "tags": [ + "api-energy" + ], + "summary": "Get Meter Costs", + "description": "List thermal rows in a half-open time window with stable pagination.", + "operationId": "get_meter_costs_api_energy_meter_costs_get", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "const": "thermal", + "type": "string", + "default": "thermal", + "title": "Scope" + } + }, + { + "name": "commodity", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "heating", + "hot_water" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Commodity" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 5000, + "minimum": 1, + "default": 500, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterCostsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/meter-costs/summary": { + "get": { + "tags": [ + "api-energy" + ], + "summary": "Get Meter Cost Summary", + "description": "Summarize thermal variable and once-per-contract daily fixed costs.", + "operationId": "get_meter_cost_summary_api_energy_meter_costs_summary_get", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "const": "thermal", + "type": "string", + "default": "thermal", + "title": "Scope" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThermalCostSummaryResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/meter-costs/recompute": { + "post": { + "tags": [ + "api-energy" + ], + "summary": "Post Meter Cost Recompute", + "description": "Atomically overwrite closed, UTC-quarter thermal rows in a bounded window.", + "operationId": "post_meter_cost_recompute_api_energy_meter_costs_recompute_post", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "const": "thermal", + "type": "string", + "default": "thermal", + "title": "Scope" + } + }, + { + "name": "start", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time", + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time", + "title": "End" + } + }, + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterCostRecomputeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/energy/meters": { "get": { "tags": [ @@ -4611,6 +4907,173 @@ "title": "MeterBindingSummary", "description": "Stable, non-sensitive binding identity embedded in meter responses." }, + "MeterCostPeriodSchema": { + "properties": { + "commodity": { + "type": "string", + "enum": [ + "heating", + "hot_water" + ], + "title": "Commodity" + }, + "period_start": { + "type": "string", + "format": "date-time", + "title": "Period Start" + }, + "period_end": { + "type": "string", + "format": "date-time", + "title": "Period End" + }, + "meter_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Meter Id" + }, + "source_binding_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Source Binding Id" + }, + "contract_version_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Contract Version Id" + }, + "quantity": { + "type": "string", + "title": "Quantity" + }, + "cost": { + "type": "string", + "title": "Cost" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "cost_breakdown": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Cost Breakdown" + }, + "pricing_snapshot": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object", + "title": "Pricing Snapshot" + }, + "quality": { + "type": "string", + "title": "Quality" + }, + "degraded": { + "type": "boolean", + "title": "Degraded" + }, + "degraded_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Degraded Reason" + } + }, + "type": "object", + "required": [ + "commodity", + "period_start", + "period_end", + "meter_id", + "source_binding_id", + "contract_version_id", + "quantity", + "cost", + "currency", + "cost_breakdown", + "pricing_snapshot", + "quality", + "degraded", + "degraded_reason" + ], + "title": "MeterCostPeriodSchema", + "description": "One auditable thermal ledger row; all Decimal values are JSON strings." + }, + "MeterCostRecomputeResponse": { + "properties": { + "processed": { + "type": "integer", + "title": "Processed" + }, + "normal": { + "type": "integer", + "title": "Normal" + }, + "degraded": { + "type": "integer", + "title": "Degraded" + } + }, + "type": "object", + "required": [ + "processed", + "normal", + "degraded" + ], + "title": "MeterCostRecomputeResponse" + }, + "MeterCostsResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MeterCostPeriodSchema" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Total matching rows before pagination." + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "MeterCostsResponse" + }, "MeterDeclareRequest": { "properties": { "label": { @@ -5830,6 +6293,62 @@ } ], "description": "Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists." + }, + "contract_version_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Contract Version Id", + "description": "Thermal active contract version identifier; omitted for electricity." + }, + "effective_from": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Effective From", + "description": "Thermal contract version start; omitted for electricity." + }, + "effective_to": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Effective To", + "description": "Thermal contract version end; omitted for electricity." + }, + "values": { + "anyOf": [ + { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Values", + "description": "Thermal normalized Decimal-string contract values; omitted for electricity." } }, "type": "object", @@ -6336,6 +6855,102 @@ "title": "SummaryResponse", "description": "Response for GET /api/energy/costs/summary.\n\nMonetary values are in ``currency``; the ``*_kwh`` fields are energy totals\nin kWh. ``metered_import``/``metered_export`` are **money**, not energy —\nonly the ``_kwh``-suffixed fields carry kWh.\n\n``total_payable = metered_net + fixed_costs − credits``" }, + "ThermalCostSummaryResponse": { + "properties": { + "currency": { + "type": "string", + "title": "Currency" + }, + "heating": { + "type": "string", + "title": "Heating" + }, + "hot_water_heating": { + "type": "string", + "title": "Hot Water Heating" + }, + "hot_water": { + "type": "string", + "title": "Hot Water" + }, + "hot_water_tax": { + "type": "string", + "title": "Hot Water Tax" + }, + "variable_subtotal": { + "type": "string", + "title": "Variable Subtotal" + }, + "fixed_breakdown": { + "$ref": "#/components/schemas/ThermalFixedBreakdown" + }, + "fixed_subtotal": { + "type": "string", + "title": "Fixed Subtotal" + }, + "all_in": { + "type": "string", + "title": "All In" + }, + "period_count": { + "type": "integer", + "title": "Period Count" + }, + "degraded_count": { + "type": "integer", + "title": "Degraded Count" + } + }, + "type": "object", + "required": [ + "currency", + "heating", + "hot_water_heating", + "hot_water", + "hot_water_tax", + "variable_subtotal", + "fixed_breakdown", + "fixed_subtotal", + "all_in", + "period_count", + "degraded_count" + ], + "title": "ThermalCostSummaryResponse" + }, + "ThermalFixedBreakdown": { + "properties": { + "heating_network": { + "type": "string", + "title": "Heating Network" + }, + "metering": { + "type": "string", + "title": "Metering" + }, + "delivery_set": { + "type": "string", + "title": "Delivery Set" + }, + "hot_water_network": { + "type": "string", + "title": "Hot Water Network" + }, + "other": { + "type": "string", + "title": "Other" + } + }, + "type": "object", + "required": [ + "heating_network", + "metering", + "delivery_set", + "hot_water_network", + "other" + ], + "title": "ThermalFixedBreakdown", + "description": "D11 annual-standing charges accrued per settled local day, as Decimal strings." + }, "TibberTestPriceSchema": { "properties": { "starts_at": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e5b0029..60ea221 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -538,6 +538,16 @@ paths: EUR\", points=[], tariff=null (200)." operationId: get_prices_api_energy_prices_get parameters: + - name: scope + in: query + required: false + schema: + enum: + - electricity + - thermal + type: string + default: electricity + title: Scope - name: start in: query required: false @@ -1092,6 +1102,180 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/energy/meter-costs: + get: + tags: + - api-energy + summary: Get Meter Costs + description: List thermal rows in a half-open time window with stable pagination. + operationId: get_meter_costs_api_energy_meter_costs_get + parameters: + - name: scope + in: query + required: false + schema: + const: thermal + type: string + default: thermal + title: Scope + - name: commodity + in: query + required: false + schema: + anyOf: + - enum: + - heating + - hot_water + type: string + - type: 'null' + title: Commodity + - name: start + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start + - name: end + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End + - name: limit + in: query + required: false + schema: + type: integer + maximum: 5000 + minimum: 1 + default: 500 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterCostsResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/meter-costs/summary: + get: + tags: + - api-energy + summary: Get Meter Cost Summary + description: Summarize thermal variable and once-per-contract daily fixed costs. + operationId: get_meter_cost_summary_api_energy_meter_costs_summary_get + parameters: + - name: scope + in: query + required: false + schema: + const: thermal + type: string + default: thermal + title: Scope + - name: start + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start + - name: end + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ThermalCostSummaryResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/meter-costs/recompute: + post: + tags: + - api-energy + summary: Post Meter Cost Recompute + description: Atomically overwrite closed, UTC-quarter thermal rows in a bounded + window. + operationId: post_meter_cost_recompute_api_energy_meter_costs_recompute_post + parameters: + - name: scope + in: query + required: false + schema: + const: thermal + type: string + default: thermal + title: Scope + - name: start + in: query + required: true + schema: + type: string + format: date-time + title: Start + - name: end + in: query + required: true + schema: + type: string + format: date-time + title: End + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterCostRecomputeResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/energy/meters: get: tags: @@ -3434,6 +3618,120 @@ components: - ended_at title: MeterBindingSummary description: Stable, non-sensitive binding identity embedded in meter responses. + MeterCostPeriodSchema: + properties: + commodity: + type: string + enum: + - heating + - hot_water + title: Commodity + period_start: + type: string + format: date-time + title: Period Start + period_end: + type: string + format: date-time + title: Period End + meter_id: + anyOf: + - type: integer + - type: 'null' + title: Meter Id + source_binding_id: + anyOf: + - type: integer + - type: 'null' + title: Source Binding Id + contract_version_id: + anyOf: + - type: integer + - type: 'null' + title: Contract Version Id + quantity: + type: string + title: Quantity + cost: + type: string + title: Cost + currency: + type: string + title: Currency + cost_breakdown: + additionalProperties: + type: string + type: object + title: Cost Breakdown + pricing_snapshot: + additionalProperties: + additionalProperties: + type: string + type: object + type: object + title: Pricing Snapshot + quality: + type: string + title: Quality + degraded: + type: boolean + title: Degraded + degraded_reason: + anyOf: + - type: string + - type: 'null' + title: Degraded Reason + type: object + required: + - commodity + - period_start + - period_end + - meter_id + - source_binding_id + - contract_version_id + - quantity + - cost + - currency + - cost_breakdown + - pricing_snapshot + - quality + - degraded + - degraded_reason + title: MeterCostPeriodSchema + description: One auditable thermal ledger row; all Decimal values are JSON strings. + MeterCostRecomputeResponse: + properties: + processed: + type: integer + title: Processed + normal: + type: integer + title: Normal + degraded: + type: integer + title: Degraded + type: object + required: + - processed + - normal + - degraded + title: MeterCostRecomputeResponse + MeterCostsResponse: + properties: + items: + items: + $ref: '#/components/schemas/MeterCostPeriodSchema' + type: array + title: Items + total: + type: integer + title: Total + description: Total matching rows before pagination. + type: object + required: + - items + - total + title: MeterCostsResponse MeterDeclareRequest: properties: label: @@ -4310,6 +4608,37 @@ components: - type: 'null' description: Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists. + contract_version_id: + anyOf: + - type: integer + - type: 'null' + title: Contract Version Id + description: Thermal active contract version identifier; omitted for electricity. + effective_from: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Effective From + description: Thermal contract version start; omitted for electricity. + effective_to: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Effective To + description: Thermal contract version end; omitted for electricity. + values: + anyOf: + - additionalProperties: + additionalProperties: + type: string + type: object + type: object + - type: 'null' + title: Values + description: Thermal normalized Decimal-string contract values; omitted + for electricity. type: object required: - kind @@ -4710,6 +5039,81 @@ components: ``total_payable = metered_net + fixed_costs − credits``' + ThermalCostSummaryResponse: + properties: + currency: + type: string + title: Currency + heating: + type: string + title: Heating + hot_water_heating: + type: string + title: Hot Water Heating + hot_water: + type: string + title: Hot Water + hot_water_tax: + type: string + title: Hot Water Tax + variable_subtotal: + type: string + title: Variable Subtotal + fixed_breakdown: + $ref: '#/components/schemas/ThermalFixedBreakdown' + fixed_subtotal: + type: string + title: Fixed Subtotal + all_in: + type: string + title: All In + period_count: + type: integer + title: Period Count + degraded_count: + type: integer + title: Degraded Count + type: object + required: + - currency + - heating + - hot_water_heating + - hot_water + - hot_water_tax + - variable_subtotal + - fixed_breakdown + - fixed_subtotal + - all_in + - period_count + - degraded_count + title: ThermalCostSummaryResponse + ThermalFixedBreakdown: + properties: + heating_network: + type: string + title: Heating Network + metering: + type: string + title: Metering + delivery_set: + type: string + title: Delivery Set + hot_water_network: + type: string + title: Hot Water Network + other: + type: string + title: Other + type: object + required: + - heating_network + - metering + - delivery_set + - hot_water_network + - other + title: ThermalFixedBreakdown + description: D11 annual-standing charges accrued per settled local day, as Decimal + strings. TibberTestPriceSchema: properties: starts_at: diff --git a/tests/test_api_energy.py b/tests/test_api_energy.py index c87a302..2154412 100644 --- a/tests/test_api_energy.py +++ b/tests/test_api_energy.py @@ -304,6 +304,35 @@ def test_prices_no_contract_returns_empty(energy_client): assert body["points"] == [] assert body["tariff"] is None assert "currency" in body + assert client.get("/api/energy/prices?scope=electricity").json() == body + + +def test_prices_thermal_returns_active_contract_snapshot(energy_client): + client, engine, _app = energy_client + now = datetime.now(UTC) + values = { + "variable": {"heating": "20.123456", "hot_water_heating": "4", "hot_water": "2", "hot_water_tax": "1"}, + "standing": {"heating_network": "0", "metering": "0", "delivery_set": "0", "hot_water_network": "0", "other": "0"}, + } + with Session(engine) as session: + contract = EnergyContract(name="thermal", kind="district_heating", scope="thermal", active=True, + currency="EUR", created_at=now, updated_at=now) + session.add(contract) + session.flush() + session.add(EnergyContractVersion(contract_id=contract.id, effective_from=now - timedelta(days=1), + values=values, created_at=now)) + session.commit() + _login(client) + response = client.get("/api/energy/prices?scope=thermal") + assert response.status_code == 200 + body = response.json() + assert body == { + "kind": "district_heating", "currency": "EUR", "points": [], + "contract_version_id": body["contract_version_id"], + "effective_from": body["effective_from"], "values": values, + } + assert client.get("/api/energy/prices").json()["points"] == [] + assert client.get("/api/energy/prices?scope=invalid").status_code == 422 # --------------------------------------------------------------------------- @@ -334,6 +363,7 @@ def test_prices_manual_contract_returns_tariff(energy_client): assert abs(tariff["sell_dal"] - 0.10) < 1e-6 # sell_normal = 0.10 assert abs(tariff["sell_normal"] - 0.10) < 1e-6 + assert client.get("/api/energy/prices?scope=electricity").json() == body # --------------------------------------------------------------------------- @@ -369,6 +399,7 @@ def test_prices_tibber_contract_returns_points(energy_client): assert abs(p["buy"] - 0.245) < 1e-6 assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4 assert p["level"] == "NORMAL" + assert client.get("/api/energy/prices", params={"scope": "electricity", "start": start, "end": end}).json() == body def test_prices_tibber_sell_reflects_sell_fee(energy_client): diff --git a/tests/test_meter_cost_api.py b/tests/test_meter_cost_api.py new file mode 100644 index 0000000..ab461a9 --- /dev/null +++ b/tests/test_meter_cost_api.py @@ -0,0 +1,213 @@ +"""API coverage for the thermal meter-cost ledger (M8-T16).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from app.models.energy import EnergyContract, EnergyContractVersion, Meter, MeterCostPeriod +from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel + +_CSRF = "test-csrf-token" +_T0 = datetime(2026, 6, 23, 10, tzinfo=UTC) + + +def _login(client: TestClient) -> str: + response = client.post("/api/auth/login", json={"username": "admin", "password": "test-password"}) + assert response.status_code == 200 + return response.json()["csrf_token"] + + +@pytest.fixture() +def meter_cost_client(auth_database): + from app.main import create_app + + engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False}) + with TestClient(create_app()) as client: + yield client, engine + engine.dispose() + + +def _row(start: datetime, *, commodity: str = "heating", degraded: bool = False) -> MeterCostPeriod: + return MeterCostPeriod( + commodity=commodity, period_start=start, period_end=start + timedelta(minutes=15), + meter_id=None, source_binding_id=None, contract_version_id=None, + quantity=Decimal("0"), cost=Decimal("0") if degraded else Decimal("1.250000000"), + currency="EUR", cost_breakdown={} if degraded else {"heating": "1.250000000"}, + pricing_snapshot={}, quality="invalid" if degraded else "valid", degraded=degraded, + degraded_reason="missing_contract" if degraded else None, created_at=_T0, updated_at=_T0, + ) + + +def _normal_ids(db: Session, contract_version_id: int) -> tuple[int, int, int]: + now = datetime.now(UTC) + meter = Meter(label="heating", commodity="heating", started_at=_T0 - timedelta(days=1), + reason="initial", created_at=now) + source = MeterSource(name="source", kind="warmtelink_serial", enabled=True, config={}, status="online", + created_at=now, updated_at=now) + db.add_all((meter, source)) + db.flush() + channel = MeterSourceChannel(source_id=source.id, channel_key="heating", label="heating", + suggested_commodity="heating", unit="GJ", latest_quality="valid", + created_at=now, updated_at=now) + db.add(channel) + db.flush() + binding = MeterSourceBinding(meter_id=meter.id, channel_id=channel.id, started_at=meter.started_at, + created_at=now, updated_at=now) + db.add(binding) + db.flush() + return meter.id, binding.id, contract_version_id + + +def test_meter_costs_require_auth_and_paginate_decimal_rows(meter_cost_client) -> None: + client, engine = meter_cost_client + assert client.get("/api/energy/meter-costs?scope=thermal").status_code == 401 + with Session(engine) as db: + db.add_all([_row(_T0, degraded=True), _row(_T0 + timedelta(minutes=15), degraded=True)]) + db.commit() + _login(client) + response = client.get("/api/energy/meter-costs?scope=thermal&limit=1&offset=1") + assert response.status_code == 200 + body = response.json() + assert body["total"] == 2 and len(body["items"]) == 1 + assert body["items"][0]["cost"] == "0.000000000" + assert body["items"][0]["degraded_reason"] == "missing_contract" + assert client.get("/api/energy/meter-costs?scope=electricity").status_code == 422 + + +def test_meter_costs_half_open_stable_pagination_and_deep_decimal_audit(meter_cost_client) -> None: + client, engine = meter_cost_client + with Session(engine) as db: + normal = _row(_T0, degraded=True) + normal.quantity = Decimal("0.050000") + normal.cost = Decimal("1.123456789") + normal.cost_breakdown = {"heating": Decimal("1.123456789")} + normal.pricing_snapshot = {"variable": {"heating": Decimal("22.46913578")}} + water = _row(_T0, commodity="hot_water", degraded=True) + later = _row(_T0 + timedelta(minutes=15), degraded=True) + db.add_all((normal, water, later)) + db.commit() + _login(client) + base = "/api/energy/meter-costs?scope=thermal&start=2026-06-23T10:00:00Z&end=2026-06-23T10:15:00Z" + response = client.get(base + "&limit=1&offset=0") + assert response.status_code == 200 + assert response.json()["total"] == 2 + item = response.json()["items"][0] + assert item["commodity"] == "heating" + assert item["quantity"] == "0.050000" + assert item["cost_breakdown"] == {"heating": "1.123456789"} + assert item["pricing_snapshot"]["variable"]["heating"] == "22.46913578" + assert client.get(base + "&commodity=hot_water").json()["items"][0]["commodity"] == "hot_water" + # Tie-breaking includes id, so the second page deterministically returns water. + assert client.get(base + "&limit=1&offset=1").json()["items"][0]["commodity"] == "hot_water" + assert client.get(base + "&offset=2").json()["items"] == [] + + +def test_meter_cost_summary_empty_and_recompute_csrf_window_validation(meter_cost_client) -> None: + client, _engine = meter_cost_client + csrf = _login(client) + summary = client.get( + "/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z" + ) + assert summary.status_code == 200 + assert summary.json()["all_in"] == "0" + assert summary.json()["fixed_breakdown"] == { + "heating_network": "0", "metering": "0", "delivery_set": "0", + "hot_water_network": "0", "other": "0", + } + url = "/api/energy/meter-costs/recompute?scope=thermal&start=2026-06-23T10:01:00Z&end=2026-06-23T10:15:00Z" + assert client.post(url).status_code == 403 + assert client.post(url, headers={"X-CSRF-Token": _CSRF}).status_code == 422 + overlarge = ( + "/api/energy/meter-costs/recompute?scope=thermal&start=2026-01-01T00:00:00Z" + "&end=2026-02-02T00:00:00Z" + ) + assert client.post(overlarge, headers={"X-CSRF-Token": _CSRF}).status_code == 422 + assert client.post( + "/api/energy/meter-costs/recompute?scope=thermal&start=2026-06-23T10:15:00Z&end=2026-06-23T10:00:00Z", + headers={"X-CSRF-Token": csrf}, + ).status_code == 422 + assert client.post( + "/api/energy/meter-costs/recompute?scope=electricity&start=2026-06-23T10:00:00Z&end=2026-06-23T10:15:00Z", + headers={"X-CSRF-Token": csrf}, + ).status_code == 422 + + +def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_client) -> None: + client, engine = meter_cost_client + now = datetime.now(UTC) + values = { + "variable": {"heating": "20", "hot_water_heating": "4", "hot_water": "2", "hot_water_tax": "1"}, + "standing": {"heating_network": "365", "metering": "73", "delivery_set": "0", "hot_water_network": "0", "other": "0"}, + } + with Session(engine) as db: + contract = EnergyContract(name="thermal", kind="district_heating", scope="thermal", active=True, + currency="EUR", created_at=now, updated_at=now) + db.add(contract) + db.flush() + db.add(EnergyContractVersion(contract_id=contract.id, effective_from=_T0 - timedelta(days=2), + values=values, created_at=now)) + db.flush() # Allocate the contract-version id before normal ledger fixtures. + meter_id, binding_id, version_id = _normal_ids(db, db.scalars(select(EnergyContractVersion.id)).one()) + heating, water = _row(_T0), _row(_T0, commodity="hot_water") + heating.meter_id = water.meter_id = meter_id + heating.source_binding_id = water.source_binding_id = binding_id + heating.contract_version_id = water.contract_version_id = version_id + heating.cost = Decimal("1.000000000") + heating.cost_breakdown = {"heating": "1.000000000"} + water.cost = Decimal("1.400000000") + water.cost_breakdown = {"hot_water_heating": "0.8", "hot_water": "0.4", "hot_water_tax": "0.2"} + db.add_all((heating, water)) + db.commit() + _login(client) + with patch("app.api.routes.api.meter_costs.local_now", return_value=datetime(2026, 6, 25, 2, tzinfo=UTC)): + response = client.get("/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z") + assert response.status_code == 200 + body = response.json() + assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8" + assert body["fixed_breakdown"] == { + "heating_network": "2", "metering": "0.4", "delivery_set": "0", + "hot_water_network": "0", "other": "0", + } + assert body["variable_subtotal"] == "2.400000000" + assert body["fixed_subtotal"] == "2.4" and body["all_in"] == "4.800000000" + assert body["period_count"] == 2 and body["degraded_count"] == 0 + + +def test_recompute_commits_once_and_rolls_back_service_or_count_failure(meter_cost_client) -> None: + client, engine = meter_cost_client + csrf = _login(client) + url = "/api/energy/meter-costs/recompute?scope=thermal&start=2026-06-23T10:00:00Z&end=2026-06-23T10:15:00Z" + headers = {"X-CSRF-Token": csrf} + + # An exception after a service-side mutation must leave no generated rows. + from app.services import meter_cost as service + + original = service.compute_period + calls = 0 + + def fail_after_first(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("service failure") + return original(*args, **kwargs) + + with patch("app.services.meter_cost.compute_period", side_effect=fail_after_first): + with pytest.raises(RuntimeError): + client.post(url, headers=headers) + with Session(engine) as db: + assert db.scalars(select(MeterCostPeriod)).all() == [] + + # The query after recomputation is part of that same transaction as well. + with patch("app.api.routes.api.meter_costs.select", side_effect=RuntimeError("count failure")): + with pytest.raises(RuntimeError): + client.post(url, headers=headers) + with Session(engine) as db: + assert db.scalars(select(MeterCostPeriod)).all() == []