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