Model/domain/modelling/recommendation.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

48 lines
1.6 KiB
Python

"""Recommendation and Measure Option — the Modelling stage's proposal types.
A Recommendation is a labelled group of mutually-exclusive Measure Options for
one target surface; the Optimiser selects at most one. The target itself is
encoded entirely in each Option's Simulation Overlay (which addresses building
parts, windows, or systems), so this type stays stable as new surfaces land.
Impact is never stored here — it is cascade-conditional (ADR-0016). See
CONTEXT.md.
"""
from dataclasses import dataclass
from typing import Optional
from domain.modelling.simulation import EpcSimulation
@dataclass(frozen=True)
class Cost:
"""A Measure Option's cost: a single fully-loaded total (labour + VAT +
preliminaries + margin rolled in) plus a separately-carried per-Measure-Type
contingency rate."""
total: float
contingency_rate: float
@dataclass(frozen=True)
class MeasureOption:
"""One mutually-exclusive way to treat a Recommendation's surface."""
measure_type: str
description: str
overlay: EpcSimulation
cost: Optional[Cost] = None
# The catalogue id of the Product this Option installs (Product.id), carried
# through to the persisted Plan Measure's ``material_id``. None when priced
# from a catalogue with no ids.
material_id: Optional[int] = None
@dataclass(frozen=True)
class Recommendation:
"""A target surface and the mutually-exclusive Measure Options that treat
it. `surface` is a human label for display/grouping; the actual target is
in each Option's overlay."""
surface: str
options: tuple[MeasureOption, ...]