diff --git a/LEARNINGS.md b/LEARNINGS.md
index ac43daf..eee8ec9 100644
--- a/LEARNINGS.md
+++ b/LEARNINGS.md
@@ -7,6 +7,27 @@
## 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
braucht createPortal
+Ein Modal-Dialog, der aus einer `
`-Tabellenzeile gerendert wird, erzeugt invalides HTML (`
` in `
` 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
Port 8000 war belegt → Port 8888 in `docker-compose.yml` + Vite-Proxy. Früh festlegen und in CLAUDE.md dokumentieren.
diff --git a/backend/app/tasks/beat_tasks.py b/backend/app/tasks/beat_tasks.py
index e654dfe..abaf670 100644
--- a/backend/app/tasks/beat_tasks.py
+++ b/backend/app/tasks/beat_tasks.py
@@ -7,6 +7,11 @@ from datetime import datetime, timedelta
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__)
diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx
index 34380fe..29da42f 100644
--- a/frontend/src/pages/Orders.tsx
+++ b/frontend/src/pages/Orders.tsx
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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 {
Plus, Package, Trash2, X, Search, SlidersHorizontal,
LayoutGrid, LayoutList, Calendar, FileSpreadsheet,
@@ -8,7 +9,7 @@ import {
ChevronRight, Filter,
} from 'lucide-react'
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 type { Order, OrderDetail, OrderItem } from '../api/orders'
import ConfirmModal from '../components/ConfirmModal'
@@ -51,6 +52,9 @@ export default function OrdersPage() {
const [showFilters, setShowFilters] = useState(false)
const [selected, setSelected] = useState>(new Set())
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(null)
// Auto-switch to list view on narrow screens
useEffect(() => {
@@ -167,6 +171,25 @@ export default function OrdersPage() {
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 ids = [...selected]
if (!ids.length) return
@@ -362,6 +385,7 @@ export default function OrdersPage() {
byStatus={byStatus}
visibleStatuses={visibleStatuses}
onNavigate={(id) => navigate(`/orders/${id}`)}
+ onDropReject={handleDropReject}
/>
) : (
setConfirmState((s) => ({ ...s, open: false }))}
/>
+ {dragRejectOpen && createPortal(
+
{ if (e.target === e.currentTarget) setDragRejectOpen(false) }}
+ >
+