M8-T14: add generic meter cost periods
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
"""add generic commodity-scoped meter cost periods
|
||||||
|
|
||||||
|
Revision ID: 20260822_19_meter_cost_periods
|
||||||
|
Revises: 20260822_18_contract_scopes
|
||||||
|
Create Date: 2026-08-22 00:00:00.000000
|
||||||
|
|
||||||
|
This additive migration creates a separate audit ledger for non-electricity
|
||||||
|
meter costs. It deliberately does not alter, migrate, or delete rows from the
|
||||||
|
existing electricity-only energy_cost_period table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260822_19_meter_cost_periods"
|
||||||
|
down_revision: Union[str, None] = "20260822_18_contract_scopes"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExactDecimal(sa.TypeDecorator):
|
||||||
|
"""Use SQLite text storage while retaining Numeric semantics elsewhere."""
|
||||||
|
|
||||||
|
impl = sa.Numeric
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def __init__(self, precision: int, scale: int) -> None:
|
||||||
|
self.precision = precision
|
||||||
|
self.scale = scale
|
||||||
|
super().__init__(precision=precision, scale=scale)
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "sqlite":
|
||||||
|
return dialect.type_descriptor(sa.String(self.precision + 2))
|
||||||
|
return dialect.type_descriptor(sa.Numeric(self.precision, self.scale, asdecimal=True))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"meter_cost_period",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("commodity", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("meter_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_binding_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("contract_version_id", sa.Integer(), nullable=True),
|
||||||
|
# SQLite NUMERIC coercion binds Decimal values as binary floats. Store
|
||||||
|
# fixed-width decimal text there, while retaining Numeric semantics on
|
||||||
|
# other supported dialects.
|
||||||
|
sa.Column("quantity", ExactDecimal(15, 6), nullable=False),
|
||||||
|
sa.Column("cost", ExactDecimal(15, 9), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column("cost_breakdown", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("pricing_snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("quality", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("degraded", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("degraded_reason", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["meter_id"], ["meter.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["source_binding_id"], ["meter_source_binding.id"], ondelete="RESTRICT"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contract_version_id"], ["energy_contract_version.id"], ondelete="RESTRICT"
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"degraded OR (meter_id IS NOT NULL AND source_binding_id IS NOT NULL "
|
||||||
|
"AND contract_version_id IS NOT NULL)",
|
||||||
|
name="ck_meter_cost_period_normal_audit_links",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint("period_end > period_start", name="ck_meter_cost_period_positive_interval"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"NOT degraded OR (degraded_reason IS NOT NULL AND length(trim(degraded_reason)) > 0)",
|
||||||
|
name="ck_meter_cost_period_degraded_reason",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("commodity", "period_start", name="uq_meter_cost_period_commodity_start"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_meter_cost_period_commodity_start", "meter_cost_period", ["commodity", "period_start"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_meter_cost_period_source_binding_id", "meter_cost_period", ["source_binding_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_meter_cost_period_source_binding_id", table_name="meter_cost_period")
|
||||||
|
op.drop_index("ix_meter_cost_period_commodity_start", table_name="meter_cost_period")
|
||||||
|
op.drop_table("meter_cost_period")
|
||||||
+193
-3
@@ -12,20 +12,25 @@ Six tables:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
|
Numeric,
|
||||||
String,
|
String,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
event,
|
event,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym, validates
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON, TypeDecorator
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models.meter_source import MeterSourceBinding
|
from app.models.meter_source import MeterSourceBinding
|
||||||
@@ -35,6 +40,106 @@ def _uuid4_str() -> str:
|
|||||||
return str(_uuid.uuid4())
|
return str(_uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal_json(value: Any) -> Any:
|
||||||
|
"""Make auditable JSON portable without admitting binary numeric values."""
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return format(value, "f")
|
||||||
|
if isinstance(value, float) or (isinstance(value, int) and not isinstance(value, bool)):
|
||||||
|
raise ValueError("JSON amounts and quantities must be decimal strings, not numeric JSON values")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _decimal_json(child) for key, child in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_decimal_json(child) for child in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_fixed_decimal(value: Decimal, precision: int, scale: int, field: str) -> Decimal:
|
||||||
|
if not isinstance(value, Decimal):
|
||||||
|
raise ValueError(f"{field} must be a Decimal, not a binary float or other numeric type")
|
||||||
|
if not value.is_finite():
|
||||||
|
raise ValueError(f"{field} must be finite")
|
||||||
|
if -value.as_tuple().exponent > scale:
|
||||||
|
raise ValueError(f"{field} exceeds scale {scale}")
|
||||||
|
integer_digits = 0 if value.is_zero() else max(value.copy_abs().adjusted() + 1, 0)
|
||||||
|
if integer_digits > precision - scale:
|
||||||
|
raise ValueError(f"{field} exceeds precision {precision},{scale}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ExactDecimal(TypeDecorator[Decimal]):
|
||||||
|
"""Fixed-point Decimal which uses SQLite TEXT, never a binary float."""
|
||||||
|
|
||||||
|
impl = Numeric
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def __init__(self, precision: int, scale: int) -> None:
|
||||||
|
self.precision = precision
|
||||||
|
self.scale = scale
|
||||||
|
super().__init__(precision=precision, scale=scale)
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "sqlite":
|
||||||
|
return dialect.type_descriptor(String(self.precision + 2))
|
||||||
|
return dialect.type_descriptor(Numeric(self.precision, self.scale, asdecimal=True))
|
||||||
|
|
||||||
|
def process_bind_param(self, value: Decimal | None, dialect) -> Decimal | str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = _validate_fixed_decimal(value, self.precision, self.scale, "decimal value")
|
||||||
|
if dialect.name == "sqlite":
|
||||||
|
return format(value, f".{self.scale}f")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def process_result_value(self, value: Decimal | str | None, _dialect) -> Decimal | None:
|
||||||
|
return None if value is None else Decimal(value)
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalJSON(TypeDecorator[dict]):
|
||||||
|
"""JSON which serializes Decimal values as strings on every write path."""
|
||||||
|
|
||||||
|
impl = JSON
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value: Any, _dialect) -> Any:
|
||||||
|
return None if value is None else _decimal_json(value)
|
||||||
|
|
||||||
|
|
||||||
|
class UTCDateTime(TypeDecorator[datetime]):
|
||||||
|
"""UTC timestamps that preserve instant identity on SQLite and other dialects."""
|
||||||
|
|
||||||
|
impl = DateTime(timezone=True)
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def __init__(self, field: str) -> None:
|
||||||
|
self.field = field
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def process_bind_param(self, value: datetime | None, _dialect) -> datetime | None:
|
||||||
|
return None if value is None else _normalise_utc_period(value, self.field)
|
||||||
|
|
||||||
|
def process_result_value(self, value: datetime | None, _dialect) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_aware_period(start: datetime, end: datetime) -> None:
|
||||||
|
if start.tzinfo is None or start.utcoffset() is None:
|
||||||
|
raise ValueError("period_start must be timezone-aware")
|
||||||
|
if end.tzinfo is None or end.utcoffset() is None:
|
||||||
|
raise ValueError("period_end must be timezone-aware")
|
||||||
|
if end <= start:
|
||||||
|
raise ValueError("period_end must be after period_start")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_utc_period(value: datetime, field: str) -> datetime:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise ValueError(f"{field} must be timezone-aware")
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Meter(Base):
|
class Meter(Base):
|
||||||
"""One physical electricity meter's installation epoch.
|
"""One physical electricity meter's installation epoch.
|
||||||
|
|
||||||
@@ -358,6 +463,91 @@ class EnergyCostPeriod(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MeterCostPeriod(Base):
|
||||||
|
"""Auditable commodity-scoped ledger row for one half-open metering period.
|
||||||
|
|
||||||
|
A degraded row intentionally permits missing audit links; services must
|
||||||
|
still require them before writing a normal row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "meter_cost_period"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
commodity: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
period_start: Mapped[datetime] = mapped_column(UTCDateTime("period_start"), nullable=False)
|
||||||
|
period_end: Mapped[datetime] = mapped_column(UTCDateTime("period_end"), nullable=False)
|
||||||
|
meter_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
source_binding_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("meter_source_binding.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
contract_version_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# SQLite reliably round-trips at most fifteen significant decimal digits.
|
||||||
|
# WarmteLink itself reports 0.001 units, so nine cost fractional digits
|
||||||
|
# retain a six-place tariff times that source precision without float loss.
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(ExactDecimal(15, 6), nullable=False)
|
||||||
|
cost: Mapped[Decimal] = mapped_column(ExactDecimal(15, 9), nullable=False)
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
cost_breakdown: Mapped[dict] = mapped_column(DecimalJSON(), nullable=False, default=dict)
|
||||||
|
pricing_snapshot: Mapped[dict] = mapped_column(DecimalJSON(), nullable=False, default=dict)
|
||||||
|
quality: Mapped[str] = mapped_column(String(32), nullable=False, default="valid")
|
||||||
|
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
degraded_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
meter: Mapped["Meter | None"] = relationship()
|
||||||
|
source_binding: Mapped["MeterSourceBinding | None"] = relationship()
|
||||||
|
contract_version: Mapped["EnergyContractVersion | None"] = relationship()
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"degraded OR (meter_id IS NOT NULL AND source_binding_id IS NOT NULL "
|
||||||
|
"AND contract_version_id IS NOT NULL)",
|
||||||
|
name="ck_meter_cost_period_normal_audit_links",
|
||||||
|
),
|
||||||
|
CheckConstraint("period_end > period_start", name="ck_meter_cost_period_positive_interval"),
|
||||||
|
CheckConstraint(
|
||||||
|
"NOT degraded OR (degraded_reason IS NOT NULL AND length(trim(degraded_reason)) > 0)",
|
||||||
|
name="ck_meter_cost_period_degraded_reason",
|
||||||
|
),
|
||||||
|
UniqueConstraint("commodity", "period_start", name="uq_meter_cost_period_commodity_start"),
|
||||||
|
Index("ix_meter_cost_period_commodity_start", "commodity", "period_start"),
|
||||||
|
Index("ix_meter_cost_period_source_binding_id", "source_binding_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates("cost_breakdown", "pricing_snapshot")
|
||||||
|
def _validate_decimal_json(self, _key: str, value: dict) -> dict:
|
||||||
|
return _decimal_json(value)
|
||||||
|
|
||||||
|
@validates("quantity", "cost")
|
||||||
|
def _validate_fixed_decimal(self, key: str, value: Decimal) -> Decimal:
|
||||||
|
precision, scale = (15, 6) if key == "quantity" else (15, 9)
|
||||||
|
return _validate_fixed_decimal(value, precision, scale, key)
|
||||||
|
|
||||||
|
@validates("period_start", "period_end")
|
||||||
|
def _normalise_period(self, key: str, value: datetime) -> datetime:
|
||||||
|
return _normalise_utc_period(value, key)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(MeterCostPeriod, "before_insert")
|
||||||
|
@event.listens_for(MeterCostPeriod, "before_update")
|
||||||
|
def _validate_meter_cost_period(_mapper, _connection, target: MeterCostPeriod) -> None:
|
||||||
|
_require_aware_period(target.period_start, target.period_end)
|
||||||
|
if not target.degraded and (
|
||||||
|
target.meter_id is None
|
||||||
|
or target.source_binding_id is None
|
||||||
|
or target.contract_version_id is None
|
||||||
|
):
|
||||||
|
raise ValueError("normal meter cost periods require meter, binding, and contract version")
|
||||||
|
if target.degraded and not target.degraded_reason:
|
||||||
|
raise ValueError("degraded meter cost periods require a degraded_reason")
|
||||||
|
|
||||||
|
|
||||||
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
||||||
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
||||||
# no composite index is needed for single-meter deployments.)
|
# no composite index is needed for single-meter deployments.)
|
||||||
|
|||||||
@@ -810,7 +810,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T14 — 建立通用 Meter Cost Period 账本
|
### M8-T14 — 建立通用 Meter Cost Period 账本
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T13
|
- **Depends**: M8-T13
|
||||||
- **Context**: 热力成本不能硬塞进 electricity 专用账本;先建立按 commodity 审计的独立表。
|
- **Context**: 热力成本不能硬塞进 electricity 专用账本;先建立按 commodity 审计的独立表。
|
||||||
|
|
||||||
@@ -819,6 +819,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
- `create alembic_app/versions/20260822_19_meter_cost_periods.py`
|
- `create alembic_app/versions/20260822_19_meter_cost_periods.py`
|
||||||
- `modify scripts/app_db_adopt.py`
|
- `modify scripts/app_db_adopt.py`
|
||||||
- `create tests/test_meter_cost_models.py`
|
- `create tests/test_meter_cost_models.py`
|
||||||
|
- `modify tests/test_energy_models.py`
|
||||||
|
|
||||||
**Steps**
|
**Steps**
|
||||||
1. 新建 `MeterCostPeriod`,字段按 §5:commodity、period start/end、nullable Meter/binding、nullable
|
1. 新建 `MeterCostPeriod`,字段按 §5:commodity、period start/end、nullable Meter/binding、nullable
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260822_18_contract_scopes"
|
APP_BASELINE_REVISION = "20260822_19_meter_cost_periods"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -1251,8 +1251,8 @@ def test_contract_scope_migration_preserves_historical_contract_audit(tmp_path:
|
|||||||
{"now": now, "pricing": pricing, "version_id": version_id},
|
{"now": now, "pricing": pricing, "version_id": version_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
command.upgrade(cfg, "head")
|
command.upgrade(cfg, "20260822_18_contract_scopes")
|
||||||
command.upgrade(cfg, "head")
|
command.upgrade(cfg, "20260822_18_contract_scopes")
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
||||||
"20260822_18_contract_scopes"
|
"20260822_18_contract_scopes"
|
||||||
@@ -1359,7 +1359,7 @@ def test_contract_scope_migration_audit_failure_restores_revision_17(tmp_path: P
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
del cfg.attributes["m8_t12_post_ddl_audit_failure"]
|
del cfg.attributes["m8_t12_post_ddl_audit_failure"]
|
||||||
command.upgrade(cfg, "head")
|
command.upgrade(cfg, "20260822_18_contract_scopes")
|
||||||
engine = _engine_with_fk(db_url)
|
engine = _engine_with_fk(db_url)
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""Migration and ORM contracts for the commodity-scoped meter cost ledger."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, event as sa_event, insert, inspect, select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError, StatementError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.energy import MeterCostPeriod
|
||||||
|
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
||||||
|
|
||||||
|
|
||||||
|
REVISION_18 = "20260822_18_contract_scopes"
|
||||||
|
REVISION_19 = "20260822_19_meter_cost_periods"
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def _config(database_url: str) -> Config:
|
||||||
|
config = Config("alembic_app.ini")
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_engine(database_url: str):
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
|
||||||
|
@sa_event.listens_for(engine, "connect")
|
||||||
|
def _enable_foreign_keys(connection, _record) -> None:
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _period(**overrides) -> MeterCostPeriod:
|
||||||
|
start = datetime(2026, 8, 22, 12, tzinfo=UTC)
|
||||||
|
values = {
|
||||||
|
"commodity": "heating",
|
||||||
|
"period_start": start,
|
||||||
|
"period_end": start + timedelta(minutes=15),
|
||||||
|
"quantity": Decimal("999999999.123456"),
|
||||||
|
"cost": Decimal("999999.123456789"),
|
||||||
|
"currency": "EUR",
|
||||||
|
"cost_breakdown": {"heating": "0.012345678"},
|
||||||
|
"pricing_snapshot": {"heating": "10.000000", "kind": "district_heating"},
|
||||||
|
"quality": "valid",
|
||||||
|
"degraded": True,
|
||||||
|
"degraded_reason": "test_fixture_without_audit_links",
|
||||||
|
"created_at": start,
|
||||||
|
"updated_at": start,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return MeterCostPeriod(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_empty_db_upgrade_shape_and_baseline(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'empty.db'}"
|
||||||
|
config = _config(database_url)
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
inspector = inspect(engine)
|
||||||
|
columns = {column["name"]: column for column in inspector.get_columns("meter_cost_period")}
|
||||||
|
assert APP_BASELINE_REVISION == REVISION_19
|
||||||
|
assert {"commodity", "period_start", "period_end", "quantity", "cost"} <= columns.keys()
|
||||||
|
assert columns["meter_id"]["nullable"]
|
||||||
|
assert columns["source_binding_id"]["nullable"]
|
||||||
|
assert columns["contract_version_id"]["nullable"]
|
||||||
|
assert {index["name"] for index in inspector.get_indexes("meter_cost_period")} >= {
|
||||||
|
"ix_meter_cost_period_commodity_start",
|
||||||
|
"ix_meter_cost_period_source_binding_id",
|
||||||
|
}
|
||||||
|
assert ("commodity", "period_start") in {
|
||||||
|
tuple(constraint["column_names"])
|
||||||
|
for constraint in inspector.get_unique_constraints("meter_cost_period")
|
||||||
|
}
|
||||||
|
foreign_keys = {
|
||||||
|
foreign_key["constrained_columns"][0]: foreign_key
|
||||||
|
for foreign_key in inspector.get_foreign_keys("meter_cost_period")
|
||||||
|
}
|
||||||
|
for column, table in {
|
||||||
|
"meter_id": "meter",
|
||||||
|
"source_binding_id": "meter_source_binding",
|
||||||
|
"contract_version_id": "energy_contract_version",
|
||||||
|
}.items():
|
||||||
|
assert foreign_keys[column]["referred_table"] == table
|
||||||
|
assert foreign_keys[column]["options"]["ondelete"] == "RESTRICT"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_revision_18_upgrade_preserves_electricity_rows(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'revision18.db'}"
|
||||||
|
config = _config(database_url)
|
||||||
|
command.upgrade(config, REVISION_18)
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
stamp = datetime(2026, 8, 22, 12)
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_cost_period "
|
||||||
|
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, "
|
||||||
|
"export_revenue, net_cost, currency, pricing, contract_version_id, "
|
||||||
|
"meter_id, source_binding_id, degraded, computed_at) "
|
||||||
|
"VALUES (:stamp, 1, 2, 3, 4, 5, 6, 7, 'EUR', :pricing, NULL, NULL, NULL, 0, :stamp)"
|
||||||
|
),
|
||||||
|
{"stamp": stamp, "pricing": '{"historic":"unchanged"}'},
|
||||||
|
)
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_19
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == (
|
||||||
|
'{"historic":"unchanged"}'
|
||||||
|
)
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM meter_cost_period")).scalar_one() == 0
|
||||||
|
command.downgrade(config, REVISION_18)
|
||||||
|
assert "meter_cost_period" not in inspect(engine).get_table_names()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_decimal_json_and_degraded_round_trip(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'roundtrip.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
with Session(engine) as session:
|
||||||
|
normal = _period()
|
||||||
|
degraded = _period(
|
||||||
|
commodity="hot_water",
|
||||||
|
meter_id=None,
|
||||||
|
source_binding_id=None,
|
||||||
|
contract_version_id=None,
|
||||||
|
quality="unverifiable",
|
||||||
|
degraded=True,
|
||||||
|
degraded_reason="missing_binding",
|
||||||
|
quantity=Decimal("0.000000"),
|
||||||
|
cost=Decimal("0.000000000"),
|
||||||
|
cost_breakdown={"hot_water": "0.000000000"},
|
||||||
|
pricing_snapshot={"reason": "missing_binding"},
|
||||||
|
)
|
||||||
|
session.add_all((normal, degraded))
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
stored = session.get(MeterCostPeriod, normal.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.quantity == Decimal("999999999.123456")
|
||||||
|
assert stored.cost == Decimal("999999.123456789")
|
||||||
|
assert stored.cost_breakdown == {"heating": "0.012345678"}
|
||||||
|
assert all(not isinstance(value, float) for value in stored.pricing_snapshot.values())
|
||||||
|
assert session.get(MeterCostPeriod, degraded.id).degraded_reason == "missing_binding"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_uses_decimal_text_and_normalises_json_snapshots(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'exact.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = _period(
|
||||||
|
cost_breakdown={"nested": [Decimal("2.000000000")]},
|
||||||
|
pricing_snapshot={"rate": Decimal("2.000000")},
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
stored = session.get(MeterCostPeriod, row.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.cost_breakdown == {"nested": ["2.000000000"]}
|
||||||
|
assert stored.pricing_snapshot == {"rate": "2.000000"}
|
||||||
|
raw = session.execute(text("SELECT typeof(quantity), typeof(cost) FROM meter_cost_period")).one()
|
||||||
|
assert raw == ("text", "text")
|
||||||
|
with pytest.raises(ValueError, match="numeric JSON"):
|
||||||
|
_period(cost_breakdown={"nested": [1]})
|
||||||
|
with pytest.raises(ValueError, match="numeric JSON"):
|
||||||
|
_period(pricing_snapshot={"nested": [1.25]})
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_core_json_bind_normalises_decimal_and_rejects_numeric(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'core-json.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
values = {
|
||||||
|
"commodity": "heating",
|
||||||
|
"period_start": datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||||||
|
"period_end": datetime(2026, 8, 22, 12, 15, tzinfo=UTC),
|
||||||
|
"quantity": Decimal("0.000000"),
|
||||||
|
"cost": Decimal("0.000000000"),
|
||||||
|
"currency": "EUR",
|
||||||
|
"cost_breakdown": {"nested": [Decimal("2.000000000")]},
|
||||||
|
"pricing_snapshot": {"rate": Decimal("2.000000")},
|
||||||
|
"quality": "valid",
|
||||||
|
"degraded": True,
|
||||||
|
"degraded_reason": "core fixture",
|
||||||
|
"created_at": datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||||||
|
"updated_at": datetime(2026, 8, 22, 12, tzinfo=UTC),
|
||||||
|
}
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(insert(MeterCostPeriod.__table__).values(values))
|
||||||
|
with Session(engine) as session:
|
||||||
|
stored = session.scalar(select(MeterCostPeriod))
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.cost_breakdown == {"nested": ["2.000000000"]}
|
||||||
|
assert stored.pricing_snapshot == {"rate": "2.000000"}
|
||||||
|
for field, numeric_value in (("cost_breakdown", {"nested": [1]}), ("pricing_snapshot", {"rate": 1.25})):
|
||||||
|
with engine.begin() as connection, pytest.raises(StatementError, match="numeric JSON"):
|
||||||
|
connection.execute(
|
||||||
|
insert(MeterCostPeriod.__table__).values(
|
||||||
|
{**values, "commodity": f"invalid-{field}", field: numeric_value}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "message"),
|
||||||
|
[
|
||||||
|
("quantity", Decimal("1000000000.000000"), "precision"),
|
||||||
|
("quantity", Decimal("1.1234567"), "scale"),
|
||||||
|
("cost", Decimal("1000000.000000000"), "precision"),
|
||||||
|
("cost", Decimal("1.1234567899"), "scale"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_meter_cost_period_rejects_decimal_precision_and_scale_overflow(field, value, message) -> None:
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
_period(**{field: value})
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_accepts_signed_decimal_boundaries_without_float_bind(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'boundaries.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
with Session(engine) as session:
|
||||||
|
rows = (
|
||||||
|
_period(quantity=Decimal("999999999.999999"), cost=Decimal("999999.999999999")),
|
||||||
|
_period(
|
||||||
|
commodity="hot_water",
|
||||||
|
quantity=Decimal("-999999999.999999"),
|
||||||
|
cost=Decimal("-999999.999999999"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.add_all(rows)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
assert session.get(MeterCostPeriod, rows[0].id).quantity == Decimal("999999999.999999")
|
||||||
|
assert session.get(MeterCostPeriod, rows[1].id).cost == Decimal("-999999.999999999")
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_rejects_duplicate_period_invalid_interval_and_json_float(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'constraints.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
with Session(engine) as session:
|
||||||
|
first = _period()
|
||||||
|
session.add(first)
|
||||||
|
session.commit()
|
||||||
|
session.add(_period(cost=Decimal("1.000000000")))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
session.commit()
|
||||||
|
session.rollback()
|
||||||
|
session.add(_period(commodity="hot_water", period_end=datetime(2026, 8, 22, 12, tzinfo=UTC)))
|
||||||
|
with pytest.raises(ValueError, match="after period_start"):
|
||||||
|
session.flush()
|
||||||
|
session.rollback()
|
||||||
|
with pytest.raises(ValueError, match="timezone-aware"):
|
||||||
|
_period(
|
||||||
|
commodity="hot_water",
|
||||||
|
period_start=datetime(2026, 8, 22, 12),
|
||||||
|
period_end=datetime(2026, 8, 22, 12, 15, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="decimal strings"):
|
||||||
|
_period(pricing_snapshot={"heating": 1.25})
|
||||||
|
with pytest.raises(ValueError, match="normal meter cost periods"):
|
||||||
|
session.add(_period(commodity="hot_water", degraded=False, degraded_reason=None))
|
||||||
|
session.flush()
|
||||||
|
session.rollback()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_uses_utc_instant_idempotency_and_db_constraints(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'utc.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC)
|
||||||
|
offset_start = datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2)))
|
||||||
|
with Session(engine) as session:
|
||||||
|
first = _period(period_start=utc_start, period_end=utc_start + timedelta(minutes=15))
|
||||||
|
assert first.period_start == utc_start
|
||||||
|
session.add(first)
|
||||||
|
session.commit()
|
||||||
|
session.add(
|
||||||
|
_period(
|
||||||
|
period_start=offset_start,
|
||||||
|
period_end=offset_start + timedelta(minutes=15),
|
||||||
|
cost=Decimal("1.000000000"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
session.commit()
|
||||||
|
session.rollback()
|
||||||
|
with engine.begin() as connection:
|
||||||
|
values = {
|
||||||
|
"start": "2026-08-23 00:00:00.000000",
|
||||||
|
"quantity": "0.000000",
|
||||||
|
"cost": "0.000000000",
|
||||||
|
"now": "2026-08-23 00:00:00.000000",
|
||||||
|
}
|
||||||
|
for commodity, end in (("equal", values["start"]), ("reversed", "2026-08-22 23:59:59.000000")):
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter_cost_period "
|
||||||
|
"(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, "
|
||||||
|
"pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) "
|
||||||
|
"VALUES (:commodity, :start, :end, :quantity, :cost, 'EUR', '{}', '{}', 'valid', "
|
||||||
|
"1, 'core test', :now, :now)"
|
||||||
|
),
|
||||||
|
{**values, "commodity": commodity, "end": end},
|
||||||
|
)
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter_cost_period "
|
||||||
|
"(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, "
|
||||||
|
"pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) "
|
||||||
|
"VALUES ('missing-reason', :start, '2026-08-23 00:15:00.000000', :quantity, :cost, "
|
||||||
|
"'EUR', '{}', '{}', 'valid', 1, NULL, :now, :now)"
|
||||||
|
),
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_core_uses_utc_instant_idempotency_and_reloads_aware(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'core-utc.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC)
|
||||||
|
values = {
|
||||||
|
"commodity": "heating",
|
||||||
|
"period_start": utc_start,
|
||||||
|
"period_end": utc_start + timedelta(minutes=15),
|
||||||
|
"quantity": Decimal("0.000000"),
|
||||||
|
"cost": Decimal("0.000000000"),
|
||||||
|
"currency": "EUR",
|
||||||
|
"cost_breakdown": {},
|
||||||
|
"pricing_snapshot": {},
|
||||||
|
"quality": "valid",
|
||||||
|
"degraded": True,
|
||||||
|
"degraded_reason": "core fixture",
|
||||||
|
"created_at": utc_start,
|
||||||
|
"updated_at": utc_start,
|
||||||
|
}
|
||||||
|
with engine.begin() as connection:
|
||||||
|
row_id = connection.execute(insert(MeterCostPeriod.__table__).values(values)).inserted_primary_key[0]
|
||||||
|
with engine.begin() as connection, pytest.raises(IntegrityError):
|
||||||
|
connection.execute(
|
||||||
|
insert(MeterCostPeriod.__table__).values(
|
||||||
|
{
|
||||||
|
**values,
|
||||||
|
"period_start": datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2))),
|
||||||
|
"period_end": datetime(2026, 8, 22, 14, 15, tzinfo=timezone(timedelta(hours=2))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with engine.connect() as connection, pytest.raises(StatementError, match="timezone-aware"):
|
||||||
|
connection.execute(
|
||||||
|
insert(MeterCostPeriod.__table__).values(
|
||||||
|
{**values, "commodity": "naive", "period_start": datetime(2026, 8, 23, 12)}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with Session(engine) as session:
|
||||||
|
stored = session.get(MeterCostPeriod, row_id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.period_start == utc_start
|
||||||
|
assert stored.period_start.tzinfo is not None
|
||||||
|
assert stored.period_start.utcoffset() == timedelta(0)
|
||||||
|
stored.quality = "unverifiable"
|
||||||
|
session.commit()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_cost_period_foreign_keys_restrict_deletion(tmp_path: Path) -> None:
|
||||||
|
database_url = f"sqlite:///{tmp_path / 'foreign_keys.db'}"
|
||||||
|
command.upgrade(_config(database_url), "head")
|
||||||
|
engine = _fk_engine(database_url)
|
||||||
|
timestamp = datetime(2026, 8, 22, 12)
|
||||||
|
with engine.begin() as connection:
|
||||||
|
meter_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter (uuid, label, commodity, started_at, ended_at, reason, note, created_at) "
|
||||||
|
"VALUES ('meter-cost-test', 'Test', 'heating', :timestamp, NULL, 'initial', NULL, :timestamp)"
|
||||||
|
),
|
||||||
|
{"timestamp": timestamp},
|
||||||
|
).lastrowid
|
||||||
|
source_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter_source (uuid, name, kind, enabled, config, status, created_at, updated_at) "
|
||||||
|
"VALUES ('source-cost-test', 'Test', 'warmtelink_serial', 1, '{}', 'online', :timestamp, :timestamp)"
|
||||||
|
),
|
||||||
|
{"timestamp": timestamp},
|
||||||
|
).lastrowid
|
||||||
|
channel_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter_source_channel (uuid, source_id, channel_key, label, unit, created_at, updated_at) "
|
||||||
|
"VALUES ('channel-cost-test', :source_id, 'heating', 'Heating', 'GJ', :timestamp, :timestamp)"
|
||||||
|
),
|
||||||
|
{"source_id": source_id, "timestamp": timestamp},
|
||||||
|
).lastrowid
|
||||||
|
binding_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter_source_binding "
|
||||||
|
"(uuid, meter_id, channel_id, started_at, ended_at, created_at, updated_at) "
|
||||||
|
"VALUES ('binding-cost-test', :meter_id, :channel_id, :timestamp, NULL, :timestamp, :timestamp)"
|
||||||
|
),
|
||||||
|
{"meter_id": meter_id, "channel_id": channel_id, "timestamp": timestamp},
|
||||||
|
).lastrowid
|
||||||
|
contract_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_contract (name, kind, scope, active, currency, created_at, updated_at) "
|
||||||
|
"VALUES ('Test', 'district_heating', 'thermal', 1, 'EUR', :timestamp, :timestamp)"
|
||||||
|
),
|
||||||
|
{"timestamp": timestamp},
|
||||||
|
).lastrowid
|
||||||
|
version_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_contract_version "
|
||||||
|
"(contract_id, effective_from, effective_to, \"values\", created_at) "
|
||||||
|
"VALUES (:contract_id, :timestamp, NULL, '{}', :timestamp)"
|
||||||
|
),
|
||||||
|
{"contract_id": contract_id, "timestamp": timestamp},
|
||||||
|
).lastrowid
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO meter_cost_period "
|
||||||
|
"(commodity, period_start, period_end, meter_id, source_binding_id, contract_version_id, "
|
||||||
|
"quantity, cost, currency, cost_breakdown, pricing_snapshot, quality, degraded, degraded_reason, "
|
||||||
|
"created_at, updated_at) VALUES "
|
||||||
|
"('heating', :timestamp, :period_end, :meter_id, :binding_id, :version_id, "
|
||||||
|
"'0.001000', '0.010000000', 'EUR', '{}', '{}', 'valid', 0, NULL, :timestamp, :timestamp)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"period_end": timestamp + timedelta(minutes=15),
|
||||||
|
"meter_id": meter_id,
|
||||||
|
"binding_id": binding_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for statement, values in (
|
||||||
|
("DELETE FROM meter WHERE id = :id", {"id": meter_id}),
|
||||||
|
("DELETE FROM meter_source_binding WHERE id = :id", {"id": binding_id}),
|
||||||
|
("DELETE FROM energy_contract_version WHERE id = :id", {"id": version_id}),
|
||||||
|
):
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
connection.execute(text(statement), values)
|
||||||
|
engine.dispose()
|
||||||
Reference in New Issue
Block a user