b6bac080bb
- 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>
524 lines
19 KiB
Python
524 lines
19 KiB
Python
import hashlib
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.models.cad_file import CadFile, ProcessingStatus
|
|
from app.models.order import Order
|
|
from app.models.order_item import OrderItem
|
|
from app.models.order_line import OrderLine
|
|
from app.models.user import User
|
|
from app.schemas.upload import ParsedExcelResponse, ParsedRow, ParsedComponent, StepUploadResponse
|
|
from app.schemas.order import OrderDetailOut
|
|
from app.services.excel_parser import parse_excel, parsed_excel_to_dict
|
|
from app.services.excel_import import import_excel_to_products, preview_excel_rows
|
|
from app.services.order_service import generate_order_number
|
|
from app.utils.auth import get_current_user
|
|
|
|
router = APIRouter(prefix="/uploads", tags=["uploads"])
|
|
|
|
|
|
# ── Preview response models ────────────────────────────────────────────
|
|
|
|
class ExcelPreviewRow(BaseModel):
|
|
row_index: int
|
|
pim_id: str | None = None
|
|
produkt_baureihe: str | None = None
|
|
gewaehltes_produkt: str | None = None
|
|
product_exists: bool = False
|
|
product_id: str | None = None
|
|
medias_rendering: bool | None = None
|
|
category_key: str | None = None
|
|
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):
|
|
excel_path: str
|
|
filename: str
|
|
category_key: str | None
|
|
row_count: int
|
|
existing_product_count: int
|
|
new_product_count: int
|
|
no_pim_id_count: int
|
|
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] = []
|
|
template_name: str | None = None
|
|
validation_id: str | None = None
|
|
|
|
|
|
# ── Finalize request models ────────────────────────────────────────────
|
|
|
|
class OutputTypeSelection(BaseModel):
|
|
row_index: int
|
|
output_type_ids: list[uuid.UUID]
|
|
|
|
|
|
class ExcelFinalizeRequest(BaseModel):
|
|
excel_path: str
|
|
included_row_indices: list[int]
|
|
output_type_selections: list[OutputTypeSelection] = []
|
|
notes: str | None = None
|
|
template_id: uuid.UUID | None = None
|
|
|
|
|
|
ALLOWED_EXCEL = {".xlsx", ".xls"}
|
|
ALLOWED_STEP = {".stp", ".step"}
|
|
|
|
|
|
@router.post("/excel", response_model=ExcelPreviewResponse)
|
|
async def upload_excel(
|
|
file: UploadFile = File(...),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Parse Excel and return a read-only preview. No products are created."""
|
|
suffix = Path(file.filename or "").suffix.lower()
|
|
if suffix not in ALLOWED_EXCEL:
|
|
raise HTTPException(400, detail="Only .xlsx / .xls files are accepted")
|
|
|
|
# Save the file
|
|
upload_dir = Path(settings.upload_dir) / "excel_files"
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
tmp_name = f"{uuid.uuid4()}{suffix}"
|
|
tmp_path = upload_dir / tmp_name
|
|
|
|
content = await file.read()
|
|
tmp_path.write_bytes(content)
|
|
|
|
try:
|
|
parsed = parse_excel(tmp_path)
|
|
except ValueError as exc:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise HTTPException(422, detail=str(exc))
|
|
|
|
parsed_dict = parsed_excel_to_dict(parsed)
|
|
parsed_dict["filename"] = file.filename
|
|
parsed_dict["excel_path"] = str(tmp_path)
|
|
|
|
rows = parsed_dict.get("rows", [])
|
|
try:
|
|
preview = await preview_excel_rows(
|
|
db, rows, category_key=parsed_dict.get("category_key"),
|
|
)
|
|
except Exception as exc:
|
|
try:
|
|
from app.services.notification_service import emit_notification
|
|
await emit_notification(
|
|
db,
|
|
actor_user_id=user.id,
|
|
target_user_id=user.id,
|
|
action="excel.import_error",
|
|
entity_type="upload",
|
|
entity_id=None,
|
|
details={
|
|
"filename": file.filename or "",
|
|
"error": str(exc)[:500],
|
|
},
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(500, detail=f"Preview failed: {str(exc)[:300]}")
|
|
|
|
annotated_rows = [
|
|
ExcelPreviewRow(
|
|
row_index=r.get("row_index", 0),
|
|
pim_id=r.get("pim_id"),
|
|
produkt_baureihe=r.get("produkt_baureihe"),
|
|
gewaehltes_produkt=r.get("gewaehltes_produkt"),
|
|
product_exists=r.get("product_exists", False),
|
|
product_id=r.get("product_id"),
|
|
medias_rendering=r.get("medias_rendering"),
|
|
category_key=r.get("category_key"),
|
|
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
|
|
]
|
|
|
|
all_warnings = preview.warnings + parsed_dict.get("warnings", [])
|
|
|
|
if all_warnings:
|
|
from app.services.notification_service import emit_notification
|
|
await emit_notification(
|
|
db,
|
|
actor_user_id=user.id,
|
|
target_user_id=user.id,
|
|
action="excel.import_warnings",
|
|
entity_type="upload",
|
|
entity_id=None,
|
|
details={
|
|
"filename": file.filename or "",
|
|
"warning_count": len(all_warnings),
|
|
"warnings": all_warnings[:10],
|
|
},
|
|
)
|
|
|
|
# Queue sanity-check validation task
|
|
validation_id: str | None = None
|
|
try:
|
|
from app.domains.imports.models import ImportValidation
|
|
val = ImportValidation(
|
|
excel_path=str(tmp_path),
|
|
tenant_id=getattr(user, "tenant_id", None),
|
|
)
|
|
db.add(val)
|
|
await db.commit()
|
|
await db.refresh(val)
|
|
validation_id = str(val.id)
|
|
from app.domains.imports.tasks import validate_excel_import
|
|
validate_excel_import.delay(validation_id, str(tmp_path), str(getattr(user, "tenant_id", "") or ""))
|
|
except Exception as exc:
|
|
pass # validation is non-critical
|
|
|
|
return ExcelPreviewResponse(
|
|
excel_path=str(tmp_path),
|
|
filename=file.filename or "",
|
|
category_key=parsed_dict.get("category_key"),
|
|
row_count=parsed_dict.get("row_count", len(rows)),
|
|
existing_product_count=preview.existing_product_count,
|
|
new_product_count=preview.new_product_count,
|
|
no_pim_id_count=preview.no_pim_id_count,
|
|
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", []),
|
|
template_name=parsed_dict.get("template_name"),
|
|
validation_id=validation_id,
|
|
)
|
|
|
|
|
|
@router.post("/excel/finalize", response_model=OrderDetailOut, status_code=status.HTTP_201_CREATED)
|
|
async def finalize_excel(
|
|
body: ExcelFinalizeRequest,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create products + order + lines from a previously parsed Excel file.
|
|
|
|
This is the second step: the user has reviewed the preview and confirmed
|
|
which rows to include and which output types to request.
|
|
"""
|
|
# 1. Validate Excel file still exists
|
|
excel_path = Path(body.excel_path)
|
|
if not excel_path.is_file():
|
|
raise HTTPException(404, detail="Excel file not found — please re-upload")
|
|
|
|
# 2. Re-parse the Excel
|
|
try:
|
|
parsed = parse_excel(excel_path)
|
|
except ValueError as exc:
|
|
raise HTTPException(422, detail=str(exc))
|
|
|
|
parsed_dict = parsed_excel_to_dict(parsed)
|
|
all_rows = parsed_dict.get("rows", [])
|
|
|
|
# 3. Filter to included rows
|
|
included_set = set(body.included_row_indices)
|
|
included_rows = [r for r in all_rows if r.get("row_index") in included_set]
|
|
|
|
if not included_rows:
|
|
raise HTTPException(400, detail="No rows selected")
|
|
|
|
# 4. Import into product library (creates/updates products)
|
|
import_result = await import_excel_to_products(
|
|
db,
|
|
included_rows,
|
|
source_excel=str(excel_path),
|
|
category_key=parsed_dict.get("category_key"),
|
|
tenant_id=getattr(user, 'tenant_id', None),
|
|
)
|
|
|
|
# 5. Seed material aliases
|
|
material_mappings = parsed_dict.get("material_mappings", [])
|
|
if material_mappings:
|
|
try:
|
|
from app.services.material_service import seed_material_aliases_from_mappings
|
|
await seed_material_aliases_from_mappings(db, material_mappings)
|
|
except Exception:
|
|
pass # non-critical
|
|
|
|
# 6. Create Order
|
|
order_number = await generate_order_number(db)
|
|
order = Order(
|
|
order_number=order_number,
|
|
template_id=body.template_id,
|
|
created_by=user.id,
|
|
source_excel=str(excel_path),
|
|
notes=body.notes,
|
|
tenant_id=getattr(user, 'tenant_id', None),
|
|
)
|
|
db.add(order)
|
|
await db.flush()
|
|
|
|
# 7. Create OrderItems (legacy compat — one per included row)
|
|
for row in import_result.rows:
|
|
# If the matched product already has a STEP file linked (from a
|
|
# previous order or direct product-library upload), inherit it so the
|
|
# submit validation passes without requiring a re-upload.
|
|
inherited_cad = (
|
|
uuid.UUID(row["product_cad_file_id"])
|
|
if row.get("product_cad_file_id")
|
|
else None
|
|
)
|
|
item = OrderItem(
|
|
order_id=order.id,
|
|
row_index=row.get("row_index", 0),
|
|
ebene1=row.get("ebene1"),
|
|
ebene2=row.get("ebene2"),
|
|
baureihe=row.get("baureihe"),
|
|
pim_id=row.get("pim_id"),
|
|
produkt_baureihe=row.get("produkt_baureihe"),
|
|
gewaehltes_produkt=row.get("gewaehltes_produkt"),
|
|
name_cad_modell=row.get("name_cad_modell"),
|
|
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
|
lagertyp=row.get("lagertyp"),
|
|
medias_rendering=row.get("medias_rendering"),
|
|
components=[
|
|
c if isinstance(c, dict) else c
|
|
for c in row.get("components", [])
|
|
],
|
|
cad_file_id=inherited_cad,
|
|
tenant_id=getattr(user, 'tenant_id', None),
|
|
)
|
|
db.add(item)
|
|
|
|
# 8. Build output type selections lookup: row_index → list[UUID]
|
|
ot_map: dict[int, list[uuid.UUID]] = {}
|
|
for sel in body.output_type_selections:
|
|
ot_map[sel.row_index] = sel.output_type_ids
|
|
|
|
# 9. Create OrderLines
|
|
for row in import_result.rows:
|
|
product_id = row.get("product_id")
|
|
if not product_id:
|
|
continue
|
|
|
|
row_idx = row.get("row_index", 0)
|
|
type_ids = ot_map.get(row_idx, [])
|
|
|
|
if not type_ids:
|
|
# Tracking-only line (no output type)
|
|
line = OrderLine(
|
|
order_id=order.id,
|
|
product_id=uuid.UUID(product_id),
|
|
output_type_id=None,
|
|
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
|
tenant_id=getattr(user, 'tenant_id', None),
|
|
)
|
|
db.add(line)
|
|
else:
|
|
for type_id in type_ids:
|
|
line = OrderLine(
|
|
order_id=order.id,
|
|
product_id=uuid.UUID(product_id),
|
|
output_type_id=type_id,
|
|
gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"),
|
|
tenant_id=getattr(user, 'tenant_id', None),
|
|
)
|
|
db.add(line)
|
|
|
|
# 10. Commit, then snapshot prices into the new draft order
|
|
try:
|
|
await db.commit()
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
# Emit error notification via its own connection (session is now invalid)
|
|
try:
|
|
from app.services.notification_service import emit_notification_sync
|
|
from sqlalchemy.exc import IntegrityError
|
|
if isinstance(exc, IntegrityError) and "order_number" in str(exc):
|
|
error_msg = "Duplicate order number — please try again"
|
|
else:
|
|
error_msg = str(exc)[:300]
|
|
emit_notification_sync(
|
|
actor_user_id=user.id,
|
|
target_user_id=str(user.id),
|
|
action="excel.finalize_error",
|
|
entity_type="upload",
|
|
entity_id=None,
|
|
details={
|
|
"filename": Path(body.excel_path).name,
|
|
"error": error_msg,
|
|
},
|
|
)
|
|
except Exception:
|
|
pass
|
|
from sqlalchemy.exc import IntegrityError
|
|
if isinstance(exc, IntegrityError) and "order_number" in str(exc):
|
|
raise HTTPException(409, detail="Duplicate order number — please try again")
|
|
raise HTTPException(500, detail=f"Order creation failed: {str(exc)[:200]}")
|
|
|
|
# Snapshot prices into the draft order so the estimate is visible immediately
|
|
try:
|
|
from app.services.pricing_service import refresh_order_price
|
|
await refresh_order_price(db, order.id)
|
|
except Exception:
|
|
pass # non-critical — estimate can be computed on first view
|
|
|
|
# Load and return full order detail
|
|
from app.api.routers.orders import _load_order_detail, _order_detail_out
|
|
order_loaded = await _load_order_detail(db, order.id)
|
|
return _order_detail_out(order_loaded)
|
|
|
|
|
|
@router.post("/step", response_model=StepUploadResponse, status_code=status.HTTP_201_CREATED)
|
|
async def upload_step(
|
|
file: UploadFile = File(...),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Upload a single STEP/STP CAD file."""
|
|
suffix = Path(file.filename or "").suffix.lower()
|
|
if suffix not in ALLOWED_STEP:
|
|
raise HTTPException(400, detail="Only .stp / .step files are accepted")
|
|
|
|
content = await file.read()
|
|
file_hash = hashlib.sha256(content).hexdigest()
|
|
|
|
# Check dedup
|
|
result = await db.execute(select(CadFile).where(CadFile.file_hash == file_hash))
|
|
existing = result.scalar_one_or_none()
|
|
if existing:
|
|
return StepUploadResponse(
|
|
cad_file_id=str(existing.id),
|
|
original_name=existing.original_name,
|
|
file_hash=file_hash,
|
|
status="already_exists",
|
|
)
|
|
|
|
# Save file
|
|
step_dir = Path(settings.upload_dir) / "step_files"
|
|
step_dir.mkdir(parents=True, exist_ok=True)
|
|
stored_name = f"{uuid.uuid4()}{suffix}"
|
|
stored_path = step_dir / stored_name
|
|
stored_path.write_bytes(content)
|
|
|
|
cad_file = CadFile(
|
|
original_name=file.filename,
|
|
stored_path=str(stored_path),
|
|
file_hash=file_hash,
|
|
file_size=len(content),
|
|
processing_status=ProcessingStatus.pending,
|
|
tenant_id=getattr(user, 'tenant_id', None),
|
|
)
|
|
db.add(cad_file)
|
|
await db.commit()
|
|
await db.refresh(cad_file)
|
|
|
|
# Enqueue background processing task (Phase 3)
|
|
try:
|
|
from app.tasks.step_tasks import process_step_file
|
|
process_step_file.delay(str(cad_file.id))
|
|
except Exception:
|
|
pass # Worker not configured yet
|
|
|
|
return StepUploadResponse(
|
|
cad_file_id=str(cad_file.id),
|
|
original_name=file.filename,
|
|
file_hash=file_hash,
|
|
status="uploaded",
|
|
)
|
|
|
|
|
|
@router.get("/validations/{validation_id}")
|
|
async def get_import_validation(
|
|
validation_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Poll the result of an Excel sanity-check validation."""
|
|
from app.domains.imports.models import ImportValidation
|
|
from app.domains.imports.schemas import ImportValidationOut
|
|
result = await db.execute(select(ImportValidation).where(ImportValidation.id == validation_id))
|
|
val = result.scalar_one_or_none()
|
|
if not val:
|
|
raise HTTPException(404, detail="Validation not found")
|
|
return ImportValidationOut.model_validate(val)
|
|
|
|
|
|
class AddAliasRequest(BaseModel):
|
|
part_name: str
|
|
material_name: str
|
|
|
|
|
|
@router.post("/validations/{validation_id}/add-alias", status_code=status.HTTP_201_CREATED)
|
|
async def add_material_alias_from_validation(
|
|
validation_id: uuid.UUID,
|
|
body: AddAliasRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Create a MaterialAlias entry mapping part_name to an existing material.
|
|
|
|
Requires admin or project_manager role.
|
|
"""
|
|
from app.utils.auth import require_admin_or_pm
|
|
from app.domains.imports.models import ImportValidation
|
|
from app.domains.materials.models import Material, MaterialAlias
|
|
|
|
# Gate to admin/PM
|
|
if user.role.value not in ("admin", "project_manager"):
|
|
raise HTTPException(status_code=403, detail="Admin or project_manager required")
|
|
|
|
# Verify the validation exists
|
|
val_result = await db.execute(select(ImportValidation).where(ImportValidation.id == validation_id))
|
|
if not val_result.scalar_one_or_none():
|
|
raise HTTPException(404, detail="Validation not found")
|
|
|
|
# Find the target material by name
|
|
mat_result = await db.execute(select(Material).where(Material.name == body.material_name))
|
|
material = mat_result.scalar_one_or_none()
|
|
if not material:
|
|
raise HTTPException(404, detail=f"Material '{body.material_name}' not found in library")
|
|
|
|
# Check for duplicate alias (case-insensitive)
|
|
from sqlalchemy import func as sql_func
|
|
dup_result = await db.execute(
|
|
select(MaterialAlias).where(
|
|
sql_func.lower(MaterialAlias.alias) == body.part_name.lower()
|
|
)
|
|
)
|
|
existing_alias = dup_result.scalar_one_or_none()
|
|
if existing_alias:
|
|
raise HTTPException(409, detail=f"Alias '{body.part_name}' already exists")
|
|
|
|
alias = MaterialAlias(material_id=material.id, alias=body.part_name)
|
|
db.add(alias)
|
|
await db.commit()
|
|
await db.refresh(alias)
|
|
return {"id": str(alias.id), "alias": alias.alias, "material_id": str(material.id), "material_name": material.name}
|