M8-R08: add atomic meter close and binding transfer
This commit is contained in:
+76
-2
@@ -53,6 +53,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.energy import Meter
|
||||
from app.models.meter_source import MeterSourceBinding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,6 +105,20 @@ class MeterIntervalError(MeterError):
|
||||
"""
|
||||
|
||||
|
||||
def close_meter(session: Session, meter: Meter, *, ended_at: datetime) -> Meter:
|
||||
"""Close an active meter at a valid, non-future exclusive boundary."""
|
||||
boundary = _as_utc(ended_at)
|
||||
if meter.ended_at is not None:
|
||||
raise MeterIntervalError("Only an active meter can be closed.")
|
||||
if boundary <= _as_utc(meter.started_at):
|
||||
raise MeterIntervalError("Meter ended_at must be strictly after started_at.")
|
||||
if boundary > datetime.now(UTC):
|
||||
raise MeterIntervalError("Meter ended_at must not be in the future.")
|
||||
_validate_bindings_fit_meter_end(session, meter, boundary)
|
||||
meter.ended_at = boundary
|
||||
return meter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -121,6 +136,23 @@ def _active_meter(session: Session, commodity: str) -> Optional[Meter]:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _validate_bindings_fit_meter_end(session: Session, meter: Meter, boundary: datetime) -> None:
|
||||
"""Reject an epoch close that would put any retained binding out of bounds.
|
||||
|
||||
Closed binding history is immutable here. Open bindings may subsequently
|
||||
be closed by the caller at the shared meter boundary, but only when that
|
||||
produces a non-empty interval.
|
||||
"""
|
||||
for binding in session.execute(
|
||||
select(MeterSourceBinding).where(MeterSourceBinding.meter_id == meter.id)
|
||||
).scalars():
|
||||
if binding.ended_at is None:
|
||||
if _as_utc(binding.started_at) >= boundary:
|
||||
raise MeterIntervalError("Open binding cannot be closed within the proposed meter epoch.")
|
||||
elif _as_utc(binding.ended_at) > boundary:
|
||||
raise MeterIntervalError("Closed binding extends beyond the proposed meter epoch.")
|
||||
|
||||
|
||||
def _meter_before(session: Session, meter: Meter) -> Optional[Meter]:
|
||||
"""Return the meter whose ``ended_at`` equals *meter*'s ``started_at``.
|
||||
|
||||
@@ -296,6 +328,8 @@ def declare_meter(
|
||||
If *started_at* is strictly earlier than the current active meter's
|
||||
``started_at`` (chronological backdate below the active epoch's start).
|
||||
"""
|
||||
if _as_utc(started_at) > datetime.now(UTC):
|
||||
raise MeterIntervalError("Meter started_at must not be in the future.")
|
||||
active = _active_meter(session, commodity)
|
||||
|
||||
if active is not None:
|
||||
@@ -308,6 +342,9 @@ def declare_meter(
|
||||
"Declare a started_at on or after the active meter's start to avoid "
|
||||
"a chronologically inconsistent epoch ordering."
|
||||
)
|
||||
# Validate before changing the epoch: retained closed binding history
|
||||
# must never be silently truncated by a later declaration.
|
||||
_validate_bindings_fit_meter_end(session, active, _as_utc(started_at))
|
||||
# Close the current active meter at the swap point (contiguous handoff).
|
||||
active.ended_at = started_at
|
||||
logger.info(
|
||||
@@ -400,6 +437,9 @@ def update_meter(
|
||||
invert).
|
||||
b. It must be **strictly before** this meter's ``ended_at`` (if set),
|
||||
so this meter's epoch remains non-empty.
|
||||
c. Every binding on this meter and its affected predecessor must remain
|
||||
wholly inside its proposed epoch. The service rejects the correction
|
||||
rather than rewriting binding history.
|
||||
|
||||
Note: triggering a billing recompute (``recompute_range``) after a
|
||||
retroactive ``started_at`` change is **out of scope** for this service
|
||||
@@ -429,6 +469,14 @@ def update_meter(
|
||||
If the new ``started_at`` would produce an invalid (empty or inverted)
|
||||
epoch for this meter or the immediately preceding one.
|
||||
"""
|
||||
# Validate the proposed epoch boundary before touching *any* mutable
|
||||
# field. PATCH accepts label/note together with started_at, so doing this
|
||||
# first keeps an invalid future timestamp from leaking a partial in-session
|
||||
# update before the API's rollback boundary is reached.
|
||||
proposed_started_at = _as_utc(started_at) if started_at is not None else None
|
||||
if proposed_started_at is not None and proposed_started_at > datetime.now(UTC):
|
||||
raise MeterIntervalError("Meter started_at must not be in the future.")
|
||||
|
||||
if label is not None:
|
||||
meter.label = label
|
||||
logger.info("Updated meter id=%d label=%r", meter.id, label)
|
||||
@@ -439,10 +487,11 @@ def update_meter(
|
||||
|
||||
if started_at is not None:
|
||||
old_started_at = meter.started_at
|
||||
assert proposed_started_at is not None
|
||||
|
||||
# --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
|
||||
if meter.ended_at is not None:
|
||||
if _as_utc(started_at) >= _as_utc(meter.ended_at):
|
||||
if proposed_started_at >= _as_utc(meter.ended_at):
|
||||
raise MeterIntervalError(
|
||||
f"New started_at ({started_at.isoformat()}) must be strictly before "
|
||||
f"this meter's ended_at ({meter.ended_at.isoformat()}). "
|
||||
@@ -454,12 +503,37 @@ def update_meter(
|
||||
|
||||
# --- Validate lower bound: new started_at must be strictly after prev's started_at.
|
||||
if prev is not None:
|
||||
if _as_utc(started_at) <= _as_utc(prev.started_at):
|
||||
if proposed_started_at <= _as_utc(prev.started_at):
|
||||
raise MeterIntervalError(
|
||||
f"New started_at ({started_at.isoformat()}) must be strictly after "
|
||||
f"the previous meter's started_at ({prev.started_at.isoformat()}). "
|
||||
"Moving the boundary that far back would collapse the previous meter's epoch."
|
||||
)
|
||||
# A boundary correction changes both adjacent meter epochs. Fail closed
|
||||
# rather than silently rewriting binding history: every existing binding
|
||||
# must still fit in its proposed epoch before either Meter is mutated.
|
||||
affected_meters = [
|
||||
(meter, proposed_started_at, _as_utc(meter.ended_at) if meter.ended_at is not None else None)
|
||||
]
|
||||
if prev is not None:
|
||||
affected_meters.append((prev, _as_utc(prev.started_at), proposed_started_at))
|
||||
for affected_meter, proposed_start, proposed_end in affected_meters:
|
||||
bindings = session.execute(
|
||||
select(MeterSourceBinding).where(MeterSourceBinding.meter_id == affected_meter.id)
|
||||
).scalars()
|
||||
for binding in bindings:
|
||||
if _as_utc(binding.started_at) < proposed_start:
|
||||
raise MeterIntervalError(
|
||||
f"Binding {binding.id} starts before meter {affected_meter.id}'s epoch."
|
||||
)
|
||||
if proposed_end is not None and (
|
||||
binding.ended_at is None or _as_utc(binding.ended_at) > proposed_end
|
||||
):
|
||||
raise MeterIntervalError(
|
||||
f"Binding {binding.id} would fall outside meter {affected_meter.id}'s epoch."
|
||||
)
|
||||
|
||||
if prev is not None:
|
||||
# Maintain continuity: update the previous meter's ended_at to match the new start.
|
||||
prev.ended_at = started_at
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user