M8-T14: add generic meter cost periods
This commit is contained in:
+193
-3
@@ -12,20 +12,25 @@ Six tables:
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
event,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||
from sqlalchemy.types import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym, validates
|
||||
from sqlalchemy.types import JSON, TypeDecorator
|
||||
|
||||
from app.db import Base
|
||||
from app.models.meter_source import MeterSourceBinding
|
||||
@@ -35,6 +40,106 @@ def _uuid4_str() -> str:
|
||||
return str(_uuid.uuid4())
|
||||
|
||||
|
||||
def _decimal_json(value: Any) -> Any:
|
||||
"""Make auditable JSON portable without admitting binary numeric values."""
|
||||
if isinstance(value, Decimal):
|
||||
return format(value, "f")
|
||||
if isinstance(value, float) or (isinstance(value, int) and not isinstance(value, bool)):
|
||||
raise ValueError("JSON amounts and quantities must be decimal strings, not numeric JSON values")
|
||||
if isinstance(value, dict):
|
||||
return {key: _decimal_json(child) for key, child in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_decimal_json(child) for child in value]
|
||||
return value
|
||||
|
||||
|
||||
def _validate_fixed_decimal(value: Decimal, precision: int, scale: int, field: str) -> Decimal:
|
||||
if not isinstance(value, Decimal):
|
||||
raise ValueError(f"{field} must be a Decimal, not a binary float or other numeric type")
|
||||
if not value.is_finite():
|
||||
raise ValueError(f"{field} must be finite")
|
||||
if -value.as_tuple().exponent > scale:
|
||||
raise ValueError(f"{field} exceeds scale {scale}")
|
||||
integer_digits = 0 if value.is_zero() else max(value.copy_abs().adjusted() + 1, 0)
|
||||
if integer_digits > precision - scale:
|
||||
raise ValueError(f"{field} exceeds precision {precision},{scale}")
|
||||
return value
|
||||
|
||||
|
||||
class ExactDecimal(TypeDecorator[Decimal]):
|
||||
"""Fixed-point Decimal which uses SQLite TEXT, never a binary float."""
|
||||
|
||||
impl = Numeric
|
||||
cache_ok = True
|
||||
|
||||
def __init__(self, precision: int, scale: int) -> None:
|
||||
self.precision = precision
|
||||
self.scale = scale
|
||||
super().__init__(precision=precision, scale=scale)
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "sqlite":
|
||||
return dialect.type_descriptor(String(self.precision + 2))
|
||||
return dialect.type_descriptor(Numeric(self.precision, self.scale, asdecimal=True))
|
||||
|
||||
def process_bind_param(self, value: Decimal | None, dialect) -> Decimal | str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = _validate_fixed_decimal(value, self.precision, self.scale, "decimal value")
|
||||
if dialect.name == "sqlite":
|
||||
return format(value, f".{self.scale}f")
|
||||
return value
|
||||
|
||||
def process_result_value(self, value: Decimal | str | None, _dialect) -> Decimal | None:
|
||||
return None if value is None else Decimal(value)
|
||||
|
||||
|
||||
class DecimalJSON(TypeDecorator[dict]):
|
||||
"""JSON which serializes Decimal values as strings on every write path."""
|
||||
|
||||
impl = JSON
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: Any, _dialect) -> Any:
|
||||
return None if value is None else _decimal_json(value)
|
||||
|
||||
|
||||
class UTCDateTime(TypeDecorator[datetime]):
|
||||
"""UTC timestamps that preserve instant identity on SQLite and other dialects."""
|
||||
|
||||
impl = DateTime(timezone=True)
|
||||
cache_ok = True
|
||||
|
||||
def __init__(self, field: str) -> None:
|
||||
self.field = field
|
||||
super().__init__()
|
||||
|
||||
def process_bind_param(self, value: datetime | None, _dialect) -> datetime | None:
|
||||
return None if value is None else _normalise_utc_period(value, self.field)
|
||||
|
||||
def process_result_value(self, value: datetime | None, _dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _require_aware_period(start: datetime, end: datetime) -> None:
|
||||
if start.tzinfo is None or start.utcoffset() is None:
|
||||
raise ValueError("period_start must be timezone-aware")
|
||||
if end.tzinfo is None or end.utcoffset() is None:
|
||||
raise ValueError("period_end must be timezone-aware")
|
||||
if end <= start:
|
||||
raise ValueError("period_end must be after period_start")
|
||||
|
||||
|
||||
def _normalise_utc_period(value: datetime, field: str) -> datetime:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{field} must be timezone-aware")
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
class Meter(Base):
|
||||
"""One physical electricity meter's installation epoch.
|
||||
|
||||
@@ -358,6 +463,91 @@ class EnergyCostPeriod(Base):
|
||||
)
|
||||
|
||||
|
||||
class MeterCostPeriod(Base):
|
||||
"""Auditable commodity-scoped ledger row for one half-open metering period.
|
||||
|
||||
A degraded row intentionally permits missing audit links; services must
|
||||
still require them before writing a normal row.
|
||||
"""
|
||||
|
||||
__tablename__ = "meter_cost_period"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
commodity: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
period_start: Mapped[datetime] = mapped_column(UTCDateTime("period_start"), nullable=False)
|
||||
period_end: Mapped[datetime] = mapped_column(UTCDateTime("period_end"), nullable=False)
|
||||
meter_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
source_binding_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("meter_source_binding.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
contract_version_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
|
||||
# SQLite reliably round-trips at most fifteen significant decimal digits.
|
||||
# WarmteLink itself reports 0.001 units, so nine cost fractional digits
|
||||
# retain a six-place tariff times that source precision without float loss.
|
||||
quantity: Mapped[Decimal] = mapped_column(ExactDecimal(15, 6), nullable=False)
|
||||
cost: Mapped[Decimal] = mapped_column(ExactDecimal(15, 9), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
cost_breakdown: Mapped[dict] = mapped_column(DecimalJSON(), nullable=False, default=dict)
|
||||
pricing_snapshot: Mapped[dict] = mapped_column(DecimalJSON(), nullable=False, default=dict)
|
||||
quality: Mapped[str] = mapped_column(String(32), nullable=False, default="valid")
|
||||
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
degraded_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
meter: Mapped["Meter | None"] = relationship()
|
||||
source_binding: Mapped["MeterSourceBinding | None"] = relationship()
|
||||
contract_version: Mapped["EnergyContractVersion | None"] = relationship()
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"degraded OR (meter_id IS NOT NULL AND source_binding_id IS NOT NULL "
|
||||
"AND contract_version_id IS NOT NULL)",
|
||||
name="ck_meter_cost_period_normal_audit_links",
|
||||
),
|
||||
CheckConstraint("period_end > period_start", name="ck_meter_cost_period_positive_interval"),
|
||||
CheckConstraint(
|
||||
"NOT degraded OR (degraded_reason IS NOT NULL AND length(trim(degraded_reason)) > 0)",
|
||||
name="ck_meter_cost_period_degraded_reason",
|
||||
),
|
||||
UniqueConstraint("commodity", "period_start", name="uq_meter_cost_period_commodity_start"),
|
||||
Index("ix_meter_cost_period_commodity_start", "commodity", "period_start"),
|
||||
Index("ix_meter_cost_period_source_binding_id", "source_binding_id"),
|
||||
)
|
||||
|
||||
@validates("cost_breakdown", "pricing_snapshot")
|
||||
def _validate_decimal_json(self, _key: str, value: dict) -> dict:
|
||||
return _decimal_json(value)
|
||||
|
||||
@validates("quantity", "cost")
|
||||
def _validate_fixed_decimal(self, key: str, value: Decimal) -> Decimal:
|
||||
precision, scale = (15, 6) if key == "quantity" else (15, 9)
|
||||
return _validate_fixed_decimal(value, precision, scale, key)
|
||||
|
||||
@validates("period_start", "period_end")
|
||||
def _normalise_period(self, key: str, value: datetime) -> datetime:
|
||||
return _normalise_utc_period(value, key)
|
||||
|
||||
|
||||
@event.listens_for(MeterCostPeriod, "before_insert")
|
||||
@event.listens_for(MeterCostPeriod, "before_update")
|
||||
def _validate_meter_cost_period(_mapper, _connection, target: MeterCostPeriod) -> None:
|
||||
_require_aware_period(target.period_start, target.period_end)
|
||||
if not target.degraded and (
|
||||
target.meter_id is None
|
||||
or target.source_binding_id is None
|
||||
or target.contract_version_id is None
|
||||
):
|
||||
raise ValueError("normal meter cost periods require meter, binding, and contract version")
|
||||
if target.degraded and not target.degraded_reason:
|
||||
raise ValueError("degraded meter cost periods require a degraded_reason")
|
||||
|
||||
|
||||
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
||||
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
||||
# no composite index is needed for single-meter deployments.)
|
||||
|
||||
Reference in New Issue
Block a user