M8-T12: scope energy contracts
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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