M8-R03: hand off channel binding during meter swap
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user