From 5b9d60e80a7f995d40f1372860ba4cd008d91884 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 23 Aug 2026 15:47:41 +0200 Subject: [PATCH] M8-T19: add scope-aware energy contract and cost UI --- docs/design/m8-warmtelink-energy.md | 2 +- frontend/src/energy/ContractForm.test.tsx | 63 +++++++ frontend/src/energy/ContractForm.tsx | 46 ++++-- frontend/src/energy/ContractManager.test.tsx | 40 +++++ frontend/src/energy/ContractManager.tsx | 56 ++++--- frontend/src/energy/CostView.test.tsx | 164 +++++++++++++++++++ frontend/src/energy/CostView.tsx | 86 ++++++++++ frontend/src/energy/TibberPrices.test.tsx | 48 ++++++ frontend/src/energy/TibberPrices.tsx | 67 ++++++-- frontend/src/energy/energy-hooks.test.tsx | 11 ++ 10 files changed, 532 insertions(+), 51 deletions(-) diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index bea5551..f99372d 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -1023,7 +1023,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 ### M8-T19 — Scope-aware Contracts / Prices / Costs UI [structural] -- **Status**: `todo` +- **Status**: `done` - **Depends**: M8-T18 - **Context**: 完成 electricity/thermal 双 scope 的合同录入、价格快照和成本审计体验。 diff --git a/frontend/src/energy/ContractForm.test.tsx b/frontend/src/energy/ContractForm.test.tsx index 812c175..620789c 100644 --- a/frontend/src/energy/ContractForm.test.tsx +++ b/frontend/src/energy/ContractForm.test.tsx @@ -65,6 +65,11 @@ const PROFILES_RESPONSE = { heffingskorting: { unit: 'EUR/year' }, }, }, + { + kind: 'district_heating', label: 'District heating', + variable: { heating: { unit: 'EUR/GJ', default: 0 } }, + standing: { delivery_set: { unit: 'EUR/year', default: 0 } }, + }, ], } @@ -79,6 +84,21 @@ const CREATED_CONTRACT = { versions: [], } +const D11_PROFILE_RESPONSE = { + profiles: [{ + kind: 'district_heating', label: 'District heating', + variable: { + heating: { unit: 'EUR/GJ' }, hot_water_heating: { unit: 'EUR/m³' }, + hot_water: { unit: 'EUR/m³' }, hot_water_tax: { unit: 'EUR/m³' }, + }, + standing: { + heating_network: { unit: 'EUR/year' }, metering: { unit: 'EUR/year' }, + delivery_set: { unit: 'EUR/year' }, hot_water_network: { unit: 'EUR/year' }, + other: { unit: 'EUR/year' }, + }, + }], +} + // --------------------------------------------------------------------------- // Import component // --------------------------------------------------------------------------- @@ -174,6 +194,49 @@ describe('ContractForm', () => { }, { timeout: 3000 }) }) + it('limits a thermal create form to its compatible profile and posts its scope', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: PROFILES_RESPONSE }) + mockPost.mockResolvedValue({ data: CREATED_CONTRACT }) + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('contract-field-variable.heating')).toBeInTheDocument()) + await user.type(screen.getByTestId('contract-name'), 'Heat') + await user.click(screen.getByTestId('contract-form-submit')) + await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', expect.objectContaining({ body: expect.objectContaining({ kind: 'district_heating', scope: 'thermal' }) }))) + }) + + it('posts all nine D11 values as unrounded Decimal strings, including zero standing fees', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: D11_PROFILE_RESPONSE }) + mockPost.mockResolvedValue({ data: CREATED_CONTRACT }) + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('contract-field-variable.heating')).toBeInTheDocument()) + await user.type(screen.getByTestId('contract-name'), 'Precise heat') + const values: Record = { + 'variable.heating': '20.123456789123456789', 'variable.hot_water_heating': '8.200000000000000001', + 'variable.hot_water': '1.234567890123456789', 'variable.hot_water_tax': '0.456789012345678901', + 'standing.heating_network': '0', 'standing.metering': '0', 'standing.delivery_set': '0', + 'standing.hot_water_network': '0', 'standing.other': '0', + } + for (const [key, value] of Object.entries(values)) { + const input = screen.getByTestId(`contract-field-${key}`) + await user.clear(input) + await user.type(input, value) + } + await user.click(screen.getByTestId('contract-form-submit')) + await waitFor(() => expect(mockPost).toHaveBeenCalled()) + const body = mockPost.mock.calls[0][1].body + expect(body).toMatchObject({ scope: 'thermal', values: { + variable: { + heating: values['variable.heating'], hot_water_heating: values['variable.hot_water_heating'], + hot_water: values['variable.hot_water'], hot_water_tax: values['variable.hot_water_tax'], + }, + standing: { + heating_network: '0', metering: '0', delivery_set: '0', hot_water_network: '0', other: '0', + }, + } }) + }) + it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => { const user = userEvent.setup() diff --git a/frontend/src/energy/ContractForm.tsx b/frontend/src/energy/ContractForm.tsx index 20af0eb..6c8b10a 100644 --- a/frontend/src/energy/ContractForm.tsx +++ b/frontend/src/energy/ContractForm.tsx @@ -38,6 +38,8 @@ export interface ContractFormProps { contractId?: number /** Existing contract kind (for add-version mode or edit). */ defaultKind?: string + /** The list/create scope currently selected by the parent. */ + scope?: 'electricity' | 'thermal' onClose: () => void onSaved: () => void } @@ -64,7 +66,7 @@ interface LeafField { /** Dot-separated path within the section, e.g. "buy.normal" */ fieldPath: string unit: string - defaultValue?: number + defaultValue?: number | string } function extractLeafFields(obj: Record, prefix = ''): LeafField[] { @@ -77,7 +79,9 @@ function extractLeafFields(obj: Record, prefix = ''): LeafField fields.push({ fieldPath: path, unit: val.unit, - defaultValue: typeof val.default === 'number' ? val.default : undefined, + defaultValue: typeof val.default === 'number' || typeof val.default === 'string' + ? val.default + : undefined, }) } else { fields.push(...extractLeafFields(val as Record, path)) @@ -93,6 +97,7 @@ function extractLeafFields(obj: Record, prefix = ''): LeafField function buildNestedValues( sectionFields: Record, fieldValues: Record, + decimalStrings: boolean, ): Record { const result: Record = {} @@ -100,7 +105,6 @@ function buildNestedValues( const sectionObj: Record = {} for (const field of fields) { const raw = fieldValues[`${section}.${field.fieldPath}`] - const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw)) // Set nested path const parts = field.fieldPath.split('.') let current = sectionObj @@ -108,7 +112,10 @@ function buildNestedValues( if (!(parts[i] in current)) current[parts[i]] = {} current = current[parts[i]] as Record } - current[parts[parts.length - 1]] = isNaN(numVal) ? 0 : numVal + // Values are Decimal JSON strings. Do not round-trip user money through JS Number. + current[parts[parts.length - 1]] = decimalStrings + ? (raw === undefined || raw === '' ? '0' : String(raw)) + : (Number.isFinite(Number(raw)) ? Number(raw) : 0) } result[section] = sectionObj } @@ -130,7 +137,7 @@ function formatLabel(path: string): string { // Component // --------------------------------------------------------------------------- -export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) { +export function ContractForm({ contractId, defaultKind, scope, onClose, onSaved }: ContractFormProps) { const isAddVersion = contractId != null // Profiles query @@ -161,7 +168,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind // Build profile options from API response - const profiles = profilesQuery.data?.profiles ?? [] + const profiles = (profilesQuery.data?.profiles ?? []).filter((p: Record) => + scope === 'thermal' ? p.kind === 'district_heating' : p.kind !== 'district_heating', + ) const profileOptions = profiles.map((p: Record) => ({ value: p.kind as string, label: (p.label as string | undefined) ?? (p.kind as string), @@ -219,8 +228,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont cursor = (cursor as Record)[part] } if (cursor != null && (typeof cursor === 'number' || typeof cursor === 'string')) { - const numVal = typeof cursor === 'number' ? cursor : parseFloat(String(cursor)) - seeded[`${section}.${leaf.fieldPath}`] = isNaN(numVal) ? 0 : numVal + seeded[`${section}.${leaf.fieldPath}`] = effectiveKind === 'district_heating' ? String(cursor) : Number(cursor) } } } @@ -231,6 +239,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont contractDetailQuery.isError, contractDetailQuery.data, sectionFields, + effectiveKind, ]) // The effective field values: user edits override prefill; prefill is the base. @@ -285,7 +294,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont return } - const values = buildNestedValues(sectionFields, fieldValues) + const values = buildNestedValues(sectionFields, fieldValues, effectiveKind === 'district_heating') try { // Convert local date string to a naive local-midnight datetime string (no Z). @@ -307,6 +316,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont const body = { name: name.trim(), kind: effectiveKind, + ...(scope ? { scope } : {}), currency, values, ...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}), @@ -409,19 +419,21 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont {fields.map((field) => { const key = `${section}.${field.fieldPath}` - const raw = fieldValues[key] - const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw)) - return ( - handleFieldChange(key, val)} - decimalScale={6} - step={0.001} + inputMode="decimal" + value={String(fieldValues[key] ?? '0')} + onChange={(event) => handleFieldChange(key, event.currentTarget.value)} data-testid={`contract-field-${key}`} /> + ) : ( + handleFieldChange(key, value)} decimalScale={6} step={0.001} + data-testid={`contract-field-${key}`} /> ) })} diff --git a/frontend/src/energy/ContractManager.test.tsx b/frontend/src/energy/ContractManager.test.tsx index 24c9730..94e34e3 100644 --- a/frontend/src/energy/ContractManager.test.tsx +++ b/frontend/src/energy/ContractManager.test.tsx @@ -66,6 +66,11 @@ const INACTIVE_CONTRACT = { updated_at: '2026-06-02T00:00:00Z', } +const ACTIVE_THERMAL_CONTRACT = { + id: 3, name: 'Active Heat Contract', kind: 'district_heating', active: true, currency: 'EUR', + created_at: '2026-06-03T00:00:00Z', updated_at: '2026-06-03T00:00:00Z', +} + const PROFILES_RESPONSE = { profiles: [ { @@ -202,4 +207,39 @@ describe('ContractManager', () => { expect(screen.getByTestId('contract-form-modal')).toBeInTheDocument() }) }) + + it('keeps the selector available while thermal data loads and requests each scope separately', async () => { + const user = userEvent.setup() + mockGet.mockImplementation((path: string, options?: { params?: { query?: { scope?: string } } }) => { + if (path === '/api/energy/contracts' && options?.params?.query?.scope === 'thermal') return new Promise(() => {}) + if (path === '/api/energy/contracts') return Promise.resolve({ data: { items: [ACTIVE_CONTRACT], total: 1 } }) + return Promise.resolve({ data: PROFILES_RESPONSE }) + }) + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('contracts-scope-selector')).toBeInTheDocument()) + await user.click(screen.getByText('Thermal')) + expect(screen.getByTestId('contracts-loading')).toBeInTheDocument() + expect(screen.getByTestId('contracts-scope-selector')).toBeInTheDocument() + await user.click(screen.getByText('Electricity')) + await waitFor(() => expect(screen.getByTestId('contracts-table')).toBeInTheDocument()) + expect(mockGet).toHaveBeenCalledWith('/api/energy/contracts', { params: { query: { scope: 'thermal' } } }) + }) + + it('keeps simultaneous active electricity and thermal contracts isolated across repeated switches', async () => { + const user = userEvent.setup() + mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) => + Promise.resolve({ data: { items: options?.params?.query?.scope === 'thermal' + ? [ACTIVE_THERMAL_CONTRACT] : [ACTIVE_CONTRACT], total: 1 } }), + ) + renderWithProviders() + await waitFor(() => expect(screen.getByText('My Active Contract')).toBeInTheDocument()) + for (let i = 0; i < 2; i += 1) { + await user.click(screen.getByText('Thermal')) + await waitFor(() => expect(screen.getByText('Active Heat Contract')).toBeInTheDocument()) + expect(screen.queryByText('My Active Contract')).not.toBeInTheDocument() + await user.click(screen.getByText('Electricity')) + await waitFor(() => expect(screen.getByText('My Active Contract')).toBeInTheDocument()) + expect(screen.queryByText('Active Heat Contract')).not.toBeInTheDocument() + } + }) }) diff --git a/frontend/src/energy/ContractManager.tsx b/frontend/src/energy/ContractManager.tsx index b80dc62..97720ed 100644 --- a/frontend/src/energy/ContractManager.tsx +++ b/frontend/src/energy/ContractManager.tsx @@ -10,6 +10,7 @@ */ import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { Table, Button, @@ -24,9 +25,9 @@ import { Modal, Accordion, Code, + SegmentedControl, } from '@mantine/core' import { - useContracts, useUpdateContract, type ContractResponse, type ContractDetailResponse, @@ -244,7 +245,14 @@ function ContractTable({ // --------------------------------------------------------------------------- export function ContractManager() { - const contractsQuery = useContracts() + const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') + const contractsQuery = useQuery({ + queryKey: ['energy-contracts', scope], + queryFn: async () => { + const res = await apiClient.GET('/api/energy/contracts', { params: { query: { scope } } }) + return res.data + }, + }) const updateMutation = useUpdateContract() const [showCreateForm, setShowCreateForm] = useState(false) @@ -252,6 +260,13 @@ export function ContractManager() { const [historyContract, setHistoryContract] = useState(null) const [activatingId, setActivatingId] = useState(null) + function handleScopeChange(nextScope: 'electricity' | 'thermal') { + setScope(nextScope) + setShowCreateForm(false) + setAddVersionContract(null) + setHistoryContract(null) + } + async function handleActivate(id: number) { setActivatingId(id) try { @@ -265,44 +280,40 @@ export function ContractManager() { // Render states // --------------------------------------------------------------------------- - if (contractsQuery.isLoading) { - return ( -
- -
- ) - } - - if (contractsQuery.isError || !contractsQuery.data) { - return ( - - Failed to load contracts. Please refresh. - - ) - } - - const contracts = contractsQuery.data.items + const contracts = contractsQuery.data?.items ?? [] return ( - Energy Contracts + + Energy Contracts + handleScopeChange(value as 'electricity' | 'thermal')} + data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} + data-testid="contracts-scope-selector" + /> + - } + {contractsQuery.isError && Failed to load contracts. Please refresh.} + {!contractsQuery.isLoading && !contractsQuery.isError && setAddVersionContract(c)} onViewHistory={(c) => setHistoryContract(c)} activatingId={activatingId} - /> + />} {/* Create new contract */} {showCreateForm && ( setShowCreateForm(false)} onSaved={() => setShowCreateForm(false)} /> @@ -313,6 +324,7 @@ export function ContractManager() { setAddVersionContract(null)} onSaved={() => setAddVersionContract(null)} /> diff --git a/frontend/src/energy/CostView.test.tsx b/frontend/src/energy/CostView.test.tsx index 82bfc2f..bb5eb36 100644 --- a/frontend/src/energy/CostView.test.tsx +++ b/frontend/src/energy/CostView.test.tsx @@ -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() + 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() + 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() + 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() + 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')) + }) }) diff --git a/frontend/src/energy/CostView.tsx b/frontend/src/energy/CostView.tsx index 7328f82..1555139 100644 --- a/frontend/src/energy/CostView.tsx +++ b/frontend/src/energy/CostView.tsx @@ -13,6 +13,7 @@ */ import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Stack, Text, @@ -43,6 +44,7 @@ import { } from 'recharts' import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks' import { formatLocalTime } from '../utils/datetime' +import apiClient from '../api/client' // --------------------------------------------------------------------------- // Cost limit — prevent accidental full-table pulls @@ -109,6 +111,16 @@ function SummaryCard({ label, value, sub, testId }: SummaryCardProps) { type RangePreset = 'today' | 'month' | 'custom' export function CostView() { + const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') + if (scope === 'thermal') return + return +} + +function ScopeSelector({ scope, onScopeChange }: { scope: 'electricity' | 'thermal'; onScopeChange: (scope: 'electricity' | 'thermal') => void }) { + return onScopeChange(value as 'electricity' | 'thermal')} data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} data-testid="costs-scope-selector" /> +} + +function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) { const [rangePreset, setRangePreset] = useState('today') // Date strings in YYYY-MM-DD format for custom range const [customStartStr, setCustomStartStr] = useState('') @@ -144,6 +156,7 @@ export function CostView() { {/* Date range selector */} + Date range @@ -410,3 +423,76 @@ export function CostView() { ) } + +function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) { + const [rangePreset, setRangePreset] = useState('today') + const [customStartStr, setCustomStartStr] = useState('') + const [customEndStr, setCustomEndStr] = useState('') + const [showConfirm, setShowConfirm] = useState(false) + const [recomputeError, setRecomputeError] = useState(null) + const [recomputeSuccess, setRecomputeSuccess] = useState(null) + const [expandedRows, setExpandedRows] = useState>(() => new Set()) + const [ledgerOffset, setLedgerOffset] = useState(0) + const { start, end } = (() => { + if (rangePreset === 'today') return getTodayRange() + if (rangePreset === 'month') return getThisMonthRange() + return { + start: customStartStr ? new Date(customStartStr).toISOString() : undefined, + end: customEndStr ? new Date(customEndStr).toISOString() : undefined, + } + })() + // The server only accepts complete UTC quarters. Never send a future end. + const closedEnd = (() => { + const now = new Date() + now.setUTCMinutes(Math.floor(now.getUTCMinutes() / 15) * 15, 0, 0) + const selectedEnd = end ? new Date(end) : now + return new Date(Math.min(selectedEnd.getTime(), now.getTime())).toISOString() + })() + const recomputeStart = start + const recomputeAvailable = !!recomputeStart && new Date(recomputeStart) < new Date(closedEnd) + const qc = useQueryClient() + const resetLedgerPage = () => { + setLedgerOffset(0) + setExpandedRows(new Set()) + } + const rows = useQuery({ queryKey: ['meter-costs', 'thermal', start, end, ledgerOffset], queryFn: async () => { + const result = await apiClient.GET('/api/energy/meter-costs', { params: { query: { scope: 'thermal', start, end, limit: COSTS_MAX_LIMIT, offset: ledgerOffset } } }) + return result.data + } }) + const meters = useQuery({ queryKey: ['energy-meters', 'thermal'], queryFn: async () => { + const result = await apiClient.GET('/api/energy/meters') + return result.data + } }) + const summary = useQuery({ queryKey: ['meter-cost-summary', 'thermal', start, end], queryFn: async () => { + const result = await apiClient.GET('/api/energy/meter-costs/summary', { params: { query: { scope: 'thermal', start, end } } }) + return result.data + } }) + const recompute = useMutation({ mutationFn: () => apiClient.POST('/api/energy/meter-costs/recompute', { params: { query: { scope: 'thermal', start: recomputeStart!, end: closedEnd } } }), onSuccess: (result) => { + void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] }); void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] }) + setRecomputeSuccess(`Recomputed ${result.data?.processed ?? 0} closed periods.`) + } }) + const currency = summary.data?.currency ?? rows.data?.items[0]?.currency ?? 'EUR' + const fixed = summary.data?.fixed_breakdown + const hasCurrentHeatingMeter = meters.data?.items.some((meter) => meter.commodity === 'heating' && meter.ended_at === null) + const hasCurrentHotWaterMeter = meters.data?.items.some((meter) => meter.commodity === 'hot_water' && meter.ended_at === null) + const missingCurrentMeters = [ + ...(hasCurrentHeatingMeter === false ? ['heating'] : []), + ...(hasCurrentHotWaterMeter === false ? ['hot-water'] : []), + ] + const totalRows = rows.data?.total ?? 0 + const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1 + const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows) + return + Date range { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" />{rangePreset === 'custom' && { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /> { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" />} + {(rows.isLoading || summary.isLoading) &&
} + {(rows.isError || summary.isError) && Failed to load thermal costs.} + {recomputeError && {recomputeError}} + {recomputeSuccess && {recomputeSuccess}} + {summary.data && {rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency}){start ?? 'Select a start date'} — {end ?? 'Select an end date'} + + {missingCurrentMeters.length > 0 && Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.}{summary.data.period_count} periods; {summary.data.degraded_count} degraded{summary.data.degraded_count > 0 && Some totals include degraded periods. Expand a row to see its recorded reason.}{fixed && Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')}}} + {rows.data?.items.length === 0 && No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.} + {rows.data && Showing {shownStart}-{shownEnd} of {totalRows}{rows.data.items.length > 0 && TimeCommodityQuantityCostBreakdownStatus{rows.data.items.flatMap((item, index) => [{formatLocalTime(item.period_start)}{item.commodity}{item.quantity}{item.cost} {item.currency}{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')}{item.degraded ? {item.degraded_reason ?? 'degraded'} : 'normal'}, ...(expandedRows.has(index) ? [Contract version: {item.contract_version_id ?? 'none'}Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}] : [])])}
}
} + {showConfirm && setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal">This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} — {closedEnd}. Continue?{!recomputeAvailable && Select a range containing at least one closed UTC quarter.}} +
+} diff --git a/frontend/src/energy/TibberPrices.test.tsx b/frontend/src/energy/TibberPrices.test.tsx index 13c371e..6e3e202 100644 --- a/frontend/src/energy/TibberPrices.test.tsx +++ b/frontend/src/energy/TibberPrices.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' // --------------------------------------------------------------------------- @@ -244,6 +245,53 @@ describe('TibberPrices', () => { expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900') }) + it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot with units', async () => { + const user = userEvent.setup() + const thermal = { + kind: 'district_heating', currency: 'EUR', points: [], tariff: null, + contract_version_id: 42, effective_from: '2026-01-01T00:00:00Z', effective_to: '2026-12-31T00:00:00Z', + 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', + }, + }, + } + mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) => + Promise.resolve({ data: options?.params?.query?.scope === 'thermal' + ? thermal + : { kind: 'manual', currency: 'EUR', points: [], tariff: { buy_dal: 0.1, buy_normal: 0.2, sell_dal: 0.03, sell_normal: 0.04 } } }), + ) + + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument()) + await user.click(screen.getByText('Thermal')) + await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument()) + + const snapshot = screen.getByTestId('thermal-price-snapshot') + expect(snapshot).toHaveTextContent('Version 42') + expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z') + expect(snapshot).toHaveTextContent('not a 15-minute market spot price') + expect(snapshot).toHaveTextContent('20.123456789123456789 EUR/GJ') + expect(snapshot).toHaveTextContent('8.200000000000000001 EUR/m³') + expect(snapshot).toHaveTextContent('1.234567890123456789 EUR/m³') + expect(snapshot).toHaveTextContent('0.456789012345678901 EUR/m³') + for (const value of Object.values(thermal.values.standing)) { + expect(snapshot).toHaveTextContent(`${value} EUR/year`) + } + expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({ + params: { query: expect.objectContaining({ scope: 'thermal' }) }, + })) + + await user.click(screen.getByText('Electricity')) + await waitFor(() => expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument()) + expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument() + }) + it('marks the currently active price slot with a dot and a caption', async () => { installChartSize() diff --git a/frontend/src/energy/TibberPrices.tsx b/frontend/src/energy/TibberPrices.tsx index 1964eb8..fd3f78f 100644 --- a/frontend/src/energy/TibberPrices.tsx +++ b/frontend/src/energy/TibberPrices.tsx @@ -22,6 +22,7 @@ import { Badge, Group, Paper, + SegmentedControl, } from '@mantine/core' import { LineChart, @@ -34,7 +35,8 @@ import { ReferenceDot, ResponsiveContainer, } from 'recharts' -import { useEnergyPrices } from './hooks' +import { useQuery } from '@tanstack/react-query' +import apiClient from '../api/client' import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime' const BUY_COLOR = '#2196f3' @@ -46,6 +48,18 @@ const FALLBACK_SLOT_MS = 60 * 60 * 1000 /** How often the "current price" marker re-evaluates which slot is active. */ const NOW_TICK_MS = 30 * 1000 +/** D11 thermal profile units. The API snapshot is Decimal strings, while the + * currency comes from its contract metadata. Keep this display-only: no rates + * are derived or prefilled in the browser. */ +function thermalUnit(section: string, key: string, currency: string): string { + if (section === 'standing') return `${currency}/year` + if (key === 'heating') return `${currency}/GJ` + if (key === 'hot_water_heating' || key === 'hot_water' || key === 'hot_water_tax') { + return `${currency}/m³` + } + return currency +} + // --------------------------------------------------------------------------- // Time range helpers // --------------------------------------------------------------------------- @@ -288,46 +302,59 @@ function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) { // --------------------------------------------------------------------------- export function TibberPrices() { + const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') const start = getTodayStart() const end = getTomorrowEnd() + const { data, isLoading, isError } = useQuery({ + queryKey: ['energy-prices', scope, start, end], + queryFn: async () => { + const res = await apiClient.GET('/api/energy/prices', { params: { query: { scope, start, end } } }) + return res.data + }, + }) - const { data, isLoading, isError } = useEnergyPrices(start, end) + const selector = ( + setScope(value as 'electricity' | 'thermal')} + data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} + data-testid="prices-scope-selector" + /> + ) if (isLoading) { return ( -
- -
+ Energy Prices{selector}
) } if (isError) { return ( - + Energy Prices{selector} Failed to load energy prices. Please refresh. - +
) } if (!data) { return ( - + Energy Prices{selector} No pricing data available. - +
) } // No active contract if (!data.kind) { return ( - + Energy Prices{selector} No active contract Activate an energy contract on the Contracts tab to see pricing data. - + ) } @@ -337,6 +364,7 @@ export function TibberPrices() { Energy Prices + {selector} {data.kind} @@ -364,6 +392,23 @@ export function TibberPrices() { Manual tariff data not available. )} + + {scope === 'thermal' && data.values && ( + + + Thermal contract snapshot + Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'} + This is a contract snapshot, not a 15-minute market spot price. + {Object.entries(data.values).map(([section, values]) => ( + + {section}: {Object.entries(values).map(([key, value]) => + `${key} ${value} ${thermalUnit(section, key, currency)}`, + ).join(', ')} + + ))} + + + )} ) } diff --git a/frontend/src/energy/energy-hooks.test.tsx b/frontend/src/energy/energy-hooks.test.tsx index 62a4fa8..e6d1206 100644 --- a/frontend/src/energy/energy-hooks.test.tsx +++ b/frontend/src/energy/energy-hooks.test.tsx @@ -150,6 +150,17 @@ describe('useCreateContract', () => { expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', { body }) }) + + it('preserves the caller-selected thermal scope in the typed create payload', async () => { + mockPost.mockResolvedValue({ data: { id: 9 } }) + const { Wrapper } = makeWrapper() + const { useCreateContract } = await import('./hooks') + const { result } = renderHook(() => useCreateContract(), { wrapper: Wrapper }) + await act(async () => { + await result.current.mutateAsync({ name: 'Heat', kind: 'district_heating', scope: 'thermal', currency: 'EUR', values: {} }) + }) + expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', expect.objectContaining({ body: expect.objectContaining({ scope: 'thermal' }) })) + }) }) describe('useEnergyPrices', () => {