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

45 lines
3.9 KiB
Markdown

# 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;
```