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
-1
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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", []),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{!hasId ? null : row.has_step ? (
|
||||
<CheckCircle size={14} className="text-green-500 mx-auto" aria-label="STEP file linked" />
|
||||
<CheckCircle size={14} className="text-green-500" aria-label="STEP file linked" />
|
||||
) : (
|
||||
<X size={14} className="text-red-400 mx-auto" aria-label="No STEP file" />
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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