M8-T12: scope energy contracts
This commit is contained in:
+58
-16
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user