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>
3.9 KiB
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: 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.
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)
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):
SELECT recommendation_id, count(*)
FROM recommendation_materials
GROUP BY recommendation_id
HAVING count(*) > 1;