M8-R08: add atomic meter close and binding transfer
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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": "m³"}.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:
|
||||
|
||||
Reference in New Issue
Block a user