M8-T19: add scope-aware energy contract and cost UI

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 963e43e3e4
commit 5b9d60e80a
10 changed files with 532 additions and 51 deletions
+164
View File
@@ -73,6 +73,48 @@ const SUMMARY = {
total_payable: 12.5,
}
const THERMAL_SUMMARY = {
currency: 'EUR', heating: '1.10', hot_water_heating: '2.20', hot_water: '3.30',
hot_water_tax: '0.40', variable_subtotal: '7.00', fixed_subtotal: '0.50', all_in: '7.50',
period_count: 4, degraded_count: 2,
fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
}
const THERMAL_VALUES = {
variable: {
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
hot_water: '1.234567890123456789', hot_water_tax: '0.456789012345678901',
},
standing: {
heating_network: '100.000000000000000001', metering: '0', delivery_set: '20.2',
hot_water_network: '30.3', other: '40.4',
},
}
const THERMAL_ROW = {
commodity: 'heating', period_start: '2026-06-22T10:00:00Z', period_end: '2026-06-22T10:15:00Z',
meter_id: 1, source_binding_id: 2, contract_version_id: 99, quantity: '1.2', cost: '0.123456789', currency: 'EUR',
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
quality: 'unverifiable', degraded: false, degraded_reason: null,
}
const THERMAL_ROW_OTHER_VERSION = {
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z',
contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
cost_breakdown: { hot_water_heating: '1.2', hot_water: '2.8', hot_water_tax: '0.339506172839506170' },
pricing_snapshot: { ...THERMAL_VALUES, variable: { ...THERMAL_VALUES.variable, hot_water: '1.234567890123456789' } },
}
const THERMAL_DEGRADED_MISSING_CONTRACT = {
commodity: 'heating', period_start: '2026-06-22T10:30:00Z', period_end: '2026-06-22T10:45:00Z',
meter_id: 1, source_binding_id: 2, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'missing_contract',
}
const THERMAL_DEGRADED_CROSS_EPOCH = {
commodity: 'hot_water', period_start: '2026-06-22T10:45:00Z', period_end: '2026-06-22T11:00:00Z',
meter_id: 2, source_binding_id: null, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'cross_meter_epoch',
}
const ACTIVE_HEATING_METER = { id: 10, commodity: 'heating', ended_at: null }
const ACTIVE_HOT_WATER_METER = { id: 11, commodity: 'hot_water', ended_at: null }
const ENDED_HEATING_METER = { id: 9, commodity: 'heating', ended_at: '2026-06-01T00:00:00Z' }
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
@@ -220,4 +262,126 @@ describe('CostView', () => {
)
})
})
it('audits thermal rows and recomputes only a closed UTC quarter through the typed client', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW, THERMAL_ROW_OTHER_VERSION, THERMAL_DEGRADED_MISSING_CONTRACT, THERMAL_DEGRADED_CROSS_EPOCH], total: 4 } })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
if (path === '/api/energy/costs') return Promise.resolve({ data: { items: [], total: 0 } })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: { processed: 1, normal: 0, degraded: 1 } })
renderWithProviders(<CostView />)
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
expect(screen.getByTestId('thermal-period-count')).toHaveTextContent('4 periods; 2 degraded')
expect(screen.getByTestId('thermal-summary-degraded')).toHaveTextContent('Expand a row to see its recorded reason')
expect(screen.queryByTestId('thermal-degraded-0')).not.toBeInTheDocument()
expect(screen.queryByTestId('thermal-degraded-1')).not.toBeInTheDocument()
expect(screen.getByTestId('thermal-degraded-2')).toHaveTextContent('missing_contract')
expect(screen.getByTestId('thermal-degraded-3')).toHaveTextContent('cross_meter_epoch')
await user.click(screen.getByTestId('thermal-cost-expand-0'))
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('Contract version: 99')
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('heating_network')
await user.click(screen.getByTestId('thermal-cost-expand-1'))
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('Contract version: 100')
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('1.234567890123456789')
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('hot_water_tax')
await user.click(screen.getByTestId('thermal-cost-expand-2'))
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Contract version: none')
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Pricing snapshot: {}')
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('summary only')
expect(screen.getAllByText(/Fixed subtotal|All-in total/)).toHaveLength(2)
expect(screen.getByTestId('thermal-costs-table')).not.toHaveTextContent('Fixed')
await user.click(screen.getByTestId('thermal-recompute-button'))
expect(screen.getByTestId('thermal-recompute-confirm-modal')).toHaveTextContent('closed 15-minute')
await user.click(screen.getByTestId('thermal-recompute-confirm'))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meter-costs/recompute', expect.any(Object)))
const options = mockPost.mock.calls.find(([path]) => path === '/api/energy/meter-costs/recompute')?.[1] as { params: { query: { end: string } } }
expect(new Date(options.params.query.end).getTime()).toBeLessThanOrEqual(Date.now())
expect(new Date(options.params.query.end).getUTCMinutes() % 15).toBe(0)
})
it('keeps the thermal confirmation cancellable and surfaces a 422 recompute failure', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
mockPost.mockRejectedValue(new Error('422'))
renderWithProviders(<CostView />)
await user.click(screen.getByTestId('costs-scope-selector')); await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-recompute-button')).toBeInTheDocument())
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-cancel'))
expect(screen.queryByTestId('thermal-recompute-confirm-modal')).not.toBeInTheDocument()
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-confirm'))
await waitFor(() => expect(screen.getByTestId('thermal-recompute-error')).toBeInTheDocument())
})
it.each([
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'],
['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'],
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: { ...THERMAL_SUMMARY, heating: heatingValue === 'Not configured' ? '0' : THERMAL_SUMMARY.heating } })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
const user = userEvent.setup()
renderWithProviders(<CostView />)
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
if (missingText) {
expect(screen.getByTestId('thermal-missing-current-meter')).toHaveTextContent(missingText)
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Not configured')
} else {
expect(screen.queryByTestId('thermal-missing-current-meter')).not.toBeInTheDocument()
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent(heatingValue)
}
})
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
const user = userEvent.setup()
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
if (path === '/api/energy/meter-costs') {
const offset = options?.params?.query?.offset ?? 0
return Promise.resolve({ data: offset === 0 ? { items: Array.from({ length: 500 }, () => THERMAL_ROW), total: 501 } : { items: [lastRow], total: 501 } })
}
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
renderWithProviders(<CostView />)
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
expect(screen.getByTestId('thermal-ledger-prev')).toBeDisabled()
expect(screen.getByTestId('thermal-ledger-next')).toBeEnabled()
await user.click(screen.getByTestId('thermal-ledger-next'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 501-501 of 501'))
expect(screen.getByTestId('thermal-ledger-prev')).toBeEnabled()
expect(screen.getByTestId('thermal-ledger-next')).toBeDisabled()
expect(mockGet).toHaveBeenCalledWith('/api/energy/meter-costs', expect.objectContaining({ params: { query: expect.objectContaining({ scope: 'thermal', offset: 500, limit: 500 }) } }))
await user.click(screen.getByTestId('thermal-cost-range-control'))
await user.click(screen.getByText('This month'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Electricity'))
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
})
})