feat(F-G-H-I): STL cache, invoices, import validation, notification settings

Phase F — STL Hash Cache:
- Migration 041: step_file_hash column on cad_files
- cache_service.py: SHA256 hash + MinIO-backed STL cache (check/store)
- render_step_thumbnail: compute+persist hash before render
- generate_stl_cache: check MinIO cache before cadquery conversion, store after

Phase G — Invoices:
- Migration 042: invoices + invoice_lines tables with RLS
- Invoice/InvoiceLine models + schemas
- billing service: generate_invoice_number (INV-YYYY-NNNN), create/list/get/delete/PDF
- WeasyPrint PDF generation; backend Dockerfile + pyproject.toml deps
- invoice_router with 6 endpoints; registered in main.py
- frontend: Billing.tsx page + api/billing.ts; route + nav link

Phase H — Import Sanity Check:
- Migration 043: import_validations table
- ImportValidation model + schemas
- run_sanity_check: material fuzzy-match (cutoff=0.8), STEP availability, duplicate detection
- validate_excel_import Celery task (queue: step_processing)
- uploads.py: create ImportValidation on /excel, fire task, expose GET /validations/{id}
- frontend: Upload.tsx polling ValidationDialog with Ampel status indicators

Phase I — Notification Settings:
- Migration 044: notification_configs table (user×event×channel toggles)
- NotificationConfig model + seeds (in_app=true, email=false)
- get/upsert/reset config endpoints on /notifications/config
- frontend: NotificationSettings.tsx page + api/notifications.ts extensions

Infrastructure:
- docker-compose.yml: add worker-thumbnail service (concurrency=1, Q=thumbnail_rendering)
- Fix Dockerfile: libgdk-pixbuf-2.0-0 (correct Debian bookworm package name)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:05:01 +01:00
parent 7706c514c8
commit f19a6ccde8
34 changed files with 1940 additions and 14 deletions
+204 -2
View File
@@ -7,8 +7,8 @@ import {
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy,
} from 'lucide-react'
import { toast } from 'sonner'
import { uploadExcel, finalizeExcelImport } from '../api/uploads'
import type { ExcelPreviewResult, OutputTypeSelection } from '../api/uploads'
import { uploadExcel, finalizeExcelImport, getImportValidation } from '../api/uploads'
import type { ExcelPreviewResult, OutputTypeSelection, ImportValidation, ValidationIssue } from '../api/uploads'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
@@ -64,6 +64,27 @@ export default function UploadPage() {
},
})
const [validationId, setValidationId] = useState<string | null>(null)
const [showValidationDialog, setShowValidationDialog] = useState(false)
const { data: validationData } = useQuery({
queryKey: ['validation', validationId],
queryFn: () => getImportValidation(validationId!),
enabled: !!validationId && showValidationDialog,
refetchInterval: (query) => {
const d = query.state.data as ImportValidation | undefined
return d?.status === 'completed' || d?.status === 'failed' ? false : 2000
},
})
const saveAlias = useMutation({
mutationFn: async ({ alias, materialName }: { alias: string; materialName: string }) => {
await api.post('/materials/seed-aliases', { mappings: [{ display_name: alias, render_name: materialName }] })
},
onSuccess: () => toast.success('Alias saved'),
onError: () => toast.error('Failed to save alias'),
})
const uploadMut = useMutation({
mutationFn: uploadExcel,
onSuccess: (data) => {
@@ -80,6 +101,10 @@ export default function UploadPage() {
setIncludedRows(inc)
setRowOutputTypes(rot)
data.warnings.forEach((w) => toast.warning(w))
if (data.validation_id) {
setValidationId(data.validation_id)
setShowValidationDialog(true)
}
setStep(2)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
@@ -496,6 +521,15 @@ export default function UploadPage() {
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
@@ -653,3 +687,171 @@ export default function UploadPage() {
</div>
)
}
// ── ValidationDialog ──────────────────────────────────────────────────────
function ValidationDialog({
validation,
onClose,
onSaveAlias,
}: {
validation: ImportValidation | undefined
onClose: () => void
onSaveAlias: (alias: string, suggestion: string) => void
}) {
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
const summary = validation?.summary
const isLoading =
!validation || validation.status === 'pending' || validation.status === 'running'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Import Validation</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{isLoading ? (
<div className="flex items-center gap-3 text-gray-500">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span>Validating import...</span>
</div>
) : (
<>
{/* Traffic light summary */}
{summary && (
<div className="flex gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">{summary.ok} OK</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-yellow-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm font-medium text-yellow-700">
{summary.warnings} Warnings
</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm font-medium text-red-700">
{summary.errors} Errors
</span>
</div>
</div>
)}
{/* Rows with issues */}
{validation.rows &&
validation.rows
.filter((r) => r.issues.length > 0)
.map((row) => (
<div
key={row.row_index}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<button
className={`w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors ${
row.status === 'error'
? 'bg-red-50'
: row.status === 'warning'
? 'bg-yellow-50'
: 'bg-white'
}`}
onClick={() =>
setExpandedRows((prev) => {
const next = new Set(prev)
next.has(row.row_index)
? next.delete(row.row_index)
: next.add(row.row_index)
return next
})
}
>
<div className="flex items-center gap-3">
<div
className={`w-2.5 h-2.5 rounded-full ${
row.status === 'error'
? 'bg-red-500'
: row.status === 'warning'
? 'bg-yellow-500'
: 'bg-green-500'
}`}
/>
<span className="text-sm font-medium text-gray-700">
Row {row.row_index + 1}
{row.pim_id ? ` — ${row.pim_id}` : ''}
{row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''}
</span>
<span className="text-xs text-gray-400">
{row.issues.length} issue{row.issues.length !== 1 ? 's' : ''}
</span>
</div>
<span className="text-gray-400 text-xs">
{expandedRows.has(row.row_index) ? '' : ''}
</span>
</button>
{expandedRows.has(row.row_index) && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{row.issues.map((issue: ValidationIssue, i: number) => (
<div
key={i}
className="px-4 py-3 flex items-start justify-between gap-4"
>
<div>
<p className="text-sm text-gray-700">{issue.message}</p>
{issue.suggestion && (
<p className="text-xs text-gray-500 mt-0.5">
Suggestion: <em>{issue.suggestion}</em>
</p>
)}
</div>
{issue.type === 'material_suggestion' &&
issue.value &&
issue.suggestion && (
<button
onClick={() =>
onSaveAlias(issue.value!, issue.suggestion!)
}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 whitespace-nowrap"
>
Save as alias
</button>
)}
</div>
))}
</div>
)}
</div>
))}
{validation.rows &&
validation.rows.filter((r) => r.issues.length > 0).length === 0 && (
<p className="text-sm text-green-600 font-medium">
All rows validated successfully.
</p>
)}
</>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
Close
</button>
</div>
</div>
</div>
)
}