M8-T16: add thermal meter cost APIs
This commit is contained in:
@@ -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,
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
Vendored
+297
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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() == []
|
||||
Reference in New Issue
Block a user