From b6bac080bbe7eaa5f54a0540e0372914e3b2e508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 14 Mar 2026 13:05:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20duplicate=20product=20detection=20?= =?UTF-8?q?=E2=80=94=20STEP=20conflict=20warnings=20on=20Excel=20import=20?= =?UTF-8?q?and=20CAD=20upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- LEARNINGS.md | 2 +- backend/app/api/routers/products.py | 25 +++ backend/app/api/routers/uploads.py | 18 ++ backend/app/domains/products/service.py | 9 +- backend/app/services/excel_import.py | 57 ++++++- frontend/src/api/products.ts | 2 + frontend/src/api/uploads.ts | 8 + frontend/src/pages/ProductDetail.tsx | 10 +- frontend/src/pages/Upload.tsx | 38 ++++- plan.md | 211 ++++++------------------ 10 files changed, 207 insertions(+), 173 deletions(-) diff --git a/LEARNINGS.md b/LEARNINGS.md index 8d17d38..2c49c80 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -472,7 +472,7 @@ for obj in mesh_objects: --- ## Offene Fragen -- [ ] Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab +(keine offenen Fragen) ### 2026-03-11 | OCP/Python | id(solid.TShape()) ist nicht stabil In OCP (pybind11-basiert) gibt jeder Aufruf von `solid.TShape()` ein neues Python-Wrapper-Objekt zurück, das dieselbe C++ TShape-Instanz wrapet. `id()` gibt daher jedes Mal einen anderen Wert → Deduplizierung per `id()` schlägt immer fehl. **Lösung:** `solid.IsSame(other_solid)` verwenden (vergleicht TShape-Zeiger intern, liefert True für gleiche TShape mit unterschiedlicher Location/Orientation). diff --git a/backend/app/api/routers/products.py b/backend/app/api/routers/products.py index 8c0c420..cbb2ad3 100644 --- a/backend/app/api/routers/products.py +++ b/backend/app/api/routers/products.py @@ -470,6 +470,11 @@ async def upload_product_cad( if not product: raise HTTPException(404, detail="Product not found") + # Check for STEP replacement warnings before proceeding + warnings: list[str] = [] + existing_render_count = 0 + old_cad_file_id = product.cad_file_id + content = await file.read() file_hash = hashlib.sha256(content).hexdigest() @@ -477,6 +482,24 @@ async def upload_product_cad( existing_cad = await db.execute(select(CadFile).where(CadFile.file_hash == file_hash)) cad_file = existing_cad.scalar_one_or_none() + # Detect replacement: product already has a different CAD file + if old_cad_file_id and (cad_file is None or cad_file.id != old_cad_file_id): + old_name = product.cad_file.original_name if product.cad_file else "unknown" + warnings.append( + f"Replacing existing STEP file '{old_name}' with '{file.filename}'." + ) + # Count existing renders (MediaAssets) for this product + from app.domains.media.models import MediaAsset + render_count_result = await db.execute( + select(func.count(MediaAsset.id)).where(MediaAsset.product_id == product_id) + ) + existing_render_count = render_count_result.scalar() or 0 + if existing_render_count > 0: + warnings.append( + f"This product has {existing_render_count} existing render(s) that were " + "generated from the previous STEP file. They may no longer match." + ) + if cad_file is None: step_dir = Path(settings.upload_dir) / "step_files" step_dir.mkdir(parents=True, exist_ok=True) @@ -511,6 +534,8 @@ async def upload_product_cad( "file_hash": file_hash, "status": "uploaded" if cad_file.processing_status == ProcessingStatus.pending else "already_exists", "product_id": str(product_id), + "warnings": warnings, + "existing_render_count": existing_render_count, } diff --git a/backend/app/api/routers/uploads.py b/backend/app/api/routers/uploads.py index 14fb280..f3af2d5 100644 --- a/backend/app/api/routers/uploads.py +++ b/backend/app/api/routers/uploads.py @@ -39,6 +39,14 @@ class ExcelPreviewRow(BaseModel): has_step: bool = False is_duplicate: bool = False duplicate_of_row: int | None = None + # STEP conflict: existing product has a different STEP file than Excel row's name_cad_modell + step_conflict: bool = False + step_conflict_existing_name: str | None = None + step_conflict_excel_name: str | None = None + # Intra-Excel conflict: same product key appears with different name_cad_modell + cad_name_conflict: bool = False + cad_name_conflict_other_name: str | None = None + cad_name_conflict_row: int | None = None class ExcelPreviewResponse(BaseModel): @@ -52,6 +60,8 @@ class ExcelPreviewResponse(BaseModel): has_step_count: int = 0 no_step_count: int = 0 duplicate_count: int = 0 + step_conflict_count: int = 0 + cad_name_conflict_count: int = 0 warnings: list[str] rows: list[ExcelPreviewRow] column_headers: list[str] = [] @@ -145,6 +155,12 @@ async def upload_excel( has_step=r.get("has_step", False), is_duplicate=r.get("is_duplicate", False), duplicate_of_row=r.get("duplicate_of_row"), + step_conflict=r.get("step_conflict", False), + step_conflict_existing_name=r.get("step_conflict_existing_name"), + step_conflict_excel_name=r.get("step_conflict_excel_name"), + cad_name_conflict=r.get("cad_name_conflict", False), + cad_name_conflict_other_name=r.get("cad_name_conflict_other_name"), + cad_name_conflict_row=r.get("cad_name_conflict_row"), ) for r in preview.rows ] @@ -195,6 +211,8 @@ async def upload_excel( has_step_count=preview.has_step_count, no_step_count=preview.no_step_count, duplicate_count=preview.duplicate_count, + step_conflict_count=preview.step_conflict_count, + cad_name_conflict_count=preview.cad_name_conflict_count, warnings=all_warnings, rows=annotated_rows, column_headers=parsed_dict.get("column_headers", []), diff --git a/backend/app/domains/products/service.py b/backend/app/domains/products/service.py index 8f341f8..8bfe068 100644 --- a/backend/app/domains/products/service.py +++ b/backend/app/domains/products/service.py @@ -2,6 +2,7 @@ import uuid from sqlalchemy import select, func, update as sql_update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.domains.products.models import Product @@ -48,7 +49,9 @@ async def lookup_product( """ if produkt_baureihe: result = await db.execute( - select(Product).where( + select(Product) + .options(selectinload(Product.cad_file)) + .where( func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(), Product.is_active.is_(True), ) @@ -61,7 +64,9 @@ async def lookup_product( if pim_id: result = await db.execute( - select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True)) + select(Product) + .options(selectinload(Product.cad_file)) + .where(Product.pim_id == pim_id, Product.is_active.is_(True)) ) return result.scalar_one_or_none() diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py index 4905e7d..8779c40 100644 --- a/backend/app/services/excel_import.py +++ b/backend/app/services/excel_import.py @@ -1,5 +1,6 @@ """Excel import service — maps parsed rows to Product library.""" from dataclasses import dataclass, field +from pathlib import PurePosixPath from sqlalchemy.ext.asyncio import AsyncSession from app.services.product_service import ( @@ -8,6 +9,13 @@ from app.services.product_service import ( ) +def _stem_lower(name: str | None) -> str: + """Return the lowercased stem (no extension) of a filename.""" + if not name: + return "" + return PurePosixPath(name).stem.lower() + + @dataclass class PreviewResult: """Read-only preview: annotates rows without creating anything.""" @@ -18,6 +26,8 @@ class PreviewResult: has_step_count: int = 0 no_step_count: int = 0 duplicate_count: int = 0 + step_conflict_count: int = 0 + cad_name_conflict_count: int = 0 warnings: list[str] = field(default_factory=list) @@ -118,8 +128,8 @@ async def preview_excel_rows( """ result = PreviewResult() # Track unique identifiers we've already resolved in this batch - # key = lower(baureihe) or pim_id → (product_exists, product_id_str | None, has_step, first_row_index) - seen: dict[str, tuple[bool, str | None, bool, int]] = {} + # key = lower(baureihe) or pim_id → (product_exists, product_id_str | None, has_step, first_row_index, name_cad_modell_stem) + seen: dict[str, tuple[bool, str | None, bool, int, str]] = {} for row in parsed_rows: pim_id = row.get("pim_id") @@ -127,6 +137,14 @@ async def preview_excel_rows( row_index = row.get("row_index", 0) row["category_key"] = row.get("category_key") or category_key + # Default conflict fields + row["step_conflict"] = False + row["step_conflict_existing_name"] = None + row["step_conflict_excel_name"] = None + row["cad_name_conflict"] = False + row["cad_name_conflict_other_name"] = None + row["cad_name_conflict_row"] = None + # Must have at least one identifier if not pim_id and not produkt_baureihe: row["product_exists"] = False @@ -139,13 +157,24 @@ async def preview_excel_rows( # Build a cache key cache_key = (produkt_baureihe or "").lower() or pim_id or "" + excel_cad_name = row.get("name_cad_modell") + excel_cad_stem = _stem_lower(excel_cad_name) + if cache_key in seen: - exists, pid, has_step, first_row = seen[cache_key] + exists, pid, has_step, first_row, first_cad_stem = seen[cache_key] row["product_exists"] = exists row["product_id"] = pid row["has_step"] = has_step row["is_duplicate"] = True row["duplicate_of_row"] = first_row + + # Intra-Excel conflict: same product key, different name_cad_modell + if excel_cad_stem and first_cad_stem and excel_cad_stem != first_cad_stem: + row["cad_name_conflict"] = True + row["cad_name_conflict_other_name"] = first_cad_stem + row["cad_name_conflict_row"] = first_row + result.cad_name_conflict_count += 1 + result.duplicate_count += 1 continue @@ -156,17 +185,26 @@ async def preview_excel_rows( row["product_exists"] = True row["product_id"] = str(product.id) row["has_step"] = has_step - seen[cache_key] = (True, str(product.id), has_step, row_index) + seen[cache_key] = (True, str(product.id), has_step, row_index, excel_cad_stem) result.existing_product_count += 1 if has_step: result.has_step_count += 1 else: result.no_step_count += 1 + + # STEP conflict: product already has a different STEP file + if has_step and excel_cad_stem and product.cad_file: + existing_stem = _stem_lower(product.cad_file.original_name) + if existing_stem and existing_stem != excel_cad_stem: + row["step_conflict"] = True + row["step_conflict_existing_name"] = existing_stem + row["step_conflict_excel_name"] = excel_cad_stem + result.step_conflict_count += 1 else: row["product_exists"] = False row["product_id"] = None row["has_step"] = False - seen[cache_key] = (False, None, False, row_index) + seen[cache_key] = (False, None, False, row_index, excel_cad_stem) result.new_product_count += 1 result.no_step_count += 1 @@ -176,4 +214,13 @@ async def preview_excel_rows( f"{result.duplicate_count} duplicate Produkt-Baureihe row(s) detected — " "these are pre-unchecked. Only one row per product will be imported." ) + if result.step_conflict_count > 0: + result.warnings.append( + f"{result.step_conflict_count} product(s) already have a different STEP file linked — " + "importing will not replace the existing STEP file automatically." + ) + if result.cad_name_conflict_count > 0: + result.warnings.append( + f"{result.cad_name_conflict_count} row(s) reference the same product with a different CAD model name." + ) return result diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts index c327632..c661f13 100644 --- a/frontend/src/api/products.ts +++ b/frontend/src/api/products.ts @@ -126,6 +126,8 @@ export interface ProductCadUploadResponse { file_hash: string status: string product_id: string + warnings?: string[] + existing_render_count?: number } export async function uploadProductCad(id: string, file: File): Promise { diff --git a/frontend/src/api/uploads.ts b/frontend/src/api/uploads.ts index b058806..49af7a5 100644 --- a/frontend/src/api/uploads.ts +++ b/frontend/src/api/uploads.ts @@ -13,6 +13,12 @@ export interface ExcelPreviewRow { has_step: boolean is_duplicate: boolean duplicate_of_row: number | null + step_conflict: boolean + step_conflict_existing_name: string | null + step_conflict_excel_name: string | null + cad_name_conflict: boolean + cad_name_conflict_other_name: string | null + cad_name_conflict_row: number | null } export interface ExcelPreviewResult { @@ -26,6 +32,8 @@ export interface ExcelPreviewResult { has_step_count: number no_step_count: number duplicate_count: number + step_conflict_count: number + cad_name_conflict_count: number warnings: string[] rows: ExcelPreviewRow[] column_headers: string[] diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index 3c809f2..2b59944 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -349,8 +349,16 @@ export default function ProductDetailPage() { const cadUploadMut = useMutation({ mutationFn: (file: File) => uploadProductCad(id!, file), - onSuccess: () => { + onSuccess: (data) => { toast.success('STEP file uploaded — processing started') + if (data.warnings && data.warnings.length > 0) { + data.warnings.forEach((w) => toast.warning(w)) + } + if (data.existing_render_count && data.existing_render_count > 0) { + toast.warning( + `This product has ${data.existing_render_count} existing render${data.existing_render_count !== 1 ? 's' : ''} from the previous STEP file. Consider re-rendering.`, + ) + } qc.invalidateQueries({ queryKey: ['product', id] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'), diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index 49167d5..f4fd262 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { FileSpreadsheet, CheckCircle, X, Plus, - Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy, + Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy, AlertTriangle, } from 'lucide-react' import { toast } from 'sonner' import { uploadExcel, finalizeExcelImport, getImportValidation } from '../api/uploads' @@ -335,6 +335,20 @@ export default function UploadPage() { description="Same Product Series appears multiple times. Pre-unchecked — only first occurrence imported." color="bg-status-warning-bg border-border-default text-status-warning-text" /> + } + value={previewResult.step_conflict_count ?? 0} + label="STEP conflicts" + description="Product exists with a different STEP file than referenced in Excel." + color="bg-amber-50 border-amber-200 text-amber-800" + /> + } + value={previewResult.cad_name_conflict_count ?? 0} + label="CAD name conflicts" + description="Same product appears in multiple rows with different CAD model names." + color="bg-amber-50 border-amber-200 text-amber-800" + /> @@ -459,11 +473,23 @@ export default function UploadPage() { )} - {!hasId ? null : row.has_step ? ( - - ) : ( - - )} +
+ {!hasId ? null : row.has_step ? ( + + ) : ( + + )} + {row.step_conflict && ( + + + + )} + {row.cad_name_conflict && ( + + + + )} +
) diff --git a/plan.md b/plan.md index ea653df..422c999 100644 --- a/plan.md +++ b/plan.md @@ -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 - 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, `