Model/docs/migrations/recommendation-material-id.md
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

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_idbigint, 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;