Model/infrastructure/postgres/modelling/recommendation_table.py
Khalim Conn-Kowlessar 31da90f5eb feat(modelling): persist recommendation.material_id from the catalogue
Expand half of the recommendation_materials retirement (ADR-0017). A
Plan Measure installs a single Product, so thread its catalogue id end to
end — Product.id -> MeasureOption.material_id -> PlanMeasure.material_id
-> recommendation.material_id — replacing the per-material BOM child
table with one nullable column on the row. ProductPostgresRepository
reads the id from MaterialRow; the four fabric generators set it on their
Option; the orchestrator carries it onto the Plan Measure; the mirror
declares + maps the column. Optional throughout (the JSON stopgap
catalogue carries no ids -> NULL).

The multi-measure integration test now pins each persisted measure's
material_id to its seeded MaterialRow id. Migration spec (live column
must be added before this deploys; contraction is the owner's next step)
in docs/migrations/recommendation-material-id.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:26:58 +00:00

125 lines
5.1 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import ClassVar, Optional
from sqlalchemy import BigInteger, Column, ForeignKey, TIMESTAMP
from sqlalchemy import Enum as SAEnum
from sqlalchemy.sql import func
from sqlmodel import Field, SQLModel
from datatypes.enums import QuantityUnits
from domain.modelling.plan import PlanMeasure
# Calculator metrics are in kg CO₂/yr; the live ``recommendation`` column is
# tonnes (legacy ``emissions_kg / 1000``). Convert on the way in.
_KG_PER_TONNE = 1000.0
class RecommendationModel(SQLModel, table=True):
"""The single SQLModel definition of the live ``recommendation`` table
(ADR-0017 amendment) — one row per persisted Plan Measure.
Carries full legacy column parity (the readers iterate the columns / sum
them) **plus** ``plan_id``, the FK that links a measure to its Plan and
replaces the retired ``plan_recommendations`` m2m. Out-of-cluster columns
(``property_id``) are plain indexed ints, not FK constraints, matching the
mirror convention so ``SQLModel.metadata.create_all`` needs no foreign
table to exist (the live FKs are owned by the Drizzle schema).
"""
__tablename__: ClassVar[str] = "recommendation" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
property_id: int = Field(index=True)
plan_id: Optional[int] = Field(
default=None,
sa_column=Column(
BigInteger,
ForeignKey("plan.id", ondelete="CASCADE"),
nullable=True,
index=True,
),
)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
)
type: str
measure_type: Optional[str] = Field(default=None)
description: str
# The single Product this measure installs — the live ``material_id`` column
# that replaces the retired ``recommendation_materials`` BOM (one material
# per Plan Measure). Plain int, out-of-cluster (mirror convention).
material_id: Optional[int] = Field(default=None, index=True)
estimated_cost: Optional[float] = Field(default=None)
starting_u_value: Optional[float] = Field(default=None)
new_u_value: Optional[float] = Field(default=None)
sap_points: Optional[float] = Field(default=None)
heat_demand: Optional[float] = Field(default=None)
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
energy_savings: Optional[float] = Field(default=None)
energy_cost_savings: Optional[float] = Field(default=None) # £/yr
property_valuation_increase: Optional[float] = Field(default=None)
rental_yield_increase: Optional[float] = Field(default=None)
total_work_hours: Optional[float] = Field(default=None)
labour_days: Optional[float] = Field(default=None)
default: bool = True
already_installed: bool = False
@classmethod
def from_domain(
cls, measure: PlanMeasure, *, property_id: int, plan_id: int
) -> "RecommendationModel":
return cls(
property_id=property_id,
plan_id=plan_id,
type=measure.measure_type,
measure_type=measure.measure_type,
description=measure.description,
material_id=measure.material_id,
estimated_cost=measure.cost.total,
sap_points=measure.impact.sap_points,
co2_equivalent_savings=(
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
),
kwh_savings=measure.kwh_savings,
energy_cost_savings=measure.energy_cost_savings,
default=True,
already_installed=False,
)
class RecommendationMaterialModel(SQLModel, table=True):
"""The live ``recommendation_materials`` table — one row per material used
by a Recommendation. ``recommendation_id`` is an intra-cluster FK;
``material_id`` is a plain int (out-of-cluster, mirror convention)."""
__tablename__: ClassVar[str] = "recommendation_materials" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
recommendation_id: int = Field(
sa_column=Column(
BigInteger, ForeignKey("recommendation.id"), nullable=False
)
)
material_id: int = Field(index=True)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
)
depth: Optional[float] = Field(default=None)
quantity: Optional[float] = Field(default=None)
quantity_unit: Optional[QuantityUnits] = Field(
default=None,
sa_column=Column(
SAEnum(
QuantityUnits,
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
),
nullable=True,
),
)
estimated_cost: Optional[float] = Field(default=None)