diff --git a/app/api/routes/api/meters.py b/app/api/routes/api/meters.py index ff4a1c2..146c5c8 100644 --- a/app/api/routes/api/meters.py +++ b/app/api/routes/api/meters.py @@ -61,7 +61,12 @@ from app.schemas.meter import ( MeterPatchRequest, 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.auth import AuthenticatedSession 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) 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( db, label=body.label, @@ -246,28 +254,39 @@ def declare_energy_meter( ).scalar_one_or_none() if channel is None: raise ChannelNotFoundError("Meter source channel was not found.") - create_binding( - db, - meter_id=new_meter.id, - channel_id=channel.id, - started_at=started_at_utc, - ) + if body.reason.value == "meter_swap": + create_binding_for_meter_swap( + db, + old_meter_id=old_meter.id if old_meter is not None else None, + 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: - # declare_meter may already have closed the previous epoch. Rolling back - # here makes Meter + binding declaration genuinely atomic. db.rollback() raise HTTPException( status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError) else status.HTTP_422_UNPROCESSABLE_ENTITY), 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) # Trigger HA discovery re-publish so the new active meter's energy-cost diff --git a/app/services/meter_sources.py b/app/services/meter_sources.py index 99a8b83..0af6a7f 100644 --- a/app/services/meter_sources.py +++ b/app/services/meter_sources.py @@ -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, diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx index 10aaaa9..4c17e3a 100644 --- a/frontend/src/energy/MeterManager.test.tsx +++ b/frontend/src/energy/MeterManager.test.tsx @@ -61,6 +61,7 @@ const ACTIVE_METER = { reason: 'initial', note: null, created_at: '2024-01-15T00:00:00Z', + bindings: [], } 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() + 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() + 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() + 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() + 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 () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx index c88ba79..8fbbd83 100644 --- a/frontend/src/energy/MeterManager.tsx +++ b/frontend/src/energy/MeterManager.tsx @@ -83,11 +83,12 @@ function toLocalDateInputString(d: Date): string { // --------------------------------------------------------------------------- interface DeclareMeterFormProps { + meters: MeterResponse[] onClose: () => void onSaved: () => void } -function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) { +function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { const [label, setLabel] = useState('') const [dateStr, setDateStr] = useState('') const [reason, setReason] = useState(null) @@ -100,8 +101,43 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) { const channels = useSourceChannels(sourceUuid) const declareMutation = useDeclareMeter() - const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) => - ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity ?? 'electricity'] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0 + const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity ?? 'electricity'] + 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) { e.preventDefault() @@ -127,7 +163,7 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) { reason: reason as MeterReason, note: note.trim() || undefined, commodity: commodity ?? 'electricity', - ...(channelUuid ? { source_channel_uuid: channelUuid } : {}), + ...(selectedChannelUuid ? { source_channel_uuid: selectedChannelUuid } : {}), }) onSaved() onClose() @@ -166,7 +202,11 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) { type="date" required 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" /> @@ -175,17 +215,17 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) { required data={REASON_OPTIONS} value={reason} - onChange={setReason} + onChange={(value) => { setReason(value); setChannelUuid(null) }} data-testid="meter-reason" /> - { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} /> - {sourceUuid && }