M8-T12: scope energy contracts

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent a9458394f2
commit b812d5ac46
13 changed files with 533 additions and 47 deletions
+16 -6
View File
@@ -46,7 +46,9 @@ from app.schemas.energy_contract import (
from app.services.auth import AuthenticatedSession
from app.services import timezone as _tz_mod
from app.services.contracts import (
CONTRACT_KIND_SCOPES,
ContractVersionError,
ContractScopeError,
activate_contract,
add_version,
create_contract,
@@ -98,6 +100,7 @@ def _contract_detail(db: Session, contract) -> ContractDetailResponse:
id=contract.id,
name=contract.name,
kind=contract.kind,
scope=contract.scope,
active=contract.active,
currency=contract.currency,
created_at=contract.created_at,
@@ -163,16 +166,22 @@ def get_profiles(
@router.get("/contracts", response_model=ContractListResponse)
def list_energy_contracts(
scope: str = "electricity",
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ContractListResponse:
"""List all energy contracts with their active status.
Returns a flat list (no embedded version history); use
Scope defaults to ``electricity`` for old clients. Returns a flat list (no embedded version history); use
GET /api/energy/contracts/{id} to fetch the full version history for a
specific contract.
"""
contracts = list_contracts(db)
if scope not in set(CONTRACT_KIND_SCOPES.values()):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown energy contract scope: {scope!r}",
)
contracts = list_contracts(db, scope=scope)
items = [ContractResponse.model_validate(c) for c in contracts]
return ContractListResponse(items=items, total=len(items))
@@ -208,10 +217,11 @@ def create_energy_contract(
name=body.name,
kind=body.kind,
currency=body.currency,
scope=body.scope,
values=body.values,
effective_from=effective_from,
)
except (ProfileNotFoundError, ProfileValidationError) as exc:
except (ProfileNotFoundError, ProfileValidationError, ContractScopeError) as exc:
_raise_422_for_profile_error(exc)
db.commit()
@@ -256,11 +266,11 @@ def patch_energy_contract(
"""Partially update a contract: rename or change activation status.
- ``name``: updates the human-readable label.
- ``active=true``: activates this contract (all others are deactivated).
- ``active=true``: activates this contract (same-scope contracts are deactivated).
- ``active=false``: deactivates this contract (no effect on others).
At most one contract may be active at any time; the service layer enforces
mutual exclusion.
At most one contract may be active per scope; the service layer enforces
scope-local mutual exclusion.
"""
contract = _get_contract_or_404(db, contract_id)
+10 -2
View File
@@ -163,8 +163,10 @@ class EnergyContract(Base):
``kind`` determines which price strategy is used (``manual`` for fixed
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
Only one contract may be ``active`` at a time; the service layer enforces
mutual exclusion. Specific pricing values live in ``EnergyContractVersion``
A contract belongs to an energy ``scope`` (currently electricity; thermal
profiles are reserved for the next milestone). Only one contract may be
``active`` per scope; the service layer enforces mutual exclusion. Specific
pricing values live in ``EnergyContractVersion``
so that price changes can be tracked without modifying historical records.
"""
@@ -180,6 +182,12 @@ class EnergyContract(Base):
# migration simple and the strategy registry extensible.
kind: Mapped[str] = mapped_column(String(32), nullable=False)
# Billing domain. The service registry derives this from ``kind`` so API
# callers cannot move a pricing strategy into an incompatible domain.
scope: Mapped[str] = mapped_column(
String(32), nullable=False, default="electricity", index=True
)
# Whether this is the currently active contract (at most one should be True).
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+3
View File
@@ -51,6 +51,7 @@ class ContractResponse(BaseModel):
id: int
name: str
kind: str
scope: str
active: bool
currency: str
created_at: datetime
@@ -69,6 +70,7 @@ class ContractDetailResponse(BaseModel):
id: int
name: str
kind: str
scope: str
active: bool
currency: str
created_at: datetime
@@ -101,6 +103,7 @@ class ContractCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
kind: str = Field(..., min_length=1, max_length=32)
scope: str | None = Field(default=None, min_length=1, max_length=32)
currency: str = Field(default="EUR", min_length=1, max_length=8)
values: dict[str, Any]
effective_from: datetime | None = Field(
+58 -16
View File
@@ -11,8 +11,8 @@ Design decisions
setting its ``effective_to`` to the new version's ``effective_from``; raises
``ContractVersionError`` if the new date is strictly earlier than the previous
version's ``effective_from``.
- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active``
to False, then sets the given contract's ``active`` to True.
- ``activate_contract``: scope-local mutual exclusion; sets other contracts in
the target scope inactive, then sets the given contract active.
- ``active_contract_version_at``: returns the single version of the currently
active contract that covers *ts* (``effective_from ≤ ts < effective_to``,
or open-ended when ``effective_to`` is None).
@@ -32,7 +32,7 @@ import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.integrations.pricing.profiles import validate_values
@@ -41,6 +41,32 @@ from app.models.energy import EnergyContract, EnergyContractVersion
logger = logging.getLogger(__name__)
# This is deliberately separate from the pricing-profile loader. T12 needs to
# reserve the thermal domain before T13 supplies its actual profile.
CONTRACT_KIND_SCOPES: dict[str, str] = {
"manual": "electricity",
"tibber": "electricity",
"district_heating": "thermal",
}
class ContractScopeError(ValueError):
"""Raised when a contract kind is unknown or its supplied scope disagrees."""
def contract_scope_for_kind(kind: str, requested_scope: str | None = None) -> str:
"""Return the registry-owned scope for *kind*, rejecting client mismatches."""
try:
scope = CONTRACT_KIND_SCOPES[kind]
except KeyError as exc:
raise ContractScopeError(f"Unknown energy contract kind: {kind!r}") from exc
if requested_scope is not None and requested_scope != scope:
raise ContractScopeError(
f"Contract kind {kind!r} belongs to scope {scope!r}, not {requested_scope!r}."
)
return scope
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -86,10 +112,14 @@ def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract |
).scalar_one_or_none()
def list_contracts(session: Session) -> list[EnergyContract]:
"""Return all contracts ordered by id (ascending)."""
def list_contracts(session: Session, *, scope: str = "electricity") -> list[EnergyContract]:
"""Return contracts in one scope, ordered by id (ascending)."""
return list(
session.execute(select(EnergyContract).order_by(EnergyContract.id)).scalars().all()
session.execute(
select(EnergyContract)
.where(EnergyContract.scope == scope)
.order_by(EnergyContract.id)
).scalars().all()
)
@@ -117,6 +147,7 @@ def create_contract(
name: str,
kind: str,
currency: str = "EUR",
scope: str | None = None,
values: dict[str, Any],
effective_from: datetime,
) -> EnergyContract:
@@ -150,6 +181,7 @@ def create_contract(
ProfileValidationError
If *values* does not conform to the profile structure.
"""
resolved_scope = contract_scope_for_kind(kind, scope)
# Validate (and fill defaults) before any DB write.
filled_values = validate_values(kind, values)
@@ -157,6 +189,7 @@ def create_contract(
contract = EnergyContract(
name=name,
kind=kind,
scope=resolved_scope,
currency=currency,
active=False, # New contracts are inactive; caller must explicitly activate.
created_at=now,
@@ -262,15 +295,18 @@ def add_version(
def activate_contract(session: Session, contract: EnergyContract) -> None:
"""Activate a contract with mutual exclusion.
Sets every other contract's ``active`` flag to False, then sets the given
contract's ``active`` to True. This guarantees at most one active contract
at any time.
Sets every other contract in the same scope inactive, then sets the given
contract active. This guarantees at most one active contract per scope.
Caller must commit after this returns.
"""
# Deactivate all contracts (including the target; we re-activate below).
for other in session.execute(select(EnergyContract)).scalars().all():
other.active = False
# This bulk update is a single write statement inside the caller's
# transaction. SQLite serializes writers, and another scope is never touched.
session.execute(
update(EnergyContract)
.where(EnergyContract.scope == contract.scope, EnergyContract.id != contract.id)
.values(active=False)
)
contract.active = True
contract.updated_at = datetime.now(UTC)
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
@@ -286,7 +322,9 @@ def deactivate_contract(session: Session, contract: EnergyContract) -> None:
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
def active_contract_versions(
session: Session, *, scope: str = "electricity"
) -> list[EnergyContractVersion]:
"""Return all versions of the currently active contract, ordered by effective_from ascending.
Returns an empty list when there is no active contract. The list spans the
@@ -295,7 +333,9 @@ def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
cost / credit accumulation (Principle C).
"""
active = session.execute(
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
select(EnergyContract)
.where(EnergyContract.active.is_(True), EnergyContract.scope == scope)
.limit(1)
).scalar_one_or_none()
if active is None:
@@ -313,7 +353,7 @@ def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
def active_contract_version_at(
session: Session, ts: datetime
session: Session, ts: datetime, *, scope: str = "electricity"
) -> EnergyContractVersion | None:
"""Return the active contract's version that covers *ts*.
@@ -336,7 +376,9 @@ def active_contract_version_at(
EnergyContractVersion | None
"""
active = session.execute(
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
select(EnergyContract)
.where(EnergyContract.active.is_(True), EnergyContract.scope == scope)
.limit(1)
).scalar_one_or_none()
if active is None: