M8-T16: add thermal meter cost APIs

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 489e5b596a
commit 3eec701448
12 changed files with 1843 additions and 19 deletions
+31 -10
View File
@@ -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,
)
))
# ---------------------------------------------------------------------------
+150
View File
@@ -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)
+2
View File
@@ -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)
+18
View File
@@ -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}
# ---------------------------------------------------------------------------
+62
View File
@@ -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
+18 -8
View File
@@ -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),
}
+2 -1
View File
@@ -891,7 +891,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T16 — Thermal Prices / Costs / Summary / Recompute API
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M8-T15
- **Context**: 对外提供 scope-aware 价格和新的热力账本,不破坏既有 electricity 路由响应。
@@ -900,6 +900,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- `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`
+297
View File
@@ -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;
+615
View File
@@ -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": {
+404
View File
@@ -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:
+31
View File
@@ -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):
+213
View File
@@ -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() == []