mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
125 lines
5.1 KiB
Python
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)
|