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:
2026-03-14 13:05:40 +01:00
parent f0dd952f63
commit b6bac080bb
10 changed files with 207 additions and 173 deletions
+1 -1
View File
@@ -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).
+25
View File
@@ -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,
}
+18
View File
@@ -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", []),
+7 -2
View File
@@ -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()
+52 -5
View File
@@ -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
+2
View File
@@ -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<ProductCadUploadResponse> {
+8
View File
@@ -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[]
+9 -1
View File
@@ -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'),
+32 -6
View File
@@ -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"
/>
<StatCard
icon={<AlertTriangle size={18} className="text-amber-600" />}
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"
/>
<StatCard
icon={<AlertTriangle size={18} className="text-amber-600" />}
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"
/>
</div>
</div>
@@ -459,11 +473,23 @@ export default function UploadPage() {
)}
</td>
<td className="px-4 py-2 text-center">
{!hasId ? null : row.has_step ? (
<CheckCircle size={14} className="text-green-500 mx-auto" aria-label="STEP file linked" />
) : (
<X size={14} className="text-red-400 mx-auto" aria-label="No STEP file" />
)}
<div className="flex items-center justify-center gap-1">
{!hasId ? null : row.has_step ? (
<CheckCircle size={14} className="text-green-500" aria-label="STEP file linked" />
) : (
<X size={14} className="text-red-400" aria-label="No STEP file" />
)}
{row.step_conflict && (
<span title={`STEP conflict: DB has "${row.step_conflict_existing_name}", Excel references "${row.step_conflict_excel_name}"`}>
<AlertTriangle size={14} className="text-amber-500" />
</span>
)}
{row.cad_name_conflict && (
<span title={`CAD name conflict with row ${row.cad_name_conflict_row}: "${row.cad_name_conflict_other_name}"`}>
<AlertTriangle size={14} className="text-amber-500" />
</span>
)}
</div>
</td>
</tr>
)
+53 -158
View File
@@ -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)