M8-R03: hand off channel binding during meter swap
This commit is contained in:
@@ -61,7 +61,12 @@ from app.schemas.meter import (
|
|||||||
MeterPatchRequest,
|
MeterPatchRequest,
|
||||||
MeterResponse,
|
MeterResponse,
|
||||||
)
|
)
|
||||||
from app.services.meter_sources import ChannelNotFoundError, MeterSourceError, create_binding
|
from app.services.meter_sources import (
|
||||||
|
ChannelNotFoundError,
|
||||||
|
MeterSourceError,
|
||||||
|
create_binding,
|
||||||
|
create_binding_for_meter_swap,
|
||||||
|
)
|
||||||
from app.services import timezone as _tz_mod
|
from app.services import timezone as _tz_mod
|
||||||
from app.services.auth import AuthenticatedSession
|
from app.services.auth import AuthenticatedSession
|
||||||
from app.services.energy_cost import recompute_range
|
from app.services.energy_cost import recompute_range
|
||||||
@@ -231,6 +236,9 @@ def declare_energy_meter(
|
|||||||
started_at_utc = _localize_started_at(body.started_at)
|
started_at_utc = _localize_started_at(body.started_at)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
old_meter = db.execute(
|
||||||
|
select(Meter).where(Meter.commodity == body.commodity, Meter.ended_at.is_(None))
|
||||||
|
).scalar_one_or_none()
|
||||||
new_meter = declare_meter(
|
new_meter = declare_meter(
|
||||||
db,
|
db,
|
||||||
label=body.label,
|
label=body.label,
|
||||||
@@ -246,28 +254,39 @@ def declare_energy_meter(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if channel is None:
|
if channel is None:
|
||||||
raise ChannelNotFoundError("Meter source channel was not found.")
|
raise ChannelNotFoundError("Meter source channel was not found.")
|
||||||
create_binding(
|
if body.reason.value == "meter_swap":
|
||||||
db,
|
create_binding_for_meter_swap(
|
||||||
meter_id=new_meter.id,
|
db,
|
||||||
channel_id=channel.id,
|
old_meter_id=old_meter.id if old_meter is not None else None,
|
||||||
started_at=started_at_utc,
|
new_meter_id=new_meter.id,
|
||||||
)
|
channel_id=channel.id,
|
||||||
|
started_at=started_at_utc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
create_binding(
|
||||||
|
db,
|
||||||
|
meter_id=new_meter.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=started_at_utc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep recompute in this transaction: a failure must not leave a new
|
||||||
|
# meter, its predecessor, or either binding at a half-applied boundary.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if started_at_utc < now:
|
||||||
|
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
|
||||||
|
db.commit()
|
||||||
except (MeterOverlapError, MeterSourceError) as exc:
|
except (MeterOverlapError, MeterSourceError) as exc:
|
||||||
# declare_meter may already have closed the previous epoch. Rolling back
|
|
||||||
# here makes Meter + binding declaration genuinely atomic.
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError)
|
status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError)
|
||||||
else status.HTTP_422_UNPROCESSABLE_ENTITY),
|
else status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
# Retroactive recompute: re-judge attribution from the new boundary onward.
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if started_at_utc < now:
|
|
||||||
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_meter)
|
db.refresh(new_meter)
|
||||||
|
|
||||||
# Trigger HA discovery re-publish so the new active meter's energy-cost
|
# Trigger HA discovery re-publish so the new active meter's energy-cost
|
||||||
|
|||||||
@@ -315,6 +315,77 @@ def create_binding(
|
|||||||
return 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(
|
def update_binding(
|
||||||
session: Session,
|
session: Session,
|
||||||
binding_id: int,
|
binding_id: int,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const ACTIVE_METER = {
|
|||||||
reason: 'initial',
|
reason: 'initial',
|
||||||
note: null,
|
note: null,
|
||||||
created_at: '2024-01-15T00:00:00Z',
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
bindings: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLOSED_METER = {
|
const CLOSED_METER = {
|
||||||
@@ -246,6 +247,202 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers a channel with closed history and one current old-meter binding for a safe swap', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
|
||||||
|
...CLOSED_METER,
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'binding-history', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
||||||
|
started_at: '2023-06-01T00:00:00Z', ended_at: '2024-01-15T00:00:00Z',
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
...ACTIVE_METER,
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'binding-current', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
||||||
|
started_at: '2024-01-15T00:00:00Z', ended_at: null,
|
||||||
|
}],
|
||||||
|
}], total: 2 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') {
|
||||||
|
return Promise.resolve({ data: { items: [
|
||||||
|
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 2, bound_meter_ids: [CLOSED_METER.id, ACTIVE_METER.id] },
|
||||||
|
{ uuid: 'occupied-channel', label: 'Other total', unit: 'kWh', binding_count: 1, bound_meter_ids: [999] },
|
||||||
|
] } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
mockPost.mockResolvedValue({ data: ACTIVE_METER })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByTestId('meter-declare-button'))
|
||||||
|
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
|
||||||
|
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
|
||||||
|
await user.click(screen.getByTestId('meter-reason'))
|
||||||
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
||||||
|
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
||||||
|
|
||||||
|
expect(await screen.findByText('Current total (kWh) — hand off from current meter')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('option', { name: 'Other total (kWh)' })).toHaveAttribute('data-combobox-disabled')
|
||||||
|
await user.click(screen.getByText('Current total (kWh) — hand off from current meter'))
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/meters',
|
||||||
|
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a channel disabled when its current open binding is ambiguous', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const competingMeter = {
|
||||||
|
...ACTIVE_METER,
|
||||||
|
id: 999,
|
||||||
|
label: 'Competing meter',
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'binding-competing', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
||||||
|
started_at: '2025-01-01T00:00:00Z', ended_at: null,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
|
||||||
|
...ACTIVE_METER,
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'binding-current', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
||||||
|
started_at: '2024-01-15T00:00:00Z', ended_at: null,
|
||||||
|
}],
|
||||||
|
}, competingMeter], total: 2 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') {
|
||||||
|
return Promise.resolve({ data: { items: [
|
||||||
|
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 2, bound_meter_ids: [ACTIVE_METER.id, competingMeter.id] },
|
||||||
|
] } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByTestId('meter-declare-button'))
|
||||||
|
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
|
||||||
|
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
|
||||||
|
await user.click(screen.getByTestId('meter-reason'))
|
||||||
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
||||||
|
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
||||||
|
|
||||||
|
expect(await screen.findByRole('option', { name: 'Current total (kWh)' })).toHaveAttribute(
|
||||||
|
'data-combobox-disabled',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a selected handoff channel when the swap date becomes unsafe', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
|
||||||
|
...ACTIVE_METER,
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
||||||
|
started_at: '2024-01-15T00:00:00Z', ended_at: null,
|
||||||
|
}],
|
||||||
|
}], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') {
|
||||||
|
return Promise.resolve({ data: { items: [
|
||||||
|
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 1, bound_meter_ids: [ACTIVE_METER.id] },
|
||||||
|
] } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
mockPost.mockResolvedValue({ data: ACTIVE_METER })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByTestId('meter-declare-button'))
|
||||||
|
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
|
||||||
|
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
|
||||||
|
await user.click(screen.getByTestId('meter-reason'))
|
||||||
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
||||||
|
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
||||||
|
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
||||||
|
|
||||||
|
const startedAt = screen.getByTestId('meter-started-at')
|
||||||
|
await user.clear(startedAt)
|
||||||
|
await user.type(startedAt, '2024-01-15')
|
||||||
|
expect(screen.getAllByLabelText('Compatible source channel (optional)')[0]).toHaveValue('')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/meters',
|
||||||
|
expect.objectContaining({ body: expect.not.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a naive UTC binding timestamp as Amsterdam local time when validating a handoff', async () => {
|
||||||
|
vi.stubEnv('TZ', 'Europe/Amsterdam')
|
||||||
|
try {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
|
||||||
|
...ACTIVE_METER,
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
||||||
|
// SQLite commonly round-trips this UTC instant without a timezone suffix.
|
||||||
|
started_at: '2024-01-14T23:00:00', ended_at: null,
|
||||||
|
}],
|
||||||
|
}], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') {
|
||||||
|
return Promise.resolve({ data: { items: [
|
||||||
|
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 1, bound_meter_ids: [ACTIVE_METER.id] },
|
||||||
|
] } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
mockPost.mockRejectedValueOnce(new Error('keep modal open after unsafe submission'))
|
||||||
|
mockPost.mockResolvedValueOnce({ data: ACTIVE_METER })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByTestId('meter-declare-button'))
|
||||||
|
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
|
||||||
|
await user.type(screen.getByTestId('meter-started-at'), '2024-01-16')
|
||||||
|
await user.click(screen.getByTestId('meter-reason'))
|
||||||
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
||||||
|
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
||||||
|
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
||||||
|
|
||||||
|
const startedAt = screen.getByTestId('meter-started-at')
|
||||||
|
await user.clear(startedAt)
|
||||||
|
await user.type(startedAt, '2024-01-15')
|
||||||
|
expect(screen.getAllByLabelText('Compatible source channel (optional)')[0]).toHaveValue('')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
|
||||||
|
'/api/energy/meters',
|
||||||
|
expect.objectContaining({ body: expect.not.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
|
||||||
|
))
|
||||||
|
|
||||||
|
await user.clear(startedAt)
|
||||||
|
await user.type(startedAt, '2024-01-16')
|
||||||
|
await user.click(screen.getAllByLabelText('Compatible source channel (optional)')[0])
|
||||||
|
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
|
||||||
|
'/api/energy/meters',
|
||||||
|
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
|
||||||
|
))
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
|
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ function toLocalDateInputString(d: Date): string {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface DeclareMeterFormProps {
|
interface DeclareMeterFormProps {
|
||||||
|
meters: MeterResponse[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
||||||
const [label, setLabel] = useState('')
|
const [label, setLabel] = useState('')
|
||||||
const [dateStr, setDateStr] = useState('')
|
const [dateStr, setDateStr] = useState('')
|
||||||
const [reason, setReason] = useState<string | null>(null)
|
const [reason, setReason] = useState<string | null>(null)
|
||||||
@@ -100,8 +101,43 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
const channels = useSourceChannels(sourceUuid)
|
const channels = useSourceChannels(sourceUuid)
|
||||||
|
|
||||||
const declareMutation = useDeclareMeter()
|
const declareMutation = useDeclareMeter()
|
||||||
const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) =>
|
const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity']
|
||||||
({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity'] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
||||||
|
const isChannelEligible = (uuid: string, startedAt: string) => {
|
||||||
|
const channel = channels.data?.items.find((item) => item.uuid === uuid)
|
||||||
|
if (!channel || channel.unit !== expectedUnit) return false
|
||||||
|
|
||||||
|
// Channel aggregates include closed binding history. For a meter swap, only
|
||||||
|
// currently open bindings determine whether this channel can be handed off.
|
||||||
|
const openBindings = meters.flatMap((meter) =>
|
||||||
|
(meter.bindings ?? [])
|
||||||
|
.filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null)
|
||||||
|
.map((binding) => ({ meter, binding })),
|
||||||
|
)
|
||||||
|
const isUnbound = channel.binding_count === 0 &&
|
||||||
|
channel.bound_meter_ids.length === 0 && openBindings.length === 0
|
||||||
|
const oldBinding = openBindings[0]
|
||||||
|
const canHandoff = reason === 'meter_swap' && oldMeter !== undefined &&
|
||||||
|
openBindings.length === 1 && oldBinding !== undefined &&
|
||||||
|
oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity &&
|
||||||
|
startedAt > toLocalDateInputString(parseBackendTimestamp(oldBinding.binding.started_at))
|
||||||
|
return isUnbound || canHandoff
|
||||||
|
}
|
||||||
|
const channelOptions = channels.data?.items
|
||||||
|
.filter((channel) => channel.unit === expectedUnit)
|
||||||
|
.map((channel) => {
|
||||||
|
const canHandoff = !(
|
||||||
|
channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
||||||
|
) && isChannelEligible(channel.uuid, dateStr)
|
||||||
|
return {
|
||||||
|
value: channel.uuid,
|
||||||
|
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : ''}`,
|
||||||
|
disabled: !isChannelEligible(channel.uuid, dateStr),
|
||||||
|
}
|
||||||
|
}) ?? []
|
||||||
|
const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr)
|
||||||
|
? channelUuid
|
||||||
|
: null
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -127,7 +163,7 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
reason: reason as MeterReason,
|
reason: reason as MeterReason,
|
||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
commodity: commodity ?? 'electricity',
|
commodity: commodity ?? 'electricity',
|
||||||
...(channelUuid ? { source_channel_uuid: channelUuid } : {}),
|
...(selectedChannelUuid ? { source_channel_uuid: selectedChannelUuid } : {}),
|
||||||
})
|
})
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -166,7 +202,11 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
value={dateStr}
|
value={dateStr}
|
||||||
onChange={(e) => setDateStr(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
const nextDateStr = e.currentTarget.value
|
||||||
|
setDateStr(nextDateStr)
|
||||||
|
setChannelUuid((uuid) => uuid && !isChannelEligible(uuid, nextDateStr) ? null : uuid)
|
||||||
|
}}
|
||||||
data-testid="meter-started-at"
|
data-testid="meter-started-at"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -175,17 +215,17 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
required
|
required
|
||||||
data={REASON_OPTIONS}
|
data={REASON_OPTIONS}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={setReason}
|
onChange={(value) => { setReason(value); setChannelUuid(null) }}
|
||||||
data-testid="meter-reason"
|
data-testid="meter-reason"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select label="Commodity" value={commodity} onChange={setCommodity} data={[
|
<Select label="Commodity" value={commodity} onChange={(value) => { setCommodity(value); setChannelUuid(null) }} data={[
|
||||||
{ value: 'electricity', label: 'Electricity' },
|
{ value: 'electricity', label: 'Electricity' },
|
||||||
{ value: 'heating', label: 'Heating' },
|
{ value: 'heating', label: 'Heating' },
|
||||||
{ value: 'hot_water', label: 'Hot water' },
|
{ value: 'hot_water', label: 'Hot water' },
|
||||||
]} />
|
]} />
|
||||||
<Select label="Bind source (optional)" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
|
<Select label="Bind source (optional)" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
|
||||||
{sourceUuid && <Select label="Compatible source channel (optional)" value={channelUuid} onChange={setChannelUuid} description="Only unbound channels with the required unit are eligible. Suggestions are informational only." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})${channel.suggested_commodity ? ` — suggestion: ${channel.suggested_commodity}` : ''}` })) ?? []} />}
|
{sourceUuid && <Select label="Compatible source channel (optional)" value={selectedChannelUuid} onChange={setChannelUuid} description={reason === 'meter_swap' ? 'An unbound channel, or the current same-commodity meter’s single binding, can be selected. Other occupied channels stay unavailable.' : 'Only unbound channels with the required unit are eligible.'} data={channelOptions} />}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Note (optional)"
|
label="Note (optional)"
|
||||||
@@ -534,6 +574,7 @@ export function MeterManager() {
|
|||||||
{/* Declare new meter */}
|
{/* Declare new meter */}
|
||||||
{showDeclareForm && (
|
{showDeclareForm && (
|
||||||
<DeclareMeterForm
|
<DeclareMeterForm
|
||||||
|
meters={meters}
|
||||||
onClose={() => setShowDeclareForm(false)}
|
onClose={() => setShowDeclareForm(false)}
|
||||||
onSaved={() => setShowDeclareForm(false)}
|
onSaved={() => setShowDeclareForm(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+192
-1
@@ -31,7 +31,7 @@ Retroactive recompute integration
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -40,6 +40,7 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.energy import Meter
|
from app.models.energy import Meter
|
||||||
|
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared helpers
|
# Shared helpers
|
||||||
@@ -67,6 +68,43 @@ def _declare_payload(**overrides) -> dict:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _add_bound_channel(engine, *, meter_id: int, started_at: datetime) -> str:
|
||||||
|
"""Persist one test-only DSMR channel binding and return its public UUID."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
source = MeterSource(
|
||||||
|
name="Test DSMR",
|
||||||
|
kind="dsmr_mqtt",
|
||||||
|
enabled=True,
|
||||||
|
config={},
|
||||||
|
status="online",
|
||||||
|
created_at=started_at,
|
||||||
|
updated_at=started_at,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
session.flush()
|
||||||
|
channel = MeterSourceChannel(
|
||||||
|
source_id=source.id,
|
||||||
|
channel_key="electricity-total",
|
||||||
|
label="Electricity total",
|
||||||
|
unit="kWh",
|
||||||
|
created_at=started_at,
|
||||||
|
updated_at=started_at,
|
||||||
|
)
|
||||||
|
session.add(channel)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
MeterSourceBinding(
|
||||||
|
meter_id=meter_id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=started_at,
|
||||||
|
created_at=started_at,
|
||||||
|
updated_at=started_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return channel.uuid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -252,6 +290,159 @@ def test_declare_meter_swap_closes_previous(meters_client):
|
|||||||
assert ended_naive == t1
|
assert ended_naive == t1
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_swap_hands_off_previous_meter_channel_atomically(meters_client):
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
t0 = datetime(2024, 6, 1, tzinfo=UTC)
|
||||||
|
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
old_response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert old_response.status_code == 201
|
||||||
|
old_id = old_response.json()["id"]
|
||||||
|
channel_uuid = _add_bound_channel(engine, meter_id=old_id, started_at=t0)
|
||||||
|
response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="New meter",
|
||||||
|
started_at=boundary.isoformat(),
|
||||||
|
reason="meter_swap",
|
||||||
|
source_channel_uuid=channel_uuid,
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
new_id = response.json()["id"]
|
||||||
|
with Session(engine) as session:
|
||||||
|
bindings = session.execute(
|
||||||
|
select(MeterSourceBinding).order_by(MeterSourceBinding.id)
|
||||||
|
).scalars().all()
|
||||||
|
old_binding_ended_at = bindings[0].ended_at
|
||||||
|
if old_binding_ended_at is not None and old_binding_ended_at.tzinfo is None:
|
||||||
|
old_binding_ended_at = old_binding_ended_at.replace(tzinfo=UTC)
|
||||||
|
new_binding_started_at = bindings[1].started_at
|
||||||
|
if new_binding_started_at.tzinfo is None:
|
||||||
|
new_binding_started_at = new_binding_started_at.replace(tzinfo=UTC)
|
||||||
|
assert [(bindings[0].meter_id, old_binding_ended_at), (bindings[1].meter_id, bindings[1].ended_at)] == [
|
||||||
|
(old_id, boundary),
|
||||||
|
(new_id, None),
|
||||||
|
]
|
||||||
|
assert new_binding_started_at == boundary
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_swap_rejects_other_meter_channel_and_rolls_back(meters_client):
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
t0 = datetime(2024, 6, 1, tzinfo=UTC)
|
||||||
|
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
old_response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
old_id = old_response.json()["id"]
|
||||||
|
other = Meter(
|
||||||
|
label="Other meter",
|
||||||
|
commodity="electricity",
|
||||||
|
started_at=t0,
|
||||||
|
ended_at=boundary + timedelta(days=1),
|
||||||
|
reason="initial",
|
||||||
|
created_at=t0,
|
||||||
|
)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(other)
|
||||||
|
session.commit()
|
||||||
|
other_id = other.id
|
||||||
|
channel_uuid = _add_bound_channel(engine, meter_id=other_id, started_at=t0)
|
||||||
|
response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="Rejected meter",
|
||||||
|
started_at=boundary.isoformat(),
|
||||||
|
reason="meter_swap",
|
||||||
|
source_channel_uuid=channel_uuid,
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.execute(select(Meter).where(Meter.label == "Rejected meter")).scalar_one_or_none() is None
|
||||||
|
assert session.get(Meter, old_id).ended_at is None
|
||||||
|
binding = session.execute(select(MeterSourceBinding)).scalar_one()
|
||||||
|
assert binding.ended_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_non_swap_cannot_take_previous_meter_channel(meters_client):
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
t0 = datetime(2024, 6, 1, tzinfo=UTC)
|
||||||
|
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
old_response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
old_id = old_response.json()["id"]
|
||||||
|
channel_uuid = _add_bound_channel(engine, meter_id=old_id, started_at=t0)
|
||||||
|
response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="Moved meter",
|
||||||
|
started_at=boundary.isoformat(),
|
||||||
|
reason="home_move",
|
||||||
|
source_channel_uuid=channel_uuid,
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.get(Meter, old_id).ended_at is None
|
||||||
|
assert session.execute(select(MeterSourceBinding)).scalar_one().ended_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_recompute_failure_rolls_back_handoff(meters_client):
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
t0 = datetime(2024, 6, 1, tzinfo=UTC)
|
||||||
|
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
old_response = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
old_id = old_response.json()["id"]
|
||||||
|
channel_uuid = _add_bound_channel(engine, meter_id=old_id, started_at=t0)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", side_effect=RuntimeError("recompute failed")):
|
||||||
|
with pytest.raises(RuntimeError, match="recompute failed"):
|
||||||
|
client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="Failed meter",
|
||||||
|
started_at=boundary.isoformat(),
|
||||||
|
reason="meter_swap",
|
||||||
|
source_channel_uuid=channel_uuid,
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.execute(select(Meter).where(Meter.label == "Failed meter")).scalar_one_or_none() is None
|
||||||
|
assert session.get(Meter, old_id).ended_at is None
|
||||||
|
assert session.execute(select(MeterSourceBinding)).scalar_one().ended_at is None
|
||||||
|
|
||||||
|
|
||||||
def test_declare_meter_overlap_returns_422(meters_client):
|
def test_declare_meter_overlap_returns_422(meters_client):
|
||||||
"""Declaring a meter with started_at before active meter's started_at → 422."""
|
"""Declaring a meter with started_at before active meter's started_at → 422."""
|
||||||
client, _ = meters_client
|
client, _ = meters_client
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.services.meter_sources import (
|
|||||||
SourceDeleteRestrictedError,
|
SourceDeleteRestrictedError,
|
||||||
close_binding,
|
close_binding,
|
||||||
create_binding,
|
create_binding,
|
||||||
|
create_binding_for_meter_swap,
|
||||||
create_source,
|
create_source,
|
||||||
delete_source,
|
delete_source,
|
||||||
upsert_discovered_channel,
|
upsert_discovered_channel,
|
||||||
@@ -178,6 +179,131 @@ def test_binding_rejects_incompatible_unit_and_close_keeps_transaction_open(sess
|
|||||||
assert session.get(MeterSourceBinding, binding.id) is None
|
assert session.get(MeterSourceBinding, binding.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_swap_hands_off_only_the_previous_meter_binding(session):
|
||||||
|
start = datetime(2026, 8, 22, tzinfo=UTC)
|
||||||
|
boundary = start + timedelta(days=1)
|
||||||
|
old_meter = _meter(session, "heating", "old")
|
||||||
|
old_meter.started_at = start
|
||||||
|
old_meter.ended_at = boundary
|
||||||
|
new_meter = Meter(
|
||||||
|
label="new",
|
||||||
|
commodity="heating",
|
||||||
|
started_at=boundary,
|
||||||
|
reason="meter_swap",
|
||||||
|
created_at=boundary,
|
||||||
|
)
|
||||||
|
session.add(new_meter)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
new_binding = create_binding_for_meter_swap(
|
||||||
|
session,
|
||||||
|
old_meter_id=old_meter.id,
|
||||||
|
new_meter_id=new_meter.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=boundary,
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
assert old_binding.ended_at == boundary
|
||||||
|
assert new_binding.started_at == boundary
|
||||||
|
assert new_binding.ended_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_swap_rejects_channel_owned_by_a_different_meter(session):
|
||||||
|
start = datetime(2026, 8, 22, tzinfo=UTC)
|
||||||
|
boundary = start + timedelta(days=1)
|
||||||
|
old_meter = _meter(session, "heating", "old")
|
||||||
|
new_meter = _meter(session, "heating", "new")
|
||||||
|
other_meter = _meter(session, "heating", "other")
|
||||||
|
old_meter.started_at = start
|
||||||
|
old_meter.ended_at = boundary
|
||||||
|
new_meter.started_at = boundary
|
||||||
|
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||||
|
create_binding(session, meter_id=other_meter.id, channel_id=channel.id, started_at=start)
|
||||||
|
|
||||||
|
with pytest.raises(BindingOverlapError, match="cannot be handed off"):
|
||||||
|
create_binding_for_meter_swap(
|
||||||
|
session,
|
||||||
|
old_meter_id=old_meter.id,
|
||||||
|
new_meter_id=new_meter.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=boundary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_swap_rejects_ambiguous_channel_without_closing_any_binding(session):
|
||||||
|
start = datetime(2026, 8, 22, tzinfo=UTC)
|
||||||
|
boundary = start + timedelta(days=1)
|
||||||
|
old_meter = _meter(session, "heating", "old")
|
||||||
|
old_meter.started_at = start
|
||||||
|
old_meter.ended_at = boundary
|
||||||
|
new_meter = Meter(
|
||||||
|
label="new",
|
||||||
|
commodity="heating",
|
||||||
|
started_at=boundary,
|
||||||
|
reason="meter_swap",
|
||||||
|
created_at=boundary,
|
||||||
|
)
|
||||||
|
other_meter = _meter(session, "heating", "other")
|
||||||
|
session.add(new_meter)
|
||||||
|
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)
|
||||||
|
session.add(
|
||||||
|
MeterSourceBinding(
|
||||||
|
meter_id=other_meter.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=start,
|
||||||
|
created_at=start,
|
||||||
|
updated_at=start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
with pytest.raises(BindingOverlapError, match="occupied or has an ambiguous binding"):
|
||||||
|
create_binding_for_meter_swap(
|
||||||
|
session,
|
||||||
|
old_meter_id=old_meter.id,
|
||||||
|
new_meter_id=new_meter.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=boundary,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert old_binding.ended_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_swap_rejects_incompatible_channel(session):
|
||||||
|
start = datetime(2026, 8, 22, tzinfo=UTC)
|
||||||
|
boundary = start + timedelta(days=1)
|
||||||
|
old_meter = _meter(session, "heating", "old")
|
||||||
|
old_meter.started_at = start
|
||||||
|
old_meter.ended_at = boundary
|
||||||
|
new_meter = Meter(
|
||||||
|
label="new",
|
||||||
|
commodity="heating",
|
||||||
|
started_at=boundary,
|
||||||
|
reason="meter_swap",
|
||||||
|
created_at=boundary,
|
||||||
|
)
|
||||||
|
session.add(new_meter)
|
||||||
|
session.flush()
|
||||||
|
_, channel = _source_and_channel(session, "dsmr_mqtt", "kWh")
|
||||||
|
|
||||||
|
with pytest.raises(BindingValidationError, match="requires unit"):
|
||||||
|
create_binding_for_meter_swap(
|
||||||
|
session,
|
||||||
|
old_meter_id=old_meter.id,
|
||||||
|
new_meter_id=new_meter.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
started_at=boundary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_source_delete_is_restricted_by_discovered_channel(session):
|
def test_source_delete_is_restricted_by_discovered_channel(session):
|
||||||
source, _ = _source_and_channel(session, "dsmr_mqtt", "kWh")
|
source, _ = _source_and_channel(session, "dsmr_mqtt", "kWh")
|
||||||
with pytest.raises(SourceDeleteRestrictedError):
|
with pytest.raises(SourceDeleteRestrictedError):
|
||||||
|
|||||||
Reference in New Issue
Block a user