feat: duplicate product detection — STEP conflict warnings on Excel import and CAD upload
- Excel preview detects when a product already has a different STEP file linked - Excel preview detects intra-Excel conflicts (same product, different CAD model names) - Product STEP upload warns when replacing an existing file and shows render count - All warnings are non-blocking (amber badges, toast warnings) - LEARNINGS.md: all open items resolved Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,191 +1,86 @@
|
||||
# Plan: Material Alias Completeness with Blocking Dialog
|
||||
# Plan: Duplicate Product Detection
|
||||
|
||||
## 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
|
||||
When importing products via Excel or STEP upload, there's no warning when a product already exists with a different STEP file. This can lead to accidental overwrites or confusion. The feature adds non-blocking warnings (yellow badges, toasts) at import time.
|
||||
|
||||
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.
|
||||
## Detection Scenarios
|
||||
|
||||
**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
|
||||
```
|
||||
1. **Excel preview**: Product exists in DB with a different STEP file linked → warning icon
|
||||
2. **Excel preview**: Same product in two rows with different `name_cad_modell` → conflict badge
|
||||
3. **STEP upload on product**: Replacing an existing STEP file on a product that has renders → toast warning
|
||||
4. **All warnings are non-blocking** — user can still proceed
|
||||
|
||||
## 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 |
|
||||
| `backend/app/services/excel_import.py` | Add STEP conflict detection in `preview_excel_rows()` |
|
||||
| `backend/app/api/routers/uploads.py` | Extend preview response with conflict fields |
|
||||
| `backend/app/api/routers/products.py` | Add render-count warning to CAD upload response |
|
||||
| `frontend/src/api/uploads.ts` | Update TypeScript interfaces |
|
||||
| `frontend/src/pages/Upload.tsx` | Display conflict warnings in preview table |
|
||||
| `frontend/src/api/products.ts` | Add warning fields to CAD upload response type |
|
||||
| `frontend/src/pages/ProductDetail.tsx` | Show toast warnings on STEP replacement |
|
||||
|
||||
## Tasks (in order)
|
||||
## Tasks
|
||||
|
||||
### [x] Task 1: Backend — `find_unmapped_materials()` service function
|
||||
### [ ] Task 1: Backend — STEP conflict detection in Excel preview
|
||||
|
||||
- **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
|
||||
- **File**: `backend/app/services/excel_import.py`
|
||||
- **What**: In `preview_excel_rows()`, after the product lookup:
|
||||
1. If product exists and has `cad_file_id`, load the CadFile and compare `original_name` (stem) with the Excel row's `name_cad_modell`
|
||||
2. If they differ → set `step_conflict=True` with details
|
||||
3. Track `name_cad_modell` per product key in the `seen` dict
|
||||
4. If same product appears again with different `name_cad_modell` → set `cad_name_conflict=True`
|
||||
- **Also**: Add `selectinload(Product.cad_file)` to `lookup_product()` in `backend/app/domains/products/service.py`
|
||||
- **Dependencies**: None
|
||||
- **Risk**: None
|
||||
|
||||
### [x] Task 2: Backend — `GET /orders/{id}/check-materials` endpoint
|
||||
### [ ] Task 2: Backend — Extend preview response models
|
||||
|
||||
- **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
|
||||
- **File**: `backend/app/api/routers/uploads.py`
|
||||
- **What**: Add to the preview row dict and response:
|
||||
- `step_conflict: bool`, `step_conflict_existing_name: str | None`, `step_conflict_excel_name: str | None`
|
||||
- `cad_name_conflict: bool`, `cad_name_conflict_other_name: str | None`, `cad_name_conflict_row: int | None`
|
||||
- Response-level: `step_conflict_count: int`, `cad_name_conflict_count: int`
|
||||
- **Dependencies**: Task 1
|
||||
- **Risk**: None
|
||||
|
||||
### [x] Task 3: Backend — `POST /materials/batch-aliases` endpoint
|
||||
### [ ] Task 3: Backend — Render warning on product STEP replacement
|
||||
|
||||
- **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
|
||||
- **File**: `backend/app/api/routers/products.py`
|
||||
- **What**: In `upload_product_cad()`, before replacing cad_file_id:
|
||||
1. Check if product already has a different `cad_file_id`
|
||||
2. Count existing MediaAssets (renders) for this product
|
||||
3. Add `warnings: list[str]` and `existing_render_count: int` to response
|
||||
- **Dependencies**: None
|
||||
- **Risk**: None
|
||||
|
||||
### [x] Task 4: Frontend — API functions for material checking and batch alias creation
|
||||
### [ ] Task 4: Frontend — Update API types
|
||||
|
||||
- **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<UnmappedMaterialCheck>
|
||||
export async function batchCreateAliases(mappings: Array<{ alias: string; material_id: string }>): Promise<{ created: number; skipped: number }>
|
||||
```
|
||||
- **Acceptance gate**: Types compile; functions callable from components
|
||||
- **File**: `frontend/src/api/uploads.ts`
|
||||
- **What**: Add conflict fields to `ExcelPreviewRow` and `ExcelPreviewResult` interfaces
|
||||
- **Also**: `frontend/src/api/products.ts` — add `warnings?: string[]` and `existing_render_count?: number` to `ProductCadUploadResponse`
|
||||
- **Dependencies**: Tasks 2, 3
|
||||
- **Risk**: None
|
||||
|
||||
### [x] Task 5: Frontend — `UnmappedMaterialsDialog` component
|
||||
### [ ] Task 5: Frontend — Show conflict warnings in Upload preview
|
||||
|
||||
- **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, `<select>` dropdown on right
|
||||
- Dropdown pre-populated with `suggestions` from API, plus option to browse all library materials
|
||||
3. **State**: `mappings: Record<string, string>` — raw_name → selected material_id
|
||||
4. **"Map All & Proceed" button**: disabled until all unmapped materials have a selection; on click calls `batchCreateAliases()`, then `onResolved()` (triggers dispatch)
|
||||
5. **Cancel button**: calls `onCancel()`, does not dispatch
|
||||
- **Styling**: Follow existing modal patterns, Tailwind classes, lucide-react icons
|
||||
- **Acceptance gate**: Dialog renders, dropdowns work, batch alias creation succeeds, dialog closes and render dispatches
|
||||
- **File**: `frontend/src/pages/Upload.tsx`
|
||||
- **What**:
|
||||
1. Add StatCards for `step_conflict_count` and `cad_name_conflict_count` (amber color, AlertTriangle icon)
|
||||
2. In the preview table rows: yellow warning icon with tooltip for `step_conflict` and `cad_name_conflict`
|
||||
- **Dependencies**: Task 4
|
||||
- **Risk**: Low — need to match existing modal patterns
|
||||
|
||||
### [x] Task 6: Frontend — Intercept "Dispatch Renders" in `OrderDetail.tsx`
|
||||
### [ ] Task 6: Frontend — Show warnings on STEP replacement
|
||||
|
||||
- **File**: `frontend/src/pages/OrderDetail.tsx`
|
||||
- **What**:
|
||||
1. Add state for material check data and dialog visibility
|
||||
2. Replace direct `dispatchMut.mutate()` on "Dispatch Renders" button with `handleDispatch()`:
|
||||
- Calls `checkOrderMaterials(id)`
|
||||
- If `unmapped.length > 0`: show `UnmappedMaterialsDialog`
|
||||
- If `unmapped.length === 0`: proceed with `dispatchMut.mutate()`
|
||||
3. `onResolved` callback: close dialog, call `dispatchMut.mutate()`
|
||||
4. Import and render `<UnmappedMaterialsDialog>` conditionally
|
||||
- **Acceptance gate**: Dispatch with unmapped materials shows dialog; mapping all and clicking proceed dispatches; dispatch with all mapped skips dialog
|
||||
- **Dependencies**: Task 5
|
||||
- **Risk**: Low
|
||||
|
||||
### [x] Task 7: Frontend — Warning badges on Materials page
|
||||
|
||||
- **File**: `frontend/src/pages/Materials.tsx`
|
||||
- **What**:
|
||||
1. In the "Custom" materials section, show a warning badge (`AlertTriangle` icon + "No alias") for materials that have no `schaeffler_code` AND no aliases
|
||||
2. Add a "Map to Library" action button that opens a dropdown to quickly assign a SCHAEFFLER material as alias
|
||||
3. Optional: "Show unmapped only" filter toggle
|
||||
- **Acceptance gate**: Custom materials without aliases show a warning; mapping removes it
|
||||
- **Dependencies**: None (independent)
|
||||
- **Risk**: Low
|
||||
- **File**: `frontend/src/pages/ProductDetail.tsx`
|
||||
- **What**: In `cadUploadMut.onSuccess`, check response for `warnings` and show `toast.warning()` for each
|
||||
- **Dependencies**: Task 4
|
||||
|
||||
## Migration Check
|
||||
|
||||
**No** — no new database columns needed. `MaterialAlias` model already exists. All changes use existing tables.
|
||||
**No** — all detection is computed from existing data.
|
||||
|
||||
## Order Recommendation
|
||||
## Order
|
||||
|
||||
1. Backend service function (Task 1)
|
||||
2. Backend endpoints (Tasks 2-3, parallel)
|
||||
3. Frontend API types (Task 4)
|
||||
4. Frontend dialog + integration (Tasks 5-6)
|
||||
5. Materials page badges (Task 7, independent)
|
||||
|
||||
## Risks / Open Questions
|
||||
|
||||
1. **Performance**: `find_unmapped_materials()` loads all aliases and library materials per check. For the current scale (~50 materials, ~100 aliases) this is fine. If scale grows, add caching.
|
||||
|
||||
2. **Material name normalization**: Should the check be case-insensitive? Yes — alias lookup is already case-insensitive, so the check should match.
|
||||
|
||||
3. **Products without cad_part_materials**: Some products may not have materials assigned yet (no STEP file processed). These are skipped — the check only validates materials that exist.
|
||||
1. Backend Tasks 1+2 (Excel preview conflicts)
|
||||
2. Backend Task 3 (STEP replacement warning)
|
||||
3. Frontend Tasks 4+5+6 (types + UI)
|
||||
|
||||
Reference in New Issue
Block a user