FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s

- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
  integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
  apportionment), anchored at the active contract's earliest version effective_from so the
  total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
This commit is contained in:
2026-06-25 11:13:30 +02:00
parent 682e06d256
commit 8b91146c29
10 changed files with 941 additions and 158 deletions
+42 -47
View File
@@ -549,43 +549,43 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_import_cost_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative import cost plus prorated standing charges.
Value = SUM(import_cost, non-degraded) + standing_charges_cumulative
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
contract (i.e. the contract billing start). This makes the cumulative total
monotonically increasing and cross-version consistent:
where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30.
Elapsed days are counted from the active contract version's effective_from date
(inclusive of today). Returns None when no non-degraded periods exist.
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
Returns None when no non-degraded period exists in [anchor, now].
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
"""
def _getter(sess: "Session") -> Any:
from decimal import Decimal
from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP
from app.services.contracts import active_contract_version_at, _as_utc
from sqlalchemy import func as _func
total = sess.query(_func.sum(_ECP.import_cost)).filter(
# Quick check: any non-degraded period at all? (avoids full summarize overhead
# when there are zero periods, which should return None)
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False)
).scalar()
if total is None:
if has_data is None:
return None
# Add prorated standing charges from the active contract version.
standing_cumulative = Decimal("0")
versions = active_contract_versions(sess)
if not versions:
# No active contract — return pure SUM(import_cost) without standing charges.
return float(has_data)
# anchor = earliest version's effective_from (contract billing start).
anchor_utc = _cu(versions[0].effective_from)
now_utc = _dt.now(UTC)
version = active_contract_version_at(sess, now_utc)
if version is not None:
vals = version.values or {}
standing = vals.get("standing", {})
network_fee = Decimal(str(standing.get("network_fee") or 0))
management_fee = Decimal(str(standing.get("management_fee") or 0))
daily_standing = (network_fee + management_fee) / Decimal("30")
effective_from = _as_utc(version.effective_from)
days = (now_utc.date() - effective_from.date()).days + 1
days = max(days, 0)
standing_cumulative = daily_standing * days
return float(Decimal(str(total)) + standing_cumulative)
result = _summarize(sess, anchor_utc, now_utc)
return result["metered_import"] + result["fixed_costs"]
return _getter
@@ -594,42 +594,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative export revenue plus prorated tax credit.
Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
where heffingskorting_cumulative = elapsed_days × heffingskorting / 365.
Elapsed days are counted from the active contract version's effective_from date
(inclusive of today). Returns None when no non-degraded periods exist.
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
Returns None when no non-degraded period exists in [anchor, now].
"""
def _getter(sess: "Session") -> Any:
from decimal import Decimal
from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP
from app.services.contracts import active_contract_version_at, _as_utc
from sqlalchemy import func as _func
total = sess.query(_func.sum(_ECP.export_revenue)).filter(
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
_ECP.degraded.is_(False)
).scalar()
if total is None:
if has_data is None:
return None
# Add prorated heffingskorting credit from the active contract version.
credit_cumulative = Decimal("0")
versions = active_contract_versions(sess)
if not versions:
# No active contract — return pure SUM(export_revenue) without credits.
return float(has_data)
anchor_utc = _cu(versions[0].effective_from)
now_utc = _dt.now(UTC)
version = active_contract_version_at(sess, now_utc)
if version is not None:
vals = version.values or {}
credits = vals.get("credits", {})
heffingskorting = Decimal(str(credits.get("heffingskorting") or 0))
daily_credit = heffingskorting / Decimal("365")
effective_from = _as_utc(version.effective_from)
days = (now_utc.date() - effective_from.date()).days + 1
days = max(days, 0)
credit_cumulative = daily_credit * days
return float(Decimal(str(total)) + credit_cumulative)
result = _summarize(sess, anchor_utc, now_utc)
return result["metered_export"] + result["credits"]
return _getter