M8-R03: hand off channel binding during meter swap

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 231c340ea6
commit 631b14e2ec
6 changed files with 669 additions and 24 deletions
+197
View File
@@ -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(<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 () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
+49 -8
View File
@@ -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<string | null>(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<string, string>)[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<string, string>)[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"
/>
<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: 'heating', label: 'Heating' },
{ 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 })) ?? []} />
{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 meters single binding, can be selected. Other occupied channels stay unavailable.' : 'Only unbound channels with the required unit are eligible.'} data={channelOptions} />}
<Textarea
label="Note (optional)"
@@ -534,6 +574,7 @@ export function MeterManager() {
{/* Declare new meter */}
{showDeclareForm && (
<DeclareMeterForm
meters={meters}
onClose={() => setShowDeclareForm(false)}
onSaved={() => setShowDeclareForm(false)}
/>