fix(critical): SQLAlchemy mapper crash + material matching for USD renders + kanban drag-to-reject
- beat_tasks.py: import app.models at module level so SQLAlchemy can
resolve relationship("Template") and relationship("User") when domain
models are imported in isolation inside task functions. Fixes all
beat tasks (batch_render_notifications, recover_stuck_cad_files) that
crashed every 60s with mapper initialization error.
- _blender_materials.py: build_mat_map_lower() now adds a slug-normalized
key variant (re.sub([^a-z0-9]+, _, kl)) for each mat_map entry. OCC
part names like 'F-802007_TR4-D1-H122AG' → slug 'f_802007_tr4_d1_h122ag'
now matches USD-imported Blender objects. Existing prefix fallback
(key.startswith(part_key)) catches AF-suffix variants.
- Orders.tsx: kanban drag-to-reject implemented. submitted/processing
cards are draggable (cursor-grab). Rejected column highlights with
red ring on drag-over. Drop opens reject reason modal via createPortal.
Confirm calls rejectOrder() mutation + invalidates orders cache.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,27 @@
|
|||||||
|
|
||||||
## Learnings
|
## Learnings
|
||||||
|
|
||||||
|
### 2026-03-12 | Caching | Composite Cache Keys für Tessellierung
|
||||||
|
Hash-basiertes Caching in Celery Tasks muss alle relevanten Parameter einschließen, nicht nur den Datei-Hash. Bei `generate_gltf_geometry_task` und `generate_usd_master_task` wurde der Cache-Key auf `{hash}:{linear}:{angular}:{engine}` erweitert. Außerdem: immer Disk-Existenz des gecachten Assets prüfen (`storage_key.exists()`) bevor ein Cache-Hit zurückgegeben wird — der Asset-Record kann existieren, die Datei aber nicht.
|
||||||
|
|
||||||
|
### 2026-03-12 | React | Modal in <tr> braucht createPortal
|
||||||
|
Ein Modal-Dialog, der aus einer `<tr>`-Tabellenzeile gerendert wird, erzeugt invalides HTML (`<div>` in `<tr>` nicht erlaubt). Fix: `createPortal(modal, document.body)` — rendert das Modal am Body-Root, außerhalb der Tabellen-Hierarchie.
|
||||||
|
|
||||||
|
### 2026-03-12 | Prozess | ROADMAP war dem Code weit hinterher
|
||||||
|
Pre-flight code audit vor dem Schreiben eines Plans ist zwingend erforderlich. Beim Sprint vom 2026-03-12 zeigte sich: P1 (dead-code cleanup, blender_render.py split), P5 (USD render wiring), P8 (Celery tenant context) und P10 (UI polish) waren bereits größtenteils im Code implementiert, aber nicht im ROADMAP.md reflektiert. Lesson: Immer zuerst grep/ls-Gates und Line-Count-Checks laufen lassen, bevor Tasks geplant werden.
|
||||||
|
|
||||||
|
### 2026-03-12 | Material | SQLAlchemy Mapper-Fehler bei isoliertem Domain-Import
|
||||||
|
Wenn eine Celery-Task nur `from app.domains.orders.models import Order` importiert (ohne `app.models`), kann SQLAlchemy die String-Referenzen `relationship("Template", ...)` und `relationship("User", ...)` nicht auflösen — alle Mapper-Initialisierungen schlagen fehl. Fix: `import app.models as _all_models` am Modul-Level von `beat_tasks.py` eintragen. So werden alle Models geladen bevor irgendeine Task-Funktion die Mapper initialisiert. Gilt für alle Tasks die Domain-Models direkt importieren.
|
||||||
|
|
||||||
|
### 2026-03-12 | Material | OCC-Part-Namen vs. USD-Part-Keys beim Material-Matching
|
||||||
|
OCC XCAF-Namen enthalten Bindestriche und _AF\d+-Suffixe (z.B. `GE360-HF-0011-EIN_HAELFTE_AF0_1_AF0`). Das USD-Export-Tool slugifiziert den Namen: `[^a-z0-9]+` → `_` (d.h. `ge360_hf_0011_ein_haelfte_af0_1_af0`). Blender importiert das USD-Objekt als `ge360_hf_0011_ein_haelfte` (base-name aus XCAF). Das Mat-Map aus der DB ist lowercased mit Bindestrichen. Fix: In `build_mat_map_lower()` auch eine slug-normierte Variante jeden Keys hinzufügen (`re.sub(r'[^a-z0-9]+', '_', kl).strip('_')`). Der bestehende Prefix-Fallback (`key.startswith(part_key)`) fangt dann den Rest ab.
|
||||||
|
|
||||||
|
### 2026-03-12 | USD | Stale USD-Master-Assets invalidieren
|
||||||
|
USD-Assets die vor dem `elif shape_has_loc`-Fix (Commit `de7f97b`, 2026-03-12 16:43) generiert wurden, haben fehlerhafte Geometrie (double-transform). Fix: `DELETE FROM media_assets WHERE asset_type='usd_master' AND created_at < '2026-03-12 16:43:33'` + `UPDATE cad_files SET step_file_hash = NULL WHERE id IN (...)`. Nächster Render generiert neue korrekte USD-Datei.
|
||||||
|
|
||||||
|
### 2026-03-12 | USD | Mesh-Prim-Benennung für Blender 5.0
|
||||||
|
Blender 5.0 importiert USD und kollabiert single-child Xform+Mesh-Hierarchien zu einem einzigen Objekt. Der Objektname entspricht dabei dem Leaf-Namen des Mesh-Prims (nicht dem Xform). Die Lösung: `mesh_path = f"{part_path}/{part_key}"` statt `f"{part_path}/Mesh"`. Damit importiert Blender jedes Objekt direkt mit dem korrekten part_key als Namen — kein Post-Import-Rename nötig. Blender setzt `obj["usd:path"]` nicht, daher ist der pfadbasierte Rename-Ansatz nicht funktionsfähig.
|
||||||
|
|
||||||
### 2026-01-15 | Architektur | Backend-Port-Konflikt
|
### 2026-01-15 | Architektur | Backend-Port-Konflikt
|
||||||
Port 8000 war belegt → Port 8888 in `docker-compose.yml` + Vite-Proxy. Früh festlegen und in CLAUDE.md dokumentieren.
|
Port 8000 war belegt → Port 8888 in `docker-compose.yml` + Vite-Proxy. Früh festlegen und in CLAUDE.md dokumentieren.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
|
# Eagerly import all models so SQLAlchemy can resolve string-based relationship
|
||||||
|
# references (e.g. relationship("Template"), relationship("User")) when domain
|
||||||
|
# models are imported individually inside task functions.
|
||||||
|
import app.models as _all_models # noqa: F401
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Plus, Package, Trash2, X, Search, SlidersHorizontal,
|
Plus, Package, Trash2, X, Search, SlidersHorizontal,
|
||||||
LayoutGrid, LayoutList, Calendar, FileSpreadsheet,
|
LayoutGrid, LayoutList, Calendar, FileSpreadsheet,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
ChevronRight, Filter,
|
ChevronRight, Filter,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { listOrders, searchOrders, deleteOrder } from '../api/orders'
|
import { listOrders, searchOrders, deleteOrder, rejectOrder } from '../api/orders'
|
||||||
import { fetchThumbnailBlob } from '../api/cad'
|
import { fetchThumbnailBlob } from '../api/cad'
|
||||||
import type { Order, OrderDetail, OrderItem } from '../api/orders'
|
import type { Order, OrderDetail, OrderItem } from '../api/orders'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
@@ -51,6 +52,9 @@ export default function OrdersPage() {
|
|||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
|
||||||
|
const [dragRejectOpen, setDragRejectOpen] = useState(false)
|
||||||
|
const [dragRejectReason, setDragRejectReason] = useState('')
|
||||||
|
const pendingRejectIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
// Auto-switch to list view on narrow screens
|
// Auto-switch to list view on narrow screens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,6 +171,25 @@ export default function OrdersPage() {
|
|||||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rejectOrderMut = useMutation({
|
||||||
|
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||||
|
rejectOrder(id, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Order rejected')
|
||||||
|
setDragRejectOpen(false)
|
||||||
|
setDragRejectReason('')
|
||||||
|
pendingRejectIdRef.current = null
|
||||||
|
qc.invalidateQueries({ queryKey: ['orders'] })
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDropReject = (orderId: string) => {
|
||||||
|
pendingRejectIdRef.current = orderId
|
||||||
|
setDragRejectReason('')
|
||||||
|
setDragRejectOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteSelected = () => {
|
const handleDeleteSelected = () => {
|
||||||
const ids = [...selected]
|
const ids = [...selected]
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
@@ -362,6 +385,7 @@ export default function OrdersPage() {
|
|||||||
byStatus={byStatus}
|
byStatus={byStatus}
|
||||||
visibleStatuses={visibleStatuses}
|
visibleStatuses={visibleStatuses}
|
||||||
onNavigate={(id) => navigate(`/orders/${id}`)}
|
onNavigate={(id) => navigate(`/orders/${id}`)}
|
||||||
|
onDropReject={handleDropReject}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ListView
|
<ListView
|
||||||
@@ -382,6 +406,52 @@ export default function OrdersPage() {
|
|||||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{dragRejectOpen && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setDragRejectOpen(false) }}
|
||||||
|
>
|
||||||
|
<div className="bg-surface rounded-xl shadow-xl p-6 w-full max-w-sm mx-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-content">Reject Order</h2>
|
||||||
|
<button onClick={() => setDragRejectOpen(false)} className="text-content-muted hover:text-content">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-content-secondary">Optionally add a rejection reason:</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface-muted text-content resize-none focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Reason (optional)"
|
||||||
|
value={dragRejectReason}
|
||||||
|
onChange={(e) => setDragRejectReason(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setDragRejectOpen(false)}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingRejectIdRef.current) {
|
||||||
|
rejectOrderMut.mutate({ id: pendingRejectIdRef.current, reason: dragRejectReason })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={rejectOrderMut.isPending}
|
||||||
|
className="btn-danger text-sm"
|
||||||
|
>
|
||||||
|
{rejectOrderMut.isPending ? <Loader2 size={14} className="animate-spin" /> : <XCircle size={14} />}
|
||||||
|
Reject Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
|
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -677,19 +747,35 @@ function KanbanBoard({
|
|||||||
byStatus,
|
byStatus,
|
||||||
visibleStatuses,
|
visibleStatuses,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onDropReject,
|
||||||
}: {
|
}: {
|
||||||
byStatus: Record<Status, Order[]>
|
byStatus: Record<Status, Order[]>
|
||||||
visibleStatuses: Status[]
|
visibleStatuses: Status[]
|
||||||
onNavigate: (id: string) => void
|
onNavigate: (id: string) => void
|
||||||
|
onDropReject?: (orderId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [dragOverRejected, setDragOverRejected] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto min-h-0">
|
<div className="flex-1 overflow-x-auto min-h-0">
|
||||||
<div className="flex gap-4 p-6 h-full min-w-max">
|
<div className="flex gap-4 p-6 h-full min-w-max">
|
||||||
{visibleStatuses.map((status) => {
|
{visibleStatuses.map((status) => {
|
||||||
const meta = STATUS_META[status]
|
const meta = STATUS_META[status]
|
||||||
const cards = byStatus[status]
|
const cards = byStatus[status]
|
||||||
|
const isRejectedCol = status === 'rejected'
|
||||||
return (
|
return (
|
||||||
<div key={status} className="flex flex-col w-72 min-w-[272px]">
|
<div
|
||||||
|
key={status}
|
||||||
|
className={`flex flex-col w-72 min-w-[272px] ${isRejectedCol && dragOverRejected ? 'ring-2 ring-red-400 rounded-xl' : ''}`}
|
||||||
|
onDragOver={isRejectedCol ? (e) => { e.preventDefault(); setDragOverRejected(true) } : undefined}
|
||||||
|
onDragLeave={isRejectedCol ? () => setDragOverRejected(false) : undefined}
|
||||||
|
onDrop={isRejectedCol ? (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverRejected(false)
|
||||||
|
const orderId = e.dataTransfer.getData('orderId')
|
||||||
|
if (orderId && onDropReject) onDropReject(orderId)
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
<div className={`${meta.header} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
|
<div className={`${meta.header} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
|
||||||
<meta.icon size={16} className="text-white/80 shrink-0" />
|
<meta.icon size={16} className="text-white/80 shrink-0" />
|
||||||
<span className="text-white font-semibold text-sm">{meta.label}</span>
|
<span className="text-white font-semibold text-sm">{meta.label}</span>
|
||||||
@@ -697,10 +783,10 @@ function KanbanBoard({
|
|||||||
{cards.length}
|
{cards.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 overflow-y-auto space-y-2 min-h-[120px]">
|
<div className={`flex-1 bg-surface-muted rounded-b-xl p-2 overflow-y-auto space-y-2 min-h-[120px] ${isRejectedCol && dragOverRejected ? 'bg-red-50' : ''}`}>
|
||||||
{cards.length === 0 ? (
|
{cards.length === 0 ? (
|
||||||
<div className="h-20 flex items-center justify-center text-content-muted text-xs">
|
<div className={`h-20 flex items-center justify-center text-xs ${isRejectedCol && dragOverRejected ? 'text-red-400 font-medium' : 'text-content-muted'}`}>
|
||||||
No orders
|
{isRejectedCol && dragOverRejected ? 'Drop to reject' : 'No orders'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
cards.map((order) => (
|
cards.map((order) => (
|
||||||
@@ -762,12 +848,19 @@ function KanbanCard({
|
|||||||
|
|
||||||
const rp = order.render_progress
|
const rp = order.render_progress
|
||||||
|
|
||||||
|
const isDraggable = order.status === 'submitted' || order.status === 'processing'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
|
draggable={isDraggable}
|
||||||
|
onDragStart={isDraggable ? (e) => {
|
||||||
|
e.dataTransfer.setData('orderId', order.id)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
} : undefined}
|
||||||
className={`w-full text-left bg-surface rounded-lg shadow-sm border border-border-default
|
className={`w-full text-left bg-surface rounded-lg shadow-sm border border-border-default
|
||||||
border-l-4 ${meta.card} hover:shadow-md hover:-translate-y-0.5
|
border-l-4 ${meta.card} hover:shadow-md hover:-translate-y-0.5
|
||||||
transition-all duration-150 p-3 group`}
|
transition-all duration-150 p-3 group ${isDraggable ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<span className="text-sm font-bold text-content font-mono leading-tight">
|
<span className="text-sm font-bold text-content font-mono leading-tight">
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ def build_mat_map_lower(material_map: dict) -> dict:
|
|||||||
for k, v in material_map.items():
|
for k, v in material_map.items():
|
||||||
kl = k.lower().strip()
|
kl = k.lower().strip()
|
||||||
mat_map_lower[kl] = v
|
mat_map_lower[kl] = v
|
||||||
|
# USD path: part_key slugs replace ALL non-alphanumeric chars with '_'
|
||||||
|
# (same regex as generate_part_key in export_step_to_usd.py).
|
||||||
|
# E.g. "F-802007_TR4-D1" → "f_802007_tr4_d1". Add slug variant so
|
||||||
|
# hyphenated OCC names match USD-imported Blender objects.
|
||||||
|
slug_key = _re.sub(r'[^a-z0-9]+', '_', kl).strip('_')
|
||||||
|
if slug_key and slug_key != kl:
|
||||||
|
mat_map_lower.setdefault(slug_key, v)
|
||||||
|
# _AF\d+ stripping for GLB object names
|
||||||
stripped = kl
|
stripped = kl
|
||||||
prev = None
|
prev = None
|
||||||
while prev != stripped:
|
while prev != stripped:
|
||||||
|
|||||||
Reference in New Issue
Block a user