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)
|
||||
Reference in New Issue
Block a user