M8-R09: add meter close unbind and transfer workflows
This commit is contained in:
@@ -123,16 +123,57 @@ describe('MeterManager — binding switch safety', () => {
|
|||||||
mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } })
|
mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } })
|
||||||
renderWithProviders(<MeterManager />)
|
renderWithProviders(<MeterManager />)
|
||||||
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
||||||
expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: 'Transfer source' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
it('disables switch submit until the binding timeline has loaded', async () => {
|
it('closes an active meter with one atomic close request and local datetime payload', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {}))
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
mockPost.mockResolvedValue({ data: { ...ACTIVE_METER, ended_at: '2026-08-24T10:00:00Z' } })
|
||||||
renderWithProviders(<MeterManager />)
|
renderWithProviders(<MeterManager />)
|
||||||
await user.click(await screen.findByRole('button', { name: 'Switch source' }))
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||||
expect(await screen.findByText('Loading binding timeline…')).toBeInTheDocument()
|
expect(await screen.findByText(/Every open binding/)).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Switch binding' })).toBeDisabled()
|
await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/meters/{meter_id}/close',
|
||||||
|
expect.objectContaining({ params: { path: { meter_id: ACTIVE_METER.id } }, body: expect.objectContaining({ ended_at: expect.not.stringMatching(/Z$/) }) }),
|
||||||
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers atomic stranded-binding recovery to the adjacent active meter and warns about a gap', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const stranded = {
|
||||||
|
...CLOSED_METER,
|
||||||
|
ended_at: '2024-01-10T00:00:00Z',
|
||||||
|
bindings: [{ uuid: 'stranded-binding', source_uuid: 'dsmr', source_channel_uuid: 'dsmr-total', started_at: '2023-06-01T00:00:00Z', ended_at: null }],
|
||||||
|
}
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [stranded, ACTIVE_METER], total: 2 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'dsmr', name: 'DSMR' }], total: 1 } })
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
||||||
|
expect(await screen.findByText(/one atomic Transfer request/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/defaults to the new meter start/)).toBeInTheDocument()
|
||||||
|
const effectiveAt = screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(effectiveAt)
|
||||||
|
await user.type(effectiveAt, '2024-01-16T12:00')
|
||||||
|
expect(screen.getByText(/leaves an unbound gap/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer recovery when another epoch overlaps the source or target timeline', async () => {
|
||||||
|
const stranded = {
|
||||||
|
...CLOSED_METER,
|
||||||
|
ended_at: '2024-01-10T00:00:00Z',
|
||||||
|
bindings: [{ uuid: 'stranded-binding', source_uuid: 'dsmr', source_channel_uuid: 'dsmr-total', started_at: '2023-06-01T00:00:00Z', ended_at: null }],
|
||||||
|
}
|
||||||
|
const ambiguous = { ...CLOSED_METER, id: 3, started_at: '2024-01-12T00:00:00Z', ended_at: '2024-01-20T00:00:00Z' }
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [stranded, ambiguous, ACTIVE_METER], total: 3 } })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('MeterManager — meter list', () => {
|
describe('MeterManager — meter list', () => {
|
||||||
@@ -285,7 +326,9 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
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(await screen.findByText('Current total (kWh) — hand off from current meter')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('option', { name: 'Other total (kWh)' })).toHaveAttribute('data-combobox-disabled')
|
// Its historical count alone does not make it unavailable: eligibility is
|
||||||
|
// derived from actual open intervals, not lifetime binding_count.
|
||||||
|
expect(screen.getByRole('option', { name: 'Other total (kWh)' })).not.toHaveAttribute('data-combobox-disabled')
|
||||||
await user.click(screen.getByText('Current total (kWh) — hand off from current meter'))
|
await user.click(screen.getByText('Current total (kWh) — hand off from current meter'))
|
||||||
await user.click(screen.getByTestId('declare-meter-submit'))
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
|
||||||
@@ -333,12 +376,12 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
await user.click(await screen.findByText('DSMR'))
|
await user.click(await screen.findByText('DSMR'))
|
||||||
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
||||||
|
|
||||||
expect(await screen.findByRole('option', { name: 'Current total (kWh)' })).toHaveAttribute(
|
expect(await screen.findByRole('option', { name: /Current total \(kWh\).*ambiguous/ })).toHaveAttribute(
|
||||||
'data-combobox-disabled',
|
'data-combobox-disabled',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears a selected handoff channel when the swap date becomes unsafe', async () => {
|
it('disables an equal handoff boundary with a reason and fails closed on submit', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockGet.mockImplementation((path: string) => {
|
mockGet.mockImplementation((path: string) => {
|
||||||
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
|
||||||
@@ -361,28 +404,22 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
renderWithProviders(<MeterManager />)
|
renderWithProviders(<MeterManager />)
|
||||||
await user.click(await screen.findByTestId('meter-declare-button'))
|
await user.click(await screen.findByTestId('meter-declare-button'))
|
||||||
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
|
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
|
||||||
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
|
await user.type(screen.getByTestId('meter-started-at'), '2024-01-15')
|
||||||
await user.click(screen.getByTestId('meter-reason'))
|
await user.click(screen.getByTestId('meter-reason'))
|
||||||
await user.click(await screen.findByText('Meter swap (same address)'))
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
||||||
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
|
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
|
||||||
await user.click(await screen.findByText('DSMR'))
|
await user.click(await screen.findByText('DSMR'))
|
||||||
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
|
const channelInput = screen.getAllByLabelText('Compatible source channel (optional)')[0]
|
||||||
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
await user.click(channelInput)
|
||||||
|
const disabled = await screen.findByText(/handoff boundary must be strictly after the current binding start/)
|
||||||
const startedAt = screen.getByTestId('meter-started-at')
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
||||||
await user.clear(startedAt)
|
await user.keyboard('{Escape}')
|
||||||
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 user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
expect(screen.getByTestId('declare-meter-error')).toHaveTextContent('Handoff boundary must be strictly after')
|
||||||
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
'/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 () => {
|
it('submits an explicitly selected handoff strictly after its binding start', async () => {
|
||||||
vi.stubEnv('TZ', 'Europe/Amsterdam')
|
vi.stubEnv('TZ', 'Europe/Amsterdam')
|
||||||
try {
|
try {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
@@ -391,8 +428,7 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
...ACTIVE_METER,
|
...ACTIVE_METER,
|
||||||
bindings: [{
|
bindings: [{
|
||||||
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
|
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-15T00:00:00Z', ended_at: null,
|
||||||
started_at: '2024-01-14T23:00:00', ended_at: null,
|
|
||||||
}],
|
}],
|
||||||
}], total: 1 } })
|
}], total: 1 } })
|
||||||
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
|
||||||
@@ -403,8 +439,7 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
}
|
}
|
||||||
return Promise.resolve({ data: { items: [] } })
|
return Promise.resolve({ data: { items: [] } })
|
||||||
})
|
})
|
||||||
mockPost.mockRejectedValueOnce(new Error('keep modal open after unsafe submission'))
|
mockPost.mockResolvedValue({ data: ACTIVE_METER })
|
||||||
mockPost.mockResolvedValueOnce({ data: ACTIVE_METER })
|
|
||||||
|
|
||||||
renderWithProviders(<MeterManager />)
|
renderWithProviders(<MeterManager />)
|
||||||
await user.click(await screen.findByTestId('meter-declare-button'))
|
await user.click(await screen.findByTestId('meter-declare-button'))
|
||||||
@@ -418,31 +453,75 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
||||||
|
|
||||||
const startedAt = screen.getByTestId('meter-started-at')
|
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.clear(startedAt)
|
||||||
await user.type(startedAt, '2024-01-16')
|
await user.type(startedAt, '2024-01-16')
|
||||||
await user.click(screen.getAllByLabelText('Compatible source channel (optional)')[0])
|
const channelInput = screen.getAllByLabelText('Compatible source channel (optional)')[0]
|
||||||
|
await user.click(channelInput)
|
||||||
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
||||||
|
await waitFor(() => expect(channelInput).toHaveValue('Current total (kWh) — hand off from current meter'))
|
||||||
await user.click(screen.getByTestId('declare-meter-submit'))
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
|
||||||
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
||||||
'/api/energy/meters',
|
'/api/energy/meters',
|
||||||
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
|
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel', started_at: '2024-01-16T00:00:00' }) }),
|
||||||
))
|
))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
} finally {
|
} finally {
|
||||||
vi.unstubAllEnvs()
|
vi.unstubAllEnvs()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['equal boundary', '2024-01-15', false],
|
||||||
|
['strictly later boundary', '2024-01-16', true],
|
||||||
|
])('handles an implicit auto-handoff at the %s with another commodity open', async (_name, date, submits) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const electricity = {
|
||||||
|
...ACTIVE_METER,
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'electricity-open', source_uuid: 'dsmr-source', source_channel_uuid: 'electricity-kwh',
|
||||||
|
started_at: '2024-01-15T00:00:00Z', ended_at: null,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
const heating = {
|
||||||
|
...ACTIVE_METER,
|
||||||
|
id: 2,
|
||||||
|
label: 'Heating meter',
|
||||||
|
commodity: 'heating',
|
||||||
|
started_at: '2024-01-01T00:00:00Z',
|
||||||
|
bindings: [{
|
||||||
|
uuid: 'heating-open', source_uuid: 'warmtelink-source', source_channel_uuid: 'heating-gj',
|
||||||
|
started_at: '2024-01-01T00:00:00Z', ended_at: null,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [electricity, heating], total: 2 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
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'), date)
|
||||||
|
await user.click(screen.getByTestId('meter-reason'))
|
||||||
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
|
||||||
|
if (!submits) {
|
||||||
|
expect(screen.getByTestId('declare-meter-error')).toHaveTextContent('Handoff boundary must be strictly after')
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters', {
|
||||||
|
body: {
|
||||||
|
label: 'Replacement meter', started_at: '2024-01-16T00:00:00', reason: 'meter_swap',
|
||||||
|
commodity: 'electricity',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
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 } })
|
||||||
@@ -678,3 +757,719 @@ describe('MeterManager — edit meter', () => {
|
|||||||
expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
|
expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('MeterManager — declare channel interval boundaries', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['closed-history overlap', '2025-06-01', false, 'overlaps closed binding history; choose a time at or after it ends'],
|
||||||
|
['closed-history equal end', '2025-12-31', true, null],
|
||||||
|
])('treats Declare Meter %s as %s with the corresponding request state', async (_name, date, eligible, reason) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const source = { uuid: 'source-1', name: 'DSMR' }
|
||||||
|
const channel = { uuid: 'channel-1', label: 'Total import', unit: 'kWh', binding_count: 1, bound_meter_ids: [8] }
|
||||||
|
const history = { ...CLOSED_METER, id: 8, started_at: '2024-01-01T00:00:00Z', ended_at: '2026-01-01T00:00:00Z', bindings: [{ uuid: 'history', source_uuid: source.uuid, source_channel_uuid: channel.uuid, started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [history], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [source], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [channel], total: 1 } })
|
||||||
|
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'), 'Boundary meter')
|
||||||
|
await user.type(screen.getByTestId('meter-started-at'), date)
|
||||||
|
await user.click(screen.getByTestId('meter-reason')); await user.click(await screen.findByText('Initial installation'))
|
||||||
|
const sourceInput = screen.getAllByLabelText('Bind source (optional)').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(sourceInput); await user.click(await screen.findByText('DSMR'))
|
||||||
|
const channelInput = screen.getAllByLabelText('Compatible source channel (optional)').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(channelInput)
|
||||||
|
if (!eligible) {
|
||||||
|
const disabled = await screen.findByText(new RegExp(reason!))
|
||||||
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await user.click(await screen.findByText('Total import (kWh)'))
|
||||||
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters', expect.objectContaining({
|
||||||
|
body: expect.objectContaining({ source_channel_uuid: 'channel-1', started_at: '2025-12-31T00:00:00' }),
|
||||||
|
})))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// These are deliberately component-level tests: the important contract is that
|
||||||
|
// each lifecycle dialog sends one endpoint request, rather than a UI-only
|
||||||
|
// assertion which could still regress into a two-step switch.
|
||||||
|
describe('MeterManager — lifecycle modal submissions', () => {
|
||||||
|
const source = { uuid: 'source-1', name: 'DSMR' }
|
||||||
|
const channel = { uuid: 'channel-1', label: 'Total import', unit: 'kWh', binding_count: 0, bound_meter_ids: [] }
|
||||||
|
const mockLifecycleReads = (items: unknown[], channels = [channel], sources = [source]) => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items, total: items.length } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: sources, total: sources.length } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: channels, total: channels.length } })
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const chooseChannel = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
const labelledInput = (label: string) => screen.getAllByLabelText(label).find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(labelledInput('Source'))
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
await user.click(labelledInput('Source channel'))
|
||||||
|
await user.click(await screen.findByText('Total import (kWh)'))
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('unbinds an active binding with one PATCH, keeps the dialog after FastAPI detail-array error, and retries', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'open-binding', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-15T00:00:00Z', ended_at: null }
|
||||||
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }])
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPatch.mockRejectedValueOnce(new ApiError(422, { detail: [{ msg: 'unbind must follow binding start' }] }))
|
||||||
|
mockPatch.mockResolvedValueOnce({ data: { ...binding, ended_at: '2026-08-24T10:00:00' } })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||||
|
const input = screen.getByTestId('unbind-modal-open-binding').querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(input); await user.type(input, '2026-08-24T10:00')
|
||||||
|
const unbindButtons = screen.getAllByRole('button', { name: 'Unbind' })
|
||||||
|
await user.click(unbindButtons[unbindButtons.length - 1])
|
||||||
|
await waitFor(() => expect(mockPatch).toHaveBeenCalledWith('/api/energy/bindings/{binding_uuid}', expect.objectContaining({
|
||||||
|
params: { path: { binding_uuid: 'open-binding' } }, body: { ended_at: '2026-08-24T10:00' },
|
||||||
|
})))
|
||||||
|
expect(await screen.findByText('unbind must follow binding start')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('unbind-modal-open-binding')).toBeInTheDocument()
|
||||||
|
const retryUnbindButtons = screen.getAllByRole('button', { name: 'Unbind' })
|
||||||
|
await user.click(retryUnbindButtons[retryUnbindButtons.length - 1])
|
||||||
|
await waitFor(() => expect(mockPatch).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('binds an unbound active meter directly with local datetime and one create-binding request', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockLifecycleReads([ACTIVE_METER])
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(start); await user.type(start, '2026-08-24T10:00')
|
||||||
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(bindButtons[bindButtons.length - 1])
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: ACTIVE_METER.id } }, body: { source_channel_uuid: 'channel-1', started_at: '2026-08-24T10:00' },
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transfers on the same meter with one atomic request and excludes its source binding from channel eligibility', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'same-source', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2024-01-15T00:00:00Z', ended_at: null }
|
||||||
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }], [{ ...channel, binding_count: 1 }])
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Transfer source' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
const effective = screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(effective); await user.type(effective, '2026-08-24T10:00')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: ACTIVE_METER.id } }, body: { from_binding_uuid: 'same-source', to_source_channel_uuid: 'channel-1', effective_at: '2026-08-24T10:00' },
|
||||||
|
})))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers a stranded binding to the unique later active meter in one transfer at its default start', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2024-01-10T00:00:00Z', bindings: [{ uuid: 'stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2023-06-01T00:00:00Z', ended_at: null }] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2024-01-15T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads([stranded, target])
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
||||||
|
expect(screen.getByTestId('transfer-effective-at')).toHaveValue('2024-01-15T00:00')
|
||||||
|
await chooseChannel(user)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: 7 } }, body: { from_binding_uuid: 'stranded', to_source_channel_uuid: 'channel-1', effective_at: '2024-01-15T00:00' },
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows source loading and channel fetch failure without enabling a submit request', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return new Promise(() => {})
|
||||||
|
return Promise.reject(new Error('channel fetch failed'))
|
||||||
|
})
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
expect(await screen.findByText('Loading sources…')).toBeInTheDocument()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a source error and an enabled channel-query loading or error state independently', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.reject(new Error('source fetch failed'))
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
expect(await screen.findByText('Could not load sources. Retry after the connection recovers.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [source], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.reject(new Error('channel fetch failed'))
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[0])
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
await user.click((screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT'))!)
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
expect(await screen.findByText('Could not load source channels. Choose another source or retry.')).toBeInTheDocument()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a direct-bind selection that becomes a closed-history overlap and does not submit it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const target = { ...ACTIVE_METER, bindings: [{ uuid: 'history', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
||||||
|
mockLifecycleReads([target])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(start); await user.type(start, '2025-06-01T10:00')
|
||||||
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
||||||
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(bindButtons[bindButtons.length - 1])
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a direct-bind selection at the equal closed-history boundary and submits it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const target = { ...ACTIVE_METER, bindings: [{ uuid: 'history', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
||||||
|
mockLifecycleReads([target]); mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
||||||
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(start); await user.type(start, '2025-12-31T00:00')
|
||||||
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(bindButtons[bindButtons.length - 1])
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings', expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'channel-1', started_at: '2025-12-31T00:00' }) })))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a same-meter transfer selection that becomes earlier than its source and does not submit it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'same-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-06-01T00:00:00Z', ended_at: null }
|
||||||
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }]); renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Transfer source' })); await chooseChannel(user)
|
||||||
|
const effective = screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(effective); await user.type(effective, '2025-05-31T23:59')
|
||||||
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a same-meter transfer exactly at its source start with a specific reason, but submits one minute later', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'same-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-06-01T00:00:00Z', ended_at: null }
|
||||||
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }])
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Transfer source' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
const effective = screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(effective); await user.type(effective, '2025-06-01T00:00')
|
||||||
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByText('Select a unit-compatible source channel.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
||||||
|
expect(await screen.findByText(/strictly after the source binding start/)).toBeInTheDocument()
|
||||||
|
await user.clear(effective); await user.type(effective, '2025-06-01T00:01')
|
||||||
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
||||||
|
await user.click(await screen.findByText('Total import (kWh)'))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: ACTIVE_METER.id } },
|
||||||
|
body: { from_binding_uuid: 'same-source', to_source_channel_uuid: 'channel-1', effective_at: '2025-06-01T00:01' },
|
||||||
|
})))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not apply the same-meter source-start rule to legal cross-meter recovery', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
// The transfer service closes this old binding at the closed Meter end.
|
||||||
|
// Keep its start strictly earlier: [2025-05-31, 2025-06-01) is a real,
|
||||||
|
// non-zero source interval while the two Meter epochs meet at the selected
|
||||||
|
// effective boundary.
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2025-06-01T00:00:00Z', bindings: [{ uuid: 'stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-05-31T00:00:00Z', ended_at: null }] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-06-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads([stranded, target]); mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
||||||
|
expect(screen.getByTestId('transfer-effective-at')).toHaveValue('2025-06-01T00:00')
|
||||||
|
await chooseChannel(user)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: 7 } },
|
||||||
|
body: expect.objectContaining({ from_binding_uuid: 'stranded', effective_at: '2025-06-01T00:00' }),
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed for an anomalous cross recovery source interval that the server would reject', async () => {
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2025-06-01T00:00:00Z', bindings: [{ uuid: 'bad-stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-06-01T00:00:00Z', ended_at: null }] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-06-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads([stranded, target])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Cannot recover: the source binding starts at or after this Meter ended/)).toBeInTheDocument()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a closed meter end as the unbind default and sends it in the PATCH payload', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'closed-binding', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
||||||
|
const closed = { ...CLOSED_METER, ended_at: '2024-02-02T03:04:00Z', bindings: [binding] }
|
||||||
|
mockLifecycleReads([closed]); mockPatch.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||||
|
expect(screen.getByTestId('unbind-modal-closed-binding').querySelector('input[type="datetime-local"]')).toHaveValue('2024-02-02T03:04')
|
||||||
|
const unbindButtons = screen.getAllByRole('button', { name: 'Unbind' })
|
||||||
|
await user.click(unbindButtons[unbindButtons.length - 1])
|
||||||
|
await waitFor(() => expect(mockPatch).toHaveBeenCalledWith('/api/energy/bindings/{binding_uuid}', expect.objectContaining({
|
||||||
|
params: { path: { binding_uuid: 'closed-binding' } }, body: { ended_at: '2024-02-02T03:04' },
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows channel loading only after source selection and never posts while loading', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [source], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') return new Promise(() => {})
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
expect(screen.queryByText('Loading source channels…')).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!)
|
||||||
|
await user.click(await screen.findByText('DSMR'))
|
||||||
|
expect(await screen.findByText('Loading source channels…')).toBeInTheDocument()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a recovery transfer selection that moves before its target epoch and does not submit it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [{ uuid: 'stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads([stranded, target]); renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' })); await chooseChannel(user)
|
||||||
|
const effective = screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(effective); await user.type(effective, '2024-12-31T23:59')
|
||||||
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Close Meter open after its own error and retries the close endpoint', async () => {
|
||||||
|
const user = userEvent.setup(); const { ApiError } = await import('../api/client')
|
||||||
|
mockLifecycleReads([ACTIVE_METER])
|
||||||
|
mockPost.mockRejectedValueOnce(new ApiError(422, { detail: 'close must follow meter start' }))
|
||||||
|
mockPost.mockResolvedValueOnce({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||||
|
const modal = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)
|
||||||
|
const input = modal.querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(input); await user.type(input, '2026-08-24T10:00')
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/close', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: ACTIVE_METER.id } }, body: { ended_at: '2026-08-24T10:00' },
|
||||||
|
})))
|
||||||
|
expect(await screen.findByText('close must follow meter start')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)).toBeInTheDocument()
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Direct Bind open after its own error and retries the create endpoint', async () => {
|
||||||
|
const user = userEvent.setup(); const { ApiError } = await import('../api/client')
|
||||||
|
mockLifecycleReads([ACTIVE_METER])
|
||||||
|
mockPost.mockRejectedValueOnce(new ApiError(422, { detail: 'binding conflict' }))
|
||||||
|
mockPost.mockResolvedValueOnce({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
||||||
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(bindButtons[bindButtons.length - 1])
|
||||||
|
expect(await screen.findByText('binding conflict')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`)).toBeInTheDocument()
|
||||||
|
const retryButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(retryButtons[retryButtons.length - 1])
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['same', false],
|
||||||
|
['cross recovery', true],
|
||||||
|
])('keeps %s Transfer open after failure, states no partial save, and retries', async (_name, recovery) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'transfer-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: recovery ? '2024-01-01T00:00:00Z' : '2024-02-01T00:00:00Z', ended_at: null }
|
||||||
|
const sourceMeter = recovery
|
||||||
|
? { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
||||||
|
: { ...ACTIVE_METER, bindings: [binding] }
|
||||||
|
const target = recovery ? { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] } : undefined
|
||||||
|
mockLifecycleReads(target ? [sourceMeter, target] : [sourceMeter])
|
||||||
|
mockPost.mockRejectedValueOnce(new Error('network failure'))
|
||||||
|
mockPost.mockResolvedValueOnce({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: recovery ? 'Recover binding' : 'Transfer source' }))[0])
|
||||||
|
await chooseChannel(user); await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1))
|
||||||
|
expect(await screen.findByText('Transfer failed. No partial source switch was saved.')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/No partial source switch was saved/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('transfer-modal-transfer-source')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['close', 'unbind', 'direct bind', 'same-meter transfer', 'cross-meter recovery'])(
|
||||||
|
'submits %s with Enter once while pending, disables its button, and ignores a second click and Enter',
|
||||||
|
async (entry) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
let release!: () => void
|
||||||
|
const pending = new Promise((resolve) => { release = () => resolve({ data: {} }) })
|
||||||
|
const binding = { uuid: 'pending-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-02-01T00:00:00Z', ended_at: null }
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads(entry === 'close' || entry === 'direct bind' ? [ACTIVE_METER] : entry === 'cross-meter recovery' ? [stranded, target] : [{ ...ACTIVE_METER, bindings: [binding] }])
|
||||||
|
if (entry === 'unbind') mockPatch.mockReturnValue(pending)
|
||||||
|
else mockPost.mockReturnValue(pending)
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
let input: HTMLInputElement
|
||||||
|
let submit: HTMLElement
|
||||||
|
if (entry === 'close') {
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||||
|
input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
submit = screen.getAllByRole('button', { name: 'Close meter' })[1]
|
||||||
|
} else if (entry === 'unbind') {
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||||
|
input = screen.getByTestId('unbind-modal-pending-source').querySelector('input[type="datetime-local"]')!
|
||||||
|
submit = screen.getAllByRole('button', { name: 'Unbind' })[1]
|
||||||
|
} else {
|
||||||
|
await user.click(await screen.findByRole('button', { name: entry === 'direct bind' ? 'Bind source' : entry === 'same-meter transfer' ? 'Transfer source' : 'Recover binding' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
input = entry === 'direct bind'
|
||||||
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
: screen.getByTestId('transfer-effective-at')
|
||||||
|
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct bind' ? 'Bind source' : 'Transfer binding' })
|
||||||
|
submit = submitButtons[submitButtons.length - 1]
|
||||||
|
}
|
||||||
|
await user.type(input, '{Enter}')
|
||||||
|
await waitFor(() => expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1))
|
||||||
|
expect(submit).toBeDisabled()
|
||||||
|
if (entry === 'unbind') expect(mockPatch).toHaveBeenCalledWith('/api/energy/bindings/{binding_uuid}', expect.objectContaining({ params: { path: { binding_uuid: 'pending-source' } } }))
|
||||||
|
else if (entry === 'close') expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/close', expect.objectContaining({ params: { path: { meter_id: ACTIVE_METER.id } } }))
|
||||||
|
else expect(mockPost).toHaveBeenCalledWith(entry === 'direct bind' ? '/api/energy/meters/{meter_id}/bindings' : '/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({ params: { path: { meter_id: entry === 'cross-meter recovery' ? 7 : ACTIVE_METER.id } } }))
|
||||||
|
await user.click(submit)
|
||||||
|
await user.type(input, '{Enter}')
|
||||||
|
expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
release()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('rejects an empty Direct Bind datetime without a request', async () => {
|
||||||
|
const user = userEvent.setup(); mockLifecycleReads([ACTIVE_METER])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
||||||
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(start)
|
||||||
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(bindButtons[bindButtons.length - 1])
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByText(/valid binding start time|Select a genuinely unbound/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['close', 'unbind', 'direct', 'same', 'cross'])(
|
||||||
|
'keeps the %s required-form empty case as a separate no-request regression',
|
||||||
|
async (entry) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'empty-regression-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-02-01T00:00:00Z', ended_at: null }
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads(entry === 'close' || entry === 'direct' ? [ACTIVE_METER] : entry === 'cross' ? [stranded, target] : [{ ...ACTIVE_METER, bindings: [binding] }])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
if (entry === 'close') {
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||||
|
const input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input')!
|
||||||
|
await user.clear(input); await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
||||||
|
} else if (entry === 'unbind') {
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||||
|
const input = screen.getByTestId('unbind-modal-empty-regression-source').querySelector('input')!
|
||||||
|
await user.clear(input); await user.click(screen.getAllByRole('button', { name: 'Unbind' })[1])
|
||||||
|
} else {
|
||||||
|
await user.click(await screen.findByRole('button', { name: entry === 'direct' ? 'Bind source' : entry === 'same' ? 'Transfer source' : 'Recover binding' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
const input = entry === 'direct'
|
||||||
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
: screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(input)
|
||||||
|
const submits = screen.getAllByRole('button', { name: entry === 'direct' ? 'Bind source' : 'Transfer binding' })
|
||||||
|
await user.click(submits[submits.length - 1])
|
||||||
|
}
|
||||||
|
expect(mockPost).not.toHaveBeenCalled(); expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['unit mismatch', { ...channel, unit: 'GJ' }, 'unit mismatch: GJ'],
|
||||||
|
['retained anomaly: open occupancy', channel, 'occupied by an open binding'],
|
||||||
|
['retained anomaly: ambiguity', channel, 'ambiguous: channel has overlapping binding history'],
|
||||||
|
])('shows Direct Bind %s reason and sends no request', async (_name, selected, reason) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const selectedSource = selected.unit === 'GJ'
|
||||||
|
? { uuid: 'warmtelink-source', name: 'WarmteLink', kind: 'warmtelink_serial' }
|
||||||
|
: source
|
||||||
|
const conflicts = selected.unit === 'kWh' ? [{ ...ACTIVE_METER, id: 9, bindings: [
|
||||||
|
{ uuid: 'conflict-a', source_channel_uuid: 'channel-1', started_at: '2024-02-01T00:00:00Z', ended_at: null },
|
||||||
|
...(_name.includes('ambiguity') ? [{ uuid: 'conflict-b', source_channel_uuid: 'channel-1', started_at: '2024-03-01T00:00:00Z', ended_at: null }] : []),
|
||||||
|
] }] : []
|
||||||
|
mockLifecycleReads([ACTIVE_METER, ...conflicts], [selected], [selectedSource])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
||||||
|
const sourceInput = screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(sourceInput); await user.click(await screen.findByText(selectedSource.name))
|
||||||
|
const channelInput = screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(channelInput)
|
||||||
|
const disabled = await screen.findByText(new RegExp(reason))
|
||||||
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
const submits = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(submits[submits.length - 1])
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers recovery only to the unique latest predecessor in a multi-generation timeline', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const older = { ...CLOSED_METER, id: 2, started_at: '2023-01-01T00:00:00Z', ended_at: '2024-01-01T00:00:00Z', bindings: [{ uuid: 'older', source_channel_uuid: 'older-channel', started_at: '2023-01-01T00:00:00Z', ended_at: null }] }
|
||||||
|
const binding = { uuid: 'predecessor', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
||||||
|
const predecessor = { ...CLOSED_METER, id: 3, started_at: '2024-01-01T00:00:00Z', ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads([older, predecessor, target]); mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
expect(await screen.findByRole('button', { name: 'Recover binding' })).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Recover binding' })); await chooseChannel(user)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: 7 } },
|
||||||
|
body: { from_binding_uuid: 'predecessor', to_source_channel_uuid: 'channel-1', effective_at: '2025-01-01T00:00' },
|
||||||
|
})))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['two equally recent predecessors', [
|
||||||
|
{ ...CLOSED_METER, id: 3, ended_at: '2025-01-01T00:00:00Z', bindings: [{ uuid: 'a', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] },
|
||||||
|
{ ...CLOSED_METER, id: 4, ended_at: '2025-01-01T00:00:00Z', bindings: [{ uuid: 'b', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] },
|
||||||
|
]],
|
||||||
|
['a non-predecessor stranded meter', [
|
||||||
|
{ ...CLOSED_METER, id: 3, ended_at: '2024-12-31T00:00:00Z', bindings: [{ uuid: 'old', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] },
|
||||||
|
{ ...CLOSED_METER, id: 4, ended_at: '2025-01-01T00:00:00Z', bindings: [] },
|
||||||
|
]],
|
||||||
|
])('fails closed for recovery with %s', async (_name, closed) => {
|
||||||
|
mockLifecycleReads([...closed, { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Close Meter equal to meter start', 'close', '2024-01-15T00:00', 'Close time must be strictly after the meter start.'],
|
||||||
|
['Unbind equal to binding start', 'unbind', '2024-02-01T00:00', 'Unbind time must be strictly after the binding start.'],
|
||||||
|
['Direct Bind in the future', 'direct', '2099-01-01T00:00', 'future binding start times are not allowed'],
|
||||||
|
['same-Meter Transfer equal to source start', 'same', '2024-02-01T00:00', 'same-meter transfer must be strictly after the source binding start'],
|
||||||
|
['cross-Meter Recovery before target epoch', 'cross', '2024-12-31T23:59', 'effective time must be within the target meter epoch'],
|
||||||
|
])('rejects %s expressible datetime boundary with a reason and no request', async (_name, entry, replacement, reason) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const binding = { uuid: 'empty-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-02-01T00:00:00Z', ended_at: null }
|
||||||
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads(entry === 'close' || entry === 'direct' ? [ACTIVE_METER] : entry === 'unbind' || entry === 'same'
|
||||||
|
? [{ ...ACTIVE_METER, bindings: [binding] }] : [stranded, target])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
if (entry === 'close') {
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||||
|
const input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input')!
|
||||||
|
await user.clear(input); await user.type(input, replacement); await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
||||||
|
} else if (entry === 'unbind') {
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||||
|
const input = screen.getByTestId('unbind-modal-empty-source').querySelector('input')!
|
||||||
|
await user.clear(input); await user.type(input, replacement); await user.click(screen.getAllByRole('button', { name: 'Unbind' })[1])
|
||||||
|
} else {
|
||||||
|
await user.click(await screen.findByRole('button', { name: entry === 'direct' ? 'Bind source' : entry === 'same' ? 'Transfer source' : 'Recover binding' }))
|
||||||
|
await chooseChannel(user)
|
||||||
|
const input = entry === 'direct'
|
||||||
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
: screen.getByTestId('transfer-effective-at')
|
||||||
|
await user.clear(input); await user.type(input, replacement)
|
||||||
|
if (entry === 'direct' || entry === 'same' || entry === 'cross') {
|
||||||
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
||||||
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
||||||
|
const disabled = await screen.findByText(new RegExp(reason))
|
||||||
|
expect(disabled.closest('[role="option"]')).toHaveAttribute(
|
||||||
|
'data-combobox-disabled',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct' ? 'Bind source' : 'Transfer binding' })
|
||||||
|
await user.click(submitButtons[submitButtons.length - 1])
|
||||||
|
}
|
||||||
|
if (entry === 'close' || entry === 'unbind') expect(await screen.findByText(reason)).toBeInTheDocument()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled(); expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['before closed history', '2024-12-31T23:59', 'overlaps closed binding history; choose a time at or after it ends'],
|
||||||
|
['inside closed history', '2025-06-01T00:00', 'overlaps closed binding history; choose a time at or after it ends'],
|
||||||
|
['at target epoch start', '2023-12-31T23:59', 'binding start must be within this meter epoch'],
|
||||||
|
['future', '2099-01-01T00:00', 'future binding start times are not allowed'],
|
||||||
|
])('shows Direct Bind %s reason, clears selection, and sends no request', async (_name, value, reason) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const target = { ...ACTIVE_METER, bindings: [{ uuid: 'closed-history', source_channel_uuid: 'channel-1', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
||||||
|
mockLifecycleReads([target]); renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
||||||
|
const input = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||||
|
await user.clear(input); await user.type(input, value)
|
||||||
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
||||||
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
||||||
|
const disabled = await screen.findByText(new RegExp(reason))
|
||||||
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
const submits = screen.getAllByRole('button', { name: 'Bind source' })
|
||||||
|
await user.click(submits[submits.length - 1])
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['same target channel early', false, '2023-12-31T23:59', 'effective time must be within the target meter epoch'],
|
||||||
|
['same source equality', false, '2025-06-01T00:00', 'same-meter transfer must be strictly after the source binding start'],
|
||||||
|
['same future', false, '2099-01-01T00:00', 'future effective times are not allowed'],
|
||||||
|
['cross target epoch early', true, '2024-12-31T23:59', 'effective time must be within the target meter epoch'],
|
||||||
|
['cross target channel overlap', true, '2025-01-02T00:00', 'overlaps closed binding history; choose a time at or after it ends'],
|
||||||
|
['cross target channel unit mismatch', true, '2025-01-02T00:00', 'unit mismatch: GJ; target needs kWh'],
|
||||||
|
['cross retained anomaly: target channel open occupancy', true, '2025-01-02T00:00', 'occupied by an open binding; close or transfer it first'],
|
||||||
|
['cross retained anomaly: target channel ambiguity', true, '2025-01-02T00:00', 'ambiguous: channel has overlapping binding history; resolve it first'],
|
||||||
|
['cross future', true, '2099-01-01T00:00', 'future effective times are not allowed'],
|
||||||
|
])('shows %s reason, clears selection, and sends no transfer request', async (_name, recovery, value, reason) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const sourceBinding = { uuid: 'matrix-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: recovery ? '2024-01-01T00:00:00Z' : '2025-06-01T00:00:00Z', ended_at: null }
|
||||||
|
const sourceMeter = recovery ? { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [sourceBinding] } : { ...ACTIVE_METER, bindings: [sourceBinding] }
|
||||||
|
const target = recovery ? { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] } : sourceMeter
|
||||||
|
const targetStart = recovery ? '2025-01-01T00:00:00Z' : '2024-01-15T00:00:00Z'
|
||||||
|
const conflictBindings = _name.includes('overlap') ? [{ uuid: 'history', source_channel_uuid: 'channel-1', started_at: recovery ? '2025-01-01T00:00:00Z' : '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] :
|
||||||
|
_name.includes('open occupancy') ? [{ uuid: 'bad-stranded-open', source_channel_uuid: 'channel-1', started_at: targetStart, ended_at: null }] :
|
||||||
|
_name.includes('ambiguity') ? [{ uuid: 'bad-stranded-a', source_channel_uuid: 'channel-1', started_at: targetStart, ended_at: null }, { uuid: 'bad-stranded-b', source_channel_uuid: 'channel-1', started_at: targetStart, ended_at: null }] : []
|
||||||
|
const targetWithConflict = { ...target, bindings: [...(target.bindings ?? []), ...conflictBindings] }
|
||||||
|
const selected = _name.includes('unit mismatch') ? { ...channel, unit: 'GJ' } : channel
|
||||||
|
const selectedSource = selected.unit === 'GJ'
|
||||||
|
? { uuid: 'warmtelink-source', name: 'WarmteLink', kind: 'warmtelink_serial' }
|
||||||
|
: source
|
||||||
|
mockLifecycleReads(recovery ? [sourceMeter, targetWithConflict] : [targetWithConflict], [selected], [selectedSource])
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: recovery ? 'Recover binding' : 'Transfer source' }))[0])
|
||||||
|
const sourceInput = screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(sourceInput); await user.click(await screen.findByText(selectedSource.name))
|
||||||
|
const channelInput = screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!
|
||||||
|
// Rows that begin eligible exercise a real selection before the invalid
|
||||||
|
// time edit; initially invalid rows instead prove their disabled option.
|
||||||
|
if (!_name.includes('unit mismatch') && !_name.includes('open occupancy') && !_name.includes('ambiguity')) {
|
||||||
|
await user.click(channelInput); await user.click(await screen.findByText(/Total import \(kWh\)/))
|
||||||
|
}
|
||||||
|
const effective = screen.getByTestId('transfer-effective-at'); await user.clear(effective); await user.type(effective, value)
|
||||||
|
await user.click(channelInput)
|
||||||
|
const disabled = await screen.findByText(new RegExp(reason))
|
||||||
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
||||||
|
expect(channelInput).toHaveValue('')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
const submits = screen.getAllByRole('button', { name: 'Transfer binding' })
|
||||||
|
await user.click(submits[submits.length - 1])
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats cross-Meter target-channel history before its start as overlap and its equal end as a valid recovery transfer', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const sourceBinding = { uuid: 'target-history-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
||||||
|
const sourceMeter = { ...CLOSED_METER, id: 3, ended_at: '2025-01-01T00:00:00Z', bindings: [sourceBinding] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
// A same-Meter target-history positive is backend-unreachable: its open
|
||||||
|
// source occupies the target Meter until now. Cross-Meter history is
|
||||||
|
// legal and exercises the actual target-channel interval boundary.
|
||||||
|
const targetWithHistory = {
|
||||||
|
...target,
|
||||||
|
bindings: [...(target.bindings ?? []), {
|
||||||
|
uuid: 'target-history', source_channel_uuid: 'channel-1',
|
||||||
|
started_at: '2025-01-02T00:00:00Z', ended_at: '2025-01-03T00:00:00Z',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
const early = '2025-01-01T00:00'
|
||||||
|
const equalEnd = '2025-01-03T00:00'
|
||||||
|
mockLifecycleReads([sourceMeter, targetWithHistory])
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
||||||
|
const effective = screen.getByTestId('transfer-effective-at')
|
||||||
|
// Select while the target channel is valid, then prove a time edit
|
||||||
|
// clears that real selection and disables the now-conflicting option.
|
||||||
|
await user.clear(effective); await user.type(effective, equalEnd)
|
||||||
|
const sourceInput = screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(sourceInput); await user.click(await screen.findByText('DSMR'))
|
||||||
|
const channelInput = screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!
|
||||||
|
await user.click(channelInput); await user.click(await screen.findByText(/Total import \(kWh\)/))
|
||||||
|
await user.clear(effective); await user.type(effective, early)
|
||||||
|
expect(channelInput).toHaveValue('')
|
||||||
|
await user.click(channelInput)
|
||||||
|
const reason = await screen.findByText(/overlaps closed binding history; choose a time at or after it ends/)
|
||||||
|
expect(reason.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await user.clear(effective); await user.type(effective, equalEnd)
|
||||||
|
await user.click(channelInput); await user.click(await screen.findByText(/Total import \(kWh\)/))
|
||||||
|
expect(channelInput).toHaveValue('Total import (kWh)')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: 7 } },
|
||||||
|
body: { from_binding_uuid: 'target-history-source', to_source_channel_uuid: 'channel-1', effective_at: equalEnd },
|
||||||
|
})))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['recovery gap', '2025-01-02T00:00', true],
|
||||||
|
['recovery target equal boundary', '2025-01-01T00:00', false],
|
||||||
|
])('submits %s with the selected target time and exact transfer endpoint', async (_name, effectiveAt, warns) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const sourceBinding = { uuid: 'recovery-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
||||||
|
const sourceMeter = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [sourceBinding] }
|
||||||
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
||||||
|
mockLifecycleReads([sourceMeter, target]); mockPost.mockResolvedValue({ data: {} }); renderWithProviders(<MeterManager />)
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' })); await chooseChannel(user)
|
||||||
|
const input = screen.getByTestId('transfer-effective-at'); await user.clear(input); await user.type(input, effectiveAt)
|
||||||
|
if (warns) expect(screen.getByText(/leaves an unbound gap/)).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
||||||
|
params: { path: { meter_id: 7 } }, body: { from_binding_uuid: 'recovery-source', to_source_channel_uuid: 'channel-1', effective_at: effectiveAt },
|
||||||
|
})))
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ import {
|
|||||||
useUpdateMeter,
|
useUpdateMeter,
|
||||||
useSources,
|
useSources,
|
||||||
useSourceChannels,
|
useSourceChannels,
|
||||||
useMeterBindings,
|
|
||||||
useCreateBinding,
|
useCreateBinding,
|
||||||
useCloseBinding,
|
useCloseBinding,
|
||||||
|
useCloseMeter,
|
||||||
|
useTransferBinding,
|
||||||
type MeterResponse,
|
type MeterResponse,
|
||||||
type MeterReason,
|
type MeterReason,
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
@@ -78,6 +79,94 @@ function toLocalDateInputString(d: Date): string {
|
|||||||
return `${y}-${m}-${day}`
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLocalDateTimeInputString(d = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${toLocalDateInputString(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedUnit(commodity: string): string {
|
||||||
|
return ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const body = err.body
|
||||||
|
if (typeof body === 'string') return body
|
||||||
|
if (body && typeof body === 'object' && 'detail' in body) {
|
||||||
|
const detail = (body as { detail?: unknown }).detail
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
const messages = detail.map((item) => item && typeof item === 'object' && typeof item.msg === 'string'
|
||||||
|
? item.msg : String(item)).filter(Boolean)
|
||||||
|
if (messages.length) return messages.join('; ')
|
||||||
|
}
|
||||||
|
if (detail != null) return typeof detail === 'object' ? JSON.stringify(detail) : String(detail)
|
||||||
|
}
|
||||||
|
return `${fallback} (error ${err.status}).`
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLocalDateTime(value: string): boolean {
|
||||||
|
return value.trim() !== '' && !Number.isNaN(new Date(value).getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eligibility = { eligible: boolean; reason?: string }
|
||||||
|
|
||||||
|
function localInstant(value: string): number | null {
|
||||||
|
const instant = new Date(value).getTime()
|
||||||
|
return Number.isNaN(instant) ? null : instant
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalsOverlap(start: number, end: number | null, otherStart: number, otherEnd: number | null): boolean {
|
||||||
|
return (end === null || otherStart < end) && (otherEnd === null || start < otherEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelIntervalEligibility(
|
||||||
|
meters: MeterResponse[], channelUuid: string, startedAt: string, excludeBindingUuids: string[] = [],
|
||||||
|
): Eligibility {
|
||||||
|
const start = localInstant(startedAt)
|
||||||
|
if (start === null) return { eligible: false, reason: 'choose a valid start time first' }
|
||||||
|
const conflicts = meters.flatMap((meter) => (meter.bindings ?? []).filter((binding) =>
|
||||||
|
binding.source_channel_uuid === channelUuid && !excludeBindingUuids.includes(binding.uuid) &&
|
||||||
|
intervalsOverlap(start, null, parseBackendTimestamp(binding.started_at).getTime(), binding.ended_at ? parseBackendTimestamp(binding.ended_at).getTime() : null),
|
||||||
|
))
|
||||||
|
if (!conflicts.length) return { eligible: true }
|
||||||
|
if (conflicts.length > 1) return { eligible: false, reason: 'ambiguous: channel has overlapping binding history; resolve it first' }
|
||||||
|
return conflicts[0].ended_at === null
|
||||||
|
? { eligible: false, reason: 'occupied by an open binding; close or transfer it first' }
|
||||||
|
: { eligible: false, reason: 'overlaps closed binding history; choose a time at or after it ends' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function meterContainsInstant(meter: MeterResponse, instant: number): boolean {
|
||||||
|
const start = parseBackendTimestamp(meter.started_at).getTime()
|
||||||
|
const end = meter.ended_at ? parseBackendTimestamp(meter.ended_at).getTime() : null
|
||||||
|
return instant >= start && (end === null || instant < end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): MeterResponse | null {
|
||||||
|
if (meter.ended_at === null) return null
|
||||||
|
const active = meters.filter((candidate) => candidate.commodity === meter.commodity && candidate.ended_at === null)
|
||||||
|
if (active.length !== 1) return null
|
||||||
|
const target = active[0]
|
||||||
|
const targetStart = parseBackendTimestamp(target.started_at).getTime()
|
||||||
|
const predecessors = meters.filter((candidate) => candidate.commodity === meter.commodity && candidate.ended_at !== null &&
|
||||||
|
parseBackendTimestamp(candidate.ended_at).getTime() <= targetStart)
|
||||||
|
const latestEnd = Math.max(...predecessors.map((candidate) => parseBackendTimestamp(candidate.ended_at!).getTime()))
|
||||||
|
const immediate = predecessors.filter((candidate) => parseBackendTimestamp(candidate.ended_at!).getTime() === latestEnd)
|
||||||
|
if (immediate.length !== 1 || immediate[0].id !== meter.id) return null
|
||||||
|
const sourceStart = parseBackendTimestamp(meter.started_at).getTime()
|
||||||
|
const sourceEnd = parseBackendTimestamp(meter.ended_at).getTime()
|
||||||
|
for (const candidate of meters) {
|
||||||
|
if (candidate.id === meter.id || candidate.id === target.id || candidate.commodity !== meter.commodity) continue
|
||||||
|
const candidateStart = parseBackendTimestamp(candidate.started_at).getTime()
|
||||||
|
const candidateEnd = candidate.ended_at ? parseBackendTimestamp(candidate.ended_at).getTime() : null
|
||||||
|
if (intervalsOverlap(sourceStart, sourceEnd, candidateStart, candidateEnd) ||
|
||||||
|
intervalsOverlap(targetStart, null, candidateStart, candidateEnd)) return null
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Declare meter form (modal)
|
// Declare meter form (modal)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -101,11 +190,16 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
const channels = useSourceChannels(sourceUuid)
|
const channels = useSourceChannels(sourceUuid)
|
||||||
|
|
||||||
const declareMutation = useDeclareMeter()
|
const declareMutation = useDeclareMeter()
|
||||||
const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity']
|
const unit = expectedUnit(commodity ?? 'electricity')
|
||||||
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
||||||
const isChannelEligible = (uuid: string, startedAt: string) => {
|
const channelEligibility = (uuid: string, startedAt: string): Eligibility => {
|
||||||
const channel = channels.data?.items.find((item) => item.uuid === uuid)
|
const channel = channels.data?.items.find((item) => item.uuid === uuid)
|
||||||
if (!channel || channel.unit !== expectedUnit) return false
|
if (!channel) return { eligible: false, reason: 'channel is unavailable; reload the source' }
|
||||||
|
if (channel.unit !== unit) return { eligible: false, reason: `unit mismatch: ${channel.unit}; this meter needs ${unit}` }
|
||||||
|
if (!startedAt) return { eligible: false, reason: 'choose a start date first' }
|
||||||
|
const start = localInstant(toLocalMidnightNaive(startedAt))
|
||||||
|
if (start === null) return { eligible: false, reason: 'choose a valid start date first' }
|
||||||
|
if (startedAt > toLocalDateInputString(new Date())) return { eligible: false, reason: 'future start dates cannot bind a channel' }
|
||||||
|
|
||||||
// Channel aggregates include closed binding history. For a meter swap, only
|
// Channel aggregates include closed binding history. For a meter swap, only
|
||||||
// currently open bindings determine whether this channel can be handed off.
|
// currently open bindings determine whether this channel can be handed off.
|
||||||
@@ -114,30 +208,45 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
.filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null)
|
.filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null)
|
||||||
.map((binding) => ({ meter, binding })),
|
.map((binding) => ({ meter, binding })),
|
||||||
)
|
)
|
||||||
const isUnbound = channel.binding_count === 0 &&
|
|
||||||
channel.bound_meter_ids.length === 0 && openBindings.length === 0
|
|
||||||
const oldBinding = openBindings[0]
|
const oldBinding = openBindings[0]
|
||||||
const canHandoff = reason === 'meter_swap' && oldMeter !== undefined &&
|
const isSingleOldBinding = reason === 'meter_swap' && oldMeter !== undefined &&
|
||||||
openBindings.length === 1 && oldBinding !== undefined &&
|
openBindings.length === 1 && oldBinding !== undefined &&
|
||||||
oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity &&
|
oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity
|
||||||
startedAt > toLocalDateInputString(parseBackendTimestamp(oldBinding.binding.started_at))
|
// A handoff must leave a non-empty interval on the old Meter. The backend
|
||||||
return isUnbound || canHandoff
|
// rejects equality too, so surface it here instead of offering a request
|
||||||
|
// that is guaranteed to fail.
|
||||||
|
if (isSingleOldBinding && start <= parseBackendTimestamp(oldBinding.binding.started_at).getTime()) {
|
||||||
|
return { eligible: false, reason: 'handoff boundary must be strictly after the current binding start' }
|
||||||
}
|
}
|
||||||
const channelOptions = channels.data?.items
|
const canHandoff = isSingleOldBinding
|
||||||
.filter((channel) => channel.unit === expectedUnit)
|
const interval = channelIntervalEligibility(
|
||||||
.map((channel) => {
|
meters, channel.uuid, toLocalMidnightNaive(startedAt), canHandoff ? [oldBinding.binding.uuid] : [],
|
||||||
const canHandoff = !(
|
)
|
||||||
channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
if (!interval.eligible) return interval
|
||||||
) && isChannelEligible(channel.uuid, dateStr)
|
if (openBindings.length && !canHandoff) {
|
||||||
|
return openBindings.length > 1
|
||||||
|
? { eligible: false, reason: 'ambiguous: multiple open bindings; resolve them first' }
|
||||||
|
: { eligible: false, reason: 'occupied by an open binding; choose a different channel or close/transfer it first' }
|
||||||
|
}
|
||||||
|
return { eligible: true }
|
||||||
|
}
|
||||||
|
const channelOptions = channels.data?.items.map((channel) => {
|
||||||
|
const result = channelEligibility(channel.uuid, dateStr)
|
||||||
|
const canHandoff = !isUnboundChannel(channel.uuid) && result.eligible
|
||||||
return {
|
return {
|
||||||
value: channel.uuid,
|
value: channel.uuid,
|
||||||
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : ''}`,
|
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : result.reason ? ` — ${result.reason}` : ''}`,
|
||||||
disabled: !isChannelEligible(channel.uuid, dateStr),
|
disabled: !result.eligible,
|
||||||
}
|
}
|
||||||
}) ?? []
|
}) ?? []
|
||||||
const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr)
|
const selectedChannelUuid = channelUuid && channelEligibility(channelUuid, dateStr).eligible
|
||||||
? channelUuid
|
? channelUuid
|
||||||
: null
|
: null
|
||||||
|
function isUnboundChannel(uuid: string): boolean {
|
||||||
|
return !meters.some((meter) => meter.bindings?.some(
|
||||||
|
(binding) => binding.source_channel_uuid === uuid && binding.ended_at === null,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -155,6 +264,29 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
setError('Reason is required.')
|
setError('Reason is required.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (sourceUuid && !selectedChannelUuid) {
|
||||||
|
const unavailable = channelOptions.length === 1 && channelOptions[0].disabled
|
||||||
|
? channelEligibility(channelOptions[0].value, dateStr).reason
|
||||||
|
: undefined
|
||||||
|
setError(unavailable ? `${unavailable[0].toUpperCase()}${unavailable.slice(1)}` : 'Select an eligible source channel or clear the optional source.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Omitting a channel for meter_swap asks the backend to auto-handoff the
|
||||||
|
// sole open binding. Keep that implicit path subject to the same strict
|
||||||
|
// boundary rule as an explicitly selected channel.
|
||||||
|
const start = localInstant(toLocalMidnightNaive(dateStr))
|
||||||
|
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
||||||
|
// The implicit backend handoff only considers bindings on the current
|
||||||
|
// commodity's old meter. Bindings are unit-compatible with their meter
|
||||||
|
// by the binding contract, so an unrelated heating/hot-water binding must
|
||||||
|
// neither make this ambiguous nor bypass this strict boundary check.
|
||||||
|
const autoHandoffCandidates = (oldMeter?.bindings ?? []).filter((binding) => binding.ended_at === null)
|
||||||
|
const oldBinding = autoHandoffCandidates[0]
|
||||||
|
if (reason === 'meter_swap' && start !== null && oldMeter !== undefined && autoHandoffCandidates.length === 1 &&
|
||||||
|
oldBinding !== undefined && start <= parseBackendTimestamp(oldBinding.started_at).getTime()) {
|
||||||
|
setError('Handoff boundary must be strictly after the current binding start.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await declareMutation.mutateAsync({
|
await declareMutation.mutateAsync({
|
||||||
@@ -167,14 +299,7 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
})
|
})
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) { setError(apiErrorMessage(err, 'Failed to declare meter. Please try again.')) }
|
||||||
if (err instanceof ApiError) {
|
|
||||||
const detail = (err.body as { detail?: string } | null)?.detail
|
|
||||||
setError(detail ?? `Error ${err.status}: failed to declare meter.`)
|
|
||||||
} else {
|
|
||||||
setError('Failed to declare meter. Please try again.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,7 +330,7 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextDateStr = e.currentTarget.value
|
const nextDateStr = e.currentTarget.value
|
||||||
setDateStr(nextDateStr)
|
setDateStr(nextDateStr)
|
||||||
setChannelUuid((uuid) => uuid && !isChannelEligible(uuid, nextDateStr) ? null : uuid)
|
setChannelUuid((uuid) => uuid && !channelEligibility(uuid, nextDateStr).eligible ? null : uuid)
|
||||||
}}
|
}}
|
||||||
data-testid="meter-started-at"
|
data-testid="meter-started-at"
|
||||||
/>
|
/>
|
||||||
@@ -225,7 +350,10 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
{ 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={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} />}
|
{reason === 'meter_swap' && <Alert color="blue">If the previous meter has exactly one compatible open binding, declaring this meter automatically hands that channel over atomically. Ambiguous bindings remain unavailable.</Alert>}
|
||||||
|
{sources.isLoading && <Text size="sm">Loading sources…</Text>}{sources.isError && <Alert color="red">Could not load sources. Retry after the connection recovers.</Alert>}
|
||||||
|
{sourceUuid && channels.isLoading && <Text size="sm">Loading source channels…</Text>}{sourceUuid && channels.isError && <Alert color="red">Could not load source channels. Choose another source or retry.</Alert>}
|
||||||
|
{sourceUuid && <Select label="Compatible source channel (optional)" value={selectedChannelUuid} onChange={setChannelUuid} description="Disabled channels explain unit, current interval, ambiguity, or required time. Closed history remains reusable." data={channelOptions} />}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Note (optional)"
|
label="Note (optional)"
|
||||||
@@ -313,14 +441,7 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
|||||||
await updateMutation.mutateAsync({ id: meter.id, body: patchBody })
|
await updateMutation.mutateAsync({ id: meter.id, body: patchBody })
|
||||||
onSaved(startedAtChanged)
|
onSaved(startedAtChanged)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) { setError(apiErrorMessage(err, 'Failed to update meter. Please try again.')) }
|
||||||
if (err instanceof ApiError) {
|
|
||||||
const detail = (err.body as { detail?: string } | null)?.detail
|
|
||||||
setError(detail ?? `Error ${err.status}: failed to update meter.`)
|
|
||||||
} else {
|
|
||||||
setError('Failed to update meter. Please try again.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -395,9 +516,10 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
|||||||
interface MeterTableProps {
|
interface MeterTableProps {
|
||||||
meters: MeterResponse[]
|
meters: MeterResponse[]
|
||||||
onEdit: (meter: MeterResponse) => void
|
onEdit: (meter: MeterResponse) => void
|
||||||
|
onClose: (meter: MeterResponse) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeterTable({ meters, onEdit }: MeterTableProps) {
|
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
|
||||||
if (meters.length === 0) {
|
if (meters.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
|
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
|
||||||
@@ -474,7 +596,11 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify="flex-end" gap="xs">
|
<Group justify="flex-end" gap="xs">
|
||||||
{isActive && <SourceSwitchButton meter={meter} />}
|
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
|
||||||
|
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
|
||||||
|
))}
|
||||||
|
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
|
||||||
|
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -494,22 +620,171 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceSwitchButton({ meter }: { meter: MeterResponse }) {
|
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
return <>{<Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) {
|
||||||
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
|
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
|
||||||
const [channelUuid, setChannelUuid] = useState<string | null>(null)
|
const [channelUuid, setChannelUuid] = useState<string | null>(null)
|
||||||
|
const [startedAt, setStartedAt] = useState(() => toLocalDateTimeInputString())
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const sources = useSources(); const channels = useSourceChannels(sourceUuid); const bindings = useMeterBindings(opened ? meter.id : null)
|
const sources = useSources(); const channels = useSourceChannels(sourceUuid); const create = useCreateBinding()
|
||||||
const create = useCreateBinding(); const close = useCloseBinding()
|
const eligibilityAt = (channel: { uuid: string; unit: string }, value: string): Eligibility => {
|
||||||
const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) => ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[meter.commodity] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
if (channel.unit !== expectedUnit(meter.commodity)) return { eligible: false, reason: `unit mismatch: ${channel.unit}; this meter needs ${expectedUnit(meter.commodity)}` }
|
||||||
const timeline = bindings.data?.items
|
const start = localInstant(value)
|
||||||
const timelineReady = bindings.isSuccess && !!timeline
|
if (start === null) return { eligible: false, reason: 'choose a valid binding start time first' }
|
||||||
async function save() { if (!timelineReady) return setError('Binding timeline has not loaded; no change was made.'); if (!channelUuid) return setError('Select a compatible unbound source channel.'); setError(null)
|
if (value > toLocalDateTimeInputString()) return { eligible: false, reason: 'future binding start times are not allowed' }
|
||||||
const started_at = new Date().toISOString(); const active = bindings.data?.items.find((binding) => !binding.ended_at)
|
if (!meterContainsInstant(meter, start)) return { eligible: false, reason: 'binding start must be within this meter epoch' }
|
||||||
try { if (active) await close.mutateAsync({ uuid: active.uuid, ended_at: started_at }) } catch (err) { const detail = err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? '') : ''; return setError(`Could not close the old binding; no change was made.${detail ? ` ${detail}` : ''}`) }
|
return channelIntervalEligibility(meters, channel.uuid, value)
|
||||||
try { await create.mutateAsync({ id: meter.id, body: { source_channel_uuid: channelUuid, started_at } }); setOpened(false) } catch (err) { const detail = err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? '') : ''; setError(`Old binding was closed, but creating the new binding failed. Retry after resolving the error.${detail ? ` ${detail}` : ''}`) }
|
|
||||||
}
|
}
|
||||||
return <><Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Switch source</Button>{opened && <Modal opened onClose={() => setOpened(false)} title="Switch source binding"><Stack><Alert color="blue">This is a two-step close then create process, not a meter swap. If create fails after close, the old binding remains closed and you can retry.</Alert>{bindings.isLoading && <Alert color="blue">Loading binding timeline…</Alert>}{bindings.isError && <Alert color="red">Failed to load binding timeline; no change was made.</Alert>}{timelineReady && timeline.length === 0 && <Alert color="gray">No existing bindings for this meter.</Alert>}<Select label="Source" 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="Compatible unbound channel" value={channelUuid} onChange={setChannelUuid} description="Eligibility is based on unit and binding state; suggestions are informational." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})` })) ?? []} />{error && <Alert color="red">{error}</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setOpened(false)}>Cancel</Button><Button onClick={save} loading={create.isPending || close.isPending} disabled={!timelineReady}>Switch binding</Button></Group></Stack></Modal>}</>
|
const eligibility = (channel: { uuid: string; unit: string }) => eligibilityAt(channel, startedAt)
|
||||||
|
const options = channels.data?.items.map((channel) => {
|
||||||
|
const result = eligibility(channel)
|
||||||
|
return { value: channel.uuid, label: `${channel.label} (${channel.unit})${result.reason ? ` — ${result.reason}` : ''}`, disabled: !result.eligible }
|
||||||
|
}) ?? []
|
||||||
|
const selectedChannel = channels.data?.items.find((channel) => channel.uuid === channelUuid)
|
||||||
|
const selectedEligible = selectedChannel !== undefined && eligibility(selectedChannel).eligible
|
||||||
|
async function save() {
|
||||||
|
if (create.isPending) return
|
||||||
|
if (!channelUuid) return setError('Select a genuinely unbound, unit-compatible channel.')
|
||||||
|
if (!isValidLocalDateTime(startedAt)) return setError('Choose a valid binding start time.')
|
||||||
|
if (!selectedEligible) return setError('The selected channel is no longer eligible. Choose an available channel.')
|
||||||
|
setError(null)
|
||||||
|
try { await create.mutateAsync({ id: meter.id, body: { source_channel_uuid: channelUuid, started_at: startedAt } }); onClose() } catch (err) { setError(apiErrorMessage(err, 'Could not bind this source.')) }
|
||||||
|
}
|
||||||
|
return <Modal opened onClose={onClose} title="Bind source" data-testid={`direct-bind-modal-${meter.id}`}><form onSubmit={(event) => { event.preventDefault(); void save() }}><Stack>
|
||||||
|
<Alert color="blue">This active meter has no open binding. Choose a currently unoccupied compatible channel.</Alert>
|
||||||
|
{sources.isLoading && <Text size="sm">Loading sources…</Text>}{sources.isError && <Alert color="red">Could not load sources. Retry after the connection recovers.</Alert>}
|
||||||
|
<Select label="Source" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
|
||||||
|
{sourceUuid && channels.isLoading && <Text size="sm">Loading source channels…</Text>}{sourceUuid && channels.isError && <Alert color="red">Could not load source channels. Choose another source or retry.</Alert>}
|
||||||
|
<Select label="Source channel" value={channelUuid} onChange={setChannelUuid} description="Disabled channels explain the unit, interval, ambiguity, or time constraint." data={options} />
|
||||||
|
<TextInput label="Binding start time" type="datetime-local" value={startedAt} onChange={(event) => { const value = event.currentTarget.value; setStartedAt(value); setChannelUuid((uuid) => { if (!isValidLocalDateTime(value)) return uuid; const channel = channels.data?.items.find((item) => item.uuid === uuid); return channel && !eligibilityAt(channel, value).eligible ? null : uuid }) }} required />
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
<Group justify="flex-end"><Button type="button" variant="default" onClick={onClose}>Cancel</Button><Button type="submit" loading={create.isPending} disabled={create.isPending || (!!channelUuid && !selectedEligible)}>Bind source</Button></Group>
|
||||||
|
</Stack></form></Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
function BindingActions({ meter, binding, meters }: { meter: MeterResponse; binding: NonNullable<MeterResponse['bindings']>[number]; meters: MeterResponse[] }) {
|
||||||
|
const [unbindOpened, setUnbindOpened] = useState(false)
|
||||||
|
const [transferOpened, setTransferOpened] = useState(false)
|
||||||
|
const recoveryTarget = recoveryTargetFor(meter, meters)
|
||||||
|
// Cross-Meter recovery closes at the old Meter boundary. An anomalous
|
||||||
|
// retained binding beginning at or after that boundary would create a
|
||||||
|
// zero-length/negative source interval that the server correctly rejects.
|
||||||
|
const recoverySourceIsClosable = meter.ended_at === null ||
|
||||||
|
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
|
||||||
|
return <>
|
||||||
|
<Button size="xs" variant="subtle" onClick={() => setUnbindOpened(true)}>Unbind</Button>
|
||||||
|
{meter.ended_at === null ? <Button size="xs" variant="subtle" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
|
||||||
|
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>}
|
||||||
|
{unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
|
||||||
|
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnbindModal({ meter, binding, onClose }: { meter: MeterResponse; binding: NonNullable<MeterResponse['bindings']>[number]; onClose: () => void }) {
|
||||||
|
const [endedAt, setEndedAt] = useState(() => meter.ended_at ? toLocalDateTimeInputString(parseBackendTimestamp(meter.ended_at)) : toLocalDateTimeInputString())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const close = useCloseBinding()
|
||||||
|
async function save() {
|
||||||
|
if (close.isPending) return
|
||||||
|
if (!isValidLocalDateTime(endedAt)) return setError('Choose a valid unbind time.')
|
||||||
|
const instant = localInstant(endedAt)
|
||||||
|
if (instant === null || instant <= parseBackendTimestamp(binding.started_at).getTime()) {
|
||||||
|
return setError('Unbind time must be strictly after the binding start.')
|
||||||
|
}
|
||||||
|
if (endedAt > toLocalDateTimeInputString()) return setError('A future unbind time is not allowed.')
|
||||||
|
if (meter.ended_at && instant > parseBackendTimestamp(meter.ended_at).getTime()) {
|
||||||
|
return setError('Unbind time must not be after the meter end.')
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
try { await close.mutateAsync({ uuid: binding.uuid, ended_at: endedAt }); onClose() } catch (err) { setError(apiErrorMessage(err, 'Could not unbind this source. History was not deleted.')) }
|
||||||
|
}
|
||||||
|
return <Modal opened onClose={onClose} title="Unbind source" data-testid={`unbind-modal-${binding.uuid}`}><form onSubmit={(event) => { event.preventDefault(); void save() }}><Stack>
|
||||||
|
<Alert color="blue">Unbinding closes this binding at the selected time. It never deletes binding history.</Alert>
|
||||||
|
<TextInput label="Unbind time" type="datetime-local" value={endedAt} onChange={(event) => setEndedAt(event.currentTarget.value)} required />
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
<Group justify="flex-end"><Button type="button" variant="default" onClick={onClose}>Cancel</Button><Button type="submit" loading={close.isPending} disabled={close.isPending}>Unbind</Button></Group>
|
||||||
|
</Stack></form></Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransferModal({ target, sourceBinding, meters, recovery, onClose }: { target: MeterResponse; sourceBinding: NonNullable<MeterResponse['bindings']>[number]; meters: MeterResponse[]; recovery: boolean; onClose: () => void }) {
|
||||||
|
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
|
||||||
|
const [channelUuid, setChannelUuid] = useState<string | null>(null)
|
||||||
|
const [effectiveAt, setEffectiveAt] = useState(() => recovery ? toLocalDateTimeInputString(parseBackendTimestamp(target.started_at)) : toLocalDateTimeInputString())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const sources = useSources(); const channels = useSourceChannels(sourceUuid)
|
||||||
|
const transfer = useTransferBinding()
|
||||||
|
const channelEligibilityAt = (channel: { uuid: string; unit: string }, value: string): Eligibility => {
|
||||||
|
if (channel.unit !== expectedUnit(target.commodity)) return { eligible: false, reason: `unit mismatch: ${channel.unit}; target needs ${expectedUnit(target.commodity)}` }
|
||||||
|
const start = localInstant(value)
|
||||||
|
if (start === null) return { eligible: false, reason: 'choose a valid effective time first' }
|
||||||
|
if (value > toLocalDateTimeInputString()) return { eligible: false, reason: 'future effective times are not allowed' }
|
||||||
|
if (!meterContainsInstant(target, start)) return { eligible: false, reason: 'effective time must be within the target meter epoch' }
|
||||||
|
// A same-meter transfer closes its source at `effective_at`. Equality
|
||||||
|
// would therefore create the forbidden zero-length [start, start)
|
||||||
|
// interval. Recovery closes at the old meter boundary instead, so it
|
||||||
|
// deliberately keeps the normal target-epoch rule and may be equal to
|
||||||
|
// the source binding's (much earlier) start.
|
||||||
|
if (!recovery && start <= parseBackendTimestamp(sourceBinding.started_at).getTime()) {
|
||||||
|
return { eligible: false, reason: 'same-meter transfer must be strictly after the source binding start' }
|
||||||
|
}
|
||||||
|
return channelIntervalEligibility(meters, channel.uuid, value, [sourceBinding.uuid])
|
||||||
|
}
|
||||||
|
const channelEligibility = (channel: { uuid: string; unit: string }) => channelEligibilityAt(channel, effectiveAt)
|
||||||
|
const options = channels.data?.items.map((channel) => {
|
||||||
|
const result = channelEligibility(channel)
|
||||||
|
return { value: channel.uuid, label: `${channel.label} (${channel.unit})${result.reason ? ` — ${result.reason}` : ''}`, disabled: !result.eligible }
|
||||||
|
}) ?? []
|
||||||
|
const selectedChannel = channels.data?.items.find((channel) => channel.uuid === channelUuid)
|
||||||
|
const selectedEligible = selectedChannel !== undefined && channelEligibility(selectedChannel).eligible
|
||||||
|
const isFuture = effectiveAt > toLocalDateTimeInputString()
|
||||||
|
async function save() {
|
||||||
|
if (transfer.isPending) return
|
||||||
|
if (!channelUuid) return setError('Select a unit-compatible source channel.')
|
||||||
|
if (!isValidLocalDateTime(effectiveAt) || isFuture) return setError('Choose a valid non-future effective time.')
|
||||||
|
if (!selectedEligible) return setError('The selected channel is no longer eligible. Choose an available channel.')
|
||||||
|
setError(null)
|
||||||
|
try { await transfer.mutateAsync({ id: target.id, body: { from_binding_uuid: sourceBinding.uuid, to_source_channel_uuid: channelUuid, effective_at: effectiveAt } }); onClose() } catch (err) { setError(apiErrorMessage(err, 'Transfer failed. No partial source switch was saved.')) }
|
||||||
|
}
|
||||||
|
return <Modal opened onClose={onClose} title={recovery ? 'Recover stranded binding' : 'Transfer source binding'} data-testid={`transfer-modal-${sourceBinding.uuid}`}><form onSubmit={(event) => { event.preventDefault(); void save() }}><Stack>
|
||||||
|
<Alert color="blue">This is one atomic Transfer request: either the old binding closes and the new one opens together, or neither change is saved.</Alert>
|
||||||
|
{recovery && <Alert color="yellow">This binding is stranded on a closed meter. Recovery defaults to the new meter start. A later time is allowed, but creates an unbound gap before it.</Alert>}
|
||||||
|
{sources.isLoading && <Text size="sm">Loading sources…</Text>}{sources.isError && <Alert color="red">Could not load sources. Retry after the connection recovers.</Alert>}
|
||||||
|
<Select label="Source" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
|
||||||
|
{sourceUuid && channels.isLoading && <Text size="sm">Loading source channels…</Text>}{sourceUuid && channels.isError && <Alert color="red">Could not load source channels. Choose another source or retry.</Alert>}
|
||||||
|
<Select label="Source channel" value={channelUuid} onChange={setChannelUuid} description="Disabled channels name the specific unit or unrelated-open-interval conflict. The server also rejects ambiguity atomically." data={options} />
|
||||||
|
<TextInput label="Effective time" type="datetime-local" value={effectiveAt} onChange={(event) => { const value = event.currentTarget.value; setEffectiveAt(value); setChannelUuid((uuid) => { if (!isValidLocalDateTime(value)) return uuid; const channel = channels.data?.items.find((item) => item.uuid === uuid); return channel && !channelEligibilityAt(channel, value).eligible ? null : uuid }) }} required data-testid="transfer-effective-at" />
|
||||||
|
{recovery && effectiveAt && effectiveAt > toLocalDateTimeInputString(parseBackendTimestamp(target.started_at)) && <Alert color="yellow">Warning: this later time leaves an unbound gap from the new meter start until this transfer.</Alert>}
|
||||||
|
{isFuture && <Alert color="red">A future effective time is not allowed.</Alert>}
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
<Group justify="flex-end"><Button type="button" variant="default" onClick={onClose}>Cancel</Button><Button type="submit" loading={transfer.isPending} disabled={transfer.isPending || (!!channelUuid && !selectedEligible)}>Transfer binding</Button></Group>
|
||||||
|
</Stack></form></Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseMeterModal({ meter, onClose }: { meter: MeterResponse; onClose: () => void }) {
|
||||||
|
const [endedAt, setEndedAt] = useState(() => toLocalDateTimeInputString())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const close = useCloseMeter()
|
||||||
|
async function save() {
|
||||||
|
if (close.isPending) return
|
||||||
|
if (!isValidLocalDateTime(endedAt)) return setError('Choose a valid close time.')
|
||||||
|
const instant = localInstant(endedAt)
|
||||||
|
if (instant === null || instant <= parseBackendTimestamp(meter.started_at).getTime()) {
|
||||||
|
return setError('Close time must be strictly after the meter start.')
|
||||||
|
}
|
||||||
|
if (endedAt > toLocalDateTimeInputString()) return setError('A future close time is not allowed.')
|
||||||
|
setError(null)
|
||||||
|
try { await close.mutateAsync({ id: meter.id, ended_at: endedAt }); onClose() } catch (err) { setError(apiErrorMessage(err, 'Could not close this meter.')) }
|
||||||
|
}
|
||||||
|
return <Modal opened onClose={onClose} title={`Close Meter — ${meter.label}`} data-testid={`close-meter-modal-${meter.id}`}><form onSubmit={(event) => { event.preventDefault(); void save() }}><Stack>
|
||||||
|
<Alert color="yellow">Closing leaves no active {meter.commodity} meter. Every open binding on this meter closes at the same boundary.</Alert>
|
||||||
|
<TextInput label="Close time" type="datetime-local" value={endedAt} onChange={(event) => setEndedAt(event.currentTarget.value)} required />
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
<Group justify="flex-end"><Button type="button" variant="default" onClick={onClose}>Cancel</Button><Button type="submit" color="red" loading={close.isPending} disabled={close.isPending}>Close meter</Button></Group>
|
||||||
|
</Stack></form></Modal>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -521,6 +796,7 @@ export function MeterManager() {
|
|||||||
|
|
||||||
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
||||||
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
|
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
|
||||||
|
const [closeMeter, setCloseMeter] = useState<MeterResponse | null>(null)
|
||||||
const [recomputeNotice, setRecomputeNotice] = useState(false)
|
const [recomputeNotice, setRecomputeNotice] = useState(false)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -569,7 +845,7 @@ export function MeterManager() {
|
|||||||
</Notification>
|
</Notification>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} />
|
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
|
||||||
|
|
||||||
{/* Declare new meter */}
|
{/* Declare new meter */}
|
||||||
{showDeclareForm && (
|
{showDeclareForm && (
|
||||||
@@ -591,6 +867,8 @@ export function MeterManager() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{closeMeter && <CloseMeterModal meter={closeMeter} onClose={() => setCloseMeter(null)} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ describe('useDeclareMeter source binding invalidation', () => {
|
|||||||
const { qc, Wrapper } = makeWrapper()
|
const { qc, Wrapper } = makeWrapper()
|
||||||
const affectedKeys = [
|
const affectedKeys = [
|
||||||
['energy-meters'], ['energy-source-channels'], ['energy-meter-bindings', 9],
|
['energy-meters'], ['energy-source-channels'], ['energy-meter-bindings', 9],
|
||||||
['energy-sources'], ['expose-catalog'], ['energy-costs'], ['energy-costs-summary'],
|
['energy-sources'], ['energy-source', 'source-1'], ['energy-channel-readings', 'source-1', 'channel-1'],
|
||||||
|
['expose-catalog'], ['energy-costs', 'electricity'], ['energy-costs-summary', 'electricity'],
|
||||||
|
['meter-costs', 'thermal', 'month'], ['meter-cost-summary', 'thermal'],
|
||||||
]
|
]
|
||||||
for (const queryKey of affectedKeys) qc.setQueryData(queryKey, { cached: true })
|
for (const queryKey of affectedKeys) qc.setQueryData(queryKey, { cached: true })
|
||||||
const { useDeclareMeter } = await import('./hooks')
|
const { useDeclareMeter } = await import('./hooks')
|
||||||
@@ -117,6 +119,37 @@ describe('useDeclareMeter source binding invalidation', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('meter lifecycle mutations', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Declare auto-handoff', 'post', async (hooks: typeof import('./hooks')) => hooks.useDeclareMeter, { label: 'swap', commodity: 'electricity', started_at: '2026-08-24T12:34', reason: 'meter_swap' }],
|
||||||
|
['Close meter', 'post', async (hooks: typeof import('./hooks')) => hooks.useCloseMeter, { id: 9, ended_at: '2026-08-24T12:34' }],
|
||||||
|
['Unbind', 'patch', async (hooks: typeof import('./hooks')) => hooks.useCloseBinding, { uuid: 'old', ended_at: '2026-08-24T12:34' }],
|
||||||
|
['Transfer', 'post', async (hooks: typeof import('./hooks')) => hooks.useTransferBinding, { id: 10, body: { from_binding_uuid: 'old', to_source_channel_uuid: 'new', effective_at: '2026-08-24T12:34' } }],
|
||||||
|
['Direct bind', 'post', async (hooks: typeof import('./hooks')) => hooks.useCreateBinding, { id: 10, body: { source_channel_uuid: 'new', started_at: '2026-08-24T12:34' } }],
|
||||||
|
['Update electricity start', 'patch', async (hooks: typeof import('./hooks')) => hooks.useUpdateMeter, { id: 10, body: { started_at: '2026-08-24T12:34' } }],
|
||||||
|
['Update heating label', 'patch', async (hooks: typeof import('./hooks')) => hooks.useUpdateMeter, { id: 11, body: { label: 'Heating meter' } }],
|
||||||
|
['Update hot water start', 'patch', async (hooks: typeof import('./hooks')) => hooks.useUpdateMeter, { id: 12, body: { started_at: '2026-08-24T12:34' } }],
|
||||||
|
])('%s invalidates every lifecycle view using a fresh QueryClient', async (_name, method, getHook, payload) => {
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
mockPatch.mockResolvedValue({ data: {} })
|
||||||
|
const { qc, Wrapper } = makeWrapper()
|
||||||
|
const affectedKeys = [
|
||||||
|
['energy-meters'], ['energy-sources'], ['energy-source', 'source-1'], ['energy-source-channels'], ['energy-meter-bindings'],
|
||||||
|
['energy-channel-readings', 'source-1', 'channel-1'], ['energy-costs', 'electricity'], ['energy-costs-summary', 'electricity'],
|
||||||
|
['meter-costs', 'thermal', 'month'], ['meter-cost-summary', 'thermal'], ['expose-catalog'],
|
||||||
|
]
|
||||||
|
for (const key of affectedKeys) qc.setQueryData(key, { cached: true })
|
||||||
|
const hooks = await import('./hooks')
|
||||||
|
const useHook = await getHook(hooks)
|
||||||
|
const result = renderHook(() => useHook(), { wrapper: Wrapper })
|
||||||
|
await act(async () => { await result.result.current.mutateAsync(payload as never) })
|
||||||
|
expect(method === 'post' ? mockPost : mockPatch).toHaveBeenCalled()
|
||||||
|
for (const key of affectedKeys) expect(qc.getQueryState(key)?.isInvalidated).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('useProfiles', () => {
|
describe('useProfiles', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,8 @@ export type MeterSourcePatch = components['schemas']['MeterSourcePatch']
|
|||||||
export type MeterSourceChannelResponse = components['schemas']['MeterSourceChannelResponse']
|
export type MeterSourceChannelResponse = components['schemas']['MeterSourceChannelResponse']
|
||||||
export type BindingResponse = components['schemas']['BindingResponse']
|
export type BindingResponse = components['schemas']['BindingResponse']
|
||||||
export type BindingCreate = components['schemas']['BindingCreate']
|
export type BindingCreate = components['schemas']['BindingCreate']
|
||||||
|
export type BindingTransferRequest = components['schemas']['BindingTransferRequest']
|
||||||
|
export type MeterCloseRequest = components['schemas']['MeterCloseRequest']
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query: list all energy contracts
|
// Query: list all energy contracts
|
||||||
@@ -477,18 +479,9 @@ export function useDeclareMeter() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: MeterDeclareRequest) =>
|
mutationFn: (body: MeterDeclareRequest) =>
|
||||||
apiClient.POST('/api/energy/meters', { body }),
|
apiClient.POST('/api/energy/meters', { body }),
|
||||||
onSuccess: (_data, variables) => {
|
// A meter_swap can hand a binding over even when the optional channel was
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
// omitted from this request, so every lifecycle write shares this boundary.
|
||||||
if (variables.source_channel_uuid) {
|
onSuccess: () => invalidateLifecycleQueries(qc),
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-sources'] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
|
|
||||||
}
|
|
||||||
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
|
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,12 +497,7 @@ export function useUpdateMeter() {
|
|||||||
params: { path: { meter_id: id } },
|
params: { path: { meter_id: id } },
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => invalidateLifecycleQueries(qc),
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
|
||||||
// Retroactive started_at correction triggers recompute server-side.
|
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,21 +518,26 @@ export function useSource(uuid: string | null) {
|
|||||||
const res = await apiClient.GET('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid! } } }); return res.data
|
const res = await apiClient.GET('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid! } } }); return res.data
|
||||||
}, refetchInterval: 3_000 })
|
}, refetchInterval: 3_000 })
|
||||||
}
|
}
|
||||||
function invalidateSourceQueries(qc: ReturnType<typeof useQueryClient>) {
|
function invalidateLifecycleQueries(qc: ReturnType<typeof useQueryClient>) {
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-sources'] }); void qc.invalidateQueries({ queryKey: ['energy-source'] });
|
void qc.invalidateQueries({ queryKey: ['energy-sources'] }); void qc.invalidateQueries({ queryKey: ['energy-source'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] }); void qc.invalidateQueries({ queryKey: ['energy-meters'] });
|
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] }); void qc.invalidateQueries({ queryKey: ['energy-meters'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-channel-readings'] }); void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
|
void qc.invalidateQueries({ queryKey: ['energy-channel-readings'] }); void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
|
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] }); void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] })
|
||||||
}
|
}
|
||||||
export function useCreateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: MeterSourceCreate) => apiClient.POST('/api/energy/sources', { body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useCreateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: MeterSourceCreate) => apiClient.POST('/api/energy/sources', { body }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
export function useUpdateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: MeterSourcePatch }) => apiClient.PATCH('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useUpdateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: MeterSourcePatch }) => apiClient.PATCH('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } }, body }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
export function useDeleteSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.DELETE('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useDeleteSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.DELETE('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
export function useDiscoverSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.POST('/api/energy/sources/{source_uuid}/discover', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useDiscoverSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.POST('/api/energy/sources/{source_uuid}/discover', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
export function useSourceChannels(uuid: string | null) { return useQuery({ queryKey: ['energy-source-channels', uuid], enabled: !!uuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels', { params: { path: { source_uuid: uuid! } } }); return res.data }, refetchInterval: 3_000 }) }
|
export function useSourceChannels(uuid: string | null) { return useQuery({ queryKey: ['energy-source-channels', uuid], enabled: !!uuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels', { params: { path: { source_uuid: uuid! } } }); return res.data }, refetchInterval: 3_000 }) }
|
||||||
export function useChannelReadings(sourceUuid: string | null, channelUuid: string | null) { return useQuery({ queryKey: ['energy-channel-readings', sourceUuid, channelUuid], enabled: !!sourceUuid && !!channelUuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings', { params: { path: { source_uuid: sourceUuid!, channel_uuid: channelUuid! }, query: { limit: 60 } } }); return res.data }, refetchInterval: 5_000 }) }
|
export function useChannelReadings(sourceUuid: string | null, channelUuid: string | null) { return useQuery({ queryKey: ['energy-channel-readings', sourceUuid, channelUuid], enabled: !!sourceUuid && !!channelUuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings', { params: { path: { source_uuid: sourceUuid!, channel_uuid: channelUuid! }, query: { limit: 60 } } }); return res.data }, refetchInterval: 5_000 }) }
|
||||||
export function useMeterBindings(id: number | null) { return useQuery({ queryKey: ['energy-meter-bindings', id], enabled: id != null, queryFn: async () => { const res = await apiClient.GET('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id! } } }); return res.data } }) }
|
export function useMeterBindings(id: number | null) { return useQuery({ queryKey: ['energy-meter-bindings', id], enabled: id != null, queryFn: async () => { const res = await apiClient.GET('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id! } } }); return res.data } }) }
|
||||||
export function useCreateBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingCreate }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useCreateBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingCreate }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
export function useCloseBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, ended_at }: { uuid: string; ended_at: string }) => apiClient.PATCH('/api/energy/bindings/{binding_uuid}', { params: { path: { binding_uuid: uuid } }, body: { ended_at } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useCloseBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, ended_at }: { uuid: string; ended_at: string }) => apiClient.PATCH('/api/energy/bindings/{binding_uuid}', { params: { path: { binding_uuid: uuid } }, body: { ended_at } }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
|
export function useCloseMeter() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ended_at }: { id: number; ended_at: string }) => apiClient.POST('/api/energy/meters/{meter_id}/close', { params: { path: { meter_id: id } }, body: { ended_at } satisfies MeterCloseRequest }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
|
export function useTransferBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingTransferRequest }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings/transfer', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query: time-range readings for a device (window + limit — never full-table)
|
// Query: time-range readings for a device (window + limit — never full-table)
|
||||||
|
|||||||
Reference in New Issue
Block a user