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"
/>
-