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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 08:26:58 +00:00
parent c5520b82f9
commit 31da90f5eb
12 changed files with 76 additions and 0 deletions

View file

@ -0,0 +1,45 @@
# Retire `recommendation_materials` — reference the Product by `recommendation.material_id`
**Context:** Modelling-stage rebuild. A Plan Measure installs a single **Product**, so the per-material `recommendation_materials` child table (depth / quantity / quantity_unit / estimated_cost per row) is replaced by a single **`recommendation.material_id`** on the row and then **dropped**. Same motivation and shape as the [`plan_recommendations` retirement](./recommendation-plan-id.md): the child table's cascade-delete + indexes are a known performance killer on large deletes. The `plan`/`recommendation`/`recommendation_materials` tables are read directly by the Drizzle FE and written by both the legacy `engine.py` path and the rebuild, so this is an **expand/contract migration on a live, two-repo schema**. The **DB migrations are FE-owned (Drizzle)**; this doc pins the ordering so the repos stay in step. See [ADR-0017](../adr/0017-plan-persistence-evolve-live-tables.md).
## Cardinality
`recommendation_materials` is **one-to-many in practice** (one recommendation → its material lines), but for the four modelled fabric measures each Option installs exactly **one** Product, so a single `recommendation.material_id` models reality faithfully. A future *bundle* Option that genuinely needs multiple Products (e.g. boiler + cylinder insulation) is out of scope and revisited when those measures land — it is a new decision, not a regression of this one.
## Status
**Expand half landed in the backend** (this branch): the `ModellingOrchestrator` now threads the catalogue id `Product.id → MeasureOption.material_id → PlanMeasure.material_id → recommendation.material_id`, and `RecommendationModel` declares the column. The repo SQLModel is a **read-only mirror** — it does not migrate the live DB.
**The contraction is the owner's, starting next (with its own ADR):** cut the legacy writers (`recommendations_functions.upload_recommendations` / `bulk_upload_recommendations_and_materials`) off `recommendation_materials`, backfill `material_id`, drop the child table, and decide the disposition of `depth` / `quantity` / `quantity_unit` (kept-for-reference vs dropped — see the grilling notes; `quantity` has reference value).
## Sequence (expand → backfill → migrate reads → contract)
The hard rule: **add the `material_id` column live before the backend that writes it deploys** (else the rebuild's `recommendation` INSERT fails on an unknown column).
| # | Step | Owner | Safe because |
|---|---|---|---|
| 1 | **Add `recommendation.material_id`**`bigint`, indexed, **nullable**, no FK constraint (mirror convention; the live FK to `material` is the FE's call) | FE (Drizzle) | additive; legacy rows keep `NULL` |
| 2 | **Deploy the rebuild backend** (writes `material_id` from the catalogue) | backend | column exists from (1); nullable so unbilled / JSON-catalogue measures write `NULL` |
| 3 | **Backfill** `material_id` from `recommendation_materials` (single-material rows) | FE (Drizzle data migration) | every existing measure gets its Product before any read cuts over |
| 4 | **Cut FE reads** off `recommendation_materials` onto `material_id` | FE | backfill (3) means no NULLs for single-material measures |
| 5 | **Stop writing `recommendation_materials`** (legacy writers) | backend | no reader uses it after (4) |
| 6 | **Drop `recommendation_materials`** + remove the `RecommendationMaterialModel` mirror | FE (Drizzle) + backend | unreferenced after (5) |
### Backfill SQL sketch (step 3)
```sql
UPDATE recommendation r
SET material_id = rm.material_id
FROM recommendation_materials rm
WHERE rm.recommendation_id = r.id
AND r.material_id IS NULL;
```
Guard before dropping the child table: assert no recommendation maps to more than one material (the modelled fabric measures never produce this; worth checking on real data before the drop):
```sql
SELECT recommendation_id, count(*)
FROM recommendation_materials
GROUP BY recommendation_id
HAVING count(*) > 1;
```

View file

@ -87,5 +87,6 @@ def recommend_floor_insulation(
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Ground floor", options=(option,))

View file

@ -59,5 +59,6 @@ def recommend_loft_insulation(
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Roof", options=(option,))

View file

@ -53,6 +53,7 @@ def recommend_ventilation(
ventilation=VentilationOverlay(mechanical_ventilation_kind=_MEV_KIND)
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Ventilation", options=(option,))

View file

@ -63,5 +63,6 @@ def recommend_cavity_wall(
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Main wall", options=(option,))

View file

@ -38,6 +38,10 @@ class PlanMeasure:
impact: MeasureImpact
kwh_savings: Optional[float] = None
energy_cost_savings: Optional[float] = None
# The catalogue id of the Product installed (from the selected Option),
# persisted as ``recommendation.material_id``. None when priced from a
# catalogue with no ids.
material_id: Optional[int] = None
@dataclass(frozen=True)

View file

@ -7,6 +7,7 @@ not "material". Read via a `ProductRepository`.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
@ -14,3 +15,8 @@ class Product:
measure_type: str
unit_cost_per_m2: float
contingency_rate: float
# The catalogue row id, threaded onto the persisted Plan Measure as
# ``recommendation.material_id`` (the single-material reference that replaces
# the retired ``recommendation_materials`` BOM). Optional: the JSON
# stopgap catalogue carries no ids.
id: Optional[int] = None

View file

@ -32,6 +32,10 @@ class MeasureOption:
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)

View file

@ -49,6 +49,10 @@ class RecommendationModel(SQLModel, table=True):
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)
@ -75,6 +79,7 @@ class RecommendationModel(SQLModel, table=True):
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=(

View file

@ -259,4 +259,5 @@ def _plan_measure(
impact=impact,
kwh_savings=before.total_consumption_kwh - after.total_consumption_kwh,
energy_cost_savings=before.total_gbp - after.total_gbp,
material_id=option.material_id,
)

View file

@ -31,4 +31,5 @@ class ProductPostgresRepository(ProductRepository):
measure_type=measure_type,
unit_cost_per_m2=row.total_cost,
contingency_rate=contingency_rate(measure_type),
id=row.id,
)

View file

@ -302,6 +302,12 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
"suspended_floor_insulation",
"mechanical_ventilation",
}
# Each persisted measure carries the catalogue id of the Product it installs
# (the MaterialRow ids seeded above), replacing the retired
# recommendation_materials BOM with a single material_id on the row.
assert by_type["cavity_wall_insulation"].material_id == 1
assert by_type["suspended_floor_insulation"].material_id == 2
assert by_type["mechanical_ventilation"].material_id == 3
for rec in rec_rows:
assert rec.default is True
assert rec.already_installed is False