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"
+ />
+
setShowCreateForm(true)} data-testid="contract-new-button">
New Contract
- }
+ {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" /> } { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} data-testid="thermal-recompute-button">Recompute
+ {(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 && Time Commodity Quantity Cost Breakdown Status {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'} setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'} , ...(expandedRows.has(index) ? [Contract version: {item.contract_version_id ?? 'none'} Pricing snapshot: {JSON.stringify(item.pricing_snapshot)} ] : [])])}
} { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous = totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next }
+ {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. } setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute }
+
+}
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', () => {