Compare commits
2
Commits
8b91146c29
...
96e85fb48e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96e85fb48e | ||
|
|
b71009620a |
+145
-7
@@ -550,13 +550,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
||||||
|
|
||||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
|
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
|
||||||
where ``anchor_utc`` is the effective_from of the earliest version of the active
|
where ``anchor_utc`` is the **max** of:
|
||||||
contract (i.e. the contract billing start). This makes the cumulative total
|
- the earliest version's effective_from (contract billing start), and
|
||||||
monotonically increasing and cross-version consistent:
|
- the earliest non-degraded EnergyCostPeriod.period_start (recording start).
|
||||||
|
|
||||||
|
This prevents counting fixed costs for the period before any data was recorded
|
||||||
|
(e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not
|
||||||
|
want to include ~€270 of standing charges for a period with no meter data).
|
||||||
|
|
||||||
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
|
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
|
||||||
|
|
||||||
Returns None when no non-degraded period exists in [anchor, now].
|
Returns None when no non-degraded period exists.
|
||||||
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
|
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
|
||||||
"""
|
"""
|
||||||
def _getter(sess: "Session") -> Any:
|
def _getter(sess: "Session") -> Any:
|
||||||
@@ -580,8 +584,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
# No active contract — return pure SUM(import_cost) without standing charges.
|
# No active contract — return pure SUM(import_cost) without standing charges.
|
||||||
return float(has_data)
|
return float(has_data)
|
||||||
|
|
||||||
# anchor = earliest version's effective_from (contract billing start).
|
# anchor = max(contract billing start, earliest recording start).
|
||||||
anchor_utc = _cu(versions[0].effective_from)
|
# This prevents accumulating fixed costs for days with no meter data.
|
||||||
|
contract_anchor_utc = _cu(versions[0].effective_from)
|
||||||
|
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
|
||||||
|
_ECP.degraded.is_(False)
|
||||||
|
).scalar()
|
||||||
|
if earliest_period_start is not None:
|
||||||
|
recording_anchor_utc = _cu(earliest_period_start)
|
||||||
|
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
|
||||||
|
else:
|
||||||
|
anchor_utc = contract_anchor_utc
|
||||||
|
|
||||||
now_utc = _dt.now(UTC)
|
now_utc = _dt.now(UTC)
|
||||||
|
|
||||||
result = _summarize(sess, anchor_utc, now_utc)
|
result = _summarize(sess, anchor_utc, now_utc)
|
||||||
@@ -595,6 +609,7 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
||||||
|
|
||||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
|
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
|
||||||
|
Anchor is the same max(contract_start, recording_start) logic as import getter.
|
||||||
|
|
||||||
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
||||||
|
|
||||||
@@ -620,7 +635,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
# No active contract — return pure SUM(export_revenue) without credits.
|
# No active contract — return pure SUM(export_revenue) without credits.
|
||||||
return float(has_data)
|
return float(has_data)
|
||||||
|
|
||||||
anchor_utc = _cu(versions[0].effective_from)
|
# anchor = max(contract billing start, earliest recording start).
|
||||||
|
contract_anchor_utc = _cu(versions[0].effective_from)
|
||||||
|
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
|
||||||
|
_ECP.degraded.is_(False)
|
||||||
|
).scalar()
|
||||||
|
if earliest_period_start is not None:
|
||||||
|
recording_anchor_utc = _cu(earliest_period_start)
|
||||||
|
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
|
||||||
|
else:
|
||||||
|
anchor_utc = contract_anchor_utc
|
||||||
|
|
||||||
now_utc = _dt.now(UTC)
|
now_utc = _dt.now(UTC)
|
||||||
|
|
||||||
result = _summarize(sess, anchor_utc, now_utc)
|
result = _summarize(sess, anchor_utc, now_utc)
|
||||||
@@ -628,6 +653,85 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
|
|
||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
|
# --- value_getter: today's import cost (local-day window, resets at local midnight) ---
|
||||||
|
|
||||||
|
def _make_import_cost_today_getter() -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter for today's import cost (metered + standing for today).
|
||||||
|
|
||||||
|
Window: [local today 00:00, local tomorrow 00:00) in UTC.
|
||||||
|
After local midnight the window rolls to the new day → value resets to that
|
||||||
|
day's running cost, implementing "resets at local midnight" semantics for HA.
|
||||||
|
|
||||||
|
Uses timezone module via module-attribute access to preserve monkeypatch safety.
|
||||||
|
Returns None when no non-degraded period exists or no active contract.
|
||||||
|
"""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from app.services.contracts import active_contract_versions
|
||||||
|
from app.models.energy import EnergyCostPeriod as _ECP
|
||||||
|
from app.services.energy_cost import summarize as _summarize
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
|
||||||
|
# Quick check: any non-degraded period at all?
|
||||||
|
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||||
|
_ECP.degraded.is_(False)
|
||||||
|
).scalar()
|
||||||
|
if has_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
versions = active_contract_versions(sess)
|
||||||
|
if not versions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
||||||
|
today_local = _tz_mod.local_now().date()
|
||||||
|
tomorrow_local = today_local + _td(days=1)
|
||||||
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
|
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||||
|
|
||||||
|
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
|
||||||
|
return result["metered_import"] + result["fixed_costs"]
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
|
# --- value_getter: today's export revenue (local-day window, resets at local midnight) ---
|
||||||
|
|
||||||
|
def _make_export_revenue_today_getter() -> Callable[["Session"], Any]:
|
||||||
|
"""Return a getter for today's export revenue (metered + tax credit for today).
|
||||||
|
|
||||||
|
Same window semantics as import_cost_today.
|
||||||
|
Returns None when no non-degraded period exists or no active contract.
|
||||||
|
"""
|
||||||
|
def _getter(sess: "Session") -> Any:
|
||||||
|
from app.services.contracts import active_contract_versions
|
||||||
|
from app.models.energy import EnergyCostPeriod as _ECP
|
||||||
|
from app.services.energy_cost import summarize as _summarize
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
|
||||||
|
# Quick check: any non-degraded period at all?
|
||||||
|
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||||
|
_ECP.degraded.is_(False)
|
||||||
|
).scalar()
|
||||||
|
if has_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
versions = active_contract_versions(sess)
|
||||||
|
if not versions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
today_local = _tz_mod.local_now().date()
|
||||||
|
tomorrow_local = today_local + _td(days=1)
|
||||||
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
|
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||||
|
|
||||||
|
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
|
||||||
|
return result["metered_export"] + result["credits"]
|
||||||
|
|
||||||
|
return _getter
|
||||||
|
|
||||||
# Price unit string: "<currency>/kWh"
|
# Price unit string: "<currency>/kWh"
|
||||||
price_unit = f"{currency}/kWh"
|
price_unit = f"{currency}/kWh"
|
||||||
|
|
||||||
@@ -672,6 +776,40 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
value_getter=_make_export_revenue_getter(),
|
value_getter=_make_export_revenue_getter(),
|
||||||
state_class="total",
|
state_class="total",
|
||||||
),
|
),
|
||||||
|
# Daily entities: reset at local midnight, state_class=total_increasing.
|
||||||
|
#
|
||||||
|
# device_class=monetary is correct here. The HA developer docs (long-term
|
||||||
|
# statistics section) prohibit MONETARY from being combined with
|
||||||
|
# state_class=measurement — they do NOT prohibit it with total or
|
||||||
|
# total_increasing. monetary+total_increasing is valid (the cumulative
|
||||||
|
# entities above use monetary+total with no issue).
|
||||||
|
#
|
||||||
|
# total_increasing is appropriate because these daily quantities are
|
||||||
|
# non-negative (import/export metered cost ≥ 0, fixed standing fee/tax
|
||||||
|
# credit ≥ 0) and monotonically non-decreasing within any given local day.
|
||||||
|
# When the local midnight rolls over the value drops back to ~1 day's fixed
|
||||||
|
# fee; HA interprets that decrease as a new cycle, giving correct daily
|
||||||
|
# aggregation without any explicit last_reset pipeline.
|
||||||
|
ExposableEntity(
|
||||||
|
key="energy.import_cost_today",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="monetary",
|
||||||
|
unit=currency,
|
||||||
|
name="Energy Import Cost Today (incl. standing)",
|
||||||
|
value_getter=_make_import_cost_today_getter(),
|
||||||
|
state_class="total_increasing",
|
||||||
|
),
|
||||||
|
ExposableEntity(
|
||||||
|
key="energy.export_revenue_today",
|
||||||
|
component="sensor",
|
||||||
|
device=device_info,
|
||||||
|
device_class="monetary",
|
||||||
|
unit=currency,
|
||||||
|
name="Energy Export Revenue Today (incl. tax credit)",
|
||||||
|
value_getter=_make_export_revenue_today_getter(),
|
||||||
|
state_class="total_increasing",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
return entities
|
return entities
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./.env:/app/.env:ro
|
- ./.env:/app/.env:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
app:
|
app:
|
||||||
container_name: home-automation-app
|
container_name: home-automation-app
|
||||||
@@ -24,4 +25,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./.env:/app/.env:ro
|
- ./.env:/app/.env:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
|
|||||||
@@ -246,4 +246,93 @@ describe('ContractForm', () => {
|
|||||||
|
|
||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prefills fields from the open version when in add-version mode', async () => {
|
||||||
|
// FU11: add-version mode should auto-prefill rate fields from the contract's
|
||||||
|
// current open version so the user only has to change what's different.
|
||||||
|
const CONTRACT_DETAIL = {
|
||||||
|
id: 42,
|
||||||
|
name: 'My Contract',
|
||||||
|
kind: 'manual',
|
||||||
|
active: true,
|
||||||
|
currency: 'EUR',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contract_id: 42,
|
||||||
|
effective_from: '2026-01-01T00:00:00Z',
|
||||||
|
effective_to: null, // open version
|
||||||
|
values: {
|
||||||
|
energy: {
|
||||||
|
buy: { normal: 0.133, dal: 0.127 },
|
||||||
|
sell: { normal: 0.05, dal: 0.05 },
|
||||||
|
energy_tax: 0.11,
|
||||||
|
ode: 0.0,
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
network_fee: 30.0,
|
||||||
|
management_fee: 60.0,
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
heffingskorting: 365.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/contracts/{contract_id}') {
|
||||||
|
return Promise.resolve({ data: CONTRACT_DETAIL })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContractForm contractId={42} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
// Wait for the form to render and profile fields to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
|
||||||
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
|
// Wait for prefill to apply: both profile and contract detail queries must resolve.
|
||||||
|
// Fill in the required effective_from date and submit; the submitted values
|
||||||
|
// body.values should contain the prefilled rates from the open version.
|
||||||
|
mockPost.mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
const effectiveInput = screen.getByTestId('contract-effective-from')
|
||||||
|
await user.type(effectiveInput, '2026-07-01')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('contract-form-submit'))
|
||||||
|
|
||||||
|
// The submitted body should contain the prefilled values from CONTRACT_DETAIL
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/energy/contracts/{contract_id}/versions',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
values: expect.objectContaining({
|
||||||
|
standing: expect.objectContaining({
|
||||||
|
network_fee: 30,
|
||||||
|
management_fee: 60,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, { timeout: 3000 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* + profile value fields for the contract's kind.
|
* + profile value fields for the contract's kind.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks'
|
import { useEnergyProfiles, useCreateContract, useAddContractVersion, useContractDetail } from './hooks'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Props
|
// Props
|
||||||
@@ -136,6 +136,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
// Profiles query
|
// Profiles query
|
||||||
const profilesQuery = useEnergyProfiles()
|
const profilesQuery = useEnergyProfiles()
|
||||||
|
|
||||||
|
// Contract detail query — only used in add-version mode to prefill from the open version.
|
||||||
|
const contractDetailQuery = useContractDetail(contractId ?? 0)
|
||||||
|
|
||||||
// Create mode fields
|
// Create mode fields
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [selectedKind, setSelectedKind] = useState<string | null>(defaultKind ?? null)
|
const [selectedKind, setSelectedKind] = useState<string | null>(defaultKind ?? null)
|
||||||
@@ -143,8 +146,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
// ISO date string "YYYY-MM-DD" for effective_from; empty = default to now
|
// ISO date string "YYYY-MM-DD" for effective_from; empty = default to now
|
||||||
const [effectiveFromStr, setEffectiveFromStr] = useState('')
|
const [effectiveFromStr, setEffectiveFromStr] = useState('')
|
||||||
|
|
||||||
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number
|
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number.
|
||||||
const [fieldValues, setFieldValues] = useState<Record<string, number | string>>({})
|
// User edits are stored here; for add-version mode this is merged with prefillValues.
|
||||||
|
const [userEdits, setUserEdits] = useState<Record<string, number | string>>({})
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -166,24 +170,90 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
// Find the selected profile structure
|
// Find the selected profile structure
|
||||||
const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind)
|
const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind)
|
||||||
|
|
||||||
// Extract section → leaf fields mapping from the selected profile
|
// Extract section → leaf fields mapping from the selected profile (memoized for stable reference).
|
||||||
const sectionFields: Record<string, LeafField[]> = {}
|
const sectionFields = useMemo<Record<string, LeafField[]>>(() => {
|
||||||
if (selectedProfile) {
|
if (!selectedProfile) return {}
|
||||||
const profileObj = selectedProfile as Record<string, unknown>
|
const profileObj = selectedProfile as Record<string, unknown>
|
||||||
|
const result: Record<string, LeafField[]> = {}
|
||||||
for (const [key, val] of Object.entries(profileObj)) {
|
for (const [key, val] of Object.entries(profileObj)) {
|
||||||
// Skip metadata fields
|
|
||||||
if (key === 'kind' || key === 'label') continue
|
if (key === 'kind' || key === 'label') continue
|
||||||
if (typeof val === 'object' && val !== null && !isLeaf(val)) {
|
if (typeof val === 'object' && val !== null && !isLeaf(val)) {
|
||||||
sectionFields[key] = extractLeafFields(val as Record<string, unknown>)
|
result[key] = extractLeafFields(val as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return result
|
||||||
|
}, [selectedProfile])
|
||||||
|
|
||||||
|
// Add-version prefill: compute the seeded values from the contract's open version.
|
||||||
|
// Returns the flat fieldValues dict or null when data is not ready.
|
||||||
|
const prefillValues = useMemo<Record<string, number | string> | null>(() => {
|
||||||
|
if (!isAddVersion) return null
|
||||||
|
if (contractDetailQuery.isLoading || contractDetailQuery.isError) return null
|
||||||
|
if (Object.keys(sectionFields).length === 0) return null
|
||||||
|
|
||||||
|
const detail = contractDetailQuery.data
|
||||||
|
if (!detail || !detail.versions || detail.versions.length === 0) return null
|
||||||
|
|
||||||
|
const versions = detail.versions as Array<{
|
||||||
|
effective_from: string
|
||||||
|
effective_to: string | null
|
||||||
|
values: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
let openVersion = versions.find((v) => v.effective_to == null)
|
||||||
|
if (!openVersion) {
|
||||||
|
openVersion = [...versions].sort((a, b) =>
|
||||||
|
a.effective_from < b.effective_from ? 1 : -1
|
||||||
|
)[0]
|
||||||
|
}
|
||||||
|
if (!openVersion?.values) return null
|
||||||
|
|
||||||
|
const seeded: Record<string, number | string> = {}
|
||||||
|
for (const [section, fields] of Object.entries(sectionFields)) {
|
||||||
|
const sectionVal = (openVersion.values as Record<string, unknown>)[section]
|
||||||
|
if (typeof sectionVal !== 'object' || sectionVal === null) continue
|
||||||
|
for (const leaf of fields) {
|
||||||
|
const parts = leaf.fieldPath.split('.')
|
||||||
|
let cursor: unknown = sectionVal
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof cursor !== 'object' || cursor === null) { cursor = undefined; break }
|
||||||
|
cursor = (cursor as Record<string, unknown>)[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(seeded).length > 0 ? seeded : null
|
||||||
|
}, [
|
||||||
|
isAddVersion,
|
||||||
|
contractDetailQuery.isLoading,
|
||||||
|
contractDetailQuery.isError,
|
||||||
|
contractDetailQuery.data,
|
||||||
|
sectionFields,
|
||||||
|
])
|
||||||
|
|
||||||
|
// The effective field values: user edits override prefill; prefill is the base.
|
||||||
|
// In add-version mode: when the user has not yet edited any field (userEdits is empty),
|
||||||
|
// prefillValues is used as the initial base. Once the user edits any field, their
|
||||||
|
// full merged value (prefill + override) is stored in userEdits.
|
||||||
|
const fieldValues: Record<string, number | string> =
|
||||||
|
isAddVersion && prefillValues !== null && Object.keys(userEdits).length === 0
|
||||||
|
? prefillValues
|
||||||
|
: userEdits
|
||||||
|
|
||||||
function handleFieldChange(key: string, val: number | string) {
|
function handleFieldChange(key: string, val: number | string) {
|
||||||
setFieldValues((prev) => ({ ...prev, [key]: val }))
|
// On first edit in add-version mode, copy prefill into userEdits so we don't
|
||||||
|
// lose the other prefilled values when the user changes one field.
|
||||||
|
setUserEdits((prev) => {
|
||||||
|
const base = isAddVersion && prefillValues !== null && Object.keys(prev).length === 0
|
||||||
|
? { ...prefillValues }
|
||||||
|
: prev
|
||||||
|
return { ...base, [key]: val }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize default values when profile changes
|
// Initialize default values when profile changes (create mode only)
|
||||||
function initDefaults(kind: string | null) {
|
function initDefaults(kind: string | null) {
|
||||||
if (!kind) return
|
if (!kind) return
|
||||||
const profile = profiles.find((p: Record<string, unknown>) => p.kind === kind)
|
const profile = profiles.find((p: Record<string, unknown>) => p.kind === kind)
|
||||||
@@ -199,7 +269,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFieldValues(defaults)
|
setUserEdits(defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -264,7 +334,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
size="md"
|
size="md"
|
||||||
data-testid="contract-form-modal"
|
data-testid="contract-form-modal"
|
||||||
>
|
>
|
||||||
{profilesQuery.isLoading && (
|
{(profilesQuery.isLoading || (isAddVersion && contractDetailQuery.isLoading)) && (
|
||||||
<Center py="md">
|
<Center py="md">
|
||||||
<Loader size="sm" data-testid="contract-form-loading" />
|
<Loader size="sm" data-testid="contract-form-loading" />
|
||||||
</Center>
|
</Center>
|
||||||
@@ -276,7 +346,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!profilesQuery.isLoading && (
|
{isAddVersion && contractDetailQuery.isError && !contractDetailQuery.isLoading && (
|
||||||
|
<Alert color="yellow" mb="sm" data-testid="contract-form-detail-error">
|
||||||
|
Could not load existing contract values for prefill — fields start at 0.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!profilesQuery.isLoading && !(isAddVersion && contractDetailQuery.isLoading) && (
|
||||||
<form onSubmit={handleSubmit} data-testid="contract-form">
|
<form onSubmit={handleSubmit} data-testid="contract-form">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{/* Create mode fields */}
|
{/* Create mode fields */}
|
||||||
|
|||||||
+356
-34
@@ -1,24 +1,29 @@
|
|||||||
"""Tests for M6-T08 + FU6: energy_cost expose provider + state publish.
|
"""Tests for M6-T08 + FU6 + FU11: energy_cost expose provider + state publish.
|
||||||
|
|
||||||
Coverage:
|
Coverage:
|
||||||
1. build_catalog contains all 4 energy_cost entities.
|
1. build_catalog contains all 6 energy_cost entities (4 original + 2 daily).
|
||||||
2. Cumulative entities (import_cost_total / export_revenue_total) have
|
2. Cumulative entities (import_cost_total / export_revenue_total) have
|
||||||
state_class="total" and device_class="monetary".
|
state_class="total" and device_class="monetary".
|
||||||
3. All 4 energy_cost entities default to enabled=False (no toggle row).
|
3. Daily entities (import_cost_today / export_revenue_today) have
|
||||||
4. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds.
|
state_class="total_increasing" and device_class="monetary".
|
||||||
5. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from
|
4. All 6 energy_cost entities default to enabled=False (no toggle row).
|
||||||
|
5. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds.
|
||||||
|
6. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from
|
||||||
non-degraded rows; degraded rows excluded.
|
non-degraded rows; degraded rows excluded.
|
||||||
6. value_getter returns None when no non-degraded period exists.
|
7. value_getter returns None when no non-degraded period exists.
|
||||||
7. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
|
8. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
|
||||||
8. Integration: build_discovery_payload on an energy_cost entity does NOT raise
|
9. Integration: build_discovery_payload on an energy_cost entity does NOT raise
|
||||||
IndexError (validates 2-element identifiers).
|
IndexError (validates 2-element identifiers).
|
||||||
9. Keys are stable fixed strings (not derived from mutable data or DB ids).
|
10. Keys are stable fixed strings (not derived from mutable data or DB ids).
|
||||||
10. Provider registered: energy_cost entities appear alongside modbus entities
|
11. Provider registered: energy_cost entities appear alongside modbus entities
|
||||||
in the full catalog.
|
in the full catalog.
|
||||||
11. Standing charges prorated into import_cost_total getter.
|
12. Standing charges prorated into import_cost_total getter.
|
||||||
12. Tax credit (heffingskorting) prorated into export_revenue_total getter.
|
13. Tax credit (heffingskorting) prorated into export_revenue_total getter.
|
||||||
13. effective_from in future → standing/credit cumulative = 0.
|
14. effective_from in future → standing/credit cumulative = 0.
|
||||||
14. No active contract → getters fall back to pure SUM.
|
15. No active contract → getters fall back to pure SUM (cumulative) or None (daily).
|
||||||
|
16. FU11: anchor = max(contract_start, recording_start) — pre-recording fixed costs excluded.
|
||||||
|
17. FU11: daily getter uses today's local window; returns None without active contract.
|
||||||
|
18. FU11: daily getter None when no non-degraded periods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -127,8 +132,8 @@ def energy_db(tmp_path: Path):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None:
|
def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None:
|
||||||
"""build_catalog must include all 4 energy_cost sensor entities."""
|
"""build_catalog must include all 6 energy_cost sensor entities (4 original + 2 daily)."""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
@@ -140,6 +145,8 @@ def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None:
|
|||||||
"energy.sell_price_now",
|
"energy.sell_price_now",
|
||||||
"energy.import_cost_total",
|
"energy.import_cost_total",
|
||||||
"energy.export_revenue_total",
|
"energy.export_revenue_total",
|
||||||
|
"energy.import_cost_today",
|
||||||
|
"energy.export_revenue_today",
|
||||||
}
|
}
|
||||||
assert expected_keys == energy_keys, (
|
assert expected_keys == energy_keys, (
|
||||||
f"Expected energy keys {expected_keys!r}, got {energy_keys!r}"
|
f"Expected energy keys {expected_keys!r}, got {energy_keys!r}"
|
||||||
@@ -184,14 +191,14 @@ def test_cumulative_entities_have_monetary_device_class(energy_db) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_all_energy_entities_are_sensors(energy_db) -> None:
|
def test_all_energy_entities_are_sensors(energy_db) -> None:
|
||||||
"""All 4 energy_cost entities must have component='sensor'."""
|
"""All 6 energy_cost entities must have component='sensor'."""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||||
assert len(energy_entries) == 4
|
assert len(energy_entries) == 6
|
||||||
for entry in energy_entries:
|
for entry in energy_entries:
|
||||||
assert entry.entity.component == "sensor", (
|
assert entry.entity.component == "sensor", (
|
||||||
f"Expected component='sensor' for {entry.entity.key!r}, "
|
f"Expected component='sensor' for {entry.entity.key!r}, "
|
||||||
@@ -205,14 +212,14 @@ def test_all_energy_entities_are_sensors(energy_db) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_energy_cost_entities_default_to_disabled(energy_db) -> None:
|
def test_energy_cost_entities_default_to_disabled(energy_db) -> None:
|
||||||
"""All 4 energy_cost entities must default to enabled=False (no toggle row)."""
|
"""All 6 energy_cost entities must default to enabled=False (no toggle row)."""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||||
assert len(energy_entries) == 4
|
assert len(energy_entries) == 6
|
||||||
for entry in energy_entries:
|
for entry in energy_entries:
|
||||||
assert entry.enabled is False, (
|
assert entry.enabled is False, (
|
||||||
f"Entity {entry.entity.key!r} must default to enabled=False "
|
f"Entity {entry.entity.key!r} must default to enabled=False "
|
||||||
@@ -629,7 +636,7 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -
|
|||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||||
assert len(energy_entries) == 4, "Expected 4 energy_cost entities in catalog"
|
assert len(energy_entries) == 6, "Expected 6 energy_cost entities in catalog"
|
||||||
|
|
||||||
for entry in energy_entries:
|
for entry in energy_entries:
|
||||||
# Must not raise — specifically no IndexError from identifiers[1]
|
# Must not raise — specifically no IndexError from identifiers[1]
|
||||||
@@ -666,7 +673,7 @@ def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> N
|
|||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||||
assert len(energy_entries) == 4
|
assert len(energy_entries) == 6
|
||||||
|
|
||||||
for entry in energy_entries:
|
for entry in energy_entries:
|
||||||
assert entry.entity.device.provides_availability is False
|
assert entry.entity.device.provides_availability is False
|
||||||
@@ -761,6 +768,8 @@ def test_energy_entity_keys_are_stable_fixed_strings(energy_db) -> None:
|
|||||||
"energy.sell_price_now",
|
"energy.sell_price_now",
|
||||||
"energy.import_cost_total",
|
"energy.import_cost_total",
|
||||||
"energy.export_revenue_total",
|
"energy.export_revenue_total",
|
||||||
|
"energy.import_cost_today",
|
||||||
|
"energy.export_revenue_today",
|
||||||
}
|
}
|
||||||
assert keys1 == expected_keys, (
|
assert keys1 == expected_keys, (
|
||||||
f"Energy keys must be exactly {expected_keys!r}, got {keys1!r}"
|
f"Energy keys must be exactly {expected_keys!r}, got {keys1!r}"
|
||||||
@@ -871,19 +880,18 @@ _STANDING_VALUES = {
|
|||||||
def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
||||||
"""import_cost_total getter must add prorated standing charges from active contract.
|
"""import_cost_total getter must add prorated standing charges from active contract.
|
||||||
|
|
||||||
Principle B/D: the getter delegates to summarize(anchor → now).
|
Principle B/D + FU11 anchor guardrail: the getter delegates to
|
||||||
anchor = earliest version's effective_from.
|
summarize(anchor → now), where anchor = max(contract_start, recording_start).
|
||||||
|
|
||||||
With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day.
|
With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day.
|
||||||
The getter uses summarize, which counts whole *local* elapsed days under Principle C.
|
The getter uses summarize, which counts whole *local* elapsed days under Principle C.
|
||||||
We pin the timezone to UTC for simplicity: local days = UTC days.
|
We pin the timezone to UTC for simplicity: local days = UTC days.
|
||||||
|
|
||||||
Setup:
|
Setup:
|
||||||
- effective_from = 10 full UTC days ago (exact UTC midnight), so today is day 11
|
- effective_from = 10 full UTC days ago (exact UTC midnight).
|
||||||
but Principle C counts only days D ≤ today, and today is included as 1 day.
|
- First period placed AT effective_from (same UTC midnight), so recording_start =
|
||||||
With anchor = 10 days ago UTC midnight and now near end of day 11:
|
contract_start → anchor = max(contract_start, recording_start) = contract_start.
|
||||||
local (UTC) dates: [10-days-ago, today_inclusive] = 11 days.
|
- Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days.
|
||||||
We place period rows within [anchor, now] so summarize sees them.
|
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -901,8 +909,9 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
|||||||
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
|
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
|
||||||
|
|
||||||
import_cost_sum = 5.00 # EUR
|
import_cost_sum = 5.00 # EUR
|
||||||
# Place period rows AFTER effective_from so summarize(anchor, now) picks them up.
|
# Place period rows AT effective_from (= recording_start = contract_start → same anchor).
|
||||||
t0 = effective_from + timedelta(hours=1)
|
# This ensures anchor = max(effective_from, effective_from) = effective_from exactly.
|
||||||
|
t0 = effective_from # first period starts exactly at contract effective_from
|
||||||
t1 = t0 + timedelta(minutes=15)
|
t1 = t0 + timedelta(minutes=15)
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
@@ -944,9 +953,12 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
|||||||
def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
||||||
"""export_revenue_total getter must add prorated heffingskorting from active contract.
|
"""export_revenue_total getter must add prorated heffingskorting from active contract.
|
||||||
|
|
||||||
Principle B/D: delegates to summarize(anchor → now).
|
Principle B/D + FU11 anchor guardrail: delegates to summarize(anchor → now),
|
||||||
|
where anchor = max(contract_start, recording_start).
|
||||||
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
|
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
|
||||||
Timezone pinned to UTC for deterministic local-day counting.
|
Timezone pinned to UTC for deterministic local-day counting.
|
||||||
|
|
||||||
|
Setup: period placed AT effective_from → recording_start = contract_start → same anchor.
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -961,8 +973,8 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
|||||||
# Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
|
# Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
|
||||||
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
|
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
|
||||||
|
|
||||||
# Place period rows AFTER effective_from.
|
# Place period rows AT effective_from → recording_start = contract_start = same anchor.
|
||||||
t0 = effective_from + timedelta(hours=1)
|
t0 = effective_from
|
||||||
t1 = t0 + timedelta(minutes=15)
|
t1 = t0 + timedelta(minutes=15)
|
||||||
export_sum = 2.50 # EUR
|
export_sum = 2.50 # EUR
|
||||||
|
|
||||||
@@ -1373,3 +1385,313 @@ def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> No
|
|||||||
assert value == pytest.approx(0.1500), (
|
assert value == pytest.approx(0.1500), (
|
||||||
f"Tibber sell price must be 0.1500 regardless of tariff={tariff_val!r}, got {value!r}"
|
f"Tibber sell price must be 0.1500 regardless of tariff={tariff_val!r}, got {value!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FU11 new tests: daily entities metadata + getter behaviour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_entities_in_catalog(energy_db) -> None:
|
||||||
|
"""FU11: catalog must include import_cost_today and export_revenue_today."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
daily_keys = {
|
||||||
|
"energy.import_cost_today",
|
||||||
|
"energy.export_revenue_today",
|
||||||
|
}
|
||||||
|
found = {e.entity.key for e in catalog if e.entity.key in daily_keys}
|
||||||
|
assert found == daily_keys, f"Expected daily keys {daily_keys!r} in catalog, got {found!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_entities_have_total_increasing_state_class(energy_db) -> None:
|
||||||
|
"""FU11: daily entities must have state_class='total_increasing' (not 'total')."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"}
|
||||||
|
for entry in catalog:
|
||||||
|
if entry.entity.key in daily_keys:
|
||||||
|
assert entry.entity.state_class == "total_increasing", (
|
||||||
|
f"Daily entity {entry.entity.key!r} must have state_class='total_increasing', "
|
||||||
|
f"got {entry.entity.state_class!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_entities_have_monetary_device_class(energy_db) -> None:
|
||||||
|
"""FU11: daily entities must have device_class='monetary' and state_class='total_increasing'.
|
||||||
|
|
||||||
|
HA developer docs prohibit MONETARY with state_class=measurement, but explicitly
|
||||||
|
allow it with total and total_increasing. monetary+total_increasing is the correct
|
||||||
|
combination for a non-negative, monotonically non-decreasing daily quantity that
|
||||||
|
resets at local midnight.
|
||||||
|
"""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"}
|
||||||
|
for entry in catalog:
|
||||||
|
if entry.entity.key in daily_keys:
|
||||||
|
assert entry.entity.device_class == "monetary", (
|
||||||
|
f"Daily entity {entry.entity.key!r} must have device_class='monetary', "
|
||||||
|
f"got {entry.entity.device_class!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_entities_default_disabled(energy_db) -> None:
|
||||||
|
"""FU11: daily entities must default to enabled=False (no toggle row seeded)."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
|
daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"}
|
||||||
|
for entry in catalog:
|
||||||
|
if entry.entity.key in daily_keys:
|
||||||
|
assert entry.enabled is False, (
|
||||||
|
f"Daily entity {entry.entity.key!r} must default to enabled=False, "
|
||||||
|
f"got enabled={entry.enabled!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
|
||||||
|
"""FU11: import_cost_today getter returns value for today's local window.
|
||||||
|
|
||||||
|
Setup: period at local 'today' (within today's UTC window), active contract.
|
||||||
|
Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day
|
||||||
|
(Principle C: today counts as elapsed if we're past local midnight — which UTC is).
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
# Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow)
|
||||||
|
# When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window.
|
||||||
|
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||||
|
# Ensure t0 is still today UTC (handle edge case where now is < 1h past UTC midnight)
|
||||||
|
if t0.date() < now_utc.date():
|
||||||
|
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# Contract starting well before today
|
||||||
|
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||||
|
|
||||||
|
import_cost_today = 1.23
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
_make_contract_with_version(
|
||||||
|
session,
|
||||||
|
values=_STANDING_VALUES,
|
||||||
|
effective_from=effective_from,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
_make_period(session, period_start=t0, import_cost=import_cost_today, degraded=False)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
# Pin timezone to UTC so today_local = UTC date (deterministic on any CI host).
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
today_entry = next(
|
||||||
|
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||||
|
)
|
||||||
|
value = today_entry.entity.value_getter(session)
|
||||||
|
|
||||||
|
# Principle C: today counts as 1 day (we're within it, and it's "today").
|
||||||
|
# daily_standing = (30+60)/30 = 3.0 EUR/day × 1 day = 3.0
|
||||||
|
from decimal import Decimal
|
||||||
|
daily_standing = Decimal("90") / Decimal("30") # = 3.0
|
||||||
|
expected = float(Decimal(str(import_cost_today)) + daily_standing * 1)
|
||||||
|
|
||||||
|
assert value == pytest.approx(expected, rel=1e-6), (
|
||||||
|
f"import_cost_today expected {expected}, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
|
||||||
|
"""FU11: export_revenue_today getter returns value for today's local window."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||||
|
if t0.date() < now_utc.date():
|
||||||
|
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||||
|
export_today = 0.75
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
_make_contract_with_version(
|
||||||
|
session,
|
||||||
|
values=_STANDING_VALUES,
|
||||||
|
effective_from=effective_from,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
_make_period(session, period_start=t0, export_revenue=export_today, degraded=False)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
today_entry = next(
|
||||||
|
e for e in catalog if e.entity.key == "energy.export_revenue_today"
|
||||||
|
)
|
||||||
|
value = today_entry.entity.value_getter(session)
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
daily_credit = Decimal("365") / Decimal("365") # = 1.0 EUR/day × 1 day
|
||||||
|
expected = float(Decimal(str(export_today)) + daily_credit * 1)
|
||||||
|
|
||||||
|
assert value == pytest.approx(expected, rel=1e-6), (
|
||||||
|
f"export_revenue_today expected {expected}, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_getter_returns_none_when_no_active_contract(energy_db) -> None:
|
||||||
|
"""FU11: daily getters return None when no active contract exists."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
# Inactive contract
|
||||||
|
_make_contract_with_version(
|
||||||
|
session,
|
||||||
|
values=_STANDING_VALUES,
|
||||||
|
effective_from=t0,
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
_make_period(session, period_start=t0, import_cost=1.0, export_revenue=0.5, degraded=False)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
import_today_entry = next(
|
||||||
|
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||||
|
)
|
||||||
|
export_today_entry = next(
|
||||||
|
e for e in catalog if e.entity.key == "energy.export_revenue_today"
|
||||||
|
)
|
||||||
|
import_val = import_today_entry.entity.value_getter(session)
|
||||||
|
export_val = export_today_entry.entity.value_getter(session)
|
||||||
|
|
||||||
|
assert import_val is None, (
|
||||||
|
f"import_cost_today must be None with no active contract, got {import_val!r}"
|
||||||
|
)
|
||||||
|
assert export_val is None, (
|
||||||
|
f"export_revenue_today must be None with no active contract, got {export_val!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_getter_returns_none_when_no_non_degraded_periods(energy_db) -> None:
|
||||||
|
"""FU11: daily getters return None when only degraded rows exist."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
_make_contract_with_version(
|
||||||
|
session,
|
||||||
|
values=_STANDING_VALUES,
|
||||||
|
effective_from=t0 - datetime.resolution * 0, # same time is fine here
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
_make_period(session, period_start=t0, import_cost=0.0, degraded=True)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
import_today_entry = next(
|
||||||
|
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||||
|
)
|
||||||
|
value = import_today_entry.entity.value_getter(session)
|
||||||
|
|
||||||
|
assert value is None, (
|
||||||
|
f"import_cost_today must be None with only degraded rows, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FU11 new test: anchor = max(contract_start, recording_start)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_db) -> None:
|
||||||
|
"""FU11: anchor = max(contract_start, recording_start) — no pre-recording fixed costs.
|
||||||
|
|
||||||
|
Scenario: contract effective_from = 180 days ago (lots of standing charges if used
|
||||||
|
as anchor), but first non-degraded period_start = 7 days ago. Expected: anchor is
|
||||||
|
7 days ago → only 8 days of standing charges (7 + today under Principle C).
|
||||||
|
|
||||||
|
Without the fix, anchor would be 180 days ago → ~180 days × 3 EUR/day = €540+.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# Contract starts 180 days ago (far before any data)
|
||||||
|
contract_start = midnight_today - timedelta(days=180)
|
||||||
|
# First (and only) data period starts 7 days ago
|
||||||
|
recording_start = midnight_today - timedelta(days=7)
|
||||||
|
|
||||||
|
t0 = recording_start # first period at recording start
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
_make_contract_with_version(
|
||||||
|
session,
|
||||||
|
values=_STANDING_VALUES,
|
||||||
|
effective_from=contract_start,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
_make_period(session, period_start=t0, import_cost=5.00, degraded=False)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
|
catalog = build_catalog(session)
|
||||||
|
import_entry = next(
|
||||||
|
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
||||||
|
)
|
||||||
|
value = import_entry.entity.value_getter(session)
|
||||||
|
|
||||||
|
# Anchor = recording_start (7 days ago UTC midnight).
|
||||||
|
# Principle C (UTC pinned): count local days from recording_start.date() to today inclusive.
|
||||||
|
# That's 7+1 = 8 days.
|
||||||
|
expected_days = (now_utc.date() - recording_start.date()).days + 1 # = 8
|
||||||
|
daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day
|
||||||
|
expected = float(Decimal("5.00") + daily_standing * expected_days)
|
||||||
|
|
||||||
|
# Without the fix, value would include ~180 days × 3 EUR/day = ~€540+ of extra standing.
|
||||||
|
# With the fix, value ≈ 5.00 + 3.0 × 8 = 29.00.
|
||||||
|
assert value == pytest.approx(expected, rel=1e-6), (
|
||||||
|
f"Expected anchor=recording_start anchor, "
|
||||||
|
f"import_cost_total={expected} (metered=5.00 + standing={float(daily_standing*expected_days)} "
|
||||||
|
f"over {expected_days} days), got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also verify that using the old anchor (contract start) would give a very different result.
|
||||||
|
old_expected_days = (now_utc.date() - contract_start.date()).days + 1 # ~181 days
|
||||||
|
old_expected = float(Decimal("5.00") + daily_standing * old_expected_days)
|
||||||
|
assert abs(value - old_expected) > 100, (
|
||||||
|
f"Old (wrong) anchor would yield ~{old_expected:.2f}, "
|
||||||
|
f"the new value {value:.2f} should differ by >€100"
|
||||||
|
)
|
||||||
|
|||||||
@@ -205,9 +205,10 @@ def test_register_provider_direct_call():
|
|||||||
def test_build_catalog_empty_with_no_devices(expose_db):
|
def test_build_catalog_empty_with_no_devices(expose_db):
|
||||||
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
||||||
|
|
||||||
The energy_cost provider is always registered and always produces its 4 entities
|
The energy_cost provider is always registered and always produces its 6 entities
|
||||||
regardless of device state, so the catalog will not be empty. This test checks
|
(4 original + 2 daily) regardless of device state, so the catalog will not be empty.
|
||||||
that no *modbus* entities are present when there are no enabled modbus devices.
|
This test checks that no *modbus* entities are present when there are no enabled
|
||||||
|
modbus devices.
|
||||||
"""
|
"""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
@@ -215,16 +216,16 @@ def test_build_catalog_empty_with_no_devices(expose_db):
|
|||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
# The modbus provider finds no enabled devices → no modbus entities.
|
# The modbus provider finds no enabled devices → no modbus entities.
|
||||||
# The energy_cost provider always produces 4 entities, so the catalog is non-empty.
|
# The energy_cost provider always produces 6 entities, so the catalog is non-empty.
|
||||||
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
||||||
assert modbus_entities == [], (
|
assert modbus_entities == [], (
|
||||||
"Expected no modbus entities when no modbus devices are enabled"
|
"Expected no modbus entities when no modbus devices are enabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The 4 energy_cost entities should always be present.
|
# The 6 energy_cost entities should always be present (4 original + 2 daily).
|
||||||
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
||||||
assert len(energy_keys) == 4, (
|
assert len(energy_keys) == 6, (
|
||||||
f"Expected exactly 4 energy_cost entities, got {energy_keys!r}"
|
f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user