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
|
## 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
|
### 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).
|
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:
|
if not product:
|
||||||
raise HTTPException(404, detail="Product not found")
|
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()
|
content = await file.read()
|
||||||
file_hash = hashlib.sha256(content).hexdigest()
|
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))
|
existing_cad = await db.execute(select(CadFile).where(CadFile.file_hash == file_hash))
|
||||||
cad_file = existing_cad.scalar_one_or_none()
|
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:
|
if cad_file is None:
|
||||||
step_dir = Path(settings.upload_dir) / "step_files"
|
step_dir = Path(settings.upload_dir) / "step_files"
|
||||||
step_dir.mkdir(parents=True, exist_ok=True)
|
step_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -511,6 +534,8 @@ async def upload_product_cad(
|
|||||||
"file_hash": file_hash,
|
"file_hash": file_hash,
|
||||||
"status": "uploaded" if cad_file.processing_status == ProcessingStatus.pending else "already_exists",
|
"status": "uploaded" if cad_file.processing_status == ProcessingStatus.pending else "already_exists",
|
||||||
"product_id": str(product_id),
|
"product_id": str(product_id),
|
||||||
|
"warnings": warnings,
|
||||||
|
"existing_render_count": existing_render_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ class ExcelPreviewRow(BaseModel):
|
|||||||
has_step: bool = False
|
has_step: bool = False
|
||||||
is_duplicate: bool = False
|
is_duplicate: bool = False
|
||||||
duplicate_of_row: int | None = None
|
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):
|
class ExcelPreviewResponse(BaseModel):
|
||||||
@@ -52,6 +60,8 @@ class ExcelPreviewResponse(BaseModel):
|
|||||||
has_step_count: int = 0
|
has_step_count: int = 0
|
||||||
no_step_count: int = 0
|
no_step_count: int = 0
|
||||||
duplicate_count: int = 0
|
duplicate_count: int = 0
|
||||||
|
step_conflict_count: int = 0
|
||||||
|
cad_name_conflict_count: int = 0
|
||||||
warnings: list[str]
|
warnings: list[str]
|
||||||
rows: list[ExcelPreviewRow]
|
rows: list[ExcelPreviewRow]
|
||||||
column_headers: list[str] = []
|
column_headers: list[str] = []
|
||||||
@@ -145,6 +155,12 @@ async def upload_excel(
|
|||||||
has_step=r.get("has_step", False),
|
has_step=r.get("has_step", False),
|
||||||
is_duplicate=r.get("is_duplicate", False),
|
is_duplicate=r.get("is_duplicate", False),
|
||||||
duplicate_of_row=r.get("duplicate_of_row"),
|
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
|
for r in preview.rows
|
||||||
]
|
]
|
||||||
@@ -195,6 +211,8 @@ async def upload_excel(
|
|||||||
has_step_count=preview.has_step_count,
|
has_step_count=preview.has_step_count,
|
||||||
no_step_count=preview.no_step_count,
|
no_step_count=preview.no_step_count,
|
||||||
duplicate_count=preview.duplicate_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,
|
warnings=all_warnings,
|
||||||
rows=annotated_rows,
|
rows=annotated_rows,
|
||||||
column_headers=parsed_dict.get("column_headers", []),
|
column_headers=parsed_dict.get("column_headers", []),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import select, func, update as sql_update
|
from sqlalchemy import select, func, update as sql_update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.domains.products.models import Product
|
from app.domains.products.models import Product
|
||||||
|
|
||||||
@@ -48,7 +49,9 @@ async def lookup_product(
|
|||||||
"""
|
"""
|
||||||
if produkt_baureihe:
|
if produkt_baureihe:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Product).where(
|
select(Product)
|
||||||
|
.options(selectinload(Product.cad_file))
|
||||||
|
.where(
|
||||||
func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(),
|
func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(),
|
||||||
Product.is_active.is_(True),
|
Product.is_active.is_(True),
|
||||||
)
|
)
|
||||||
@@ -61,7 +64,9 @@ async def lookup_product(
|
|||||||
|
|
||||||
if pim_id:
|
if pim_id:
|
||||||
result = await db.execute(
|
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Excel import service — maps parsed rows to Product library."""
|
"""Excel import service — maps parsed rows to Product library."""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import PurePosixPath
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.services.product_service import (
|
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
|
@dataclass
|
||||||
class PreviewResult:
|
class PreviewResult:
|
||||||
"""Read-only preview: annotates rows without creating anything."""
|
"""Read-only preview: annotates rows without creating anything."""
|
||||||
@@ -18,6 +26,8 @@ class PreviewResult:
|
|||||||
has_step_count: int = 0
|
has_step_count: int = 0
|
||||||
no_step_count: int = 0
|
no_step_count: int = 0
|
||||||
duplicate_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)
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,8 +128,8 @@ async def preview_excel_rows(
|
|||||||
"""
|
"""
|
||||||
result = PreviewResult()
|
result = PreviewResult()
|
||||||
# Track unique identifiers we've already resolved in this batch
|
# 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)
|
# 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]] = {}
|
seen: dict[str, tuple[bool, str | None, bool, int, str]] = {}
|
||||||
|
|
||||||
for row in parsed_rows:
|
for row in parsed_rows:
|
||||||
pim_id = row.get("pim_id")
|
pim_id = row.get("pim_id")
|
||||||
@@ -127,6 +137,14 @@ async def preview_excel_rows(
|
|||||||
row_index = row.get("row_index", 0)
|
row_index = row.get("row_index", 0)
|
||||||
row["category_key"] = row.get("category_key") or category_key
|
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
|
# Must have at least one identifier
|
||||||
if not pim_id and not produkt_baureihe:
|
if not pim_id and not produkt_baureihe:
|
||||||
row["product_exists"] = False
|
row["product_exists"] = False
|
||||||
@@ -139,13 +157,24 @@ async def preview_excel_rows(
|
|||||||
# Build a cache key
|
# Build a cache key
|
||||||
cache_key = (produkt_baureihe or "").lower() or pim_id or ""
|
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:
|
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_exists"] = exists
|
||||||
row["product_id"] = pid
|
row["product_id"] = pid
|
||||||
row["has_step"] = has_step
|
row["has_step"] = has_step
|
||||||
row["is_duplicate"] = True
|
row["is_duplicate"] = True
|
||||||
row["duplicate_of_row"] = first_row
|
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
|
result.duplicate_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -156,17 +185,26 @@ async def preview_excel_rows(
|
|||||||
row["product_exists"] = True
|
row["product_exists"] = True
|
||||||
row["product_id"] = str(product.id)
|
row["product_id"] = str(product.id)
|
||||||
row["has_step"] = has_step
|
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
|
result.existing_product_count += 1
|
||||||
if has_step:
|
if has_step:
|
||||||
result.has_step_count += 1
|
result.has_step_count += 1
|
||||||
else:
|
else:
|
||||||
result.no_step_count += 1
|
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:
|
else:
|
||||||
row["product_exists"] = False
|
row["product_exists"] = False
|
||||||
row["product_id"] = None
|
row["product_id"] = None
|
||||||
row["has_step"] = False
|
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.new_product_count += 1
|
||||||
result.no_step_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 — "
|
f"{result.duplicate_count} duplicate Produkt-Baureihe row(s) detected — "
|
||||||
"these are pre-unchecked. Only one row per product will be imported."
|
"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
|
return result
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export interface ProductCadUploadResponse {
|
|||||||
file_hash: string
|
file_hash: string
|
||||||
status: string
|
status: string
|
||||||
product_id: string
|
product_id: string
|
||||||
|
warnings?: string[]
|
||||||
|
existing_render_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadProductCad(id: string, file: File): Promise<ProductCadUploadResponse> {
|
export async function uploadProductCad(id: string, file: File): Promise<ProductCadUploadResponse> {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface ExcelPreviewRow {
|
|||||||
has_step: boolean
|
has_step: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
duplicate_of_row: number | null
|
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 {
|
export interface ExcelPreviewResult {
|
||||||
@@ -26,6 +32,8 @@ export interface ExcelPreviewResult {
|
|||||||
has_step_count: number
|
has_step_count: number
|
||||||
no_step_count: number
|
no_step_count: number
|
||||||
duplicate_count: number
|
duplicate_count: number
|
||||||
|
step_conflict_count: number
|
||||||
|
cad_name_conflict_count: number
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
rows: ExcelPreviewRow[]
|
rows: ExcelPreviewRow[]
|
||||||
column_headers: string[]
|
column_headers: string[]
|
||||||
|
|||||||
@@ -349,8 +349,16 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
const cadUploadMut = useMutation({
|
const cadUploadMut = useMutation({
|
||||||
mutationFn: (file: File) => uploadProductCad(id!, file),
|
mutationFn: (file: File) => uploadProductCad(id!, file),
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
toast.success('STEP file uploaded — processing started')
|
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] })
|
qc.invalidateQueries({ queryKey: ['product', id] })
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
FileSpreadsheet, CheckCircle, X, Plus,
|
FileSpreadsheet, CheckCircle, X, Plus,
|
||||||
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy,
|
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy, AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { uploadExcel, finalizeExcelImport, getImportValidation } from '../api/uploads'
|
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."
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -459,11 +473,23 @@ export default function UploadPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{!hasId ? null : row.has_step ? (
|
<div className="flex items-center justify-center gap-1">
|
||||||
<CheckCircle size={14} className="text-green-500 mx-auto" aria-label="STEP file linked" />
|
{!hasId ? null : row.has_step ? (
|
||||||
) : (
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,191 +1,86 @@
|
|||||||
# Plan: Material Alias Completeness with Blocking Dialog
|
# Plan: Duplicate Product Detection
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The material system currently has three resolution tiers in `resolve_material_map()`:
|
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.
|
||||||
1. **Alias lookup** (case-insensitive) — maps raw names to SCHAEFFLER library materials
|
|
||||||
2. **Exact Material.name match** — the raw name IS a library material
|
|
||||||
3. **Pass-through** — unresolved names fall through, causing magenta "FailedMaterial" in Blender renders
|
|
||||||
|
|
||||||
Materials are stored on `Product.cad_part_materials` as `[{part_name, material}]` JSONB. They are auto-populated from Excel components during STEP processing (`_auto_populate_materials_for_cad`). The Materials page shows "Custom" materials (no `schaeffler_code`) separately from categorized SCHAEFFLER library materials.
|
## 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.
|
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
|
||||||
**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.
|
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
|
||||||
### Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Frontend (OrderDetail.tsx)
|
|
||||||
|
|
|
||||||
|-- Click "Dispatch Renders"
|
|
||||||
|-- Call GET /api/orders/{id}/check-materials (new endpoint)
|
|
||||||
|-- If unmapped materials exist:
|
|
||||||
| |-- Show UnmappedMaterialsDialog (new component)
|
|
||||||
| |-- User maps each material via dropdown
|
|
||||||
| |-- Call POST /api/materials/batch-aliases (new endpoint)
|
|
||||||
| |-- On success, proceed with dispatchRenders()
|
|
||||||
|-- If all mapped: proceed directly
|
|
||||||
```
|
|
||||||
|
|
||||||
## Affected Files
|
## Affected Files
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `backend/app/services/material_service.py` | Add `find_unmapped_materials()` function |
|
| `backend/app/services/excel_import.py` | Add STEP conflict detection in `preview_excel_rows()` |
|
||||||
| `backend/app/api/routers/orders.py` | Add `GET /orders/{id}/check-materials` endpoint |
|
| `backend/app/api/routers/uploads.py` | Extend preview response with conflict fields |
|
||||||
| `backend/app/api/routers/materials.py` | Add `POST /materials/batch-aliases` endpoint |
|
| `backend/app/api/routers/products.py` | Add render-count warning to CAD upload response |
|
||||||
| `frontend/src/api/materials.ts` | Add `checkOrderMaterials()`, `batchCreateAliases()` API functions + types |
|
| `frontend/src/api/uploads.ts` | Update TypeScript interfaces |
|
||||||
| `frontend/src/components/orders/UnmappedMaterialsDialog.tsx` | New blocking dialog component |
|
| `frontend/src/pages/Upload.tsx` | Display conflict warnings in preview table |
|
||||||
| `frontend/src/pages/OrderDetail.tsx` | Intercept "Dispatch Renders" with material check |
|
| `frontend/src/api/products.ts` | Add warning fields to CAD upload response type |
|
||||||
| `frontend/src/pages/Materials.tsx` | Add warning badges for unmapped custom materials |
|
| `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`
|
- **File**: `backend/app/services/excel_import.py`
|
||||||
- **What**: Add an async function `find_unmapped_materials(material_names: list[str], db: AsyncSession) -> list[dict]` that:
|
- **What**: In `preview_excel_rows()`, after the product lookup:
|
||||||
1. Takes a list of raw material name strings
|
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. Loads all `MaterialAlias` records and all `Material` records with `schaeffler_code`
|
2. If they differ → set `step_conflict=True` with details
|
||||||
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)
|
3. Track `name_cad_modell` per product key in the `seen` dict
|
||||||
4. Returns a list of `{"raw_name": str, "suggestions": [{"id": str, "name": str, "schaeffler_code": str}]}` for each unmapped name
|
4. If same product appears again with different `name_cad_modell` → set `cad_name_conflict=True`
|
||||||
5. `suggestions`: top 5 SCHAEFFLER materials by `difflib.SequenceMatcher` similarity (ratio > 0.3)
|
- **Also**: Add `selectinload(Product.cad_file)` to `lookup_product()` in `backend/app/domains/products/service.py`
|
||||||
- **Acceptance gate**: Returns empty list when all materials are mapped; returns unmapped entries with suggestions when some are not
|
|
||||||
- **Dependencies**: None
|
- **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`
|
- **File**: `backend/app/api/routers/uploads.py`
|
||||||
- **What**: Add endpoint that:
|
- **What**: Add to the preview row dict and response:
|
||||||
1. Loads all `OrderLine` records for the order, joining `Product`
|
- `step_conflict: bool`, `step_conflict_existing_name: str | None`, `step_conflict_excel_name: str | None`
|
||||||
2. Collects all unique material names from `product.cad_part_materials[*].material` across all products
|
- `cad_name_conflict: bool`, `cad_name_conflict_other_name: str | None`, `cad_name_conflict_row: int | None`
|
||||||
3. Calls `find_unmapped_materials()` with those names
|
- Response-level: `step_conflict_count: int`, `cad_name_conflict_count: int`
|
||||||
4. Returns `{"unmapped": [...], "total_materials": int, "mapped_count": int}`
|
|
||||||
- **Response schema**:
|
|
||||||
```python
|
|
||||||
class MaterialSuggestion(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
schaeffler_code: str
|
|
||||||
|
|
||||||
class UnmappedMaterial(BaseModel):
|
|
||||||
raw_name: str
|
|
||||||
suggestions: list[MaterialSuggestion]
|
|
||||||
|
|
||||||
class UnmappedMaterialCheck(BaseModel):
|
|
||||||
unmapped: list[UnmappedMaterial]
|
|
||||||
total_materials: int
|
|
||||||
mapped_count: int
|
|
||||||
```
|
|
||||||
- **Auth**: `get_current_user` (any authenticated user can check)
|
|
||||||
- **Acceptance gate**: Returns correct unmapped materials for an order with mixed mapped/unmapped materials
|
|
||||||
- **Dependencies**: Task 1
|
- **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`
|
- **File**: `backend/app/api/routers/products.py`
|
||||||
- **What**: Add batch alias creation endpoint:
|
- **What**: In `upload_product_cad()`, before replacing cad_file_id:
|
||||||
1. Accepts `{"mappings": [{"alias": str, "material_id": uuid}]}`
|
1. Check if product already has a different `cad_file_id`
|
||||||
2. For each mapping, creates a `MaterialAlias` record (skips if alias already exists, case-insensitive)
|
2. Count existing MediaAssets (renders) for this product
|
||||||
3. Returns `{"created": int, "skipped": int}`
|
3. Add `warnings: list[str]` and `existing_render_count: int` to response
|
||||||
- **Auth**: `require_admin_or_pm` — only admins/PMs should be able to create aliases
|
|
||||||
- **Validation**: Verify each `material_id` exists; reject if alias is empty; skip duplicate aliases
|
|
||||||
- **Acceptance gate**: Batch creates multiple aliases in one request; subsequent `resolve_material_map()` calls resolve through new aliases
|
|
||||||
- **Dependencies**: None
|
- **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`
|
- **File**: `frontend/src/api/uploads.ts`
|
||||||
- **What**: Add TypeScript interfaces and API functions:
|
- **What**: Add conflict fields to `ExcelPreviewRow` and `ExcelPreviewResult` interfaces
|
||||||
```typescript
|
- **Also**: `frontend/src/api/products.ts` — add `warnings?: string[]` and `existing_render_count?: number` to `ProductCadUploadResponse`
|
||||||
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
|
|
||||||
- **Dependencies**: Tasks 2, 3
|
- **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`
|
- **File**: `frontend/src/pages/Upload.tsx`
|
||||||
- **What**: Create a modal dialog component:
|
- **What**:
|
||||||
1. **Props**: `open: boolean`, `unmapped: UnmappedMaterial[]`, `onResolved: () => void`, `onCancel: () => void`
|
1. Add StatCards for `step_conflict_count` and `cad_name_conflict_count` (amber color, AlertTriangle icon)
|
||||||
2. **UI**: Fixed overlay (same pattern as existing modals), wider (`max-w-xl`):
|
2. In the preview table rows: yellow warning icon with tooltip for `step_conflict` and `cad_name_conflict`
|
||||||
- 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
|
|
||||||
- **Dependencies**: Task 4
|
- **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`
|
- **File**: `frontend/src/pages/ProductDetail.tsx`
|
||||||
- **What**:
|
- **What**: In `cadUploadMut.onSuccess`, check response for `warnings` and show `toast.warning()` for each
|
||||||
1. Add state for material check data and dialog visibility
|
- **Dependencies**: Task 4
|
||||||
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
|
|
||||||
|
|
||||||
## Migration Check
|
## 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)
|
1. Backend Tasks 1+2 (Excel preview conflicts)
|
||||||
2. Backend endpoints (Tasks 2-3, parallel)
|
2. Backend Task 3 (STEP replacement warning)
|
||||||
3. Frontend API types (Task 4)
|
3. Frontend Tasks 4+5+6 (types + UI)
|
||||||
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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user