M8-R08: add atomic meter close and binding transfer

This commit is contained in:
2026-08-24 18:37:46 +02:00
parent 2be4f78f8a
commit 8dc3f71aaf
15 changed files with 1667 additions and 35 deletions
+83 -1
View File
@@ -16,6 +16,7 @@ from app.models.energy import DsmrReading, Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
from app.schemas.meter_source import (
BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelBindingSummaryResponse,
BindingTransferRequest, BindingTransferResponse,
ChannelReadingResponse,
ChannelReadingsResponse, CommoditiesResponse, CommodityResponse, DiscoverResponse,
DiscoverChannelResponse,
@@ -29,8 +30,9 @@ from app.services.dsmr_ingest import apply_dsmr_subscription
from app.services.meter_sources import (
BindingNotFoundError, ChannelNotFoundError, MeterNotFoundError,
MeterSourceError, SourceDeleteRestrictedError, SourceNotFoundError, create_binding,
create_source, delete_source, list_bindings, list_sources, update_binding, update_source,
create_source, delete_source, list_bindings, list_sources, transfer_binding, update_binding, update_source,
)
from app.services.energy_cost import recompute_range as electricity_recompute_range
from app.services import timezone as _tz_mod
from app.services.warmtelink_worker import warmtelink_worker_manager
@@ -104,6 +106,25 @@ def _binding_error(exc: MeterSourceError) -> HTTPException:
return HTTPException(status_code=422, detail=str(exc))
def _recompute_binding_commodity(db: Session, commodity: str, start: datetime) -> None:
end = datetime.now(UTC)
if start >= end:
return
if commodity == "electricity":
electricity_recompute_range(db, start, end, commit=False, strict=True)
else:
from app.services.meter_cost import recompute_range
recompute_range(db, start, end, commit=False)
def _republish_after_commit(db: Session) -> None:
try:
from app.services.ha_discovery import publish_discovery
publish_discovery(db)
except Exception:
pass
@router.get("/source-profiles", response_model=SourceProfilesResponse)
def source_profiles(_auth: AuthenticatedSession = Depends(require_session)) -> SourceProfilesResponse:
"""Return profile metadata; default secrets are never populated with stored values."""
@@ -300,12 +321,19 @@ def post_meter_binding(meter_id: int, body: BindingCreate, db: Session = Depends
try:
binding = create_binding(db, meter_id=meter_id, channel_id=channel.id, started_at=_as_utc(body.started_at),
ended_at=_as_utc(body.ended_at) if body.ended_at else None)
meter = db.get(Meter, meter_id)
db.flush()
_recompute_binding_commodity(db, meter.commodity, _as_utc(body.started_at))
db.commit()
db.refresh(binding)
_republish_after_commit(db)
return binding_response(binding)
except MeterSourceError as exc:
db.rollback()
raise _binding_error(exc) from exc
except Exception:
db.rollback()
raise
@router.patch("/bindings/{binding_uuid}", response_model=BindingResponse)
@@ -323,10 +351,64 @@ def patch_binding(binding_uuid: str, body: BindingPatch, db: Session = Depends(g
changes["started_at"] = _as_utc(body.started_at) if body.started_at is not None else None
if "ended_at" in body.model_fields_set:
changes["ended_at"] = _as_utc(body.ended_at) if body.ended_at is not None else None
old_started_at = _as_utc(binding.started_at)
old_ended_at = _as_utc(binding.ended_at) if binding.ended_at is not None else None
updated = update_binding(db, binding.id, **changes)
meter = db.get(Meter, updated.meter_id)
if "started_at" in changes:
earliest = min(old_started_at, _as_utc(updated.started_at))
elif "ended_at" in changes:
new_ended_at = _as_utc(updated.ended_at) if updated.ended_at is not None else None
changed_ends = [value for value in (old_ended_at, new_ended_at) if value is not None]
earliest = min(changed_ends) if changed_ends else old_started_at
else:
earliest = old_started_at
db.flush()
_recompute_binding_commodity(db, meter.commodity, earliest)
db.commit()
db.refresh(updated)
_republish_after_commit(db)
return binding_response(updated)
except MeterSourceError as exc:
db.rollback()
raise _binding_error(exc) from exc
except Exception:
db.rollback()
raise
@router.post("/meters/{meter_id}/bindings/transfer", response_model=BindingTransferResponse)
def post_binding_transfer(
meter_id: int, body: BindingTransferRequest, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf),
) -> BindingTransferResponse:
source = db.execute(select(MeterSourceBinding).where(
MeterSourceBinding.uuid == body.from_binding_uuid
)).scalar_one_or_none()
channel = db.execute(select(MeterSourceChannel).where(
MeterSourceChannel.uuid == body.to_source_channel_uuid
)).scalar_one_or_none()
if source is None or channel is None:
raise HTTPException(status_code=404, detail="Meter source binding or channel not found.")
effective_at = _as_utc(body.effective_at)
try:
closed, created = transfer_binding(db, target_meter_id=meter_id, from_binding_id=source.id,
to_channel_id=channel.id, effective_at=effective_at)
meter = db.get(Meter, meter_id)
if closed.meter_id == meter.id:
earliest = effective_at
else:
earliest = min(_as_utc(closed.ended_at), effective_at)
db.flush()
_recompute_binding_commodity(db, meter.commodity, earliest)
db.commit()
db.refresh(closed)
db.refresh(created)
except MeterSourceError as exc:
db.rollback()
raise _binding_error(exc) from exc
except Exception:
db.rollback()
raise
_republish_after_commit(db)
return BindingTransferResponse(closed_binding=binding_response(closed), created_binding=binding_response(created))
+64 -4
View File
@@ -55,6 +55,7 @@ from app.dependencies import get_db
from app.models.energy import Meter
from app.models.meter_source import MeterSourceChannel
from app.schemas.meter import (
MeterCloseRequest,
MeterDeclareRequest,
MeterBindingSummary,
MeterListResponse,
@@ -66,6 +67,7 @@ from app.services.meter_sources import (
MeterSourceError,
create_binding,
create_binding_for_meter_swap,
close_open_bindings_for_meter,
)
from app.services import timezone as _tz_mod
from app.services.auth import AuthenticatedSession
@@ -74,6 +76,7 @@ from app.services.meters import (
MeterIntervalError,
MeterOverlapError,
declare_meter,
close_meter,
list_meters,
update_meter,
)
@@ -151,7 +154,7 @@ def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
# started_at is in the future — nothing to recompute.
logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start)
return 0
n = recompute_range(db, start, end, commit=False)
n = recompute_range(db, start, end, commit=False, strict=True)
logger.info(
"%s: recomputed %d period(s) in window [%s, %s).",
label,
@@ -162,6 +165,17 @@ def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
return n
def _recompute_commodity(db: Session, commodity: str, start: datetime, label: str) -> int:
if commodity == "electricity":
return _trigger_recompute(db, start, label)
from app.services.meter_cost import recompute_range as thermal_recompute_range
end = datetime.now(UTC)
if start >= end:
return 0
return thermal_recompute_range(db, start, end, commit=False)
def _meter_response(meter: Meter) -> MeterResponse:
"""Serialize meter plus binding summaries without exposing source config."""
response = MeterResponse.model_validate(meter)
@@ -248,6 +262,16 @@ def declare_energy_meter(
note=body.note,
)
db.flush() # assign PK before an optional binding and recompute
# A closed predecessor must never retain an open interval. For a
# meter swap with no selected channel we can safely hand off exactly
# one compatible open channel; ambiguity is fail-closed.
auto_channel = None
if body.source_channel_uuid is None and old_meter is not None and body.reason.value == "meter_swap":
candidates = [b for b in old_meter.source_bindings if b.ended_at is None and b.channel.unit == {"electricity": "kWh", "heating": "GJ", "hot_water": ""}.get(body.commodity)]
if len(candidates) > 1:
raise MeterSourceError("Meter swap has ambiguous open bindings; select a channel explicitly.")
if len(candidates) == 1:
auto_channel = candidates[0].channel
if body.source_channel_uuid is not None:
channel = db.execute(
select(MeterSourceChannel).where(MeterSourceChannel.uuid == body.source_channel_uuid)
@@ -269,14 +293,20 @@ def declare_energy_meter(
channel_id=channel.id,
started_at=started_at_utc,
)
elif auto_channel is not None:
create_binding_for_meter_swap(db, old_meter_id=old_meter.id, new_meter_id=new_meter.id,
channel_id=auto_channel.id, started_at=started_at_utc)
if old_meter is not None:
close_open_bindings_for_meter(db, old_meter.id, ended_at=started_at_utc)
# Keep recompute in this transaction: a failure must not leave a new
# meter, its predecessor, or either binding at a half-applied boundary.
now = datetime.now(UTC)
if started_at_utc < now:
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
db.flush()
_recompute_commodity(db, body.commodity, started_at_utc, "POST /api/energy/meters")
db.commit()
except (MeterOverlapError, MeterSourceError) as exc:
except (MeterIntervalError, MeterOverlapError, MeterSourceError) as exc:
db.rollback()
raise HTTPException(
status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError)
@@ -304,6 +334,30 @@ def declare_energy_meter(
return _meter_response(new_meter)
@router.post("/meters/{meter_id}/close", response_model=MeterResponse)
def close_energy_meter(
meter_id: int, body: MeterCloseRequest, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf),
) -> MeterResponse:
meter = _get_meter_or_404(db, meter_id)
boundary = _localize_started_at(body.ended_at)
try:
close_meter(db, meter, ended_at=boundary)
close_open_bindings_for_meter(db, meter.id, ended_at=boundary)
db.flush()
_recompute_commodity(db, meter.commodity, boundary, f"POST /api/energy/meters/{meter_id}/close")
db.commit()
except (MeterIntervalError, MeterSourceError) as exc:
db.rollback()
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception:
db.rollback()
raise
db.refresh(meter)
_trigger_discovery_republish(db)
return _meter_response(meter)
# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------
@@ -361,7 +415,13 @@ def patch_energy_meter(
# Window = [min(old, new), now) — covers all periods whose attribution
# may have changed due to the boundary shift in either direction.
window_start = min(old_started_at, new_started_at_utc)
_trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}")
db.flush()
_recompute_commodity(
db,
meter.commodity,
window_start,
f"PATCH /api/energy/meters/{meter_id}",
)
db.commit()
except MeterIntervalError as exc: