# Plan: Material Alias Completeness with Blocking Dialog ## Context The material system currently has three resolution tiers in `resolve_material_map()`: 1. **Alias lookup** (case-insensitive) — maps raw names to SCHAEFFLER library materials 2. **Exact Material.name match** — the raw name IS a library material 3. **Pass-through** — unresolved names fall through, causing magenta "FailedMaterial" in Blender renders Materials are stored on `Product.cad_part_materials` as `[{part_name, material}]` JSONB. They are auto-populated from Excel components during STEP processing (`_auto_populate_materials_for_cad`). The Materials page shows "Custom" materials (no `schaeffler_code`) separately from categorized SCHAEFFLER library materials. **Problem**: When a product has a material name like "Steel--Stahl" that has no alias pointing to a SCHAEFFLER library material, the render silently produces magenta parts. There is no pre-flight check before dispatching renders. **Solution**: Add a blocking dialog at "Dispatch Renders" that checks for unmapped materials, lets the user map them inline to library materials, and only proceeds when all materials are resolved. ### Architecture ``` Frontend (OrderDetail.tsx) | |-- Click "Dispatch Renders" |-- Call GET /api/orders/{id}/check-materials (new endpoint) |-- If unmapped materials exist: | |-- Show UnmappedMaterialsDialog (new component) | |-- User maps each material via dropdown | |-- Call POST /api/materials/batch-aliases (new endpoint) | |-- On success, proceed with dispatchRenders() |-- If all mapped: proceed directly ``` ## Affected Files | File | Change | |------|--------| | `backend/app/services/material_service.py` | Add `find_unmapped_materials()` function | | `backend/app/api/routers/orders.py` | Add `GET /orders/{id}/check-materials` endpoint | | `backend/app/api/routers/materials.py` | Add `POST /materials/batch-aliases` endpoint | | `frontend/src/api/materials.ts` | Add `checkOrderMaterials()`, `batchCreateAliases()` API functions + types | | `frontend/src/components/orders/UnmappedMaterialsDialog.tsx` | New blocking dialog component | | `frontend/src/pages/OrderDetail.tsx` | Intercept "Dispatch Renders" with material check | | `frontend/src/pages/Materials.tsx` | Add warning badges for unmapped custom materials | ## Tasks (in order) ### [x] Task 1: Backend — `find_unmapped_materials()` service function - **File**: `backend/app/services/material_service.py` - **What**: Add an async function `find_unmapped_materials(material_names: list[str], db: AsyncSession) -> list[dict]` that: 1. Takes a list of raw material name strings 2. Loads all `MaterialAlias` records and all `Material` records with `schaeffler_code` 3. For each raw name, checks: (a) alias match (case-insensitive), (b) exact `Material.name` match where it has a `schaeffler_code` (i.e. it IS a library material) 4. Returns a list of `{"raw_name": str, "suggestions": [{"id": str, "name": str, "schaeffler_code": str}]}` for each unmapped name 5. `suggestions`: top 5 SCHAEFFLER materials by `difflib.SequenceMatcher` similarity (ratio > 0.3) - **Acceptance gate**: Returns empty list when all materials are mapped; returns unmapped entries with suggestions when some are not - **Dependencies**: None - **Risk**: None ### [x] Task 2: Backend — `GET /orders/{id}/check-materials` endpoint - **File**: `backend/app/api/routers/orders.py` - **What**: Add endpoint that: 1. Loads all `OrderLine` records for the order, joining `Product` 2. Collects all unique material names from `product.cad_part_materials[*].material` across all products 3. Calls `find_unmapped_materials()` with those names 4. Returns `{"unmapped": [...], "total_materials": int, "mapped_count": int}` - **Response schema**: ```python class MaterialSuggestion(BaseModel): id: uuid.UUID name: str schaeffler_code: str class UnmappedMaterial(BaseModel): raw_name: str suggestions: list[MaterialSuggestion] class UnmappedMaterialCheck(BaseModel): unmapped: list[UnmappedMaterial] total_materials: int mapped_count: int ``` - **Auth**: `get_current_user` (any authenticated user can check) - **Acceptance gate**: Returns correct unmapped materials for an order with mixed mapped/unmapped materials - **Dependencies**: Task 1 - **Risk**: None ### [x] Task 3: Backend — `POST /materials/batch-aliases` endpoint - **File**: `backend/app/api/routers/materials.py` - **What**: Add batch alias creation endpoint: 1. Accepts `{"mappings": [{"alias": str, "material_id": uuid}]}` 2. For each mapping, creates a `MaterialAlias` record (skips if alias already exists, case-insensitive) 3. Returns `{"created": int, "skipped": int}` - **Auth**: `require_admin_or_pm` — only admins/PMs should be able to create aliases - **Validation**: Verify each `material_id` exists; reject if alias is empty; skip duplicate aliases - **Acceptance gate**: Batch creates multiple aliases in one request; subsequent `resolve_material_map()` calls resolve through new aliases - **Dependencies**: None - **Risk**: None ### [x] Task 4: Frontend — API functions for material checking and batch alias creation - **File**: `frontend/src/api/materials.ts` - **What**: Add TypeScript interfaces and API functions: ```typescript export interface MaterialSuggestion { id: string name: string schaeffler_code: string } export interface UnmappedMaterial { raw_name: string suggestions: MaterialSuggestion[] } export interface UnmappedMaterialCheck { unmapped: UnmappedMaterial[] total_materials: number mapped_count: number } export async function checkOrderMaterials(orderId: string): Promise export async function batchCreateAliases(mappings: Array<{ alias: string; material_id: string }>): Promise<{ created: number; skipped: number }> ``` - **Acceptance gate**: Types compile; functions callable from components - **Dependencies**: Tasks 2, 3 - **Risk**: None ### [x] Task 5: Frontend — `UnmappedMaterialsDialog` component - **File**: `frontend/src/components/orders/UnmappedMaterialsDialog.tsx` - **What**: Create a modal dialog component: 1. **Props**: `open: boolean`, `unmapped: UnmappedMaterial[]`, `onResolved: () => void`, `onCancel: () => void` 2. **UI**: Fixed overlay (same pattern as existing modals), wider (`max-w-xl`): - Warning icon (`AlertTriangle` from lucide-react) + title "Unmapped Materials" - Subtitle: "The following materials have no alias to a library material. Map them before rendering." - For each unmapped material: row with raw name on left, `