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
+118 -2
View File
@@ -26,6 +26,7 @@ from app.services.meter_sources import (
create_binding_for_meter_swap,
create_source,
delete_source,
transfer_binding,
upsert_discovered_channel,
)
@@ -184,7 +185,7 @@ def test_meter_swap_hands_off_only_the_previous_meter_binding(session):
boundary = start + timedelta(days=1)
old_meter = _meter(session, "heating", "old")
old_meter.started_at = start
old_meter.ended_at = boundary
old_meter.ended_at = None
new_meter = Meter(
label="new",
commodity="heating",
@@ -198,6 +199,7 @@ def test_meter_swap_hands_off_only_the_previous_meter_binding(session):
old_binding = create_binding(
session, meter_id=old_meter.id, channel_id=channel.id, started_at=start
)
old_meter.ended_at = boundary
session.flush()
new_binding = create_binding_for_meter_swap(
@@ -241,7 +243,7 @@ def test_meter_swap_rejects_ambiguous_channel_without_closing_any_binding(sessio
boundary = start + timedelta(days=1)
old_meter = _meter(session, "heating", "old")
old_meter.started_at = start
old_meter.ended_at = boundary
old_meter.ended_at = None
new_meter = Meter(
label="new",
commodity="heating",
@@ -254,6 +256,7 @@ def test_meter_swap_rejects_ambiguous_channel_without_closing_any_binding(sessio
session.flush()
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
old_binding = create_binding(session, meter_id=old_meter.id, channel_id=channel.id, started_at=start)
old_meter.ended_at = boundary
session.add(
MeterSourceBinding(
meter_id=other_meter.id,
@@ -304,6 +307,119 @@ def test_meter_swap_rejects_incompatible_channel(session):
)
def test_transfer_closes_and_opens_at_shared_boundary(session):
start = datetime(2026, 8, 22, tzinfo=UTC)
meter = _meter(session, "heating")
meter.started_at = start
_, old_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
_, new_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
old = create_binding(session, meter_id=meter.id, channel_id=old_channel.id, started_at=start)
session.flush()
closed, created = transfer_binding(
session, target_meter_id=meter.id, from_binding_id=old.id,
to_channel_id=new_channel.id, effective_at=start + timedelta(hours=1),
)
assert closed.ended_at == created.started_at == start + timedelta(hours=1)
assert created.channel_id == new_channel.id
def test_transfer_rejects_future_without_mutating_source_binding(session):
start = datetime.now(UTC) - timedelta(hours=2)
meter = _meter(session, "heating")
meter.started_at = start
_, old_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
_, new_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
old = create_binding(session, meter_id=meter.id, channel_id=old_channel.id, started_at=start)
session.flush()
with pytest.raises(BindingValidationError, match="future"):
transfer_binding(session, target_meter_id=meter.id, from_binding_id=old.id,
to_channel_id=new_channel.id, effective_at=datetime.now(UTC) + timedelta(minutes=1))
assert old.ended_at is None
def test_cross_meter_transfer_recovers_stranded_same_channel(session):
start = datetime(2026, 8, 20, tzinfo=UTC)
boundary = start + timedelta(days=1)
old = _meter(session, "heating", "old")
old.started_at, old.ended_at = start, None
new = Meter(label="new", commodity="heating", started_at=boundary, reason="meter_swap", created_at=boundary)
session.add(new)
session.flush()
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
stranded = create_binding(session, meter_id=old.id, channel_id=channel.id, started_at=start)
old.ended_at = boundary
session.flush()
closed, created = transfer_binding(
session, target_meter_id=new.id, from_binding_id=stranded.id,
to_channel_id=channel.id, effective_at=boundary + timedelta(hours=2),
)
assert closed.ended_at == boundary
assert created.started_at == boundary + timedelta(hours=2)
assert created.channel_id == channel.id
def test_cross_meter_transfer_recovers_unique_gapped_predecessor(session):
"""A deliberate no-meter gap does not make the latest predecessor ambiguous."""
start = datetime(2026, 8, 20, tzinfo=UTC)
old_end = start + timedelta(days=1)
target_start = old_end + timedelta(hours=3)
old = _meter(session, "heating", "old")
old.started_at, old.ended_at = start, old_end
target = Meter(label="target", commodity="heating", started_at=target_start,
reason="initial", created_at=target_start)
session.add(target)
session.flush()
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
stranded = MeterSourceBinding(
meter_id=old.id, channel_id=channel.id, started_at=start,
created_at=start, updated_at=start,
)
session.add(stranded)
session.flush()
closed, created = transfer_binding(
session, target_meter_id=target.id, from_binding_id=stranded.id,
to_channel_id=channel.id, effective_at=target_start + timedelta(hours=1),
)
assert closed.ended_at == old_end
assert created.started_at == target_start + timedelta(hours=1)
def test_cross_meter_transfer_rejects_closed_source_and_ambiguous_predecessor(session):
start = datetime(2026, 8, 20, tzinfo=UTC)
boundary = start + timedelta(days=1)
old = _meter(session, "heating", "old")
old.started_at, old.ended_at = start, None
target = Meter(label="target", commodity="heating", started_at=boundary, reason="meter_swap", created_at=boundary)
session.add(target)
session.flush()
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
source = create_binding(session, meter_id=old.id, channel_id=channel.id, started_at=start)
old.ended_at = boundary
session.flush()
close_binding(session, source.id, ended_at=boundary - timedelta(hours=1))
with pytest.raises(BindingValidationError, match="open binding"):
transfer_binding(session, target_meter_id=target.id, from_binding_id=source.id,
to_channel_id=channel.id, effective_at=boundary)
assert source.ended_at == boundary - timedelta(hours=1)
source.ended_at = None # synthetic retained bad row, exactly the recovery input.
duplicate = Meter(label="duplicate", commodity="heating", started_at=start + timedelta(hours=1),
ended_at=boundary, reason="other", created_at=start)
session.add(duplicate)
session.flush()
with pytest.raises(BindingValidationError, match="unique immediately preceding"):
transfer_binding(session, target_meter_id=target.id, from_binding_id=source.id,
to_channel_id=channel.id, effective_at=boundary)
assert source.ended_at is None
def test_source_delete_is_restricted_by_discovered_channel(session):
source, _ = _source_and_channel(session, "dsmr_mqtt", "kWh")
with pytest.raises(SourceDeleteRestrictedError):