FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
- 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:
@@ -75,6 +75,7 @@ from app.schemas.energy import (
|
|||||||
from app.services.auth import AuthenticatedSession
|
from app.services.auth import AuthenticatedSession
|
||||||
from app.services.contracts import active_contract_version_at
|
from app.services.contracts import active_contract_version_at
|
||||||
from app.services.energy_cost import recompute_range, summarize
|
from app.services.energy_cost import recompute_range, summarize
|
||||||
|
from app.services.timezone import local_midnight_utc, local_now
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -358,12 +359,15 @@ def get_costs_summary(
|
|||||||
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
|
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
|
||||||
contract version at ``end``**. When no active contract exists they are 0.
|
contract version at ``end``**. When no active contract exists they are 0.
|
||||||
"""
|
"""
|
||||||
now = datetime.now(UTC)
|
if start is None or end is None:
|
||||||
|
# Default to the server's local today: [local_midnight, next_local_midnight).
|
||||||
|
# This ensures "today" aligns with the local calendar day (NL time) rather
|
||||||
|
# than UTC midnight.
|
||||||
|
local_today = local_now().date()
|
||||||
if start is None:
|
if start is None:
|
||||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
start = local_midnight_utc(local_today)
|
||||||
if end is None:
|
if end is None:
|
||||||
end = start + timedelta(days=1)
|
end = local_midnight_utc(local_today + timedelta(days=1))
|
||||||
|
|
||||||
result = summarize(db, _as_utc(start), _as_utc(end))
|
result = summarize(db, _as_utc(start), _as_utc(end))
|
||||||
return SummaryResponse(**result)
|
return SummaryResponse(**result)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from app.schemas.energy_contract import (
|
|||||||
VersionCreate,
|
VersionCreate,
|
||||||
)
|
)
|
||||||
from app.services.auth import AuthenticatedSession
|
from app.services.auth import AuthenticatedSession
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
from app.services.contracts import (
|
from app.services.contracts import (
|
||||||
ContractVersionError,
|
ContractVersionError,
|
||||||
activate_contract,
|
activate_contract,
|
||||||
@@ -113,6 +114,30 @@ def _raise_422_for_profile_error(exc: Exception) -> Any:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _localize_effective_from(dt: datetime | None) -> datetime:
|
||||||
|
"""Resolve *dt* to an aware UTC datetime for storage.
|
||||||
|
|
||||||
|
Rules (Principle A):
|
||||||
|
- If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before).
|
||||||
|
- If *dt* is timezone-aware → convert to UTC as-is.
|
||||||
|
- If *dt* is timezone-naive → interpret as server local wall-clock time,
|
||||||
|
localize with ``local_tz()``, then convert to UTC.
|
||||||
|
|
||||||
|
This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it
|
||||||
|
interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
|
||||||
|
not as UTC midnight.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
# Already aware: convert to UTC.
|
||||||
|
return dt.astimezone(UTC)
|
||||||
|
# Naive: assume server local wall-clock.
|
||||||
|
tz = _tz_mod.local_tz()
|
||||||
|
local_dt = dt.replace(tzinfo=tz)
|
||||||
|
return local_dt.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /api/energy/profiles
|
# GET /api/energy/profiles
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -175,7 +200,7 @@ def create_energy_contract(
|
|||||||
The new contract is created with ``active=False``; use
|
The new contract is created with ``active=False``; use
|
||||||
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
|
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
|
||||||
"""
|
"""
|
||||||
effective_from = body.effective_from or datetime.now(UTC)
|
effective_from = _localize_effective_from(body.effective_from)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
contract = create_contract(
|
contract = create_contract(
|
||||||
@@ -284,12 +309,13 @@ def add_contract_version(
|
|||||||
Historical versions are never modified; this endpoint is append-only.
|
Historical versions are never modified; this endpoint is append-only.
|
||||||
"""
|
"""
|
||||||
contract = _get_contract_or_404(db, contract_id)
|
contract = _get_contract_or_404(db, contract_id)
|
||||||
|
effective_from = _localize_effective_from(body.effective_from)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
add_version(
|
add_version(
|
||||||
db,
|
db,
|
||||||
contract,
|
contract,
|
||||||
effective_from=body.effective_from,
|
effective_from=effective_from,
|
||||||
values=body.values,
|
values=body.values,
|
||||||
)
|
)
|
||||||
except ContractVersionError as exc:
|
except ContractVersionError as exc:
|
||||||
|
|||||||
+42
-47
@@ -549,43 +549,43 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
||||||
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
"""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.
|
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
|
||||||
Elapsed days are counted from the active contract version's effective_from date
|
|
||||||
(inclusive of today). Returns None when no non-degraded periods exist.
|
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:
|
def _getter(sess: "Session") -> Any:
|
||||||
from decimal import Decimal
|
|
||||||
from datetime import UTC, datetime as _dt
|
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.models.energy import EnergyCostPeriod as _ECP
|
||||||
from app.services.contracts import active_contract_version_at, _as_utc
|
|
||||||
from sqlalchemy import func as _func
|
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)
|
_ECP.degraded.is_(False)
|
||||||
).scalar()
|
).scalar()
|
||||||
if total is None:
|
if has_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Add prorated standing charges from the active contract version.
|
versions = active_contract_versions(sess)
|
||||||
standing_cumulative = Decimal("0")
|
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)
|
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)
|
result = _summarize(sess, anchor_utc, now_utc)
|
||||||
days = (now_utc.date() - effective_from.date()).days + 1
|
return result["metered_import"] + result["fixed_costs"]
|
||||||
days = max(days, 0)
|
|
||||||
standing_cumulative = daily_standing * days
|
|
||||||
|
|
||||||
return float(Decimal(str(total)) + standing_cumulative)
|
|
||||||
|
|
||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
@@ -594,42 +594,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
||||||
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
"""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.
|
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
||||||
Elapsed days are counted from the active contract version's effective_from date
|
|
||||||
(inclusive of today). Returns None when no non-degraded periods exist.
|
Returns None when no non-degraded period exists in [anchor, now].
|
||||||
"""
|
"""
|
||||||
def _getter(sess: "Session") -> Any:
|
def _getter(sess: "Session") -> Any:
|
||||||
from decimal import Decimal
|
|
||||||
from datetime import UTC, datetime as _dt
|
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.models.energy import EnergyCostPeriod as _ECP
|
||||||
from app.services.contracts import active_contract_version_at, _as_utc
|
|
||||||
from sqlalchemy import func as _func
|
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)
|
_ECP.degraded.is_(False)
|
||||||
).scalar()
|
).scalar()
|
||||||
if total is None:
|
if has_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Add prorated heffingskorting credit from the active contract version.
|
versions = active_contract_versions(sess)
|
||||||
credit_cumulative = Decimal("0")
|
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)
|
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)
|
result = _summarize(sess, anchor_utc, now_utc)
|
||||||
days = (now_utc.date() - effective_from.date()).days + 1
|
return result["metered_export"] + result["credits"]
|
||||||
days = max(days, 0)
|
|
||||||
credit_cumulative = daily_credit * days
|
|
||||||
|
|
||||||
return float(Decimal(str(total)) + credit_cumulative)
|
|
||||||
|
|
||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,32 @@ def deactivate_contract(session: Session, contract: EnergyContract) -> None:
|
|||||||
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
||||||
|
|
||||||
|
|
||||||
|
def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
|
||||||
|
"""Return all versions of the currently active contract, ordered by effective_from ascending.
|
||||||
|
|
||||||
|
Returns an empty list when there is no active contract. The list spans the
|
||||||
|
full history of the active contract (all closed + the current open version)
|
||||||
|
and is used to iterate over pricing-rate segments for cross-version fixed-
|
||||||
|
cost / credit accumulation (Principle C).
|
||||||
|
"""
|
||||||
|
active = session.execute(
|
||||||
|
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if active is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(EnergyContractVersion)
|
||||||
|
.where(EnergyContractVersion.contract_id == active.id)
|
||||||
|
.order_by(EnergyContractVersion.effective_from)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def active_contract_version_at(
|
def active_contract_version_at(
|
||||||
session: Session, ts: datetime
|
session: Session, ts: datetime
|
||||||
) -> EnergyContractVersion | None:
|
) -> EnergyContractVersion | None:
|
||||||
|
|||||||
+100
-24
@@ -60,7 +60,8 @@ from app.integrations.pricing.strategies import (
|
|||||||
get_strategy,
|
get_strategy,
|
||||||
)
|
)
|
||||||
from app.models.energy import DsmrReading, EnergyCostPeriod
|
from app.models.energy import DsmrReading, EnergyCostPeriod
|
||||||
from app.services.contracts import active_contract_version_at
|
from app.services.contracts import active_contract_version_at, active_contract_versions
|
||||||
|
from app.services.timezone import local_date, local_now
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -496,13 +497,23 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
Computes the total payable as:
|
Computes the total payable as:
|
||||||
|
|
||||||
total_payable = Σ(net_cost) -- metered electricity
|
total_payable = Σ(net_cost) -- metered electricity
|
||||||
+ fixed_costs -- (network_fee + management_fee) EUR/month ÷ 30 × days
|
+ fixed_costs -- per-day standing charges, cross-version
|
||||||
- credits -- heffingskorting EUR/year ÷ 365 × days
|
- credits -- per-day heffingskorting, cross-version
|
||||||
|
|
||||||
The fixed costs and credits are derived from the *currently active contract
|
**Fixed-cost / credit counting — Principle C**:
|
||||||
version* at *end* (i.e. the version in effect at the end of the requested
|
Only *already-elapsed* whole local calendar days are counted. For each
|
||||||
interval). When no active contract version exists, fixed costs and credits
|
local calendar date D in the window ``[start_local_date, min(end_local_date,
|
||||||
are both 0; only the metered sum is returned.
|
tomorrow_local))``, the contract version whose rate covers D (the one whose
|
||||||
|
effective_from local-date ≤ D < next version's effective_from local-date) is
|
||||||
|
used. Days that have not yet started in local time (D > today_local) are
|
||||||
|
never counted. This is cross-version: if the active contract has V1 from
|
||||||
|
June 1 and V2 from June 25, querying June 1–30 uses V1 for days 1-24 and V2
|
||||||
|
for day 25. Switching versions never resets the counter.
|
||||||
|
|
||||||
|
**Timezone note**: the ``days`` field in the returned dict still represents
|
||||||
|
the window length in calendar days (total_seconds / 86400), for backward
|
||||||
|
compatibility with existing API consumers. The fixed/credit calculation
|
||||||
|
independently counts whole local days as described above.
|
||||||
|
|
||||||
All arithmetic uses Decimal; the returned dict contains Python floats for
|
All arithmetic uses Decimal; the returned dict contains Python floats for
|
||||||
JSON-serialisation convenience.
|
JSON-serialisation convenience.
|
||||||
@@ -512,9 +523,9 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
session:
|
session:
|
||||||
Active read-only SQLAlchemy session.
|
Active read-only SQLAlchemy session.
|
||||||
start:
|
start:
|
||||||
Inclusive start of the summary interval.
|
Inclusive start of the summary interval (UTC or naive-UTC).
|
||||||
end:
|
end:
|
||||||
Exclusive end of the summary interval.
|
Exclusive end of the summary interval (UTC or naive-UTC).
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -524,13 +535,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
metered_import float Σ import_cost from non-degraded periods
|
metered_import float Σ import_cost from non-degraded periods
|
||||||
metered_export float Σ export_revenue from non-degraded periods
|
metered_export float Σ export_revenue from non-degraded periods
|
||||||
metered_net float Σ net_cost from non-degraded periods
|
metered_net float Σ net_cost from non-degraded periods
|
||||||
fixed_costs float standing charges apportioned over the interval
|
fixed_costs float standing charges for elapsed whole local days
|
||||||
credits float energy-tax credit apportioned over the interval
|
credits float energy-tax credit for elapsed whole local days
|
||||||
total_payable float metered_net + fixed_costs − credits
|
total_payable float metered_net + fixed_costs − credits
|
||||||
period_count int number of non-degraded periods in range
|
period_count int number of non-degraded periods in range
|
||||||
degraded_count int number of degraded periods in range
|
degraded_count int number of degraded periods in range
|
||||||
days float interval length in days (total_seconds / 86400)
|
days float interval length in days (total_seconds / 86400)
|
||||||
"""
|
"""
|
||||||
|
from datetime import timedelta as _td, date as _date
|
||||||
|
|
||||||
start_utc = _as_utc(start)
|
start_utc = _as_utc(start)
|
||||||
end_utc = _as_utc(end)
|
end_utc = _as_utc(end)
|
||||||
|
|
||||||
@@ -550,32 +563,95 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
|
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
|
||||||
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
|
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
|
||||||
|
|
||||||
# --- Interval length in days ---
|
# --- Interval length in days (window, not elapsed — kept for API compat) ---
|
||||||
total_seconds = (end_utc - start_utc).total_seconds()
|
total_seconds = (end_utc - start_utc).total_seconds()
|
||||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||||
|
|
||||||
# --- Active contract version at *end* for standing charges ---
|
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
||||||
version = active_contract_version_at(session, end_utc)
|
today_local: _date = local_now().date()
|
||||||
|
|
||||||
|
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
||||||
|
#
|
||||||
|
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
|
||||||
|
# AND D ≤ today_local (only elapsed / today days count as "whole days").
|
||||||
|
#
|
||||||
|
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
|
||||||
|
# AND the local midnight is within the billing window."
|
||||||
|
#
|
||||||
|
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
|
||||||
|
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
|
||||||
|
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
|
||||||
|
#
|
||||||
|
# Implementation: compute the first and last local date whose midnight is in range.
|
||||||
|
from app.services.timezone import local_midnight_utc as _lmu
|
||||||
|
|
||||||
|
local_start_date: _date = local_date(start_utc)
|
||||||
|
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
|
||||||
|
if _lmu(local_start_date) >= start_utc:
|
||||||
|
first_counted: _date = local_start_date
|
||||||
|
else:
|
||||||
|
first_counted = local_start_date + _td(days=1)
|
||||||
|
|
||||||
|
local_end_date: _date = local_date(end_utc)
|
||||||
|
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
||||||
|
if _lmu(local_end_date) < end_utc:
|
||||||
|
last_counted: _date = local_end_date
|
||||||
|
else:
|
||||||
|
last_counted = local_end_date - _td(days=1)
|
||||||
|
|
||||||
|
# Cap: only elapsed or today's days.
|
||||||
|
last_counted = min(last_counted, today_local)
|
||||||
|
|
||||||
|
# If the range is empty (first_counted > last_counted), no days are counted.
|
||||||
|
|
||||||
|
# Fetch all versions of the active contract once (single DB call).
|
||||||
|
versions = active_contract_versions(session)
|
||||||
|
|
||||||
fixed_dec = Decimal("0")
|
fixed_dec = Decimal("0")
|
||||||
credits_dec = Decimal("0")
|
credits_dec = Decimal("0")
|
||||||
currency = "EUR"
|
currency = "EUR"
|
||||||
|
|
||||||
if version is not None:
|
if versions:
|
||||||
currency = version.contract.currency
|
# Currency comes from the contract regardless of day count.
|
||||||
vals: dict = version.values or {}
|
currency = versions[0].contract.currency
|
||||||
standing: dict = vals.get("standing", {})
|
|
||||||
creds: dict = vals.get("credits", {})
|
if versions and first_counted <= last_counted:
|
||||||
|
# For each version segment, find its overlap with [first_counted, last_counted]
|
||||||
|
# and accumulate whole days.
|
||||||
|
version_segments: list[tuple[_date, _date | None, dict]] = []
|
||||||
|
for v in versions:
|
||||||
|
v_start_local = local_date(_as_utc(v.effective_from))
|
||||||
|
v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None
|
||||||
|
version_segments.append((v_start_local, v_end_local, v.values or {}))
|
||||||
|
|
||||||
|
for v_start, v_end_excl, v_values in version_segments:
|
||||||
|
# The version covers [v_start, v_end_excl) in local dates,
|
||||||
|
# where v_end_excl=None means open-ended (no upper bound).
|
||||||
|
# Intersection with [first_counted, last_counted]:
|
||||||
|
seg_start = max(first_counted, v_start)
|
||||||
|
if v_end_excl is not None:
|
||||||
|
seg_end_excl = min(last_counted + _td(days=1), v_end_excl)
|
||||||
|
else:
|
||||||
|
seg_end_excl = last_counted + _td(days=1)
|
||||||
|
|
||||||
|
if seg_start >= seg_end_excl:
|
||||||
|
continue # no overlap
|
||||||
|
|
||||||
|
n_days = (seg_end_excl - seg_start).days
|
||||||
|
if n_days <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
standing: dict = v_values.get("standing", {})
|
||||||
|
creds: dict = v_values.get("credits", {})
|
||||||
|
|
||||||
network_fee = _to_decimal(standing.get("network_fee", 0))
|
network_fee = _to_decimal(standing.get("network_fee", 0))
|
||||||
management_fee = _to_decimal(standing.get("management_fee", 0))
|
management_fee = _to_decimal(standing.get("management_fee", 0))
|
||||||
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
|
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
|
||||||
|
|
||||||
# Standing charges: EUR/month → EUR/day (÷ 30) × days.
|
# Standing charges: EUR/month → EUR/day (÷ 30) × n_days.
|
||||||
fixed_dec = (network_fee + management_fee) / Decimal("30") * days
|
fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days))
|
||||||
|
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days.
|
||||||
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × days.
|
credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days))
|
||||||
credits_dec = heffingskorting / Decimal("365") * days
|
|
||||||
|
|
||||||
total_payable = sum_net + fixed_dec - credits_dec
|
total_payable = sum_net + fixed_dec - credits_dec
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Server local-timezone helpers.
|
||||||
|
|
||||||
|
All time is stored as UTC (no change). This module provides a single,
|
||||||
|
monkeypatch-friendly entry point for converting UTC moments to the server's
|
||||||
|
local timezone — used for business-day boundary calculations (standing charges,
|
||||||
|
effective-from date interpretation, etc.).
|
||||||
|
|
||||||
|
Design goals
|
||||||
|
------------
|
||||||
|
- Zero third-party dependencies (no tzlocal, no pytz).
|
||||||
|
- Testable without depending on CI host timezone:
|
||||||
|
monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests.
|
||||||
|
- DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each
|
||||||
|
moment is independently correct even during DST transitions.
|
||||||
|
- Consistent with the existing ``.astimezone()`` pattern already used in
|
||||||
|
``homeassistant_inbound.py`` and ``poo.py``.
|
||||||
|
|
||||||
|
Priority for resolving the local timezone
|
||||||
|
-----------------------------------------
|
||||||
|
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
|
||||||
|
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
|
||||||
|
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``.
|
||||||
|
This matches the behaviour callers already relied on implicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import tzinfo
|
||||||
|
|
||||||
|
|
||||||
|
def local_tz() -> "tzinfo":
|
||||||
|
"""Return the server's local timezone.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
|
||||||
|
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
|
||||||
|
2. System local timezone via ``datetime.now().astimezone().tzinfo``.
|
||||||
|
|
||||||
|
**Monkeypatch this function in tests** to get deterministic timezone
|
||||||
|
behaviour regardless of CI host configuration::
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.timezone.local_tz",
|
||||||
|
lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
"""
|
||||||
|
tz_env = os.environ.get("TZ", "").strip()
|
||||||
|
if tz_env:
|
||||||
|
return ZoneInfo(tz_env)
|
||||||
|
# System fallback — identical to the .astimezone() pattern already used
|
||||||
|
# in homeassistant_inbound.py and poo.py.
|
||||||
|
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def to_local(dt: datetime) -> datetime:
|
||||||
|
"""Convert *dt* to the server local timezone.
|
||||||
|
|
||||||
|
Handles three input cases:
|
||||||
|
- timezone-aware (any zone): converted via ``astimezone``.
|
||||||
|
- timezone-naive: assumed UTC (SQLite read-back convention), then converted.
|
||||||
|
|
||||||
|
Returns a timezone-aware datetime in ``local_tz()``.
|
||||||
|
"""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(local_tz())
|
||||||
|
|
||||||
|
|
||||||
|
def local_now() -> datetime:
|
||||||
|
"""Return the current moment as a timezone-aware local datetime."""
|
||||||
|
return datetime.now(local_tz())
|
||||||
|
|
||||||
|
|
||||||
|
def local_date(dt: datetime) -> date:
|
||||||
|
"""Return the local calendar date for *dt*.
|
||||||
|
|
||||||
|
Equivalent to ``to_local(dt).date()``. A distinct helper to make
|
||||||
|
call sites readable.
|
||||||
|
"""
|
||||||
|
return to_local(dt).date()
|
||||||
|
|
||||||
|
|
||||||
|
def local_midnight_utc(d: date) -> datetime:
|
||||||
|
"""Return the UTC instant that corresponds to local midnight on *d*.
|
||||||
|
|
||||||
|
Example (Europe/Amsterdam, CEST=UTC+2):
|
||||||
|
local_midnight_utc(date(2026, 6, 25))
|
||||||
|
→ datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
This is used to convert a local calendar date back to a UTC boundary, e.g.
|
||||||
|
to compute the start/end of a local day for DB queries.
|
||||||
|
"""
|
||||||
|
tz = local_tz()
|
||||||
|
# Build a naive local datetime at midnight, then localize and convert to UTC.
|
||||||
|
local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
|
||||||
|
return local_midnight.astimezone(timezone.utc)
|
||||||
@@ -218,9 +218,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
const values = buildNestedValues(sectionFields, fieldValues)
|
const values = buildNestedValues(sectionFields, fieldValues)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert local date string to ISO datetime if provided
|
// Convert local date string to a naive local-midnight datetime string (no Z).
|
||||||
|
// The backend interprets naive datetimes as server local wall-clock time,
|
||||||
|
// so "2026-06-25T00:00:00" → CEST local midnight → stored as UTC 22:00Z.
|
||||||
|
// When effectiveFromStr is empty, fall back to the current instant (aware,
|
||||||
|
// sent as ISO with Z) so the backend stores it unchanged.
|
||||||
const effectiveFromISO = effectiveFromStr
|
const effectiveFromISO = effectiveFromStr
|
||||||
? new Date(effectiveFromStr).toISOString()
|
? `${effectiveFromStr}T00:00:00`
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (isAddVersion) {
|
if (isAddVersion) {
|
||||||
|
|||||||
+337
-18
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for M6-T07: billing engine (app/services/energy_cost.py).
|
"""Tests for M6-T07 + FU10: billing engine (app/services/energy_cost.py).
|
||||||
|
|
||||||
Acceptance criteria covered
|
Acceptance criteria covered
|
||||||
----------------------------
|
----------------------------
|
||||||
@@ -13,8 +13,9 @@ Acceptance criteria covered
|
|||||||
5. Missing readings (no DsmrReading covering a boundary) → degraded row.
|
5. Missing readings (no DsmrReading covering a boundary) → degraded row.
|
||||||
6. Cross-version selection: two contract versions with different effective
|
6. Cross-version selection: two contract versions with different effective
|
||||||
dates; each t0 picks the correct version (asserts contract_version_id).
|
dates; each t0 picks the correct version (asserts contract_version_id).
|
||||||
7. ``summarize``: Σnet + standing charges (month/30 × days) − heffingskorting
|
7. ``summarize`` (Principle C): fixed costs & credits count only elapsed whole
|
||||||
(year/365 × days) — hand-calculated against known values.
|
*local* calendar days, cross-version. Sub-day windows → 0 fixed/credit.
|
||||||
|
One whole local day → full rate. Future dates → 0. Cross-version → sum.
|
||||||
8. ``compute_closed_periods``: only processes closed and uncalculated periods;
|
8. ``compute_closed_periods``: only processes closed and uncalculated periods;
|
||||||
does not touch already-computed (non-degraded) rows.
|
does not touch already-computed (non-degraded) rows.
|
||||||
9. ``recompute_range``: explicitly overwrites all periods in [start, end)
|
9. ``recompute_range``: explicitly overwrites all periods in [start, end)
|
||||||
@@ -50,6 +51,7 @@ from app.services.energy_cost import (
|
|||||||
recompute_range,
|
recompute_range,
|
||||||
summarize,
|
summarize,
|
||||||
)
|
)
|
||||||
|
from app.services import timezone as tz_module
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -892,22 +894,36 @@ class TestSummarize:
|
|||||||
assert result["period_count"] == 2
|
assert result["period_count"] == 2
|
||||||
assert result["degraded_count"] == 0
|
assert result["degraded_count"] == 0
|
||||||
|
|
||||||
def test_fixed_costs_formula(self, energy_db: Session) -> None:
|
def test_fixed_costs_zero_for_sub_day_window(self, energy_db: Session) -> None:
|
||||||
"""Fixed costs = (network_fee + management_fee) / 30 × days."""
|
"""Principle C: a 30-minute window within the same local day counts 0 whole days.
|
||||||
self._setup_two_periods(energy_db)
|
|
||||||
# Interval is 30 minutes = 0.5/48 day = 0.020833... days
|
|
||||||
days = Decimal("1800") / Decimal("86400")
|
|
||||||
expected_fixed = (Decimal("9.87") + Decimal("9.87")) / Decimal("30") * days
|
|
||||||
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
|
||||||
assert abs(Decimal(str(result["fixed_costs"])) - expected_fixed) < Decimal("1e-9")
|
|
||||||
|
|
||||||
def test_credits_formula(self, energy_db: Session) -> None:
|
Fixed costs are only added for elapsed whole local calendar days.
|
||||||
"""Credits = heffingskorting / 365 × days."""
|
A window [10:00, 10:30) UTC stays within the same local calendar date
|
||||||
|
regardless of timezone offset (any UTC-11..UTC+14 range), so no whole
|
||||||
|
day is covered → fixed_costs = 0.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
self._setup_two_periods(energy_db)
|
self._setup_two_periods(energy_db)
|
||||||
days = Decimal("1800") / Decimal("86400")
|
# Pin the local timezone so the test is deterministic on any CI host.
|
||||||
expected_credits = Decimal("600.0") / Decimal("365") * days
|
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
|
||||||
|
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
|
||||||
|
):
|
||||||
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
assert abs(Decimal(str(result["credits"])) - expected_credits) < Decimal("1e-9")
|
assert result["fixed_costs"] == 0.0, (
|
||||||
|
"Sub-day window should contribute 0 whole-day fixed costs under Principle C"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_credits_zero_for_sub_day_window(self, energy_db: Session) -> None:
|
||||||
|
"""Principle C: a 30-minute window within the same local day counts 0 whole days for credits."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
self._setup_two_periods(energy_db)
|
||||||
|
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
|
||||||
|
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
|
||||||
|
):
|
||||||
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
|
assert result["credits"] == 0.0, (
|
||||||
|
"Sub-day window should contribute 0 whole-day credits under Principle C"
|
||||||
|
)
|
||||||
|
|
||||||
def test_total_payable_formula(self, energy_db: Session) -> None:
|
def test_total_payable_formula(self, energy_db: Session) -> None:
|
||||||
"""total_payable = metered_net + fixed_costs − credits."""
|
"""total_payable = metered_net + fixed_costs − credits."""
|
||||||
@@ -949,14 +965,26 @@ class TestSummarize:
|
|||||||
assert result["period_count"] == 0
|
assert result["period_count"] == 0
|
||||||
|
|
||||||
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
|
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
|
||||||
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day interval."""
|
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day interval.
|
||||||
|
|
||||||
|
Principle C: the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC) spans
|
||||||
|
exactly one local calendar day in Europe/Amsterdam (CEST=UTC+2: 02:00→02:00).
|
||||||
|
effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23.
|
||||||
|
Today (2026-06-25) ≥ June 23, so the day is elapsed → 1 day counted.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
contract = _make_contract(energy_db, kind="manual", active=True)
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
||||||
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
||||||
energy_db.commit()
|
energy_db.commit()
|
||||||
|
|
||||||
# Summarize over exactly 1 day (no periods in DB — only standing/credits).
|
# Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
|
||||||
|
# Pin timezone to Europe/Amsterdam for deterministic local-date mapping.
|
||||||
start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
|
start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
|
||||||
end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC)
|
end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC)
|
||||||
|
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
||||||
result = summarize(energy_db, start, end)
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
# fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658
|
# fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658
|
||||||
@@ -968,6 +996,297 @@ class TestSummarize:
|
|||||||
assert abs(result["total_payable"] - expected_total) < 1e-9
|
assert abs(result["total_payable"] - expected_total) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7b. summarize — Principle C (whole-local-day counting, cross-version)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# All tests in this section pin the local timezone to Europe/Amsterdam via
|
||||||
|
# monkeypatch so assertions are deterministic regardless of CI host timezone.
|
||||||
|
# "Today" in these tests is 2026-06-25 (CEST = UTC+2).
|
||||||
|
#
|
||||||
|
# Reference rates:
|
||||||
|
# network_fee = 9.87 EUR/month, management_fee = 9.87 EUR/month
|
||||||
|
# daily_fixed = (9.87 + 9.87) / 30 = 0.658 EUR/day
|
||||||
|
# heffingskorting = 600.0 EUR/year
|
||||||
|
# daily_credit = 600.0 / 365 ≈ 1.6438... EUR/day
|
||||||
|
#
|
||||||
|
# Window semantics:
|
||||||
|
# start_local_date = local_date(start_utc) (inclusive)
|
||||||
|
# end_local_date = local_date(end_utc) (exclusive upper bound)
|
||||||
|
# cap = min(end_local_date, today_local + 1 day)
|
||||||
|
# counted_days = max(0, (cap - start_local_date).days)
|
||||||
|
# restricted to the version's [v_start, v_end) range
|
||||||
|
#
|
||||||
|
# CEST = UTC+2; local midnight June D = UTC June (D-1) 22:00.
|
||||||
|
# So:
|
||||||
|
# start "This Month" (June 1 local 00:00) = May 31 22:00 UTC
|
||||||
|
# end "Next Month" (July 1 local 00:00) = June 30 22:00 UTC
|
||||||
|
# In UTC we feed actual UTC boundaries.
|
||||||
|
#
|
||||||
|
# Table (today = June 25 local, effective_from = June 1 local 00:00 = May 31 22:00 UTC):
|
||||||
|
#
|
||||||
|
# | Window (UTC) | Local dates | Elapsed | Expected |
|
||||||
|
# |----------------------------------|-------------------------|---------|----------|
|
||||||
|
# | June 25 UTC+2 today (1 local day)| [June 25, June 26) | 1 day | 1 day |
|
||||||
|
# | June 25 UTC+2 → June 28 UTC+2 | [June 25, June 28) | 1 day* | 1 day |
|
||||||
|
# | June 1 CEST → July 1 CEST (mon) | [June 1, July 1) | 25 days*| 25 days |
|
||||||
|
# | May 1 → June 1 (all past) | [May 1, June 1) | 31 days | 31 days |
|
||||||
|
# | July 1 → Aug 1 (all future) | [July 1, Aug 1) | 0 days | 0 days |
|
||||||
|
#
|
||||||
|
# *today local = June 25: "today" counts but June 26+ does not.
|
||||||
|
|
||||||
|
_AMS = None # ZoneInfo resolved lazily
|
||||||
|
|
||||||
|
|
||||||
|
def _ams():
|
||||||
|
global _AMS
|
||||||
|
if _AMS is None:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
_AMS = ZoneInfo("Europe/Amsterdam")
|
||||||
|
return _AMS
|
||||||
|
|
||||||
|
|
||||||
|
def _local_midnight_utc_ams(year: int, month: int, day: int) -> datetime:
|
||||||
|
"""Return UTC instant for Europe/Amsterdam local midnight on the given date."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
ams = ZoneInfo("Europe/Amsterdam")
|
||||||
|
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).replace(tzinfo=None).replace(
|
||||||
|
tzinfo=ams
|
||||||
|
).astimezone(_UTC)
|
||||||
|
|
||||||
|
|
||||||
|
# Simpler helper using ZoneInfo directly:
|
||||||
|
def _ams_midnight(year: int, month: int, day: int) -> datetime:
|
||||||
|
"""UTC datetime corresponding to Europe/Amsterdam local midnight on year/month/day."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
ams = ZoneInfo("Europe/Amsterdam")
|
||||||
|
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).astimezone(_UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarizePrincipleC:
|
||||||
|
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone."""
|
||||||
|
|
||||||
|
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
|
||||||
|
# Today local = June 25 CEST (since today is 2026-06-25).
|
||||||
|
|
||||||
|
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
|
||||||
|
"""Create an active manual contract with a single version."""
|
||||||
|
contract = _make_contract(session, kind="manual", active=True)
|
||||||
|
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict:
|
||||||
|
"""Run summarize with Europe/Amsterdam local_tz monkeypatched."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
return summarize(session, start_utc, end_utc)
|
||||||
|
|
||||||
|
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
|
||||||
|
"""Today's window (local today 00:00 → local tomorrow 00:00) counts 1 day.
|
||||||
|
|
||||||
|
Matches table row: Today 6/25→6/26 → 1 day.
|
||||||
|
"""
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 6, 25)
|
||||||
|
end = _ams_midnight(2026, 6, 26)
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
||||||
|
f"Expected 1 day of fixed costs, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
||||||
|
f"Expected 1 day of credits, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
||||||
|
"""A fully future window (all local dates > today) counts 0 days.
|
||||||
|
|
||||||
|
Matches table row: 7/1→8/1 (all future) → 0 days.
|
||||||
|
"""
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 7, 1)
|
||||||
|
end = _ams_midnight(2026, 8, 1)
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end)
|
||||||
|
|
||||||
|
assert result["fixed_costs"] == 0.0, (
|
||||||
|
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert result["credits"] == 0.0, (
|
||||||
|
f"All-future window must count 0 days; got credits={result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
|
||||||
|
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
|
||||||
|
|
||||||
|
Today = June 25 local, so June 26–30 are not yet elapsed.
|
||||||
|
Matches table row: 6/1→7/1 (This Month) → 25 days.
|
||||||
|
"""
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 6, 1)
|
||||||
|
end = _ams_midnight(2026, 7, 1)
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 25) < 1e-9, (
|
||||||
|
f"Expected 25 days of fixed costs (June 1-25 elapsed), got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 25) < 1e-9, (
|
||||||
|
f"Expected 25 days of credits, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_past_month_window_counts_all_31_days(self, energy_db: Session) -> None:
|
||||||
|
"""Last month (May 1 → June 1 local) counts all 31 days (fully past).
|
||||||
|
|
||||||
|
Matches table row: 5/1→6/1 (all past) → 31 days.
|
||||||
|
effective_from is June 1 so May is before the contract — 0 days from contract.
|
||||||
|
Use an earlier effective_from to cover May.
|
||||||
|
"""
|
||||||
|
eff_utc = _ams_midnight(2026, 1, 1) # contract starts Jan 1 → covers May
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 5, 1)
|
||||||
|
end = _ams_midnight(2026, 6, 1)
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 31) < 1e-9, (
|
||||||
|
f"Expected 31 days of fixed costs (May 1-31 all past), got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 31) < 1e-9, (
|
||||||
|
f"Expected 31 days of credits, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
|
||||||
|
"""A window partially in the future caps at today (only elapsed days counted).
|
||||||
|
|
||||||
|
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
|
||||||
|
Today = June 25 → only 1 day elapsed (June 25).
|
||||||
|
Matches table row: 6/25→6/28 → 1 day.
|
||||||
|
"""
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 6, 25)
|
||||||
|
end = _ams_midnight(2026, 6, 28)
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
||||||
|
f"Expected 1 day of fixed costs (only June 25 elapsed), got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
||||||
|
f"Expected 1 day of credits, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_version_integration(self, energy_db: Session) -> None:
|
||||||
|
"""Cross-version integration: V1 June 1-24, V2 June 25+ (today's boundary).
|
||||||
|
|
||||||
|
Window: This Month (June 1 → July 1 local) = 25 days total.
|
||||||
|
- V1: June 1-24 (24 days) at rate1
|
||||||
|
- V2: June 25-25 (1 day, today) at rate2
|
||||||
|
|
||||||
|
V1 rates: network_fee=6.0, management_fee=6.0 → daily = 12/30 = 0.4
|
||||||
|
heffingskorting=300 → daily = 300/365
|
||||||
|
V2 rates: network_fee=12.0, management_fee=12.0 → daily = 24/30 = 0.8
|
||||||
|
heffingskorting=600 → daily = 600/365
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
fixed_costs = 24 × 0.4 + 1 × 0.8 = 9.6 + 0.8 = 10.4
|
||||||
|
credits = 24 × (300/365) + 1 × (600/365) = (7200+600)/365 = 7800/365
|
||||||
|
"""
|
||||||
|
_VALUES_V1 = {
|
||||||
|
"energy": {"buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.0, "ode": 0.0},
|
||||||
|
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
||||||
|
"credits": {"heffingskorting": 300.0},
|
||||||
|
}
|
||||||
|
_VALUES_V2 = {
|
||||||
|
"energy": {"buy": {"normal": 0.20, "dal": 0.20}, "sell": {"normal": 0.08, "dal": 0.08},
|
||||||
|
"energy_tax": 0.0, "ode": 0.0},
|
||||||
|
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
# V1: effective June 1 CEST local. V2: effective June 25 CEST local.
|
||||||
|
v1_from = _ams_midnight(2026, 6, 1)
|
||||||
|
v2_from = _ams_midnight(2026, 6, 25)
|
||||||
|
|
||||||
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
||||||
|
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
|
||||||
|
effective_to=v2_from)
|
||||||
|
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
||||||
|
energy_db.commit()
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 6, 1)
|
||||||
|
end = _ams_midnight(2026, 7, 1)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
|
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
|
||||||
|
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
|
||||||
|
expected_credits = 24 * 300 / 365 + 1 * 600 / 365
|
||||||
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
||||||
|
f"Cross-version fixed_costs wrong: expected {expected_fixed}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
||||||
|
f"Cross-version credits wrong: expected {expected_credits}, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_version_switch_does_not_reset_counter(self, energy_db: Session) -> None:
|
||||||
|
"""Adding a new version does not reset the cumulative counter.
|
||||||
|
|
||||||
|
With V1 from June 1 and V2 from June 25, querying June 25 → July 1
|
||||||
|
still returns V2's 1-day rate without losing the historical V1 days
|
||||||
|
(the MQTT getter anchors at V1.effective_from to accumulate everything).
|
||||||
|
This test just checks that V2-only window returns V2 rate × 1 day.
|
||||||
|
"""
|
||||||
|
_VALUES_V1 = {
|
||||||
|
"energy": {"buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.0, "ode": 0.0},
|
||||||
|
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
||||||
|
"credits": {"heffingskorting": 300.0},
|
||||||
|
}
|
||||||
|
_VALUES_V2 = {
|
||||||
|
"energy": {"buy": {"normal": 0.20, "dal": 0.20}, "sell": {"normal": 0.08, "dal": 0.08},
|
||||||
|
"energy_tax": 0.0, "ode": 0.0},
|
||||||
|
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
v1_from = _ams_midnight(2026, 6, 1)
|
||||||
|
v2_from = _ams_midnight(2026, 6, 25)
|
||||||
|
|
||||||
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
||||||
|
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
|
||||||
|
effective_to=v2_from)
|
||||||
|
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
||||||
|
energy_db.commit()
|
||||||
|
|
||||||
|
# Window = June 25 only (today, 1 day elapsed at V2 rate)
|
||||||
|
start = _ams_midnight(2026, 6, 25)
|
||||||
|
end = _ams_midnight(2026, 7, 1)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
|
# Only 1 day at V2 rate
|
||||||
|
expected_fixed = 1 * 24 / 30
|
||||||
|
expected_credits = 1 * 600 / 365
|
||||||
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
|
||||||
|
assert abs(result["credits"] - expected_credits) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 8. compute_closed_periods
|
# 8. compute_closed_periods
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+81
-35
@@ -871,27 +871,39 @@ _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).
|
||||||
|
anchor = earliest version's effective_from.
|
||||||
|
|
||||||
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.
|
||||||
Elapsed days = (now.date() - effective_from.date()).days + 1.
|
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.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
- effective_from = 10 full UTC days ago (exact UTC midnight), so today is day 11
|
||||||
|
but Principle C counts only days D ≤ today, and today is included as 1 day.
|
||||||
|
With anchor = 10 days ago UTC midnight and now near end of day 11:
|
||||||
|
local (UTC) dates: [10-days-ago, today_inclusive] = 11 days.
|
||||||
|
We place period rows within [anchor, now] so summarize sees them.
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from app.integrations.expose import build_catalog
|
|
||||||
|
|
||||||
# Set effective_from to 10 days ago (UTC) to get a known elapsed day count.
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
|
||||||
effective_from = datetime(
|
|
||||||
now_utc.year, now_utc.month, now_utc.day, 0, 0, 0, tzinfo=timezone.utc
|
|
||||||
).replace(day=1)
|
|
||||||
# Use a date we know: 10 days before today (simulate by computing days ourselves)
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=9)
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
# Expected elapsed days = 9 + 1 = 10 (from effective_from date to today, inclusive)
|
now_utc = datetime.now(timezone.utc)
|
||||||
expected_days = (now_utc.date() - effective_from.date()).days + 1
|
# anchor = exactly 10 UTC days ago at midnight
|
||||||
|
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10)
|
||||||
|
|
||||||
|
# Under UTC timezone (pinned), Principle C counts whole days from effective_from.date()
|
||||||
|
# to today (inclusive) = 11 days total (10 + today).
|
||||||
|
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
|
||||||
|
|
||||||
import_cost_sum = 5.00 # EUR
|
import_cost_sum = 5.00 # EUR
|
||||||
t0 = datetime(2025, 10, 1, 6, 0, tzinfo=timezone.utc)
|
# Place period rows AFTER effective_from so summarize(anchor, now) picks them up.
|
||||||
t1 = datetime(2025, 10, 1, 6, 15, tzinfo=timezone.utc)
|
t0 = effective_from + timedelta(hours=1)
|
||||||
|
t1 = t0 + timedelta(minutes=15)
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
_make_contract_with_version(
|
_make_contract_with_version(
|
||||||
@@ -905,6 +917,8 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
|
# Pin to UTC so local days = UTC days (deterministic on any CI host).
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
import_entry = next(
|
import_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
||||||
@@ -915,7 +929,7 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
|||||||
daily_standing = Decimal("90") / Decimal("30")
|
daily_standing = Decimal("90") / Decimal("30")
|
||||||
expected = float(Decimal(str(import_cost_sum)) + daily_standing * expected_days)
|
expected = float(Decimal(str(import_cost_sum)) + daily_standing * expected_days)
|
||||||
|
|
||||||
assert value == pytest.approx(expected, rel=1e-9), (
|
assert value == pytest.approx(expected, rel=1e-6), (
|
||||||
f"Expected import_cost_total={expected} (sum={import_cost_sum} + "
|
f"Expected import_cost_total={expected} (sum={import_cost_sum} + "
|
||||||
f"standing={float(daily_standing * expected_days)} over {expected_days} days), "
|
f"standing={float(daily_standing * expected_days)} over {expected_days} days), "
|
||||||
f"got {value!r}"
|
f"got {value!r}"
|
||||||
@@ -930,20 +944,26 @@ 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.
|
||||||
|
|
||||||
With heffingskorting=365 EUR/year, daily_credit = 1.0 EUR/day.
|
Principle B/D: delegates to summarize(anchor → now).
|
||||||
|
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
|
||||||
|
Timezone pinned to UTC for deterministic local-day counting.
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4)
|
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4)
|
||||||
|
|
||||||
# Expected elapsed days = 4 + 1 = 5
|
# Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
|
||||||
expected_days = (now_utc.date() - effective_from.date()).days + 1
|
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
|
||||||
|
|
||||||
t0 = datetime(2025, 11, 1, 6, 0, tzinfo=timezone.utc)
|
# Place period rows AFTER effective_from.
|
||||||
t1 = datetime(2025, 11, 1, 6, 15, tzinfo=timezone.utc)
|
t0 = effective_from + timedelta(hours=1)
|
||||||
|
t1 = t0 + timedelta(minutes=15)
|
||||||
export_sum = 2.50 # EUR
|
export_sum = 2.50 # EUR
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
@@ -958,6 +978,7 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
export_entry = next(
|
export_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
||||||
@@ -968,7 +989,7 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
|||||||
daily_credit = Decimal("365") / Decimal("365")
|
daily_credit = Decimal("365") / Decimal("365")
|
||||||
expected = float(Decimal(str(export_sum)) + daily_credit * expected_days)
|
expected = float(Decimal(str(export_sum)) + daily_credit * expected_days)
|
||||||
|
|
||||||
assert value == pytest.approx(expected, rel=1e-9), (
|
assert value == pytest.approx(expected, rel=1e-6), (
|
||||||
f"Expected export_revenue_total={expected} (sum={export_sum} + "
|
f"Expected export_revenue_total={expected} (sum={export_sum} + "
|
||||||
f"credit={float(daily_credit * expected_days)} over {expected_days} days), "
|
f"credit={float(daily_credit * expected_days)} over {expected_days} days), "
|
||||||
f"got {value!r}"
|
f"got {value!r}"
|
||||||
@@ -981,14 +1002,32 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None:
|
def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None:
|
||||||
"""When contract version effective_from is in the future, standing cumulative must be 0."""
|
"""When contract version effective_from is in the future, standing cumulative must be 0.
|
||||||
|
|
||||||
|
Principle D: anchor = effective_from (future).
|
||||||
|
summarize(anchor → now) has a negative/empty window → 0 days, 0 fixed_costs.
|
||||||
|
The global `has_data` check passes (there are non-degraded rows from the past),
|
||||||
|
but no periods fall within [anchor, now] window → metered = 0, fixed = 0.
|
||||||
|
The getter returns 0 + 0 = 0.
|
||||||
|
|
||||||
|
Note: under the new design, if a contract has a future effective_from, the
|
||||||
|
getter returns the pure metered sum from the summarize window (which is 0
|
||||||
|
since no periods exist after the future anchor). The old test expected to
|
||||||
|
return the raw global SUM, but with Principle D the anchor is the future
|
||||||
|
date → window is empty → metered = 0.
|
||||||
|
"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
future_effective = now_utc + timedelta(days=30)
|
future_effective = now_utc + timedelta(days=30)
|
||||||
|
|
||||||
t0 = datetime(2025, 12, 1, 6, 0, tzinfo=timezone.utc)
|
# Place a period BEFORE the future effective_from (global sum = 4.00)
|
||||||
|
# but it won't be in [anchor, now] window.
|
||||||
|
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
||||||
import_cost_sum = 4.00
|
import_cost_sum = 4.00
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
@@ -1002,30 +1041,37 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
import_entry = next(
|
import_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
||||||
)
|
)
|
||||||
# Note: active_contract_version_at uses ts=now, but effective_from > now,
|
|
||||||
# so the version does not cover now → version lookup returns None → standing = 0.
|
|
||||||
value = import_entry.entity.value_getter(session)
|
value = import_entry.entity.value_getter(session)
|
||||||
|
|
||||||
# Standing cumulative must be 0; only the raw SUM is returned.
|
# anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0
|
||||||
assert value == pytest.approx(import_cost_sum, rel=1e-9), (
|
# Principle C: no future local days counted → 0 fixed costs.
|
||||||
f"When effective_from is in future, standing must be 0; "
|
# Result = 0 (no periods in window) + 0 (0 standing days) = 0.
|
||||||
f"expected {import_cost_sum}, got {value!r}"
|
assert value == pytest.approx(0.0, abs=1e-9), (
|
||||||
|
f"When effective_from is in future, summarize window is empty → value must be 0.0; "
|
||||||
|
f"got {value!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None:
|
def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None:
|
||||||
"""When contract version effective_from is in the future, credit cumulative must be 0."""
|
"""When contract version effective_from is in the future, credit cumulative must be 0.
|
||||||
|
|
||||||
|
Same logic as import: anchor is future → summarize window empty → 0 credits.
|
||||||
|
"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
future_effective = now_utc + timedelta(days=60)
|
future_effective = now_utc + timedelta(days=60)
|
||||||
|
|
||||||
t0 = datetime(2025, 12, 2, 6, 0, tzinfo=timezone.utc)
|
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
||||||
export_sum = 3.00
|
export_sum = 3.00
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
@@ -1039,16 +1085,16 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) ->
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
with Session(energy_db) as session:
|
||||||
|
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
export_entry = next(
|
export_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
||||||
)
|
)
|
||||||
value = export_entry.entity.value_getter(session)
|
value = export_entry.entity.value_getter(session)
|
||||||
|
|
||||||
# Credit cumulative must be 0; only the raw SUM is returned.
|
# anchor is in future → empty window → 0 credits
|
||||||
assert value == pytest.approx(export_sum, rel=1e-9), (
|
assert value == pytest.approx(0.0, abs=1e-9), (
|
||||||
f"When effective_from is in future, credit must be 0; "
|
f"When effective_from is in future, credit must be 0; got {value!r}"
|
||||||
f"expected {export_sum}, got {value!r}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Tests for app/services/timezone.py (FU10-energy-tz-accrual).
|
||||||
|
|
||||||
|
All tests monkeypatch ``local_tz`` to a fixed zone so they are deterministic
|
||||||
|
on any CI host timezone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import timezone as tz_mod
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def ams(monkeypatch):
|
||||||
|
"""Pin local_tz to Europe/Amsterdam for all tests in this module."""
|
||||||
|
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def utc_tz(monkeypatch):
|
||||||
|
"""Pin local_tz to UTC for simple arithmetic tests."""
|
||||||
|
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("UTC"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# local_tz() — resolution and monkeypatch contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_tz_returns_tzinfo(ams):
|
||||||
|
"""local_tz() must return a tzinfo object."""
|
||||||
|
tz = tz_mod.local_tz()
|
||||||
|
assert tz is not None
|
||||||
|
# Verify it behaves like a valid tzinfo.
|
||||||
|
d = datetime(2026, 6, 25, 12, 0, tzinfo=tz)
|
||||||
|
assert d.utcoffset() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_tz_env_var_overrides(monkeypatch):
|
||||||
|
"""When TZ env var is set, local_tz() returns that zone."""
|
||||||
|
monkeypatch.setenv("TZ", "America/New_York")
|
||||||
|
tz = tz_mod.local_tz()
|
||||||
|
# ZoneInfo("America/New_York") should have UTC-5 or UTC-4 offset.
|
||||||
|
d = datetime(2026, 1, 1, 12, 0, tzinfo=tz) # January → UTC-5
|
||||||
|
offset_hours = d.utcoffset().total_seconds() / 3600
|
||||||
|
assert offset_hours == pytest.approx(-5.0)
|
||||||
|
monkeypatch.delenv("TZ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# to_local() — conversion correctness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_local_aware_utc(ams):
|
||||||
|
"""Aware UTC datetime must convert to CEST (UTC+2) in summer."""
|
||||||
|
dt_utc = datetime(2026, 6, 25, 12, 0, tzinfo=UTC)
|
||||||
|
local = tz_mod.to_local(dt_utc)
|
||||||
|
assert local.tzinfo is not None
|
||||||
|
assert local.hour == 14 # CEST = UTC+2
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_local_naive_treated_as_utc(ams):
|
||||||
|
"""Naive datetime must be treated as UTC and converted to local."""
|
||||||
|
naive = datetime(2026, 6, 25, 12, 0) # naive = UTC convention
|
||||||
|
local = tz_mod.to_local(naive)
|
||||||
|
assert local.hour == 14 # CEST = UTC+2
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_local_winter_offset(monkeypatch):
|
||||||
|
"""CET (UTC+1) applies in January."""
|
||||||
|
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
dt_utc = datetime(2026, 1, 15, 12, 0, tzinfo=UTC)
|
||||||
|
local = tz_mod.to_local(dt_utc)
|
||||||
|
assert local.hour == 13 # CET = UTC+1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# local_date() — date extraction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_date_around_cest_midnight(ams):
|
||||||
|
"""UTC 22:00 on June 24 is local midnight June 25 in CEST."""
|
||||||
|
# CEST = UTC+2; June 24 22:00 UTC = June 25 00:00 CEST
|
||||||
|
dt = datetime(2026, 6, 24, 22, 0, tzinfo=UTC)
|
||||||
|
d = tz_mod.local_date(dt)
|
||||||
|
assert d == date(2026, 6, 25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_date_just_before_cest_midnight(ams):
|
||||||
|
"""UTC 21:59 on June 24 is still June 24 in CEST (23:59 local)."""
|
||||||
|
dt = datetime(2026, 6, 24, 21, 59, tzinfo=UTC)
|
||||||
|
d = tz_mod.local_date(dt)
|
||||||
|
assert d == date(2026, 6, 24)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# local_midnight_utc() — round-trip correctness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_midnight_utc_cest_summer(ams):
|
||||||
|
"""Europe/Amsterdam local midnight June 25 → June 24 22:00 UTC (CEST=UTC+2)."""
|
||||||
|
utc_midnight = tz_mod.local_midnight_utc(date(2026, 6, 25))
|
||||||
|
expected = datetime(2026, 6, 24, 22, 0, tzinfo=UTC)
|
||||||
|
assert utc_midnight == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_midnight_utc_cet_winter(monkeypatch):
|
||||||
|
"""Europe/Amsterdam local midnight Jan 15 → Jan 14 23:00 UTC (CET=UTC+1)."""
|
||||||
|
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
utc_midnight = tz_mod.local_midnight_utc(date(2026, 1, 15))
|
||||||
|
expected = datetime(2026, 1, 14, 23, 0, tzinfo=UTC)
|
||||||
|
assert utc_midnight == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_midnight_utc_roundtrip(ams):
|
||||||
|
"""local_date(local_midnight_utc(D)) must return D for any date D."""
|
||||||
|
for day in range(1, 32):
|
||||||
|
d = date(2026, 6, day) if day <= 30 else date(2026, 7, day - 30)
|
||||||
|
if day > 30:
|
||||||
|
continue
|
||||||
|
midnight_utc = tz_mod.local_midnight_utc(d)
|
||||||
|
recovered = tz_mod.local_date(midnight_utc)
|
||||||
|
assert recovered == d, f"Round-trip failed for {d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# local_now() — sanity check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_now_is_aware(ams):
|
||||||
|
"""local_now() must return a timezone-aware datetime."""
|
||||||
|
now = tz_mod.local_now()
|
||||||
|
assert now.tzinfo is not None
|
||||||
|
assert now.utcoffset() is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# effective_from naive→local→UTC (Principle A — contract routes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_localize_naive_effective_from(monkeypatch):
|
||||||
|
"""A naive '2026-06-25T00:00:00' must be interpreted as CEST local midnight.
|
||||||
|
|
||||||
|
Expected: CEST midnight June 25 = UTC June 24 22:00.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
# Simulate what the route helper does:
|
||||||
|
from app.api.routes.api.energy_contracts import _localize_effective_from
|
||||||
|
naive = datetime(2026, 6, 25, 0, 0, 0) # naive, no tzinfo
|
||||||
|
utc_result = _localize_effective_from(naive)
|
||||||
|
assert utc_result.tzinfo is not None
|
||||||
|
expected = datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
|
||||||
|
assert utc_result == expected, (
|
||||||
|
f"Naive local midnight should localize to {expected}, got {utc_result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_localize_aware_effective_from():
|
||||||
|
"""An aware datetime must be stored as UTC unchanged (no double conversion)."""
|
||||||
|
from app.api.routes.api.energy_contracts import _localize_effective_from
|
||||||
|
aware = datetime(2026, 6, 25, 0, 0, 0, tzinfo=UTC)
|
||||||
|
utc_result = _localize_effective_from(aware)
|
||||||
|
assert utc_result == aware
|
||||||
|
|
||||||
|
|
||||||
|
def test_localize_none_effective_from():
|
||||||
|
"""None effective_from must return current UTC time (as before)."""
|
||||||
|
from app.api.routes.api.energy_contracts import _localize_effective_from
|
||||||
|
before = datetime.now(UTC)
|
||||||
|
result = _localize_effective_from(None)
|
||||||
|
after = datetime.now(UTC)
|
||||||
|
assert result.tzinfo is not None
|
||||||
|
assert before <= result <= after
|
||||||
Reference in New Issue
Block a user