M8-R03: hand off channel binding during meter swap

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 231c340ea6
commit 631b14e2ec
6 changed files with 669 additions and 24 deletions
+71
View File
@@ -315,6 +315,77 @@ def create_binding(
return binding
def create_binding_for_meter_swap(
session: Session,
*,
old_meter_id: int | None,
new_meter_id: int,
channel_id: int,
started_at: datetime,
) -> MeterSourceBinding:
"""Create a binding during a physical meter swap, handing off one channel if safe.
A channel is transferable only when exactly one of its bindings covered the
instant immediately before ``started_at`` and that binding belongs to the
meter which this declaration just closed. All other occupied or ambiguous
cases retain the normal fail-closed overlap behaviour.
This function deliberately does not commit. The caller must keep the meter
declaration, binding handoff, and any billing recompute in one transaction.
"""
new_meter = _get_meter(session, new_meter_id)
channel = get_channel(session, channel_id)
expected_unit = COMMODITY_UNITS.get(new_meter.commodity)
if expected_unit is None or channel.unit != expected_unit:
raise BindingValidationError(
f"Meter commodity {new_meter.commodity!r} requires unit {expected_unit!r}, "
f"but channel has {channel.unit!r}."
)
boundary = _as_utc(started_at)
if _as_utc(new_meter.started_at) != boundary:
raise BindingValidationError(
"Meter-swap binding must start at the new meter's started_at boundary."
)
covering_bindings = [
binding
for binding in session.execute(
select(MeterSourceBinding).where(MeterSourceBinding.channel_id == channel_id)
).scalars()
if _as_utc(binding.started_at) < boundary
and (binding.ended_at is None or _as_utc(binding.ended_at) >= boundary)
]
if not covering_bindings:
return create_binding(
session,
meter_id=new_meter_id,
channel_id=channel_id,
started_at=started_at,
)
if old_meter_id is None or len(covering_bindings) != 1:
raise BindingOverlapError("Channel is occupied or has an ambiguous binding at meter swap.")
old_meter = _get_meter(session, old_meter_id)
old_binding = covering_bindings[0]
if (
old_meter.commodity != new_meter.commodity
or old_meter.ended_at is None
or _as_utc(old_meter.ended_at) != boundary
or old_binding.meter_id != old_meter.id
):
raise BindingOverlapError("Channel is occupied by a binding that cannot be handed off.")
update_binding(session, old_binding.id, ended_at=started_at)
return create_binding(
session,
meter_id=new_meter_id,
channel_id=channel_id,
started_at=started_at,
)
def update_binding(
session: Session,
binding_id: int,