151 lines
6.6 KiB
Python
151 lines
6.6 KiB
Python
"""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)
|