diff --git a/docs/craftvia/lanes/import.md b/docs/craftvia/lanes/import.md new file mode 100644 index 0000000..738b64d --- /dev/null +++ b/docs/craftvia/lanes/import.md @@ -0,0 +1,106 @@ +# Lane L3 – Auftragsimport + +Branch `lane/import` (von `feature/craftvia-mvp` @ `bf44567`). Spec §9 komplett, §7.3, §31, US-002, US-003, ARCHITEKTUR §4.4/§4.5. + +## Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §9.2 Dateien | PDF (Text und Scan: Claude liest PDF nativ als `document`-Block, inkl. Seitenbild → kein separates OCR), JPG, PNG. Allowlist + Magic Bytes + Größenlimit (PDF 25 MB, Bild 15 MB) | +| §9.3 1–2 Upload, Typprüfung | `services/imports/upload.ts#createImport` → Dokument (Kategorie `order_confirmation`, Sichtbarkeit `backoffice_only`, SHA-256) → `ImportJob uploaded` → `dispatchJob("import-extraction")` | +| §9.3 3–5 Texterkennung, Analyse, Extraktion | `ai/extraction/anthropic.ts` (Anthropic SDK 0.115, Modell `ANTHROPIC_MODEL`, Default `claude-opus-5`): Streaming + `finalMessage()`, adaptive thinking, Structured Output (`output_config.format` JSON-Schema aus `lib/imports/extraction.ts`), Volltext `text` + 22 Felder mit `value/confidence/source`. Prompt: nichts erfinden, unsichere Felder < 0.8, deutsche Datums-/Zahlenformate normalisieren, Kunde ≠ Briefkopf, Objekt ≠ Kundenadresse. Refusal/`max_tokens` → Fehler; bei Opus 5 serverseitiger Fallback (`fallbacks: "default"`, Beta `server-side-fallback-2026-07-01`) | +| §9.3 6 Plausibilität | `lib/imports/plausibility.ts`: Datum gültig und plausibel (Auftrags-/Dokumentdatum ≤ +1 Monat, Ausführung −2…+3 Jahre), PLZ 5-stellig (DE), E-Mail-/Telefonformat, Ende ≥ Beginn. Verstoß: Konfidenz ≤ 0.4 + Hinweis | +| §9.3 7 / §7.3 / US-003 Dubletten | Kandidaten über Kundennummer, Firmenname (Rechtsform-normalisiert), Personenname, E-Mail, Telefon, Adresse (Straße/Str./Strasse) mit Score + Gründen; Objekt-Kandidaten = Objekte der Kandidaten an gleicher Adresse. Keine automatische Zuordnung/Zusammenführung | +| §9.5 Vertrauenswerte | je Feld gespeichert (`ImportJob.extraction.fields`), Prüfmaske markiert < 0.8 mit Warnfarbe + Icon + Text „unsicher · Sicherheit n %“, Quelle als Tooltip und Hinweistext | +| §9.6 Manuelle Prüfung | `/imports/[id]`: links Original (PDF-iframe / Bild), rechts Formular Kunde · Objekt · Ansprechpartner · Auftrag · Positionen/Material; Kundenentscheidung (Kandidat mit Score/Gründen · Suche · neu anlegen · Link „Dubletten im Kundenstamm zusammenführen“ → L1), Objekt analog (keins/bestehend/neu), Positionen editierbar mit „als Materialvorgabe übernehmen“. **Kein Auftrag ohne Bestätigung** | +| §9.3 10 Bestätigung | `services/imports/confirm.ts#confirmImport`: eine Transaktion – atomarer Statuswechsel `review_required → confirmed` (verhindert Doppelaufträge), Kunde/Kontakt/Objekt anlegen oder zuordnen, `createWorkOrder` (Status-Historie `review_required → planned`, `sourceImportId`, Materialvorgabe), Originaldokument an Auftrag/Kunde/Objekt verknüpft, `corrections` (Diff Extraktion ↔ bestätigt + Entscheidungen). Audit: `import_job` (import), `work_order`/`customer`/`site`/`contact` (create) | +| §9.7 Originaldokument | bleibt dauerhaft (auch bei Verwerfen); gespeichert: Importdatum, importierender Nutzer, erkannter Text, Extraktion, Extraktionsversion, Modell, Provider, Korrekturen, `AiGeneration` | +| §31 Provider-Abstraktion | `DocumentExtractionProvider` (ARCHITEKTUR §4.5), `getExtractionProvider()` → `null` ohne Key bzw. bei `AI_EXTRACTION_PROVIDER≠anthropic`; `FakeExtractionProvider` für Tests | +| Graceful degradation | ohne Provider: `review_required` mit leerer Extraktion + Hinweis „manuell erfassen“ | +| Fehler | Provider-/Dateifehler → `failed` + `errorMessage` + Audit + Event `import.failed`; „Neu verarbeiten“ (`failed → uploaded` + Dispatch). Erfolg → Event `import.ready_for_review` | + +## Routen / Screens + +| Route | Inhalt | +|---|---| +| `/imports` | Upload (Drag & Drop + Dateiauswahl, Fortschrittsbalken per XHR), Liste mit Status (Pill = Farbe + Icon + Text), Aktionen Prüfen / Neu verarbeiten / Auftrag, Auto-Refresh während Verarbeitung | +| `/imports/[id]` | Prüfmaske bzw. Statusansicht (Verarbeitung, fehlgeschlagen + Neu verarbeiten/Verwerfen, bestätigt + Link zum Auftrag, verworfen), erkannter Text aufklappbar | +| `/imports/[id]/file` | Original für die Vorschau (inline, nur eigener Origin als Frame; `?download=1`) – Session + `import:write` (DB-autoritativ) + Modul + Dokument-Sichtbarkeit | +| `POST /api/v1/work-orders/import` | multipart `file` → `201 { id, status }` | +| `GET /api/v1/imports/[id]` | Status, Extraktion inkl. Konfidenzen, Kandidaten | +| `POST /api/v1/imports/[id]/confirm` | JSON = Prüfformular (`lib/imports/review.ts#reviewFormSchema`) → `{ workOrderId, workOrderNumber, customerId, siteId, contactId }` | + +Fehler-Mapping API: `not_found` 404, `forbidden` 403, `invalid` 400 (mit Feldpfaden), `conflict` 409, ohne Session 401. + +## Dateien + +- `src/lib/imports/` – `extraction.ts` (Felder, Zod, JSON-Schema, gespeicherte Form), `plausibility.ts`, `review.ts` (Formularschema, Mapping, Feld-Konfidenzen, Korrektur-Diff), `status.ts` +- `src/server/ai/extraction/` – `anthropic.ts` (Provider + `getExtractionProvider`), `fake.ts` +- `src/server/services/imports/` – `upload.ts`, `process.ts`, `confirm.ts` (inkl. Verwerfen/Neu verarbeiten), `queries.ts`, Stubs `document-store-stub.ts`, `duplicates-stub.ts`, `work-orders-stub.ts` +- `src/server/jobs/processors/import-extraction.ts` +- `src/server/actions/imports/imports.ts` (confirm, discard, retry, Kundensuche – je `moduleGuard("imports")` + `await guard(...)`) +- `src/app/(app)/imports/page.tsx`, `src/app/(app)/imports/[id]/page.tsx`, `src/app/(app)/imports/[id]/file/route.ts` +- `src/app/api/v1/imports/_context.ts`, `src/app/api/v1/imports/[id]/route.ts`, `src/app/api/v1/imports/[id]/confirm/route.ts`, `src/app/api/v1/work-orders/import/route.ts` +- `src/components/imports/` – `uploader.tsx`, `review-form.tsx`, `status-pill.tsx`, `job-actions.tsx`, `auto-refresh.tsx` +- `messages/de/imports.json`, `messages/en/imports.json` +- `scripts/make-sample-pdfs.ts` → `docs/craftvia/samples/01-musterbau-auftragsbestaetigung.pdf`, `02-elbblick-wartungsauftrag.pdf`, `03-privatkunde-reparatur.pdf` (fiktiv; Nr. 3 enthält absichtlich PLZ „2148“, E-Mail „(at)“ und Ende vor Beginn für die Plausibilitätshinweise) +- Tests: `scripts/test-import-rules.ts`, `scripts/test-import-flow.ts`, `scripts/test-import-live.ts` + +Fremd-Einzeiler: `src/server/jobs/processors/index.ts` (Processor-Registrierung). `src/lib/nav.ts` enthielt `/imports` bereits. + +## Tests + +| Skript | Prüfungen | Inhalt | +|---|---|---| +| `test-import-rules.ts` | 55 | Plausibilitätsregeln, Parsing der Provider-Ausgabe, JSON-Schema (strict-tauglich), Mapping Extraktion → Formular, Formularvalidierung, Korrektur-Diff, Normalisierung Dubletten, Magic Bytes/Dateiname, PDF-Generator | +| `test-import-flow.ts` | 75 | Upload/Validierung, Verarbeitung mit Fake-Provider, Dubletten-/Objektkandidaten, AiGeneration, kein Auftrag ohne Bestätigung, Idempotenz; **Rollen** (Monteur/Teamleiter → `forbidden`); **Mandantentrennung** (B kann A weder lesen, laden, bestätigen, verwerfen, neu verarbeiten noch A-Kunden zuordnen; Kandidaten/Suche nur im Mandanten); Bestätigung bestehender vs. neuer Kunde inkl. Dokumentverknüpfung, Korrekturen, Audit; Doppelbestätigung → conflict; vergebene Kundennummer → conflict ohne Teilanlage; Verwerfen; Provider-Fehler/Datei fehlt/Dispatch-Fehler → failed + Neu verarbeiten; ohne Provider → manuell | +| `test-import-live.ts` | 6 | nur mit `ANTHROPIC_API_KEY` (sonst übersprungen): echte Extraktion einer generierten Auftragsbestätigung | + +## Gate + +`npm run gate` grün (Lane-DB `craftvia_import`, `RLS_DATABASE_URL` auf dieselbe DB): prisma generate, `tsc` ohne Fehler, Lint 0 Fehler (2 Warnungen im Fundament-Platzhalter `services/notifications/handle-event.ts`), Build inkl. Modul-Guard-Check (14 Action-Dateien), **25/25 Testskripte grün**. `test-import-live.ts` ohne `ANTHROPIC_API_KEY` übersprungen (Exit 0) – die Live-Extraktion gegen Claude ist damit **nicht** verifiziert. + +## Smoke (Dev-Server :3103, curl mit Seed-Logins) + +| Prüfung | Ergebnis | +|---|---| +| ohne Session: `/imports`, `/imports/[id]`, `/imports/[id]/file`, `/api/v1/**` | 307 → `/login` | +| Backoffice `GET /imports` | 200, Upload-Bereich gerendert | +| Backoffice `POST /api/v1/work-orders/import` (Beispiel-PDF 01) | 201, ohne API-Key direkt `review_required` mit Hinweis „manuell erfassen“ | +| Upload `package.json` | 400 | +| `GET /api/v1/imports/[id]` / unbekannte ID | 200 / 404 | +| `GET /imports/[id]` | 200, Prüfmaske + Originaldokument gerendert | +| `GET /imports/[id]/file` | 200, `inline`, **aber `X-Frame-Options: DENY`** (siehe Bedarf Punkt 2) | +| `POST /api/v1/imports/[id]/confirm` mit `{}` | 400 mit Feldpfaden | +| Monteur: API / Datei / Upload | 403 / 404 / 403 | + +Keine visuelle Browser-Prüfung (Login-Maske würde Passworteingabe durch den Agenten erfordern) – Layout 1024/768/375 px noch manuell prüfen. Der Smoke hinterlässt einen Import im Mandanten `demo` der Lane-DB. + +## Stubs / Abhängigkeiten zu anderen Lanes + +| Stub (L3-Pfad) | Vertrag | Ablösen | +|---|---|---| +| `services/imports/document-store-stub.ts#storeFile` | ARCHITEKTUR §4.3 `services/documents/store.ts#storeFile` (auf dem Basis-Commit nicht vorhanden; keiner Lane zugeordnet) | Import in `upload.ts` umstellen; `readDocumentBytes` durch Service-Funktion ersetzen | +| `services/imports/duplicates-stub.ts#findDuplicateCustomers` | L1 `lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → { customerId, score, reasons[] }[]` | Import in `process.ts` umstellen (`findSiteCandidates` bleibt L3) | +| `services/imports/work-orders-stub.ts#createWorkOrder` + `CreateWorkOrderInput` | L2 `createWorkOrder(ctx, input)`; Stub schreibt Status-Historie direkt statt über `transitionWorkOrder` | Import in `confirm.ts` umstellen; L2-Service muss einen Transaktions-Client in `ctx.db` akzeptieren und `sourceImportId`, Status `planned` (Historie ab `review_required`) und Materialvorgabe unterstützen | +| `app/api/v1/imports/_context.ts#importsApiContext` | gemeinsames `requireApiContext` (in `services/context.ts` erwähnt, nicht vorhanden) | durch zentrale Funktion ersetzen | + +Links in andere Lanes: `/work-orders/[id]` (L2), `/customers/[id]` für das Zusammenführen (L1, Merge mit Bestätigung). +Hinweis Pfad: `src/app/api/v1/work-orders/import/route.ts` liegt im L2-Ordner `api/v1/work-orders` (vom Auftrag so gefordert) – neue Datei, kein Konflikt mit L2-Dateien zu erwarten. + +## Bedarf an Fundament / Architektur (nicht geändert) + +1. **Upload > 10 MB:** `src/proxy.ts` puffert Request-Bodies standardmäßig nur bis 10 MB (`proxyClientMaxBodySize`, danach wird der Body still abgeschnitten). Für die geforderten 25 MB in `next.config.ts` `experimental.proxyClientMaxBodySize: "26mb"` setzen. Server Actions werden für Uploads bewusst nicht genutzt (1-MB-Limit). +2. **PDF-Vorschau im iframe:** globale Header in `next.config.ts` setzen `X-Frame-Options: DENY` und `frame-ancestors 'none'` für alle Pfade. Die Datei-Route setzt `SAMEORIGIN`/`frame-ancestors 'self'`, **im Smoke verifiziert: der globale Header gewinnt (`X-Frame-Options: DENY`) → die iframe-Vorschau bleibt im Browser leer**; „In neuem Tab öffnen“ funktioniert. `object`/`embed` unterliegen derselben Regel, daher kein Umweg in L3. Fix im Fundament: in `next.config.ts` für `/imports/:id/file` `X-Frame-Options: SAMEORIGIN` und `frame-ancestors 'self'` statt `DENY`/`'none'` ausliefern. Außerdem braucht die Vorschau echte Bytes → `S3_*` muss gesetzt sein (ohne S3 speichert der Storage-Stub keine Bytes; Verarbeitung mit Provider endet dann mit `file_unavailable`). +3. **RLS_ENFORCED=true:** `dbForTenant` öffnet je Operation eine eigene Transaktion; innerhalb von `ctx.db.$transaction` (Bestätigung) ist das nicht atomar. Betrifft alle Lanes mit Transaktionen – zentral in `db.ts` lösen. +4. `documents/store.ts` (§4.3) ist keiner Lane zugeordnet – Zuständigkeit klären. +5. **Eigene Lane-Datenbank und RLS-Test:** `scripts/test-rls-enforcement.ts` legt Fixtures über `DATABASE_URL` an, verbindet `craftvia_app` aber ohne `RLS_DATABASE_URL` fest auf die DB `craftvia`. Mit `DATABASE_URL=…/craftvia_import` schlägt der Test deshalb fehl (0 Zeilen, FK-Verletzung) – kein Codefehler. Gate daher mit `RLS_DATABASE_URL=postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia_import?schema=public` ausgeführt; der Test ist damit grün. Vorschlag Fundament: Default-URL aus `DATABASE_URL` ableiten. + +## Bekannte Lücken + +- Keine Migration nötig: Hinweise und Objektkandidaten liegen in `ImportJob.extraction` (`{ fields, hints, siteCandidates }`), Dublettenkandidaten in `duplicate_candidates`. +- Malware-Scan nur Magic-Byte-/Typprüfung (ClamAV-Hook gehört zum Dokumentservice §4.3). +- Kostenlimit/Region/Aufbewahrung je Mandant (§31) nicht umgesetzt; Konfiguration nur per Env. +- Kundensuche in der Prüfmaske: Top 10 nach Name/Nummer/Ort/E-Mail; kein Paging. +- Keine Teamzuweisung/Auftragsart in der Prüfmaske (erfolgt danach im Auftrag, L2). +- Worker: mit `REDIS_URL` läuft die Extraktion in `npm run worker:craftvia`; ohne laufenden Worker bleibt ein Import auf „Hochgeladen“. diff --git a/docs/craftvia/samples/01-musterbau-auftragsbestaetigung.pdf b/docs/craftvia/samples/01-musterbau-auftragsbestaetigung.pdf new file mode 100644 index 0000000..75a61e2 --- /dev/null +++ b/docs/craftvia/samples/01-musterbau-auftragsbestaetigung.pdf @@ -0,0 +1,61 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [6 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> +endobj +5 0 obj +<< /Length 2221 >> +stream +BT /F2 16 Tf 56 790 Td (Kranich Haustechnik GmbH) Tj ET +BT /F1 8 Tf 56 777 Td (Deichweg 4 \267 21029 Hamburg \267 Tel. 040 0000 1000 \267 info@kranich-haustechnik.example.org) Tj ET +BT /F1 10 Tf 56 737 Td (Musterbau GmbH) Tj ET +BT /F1 10 Tf 56 722 Td (z. Hd. Frau Jana K\366hler) Tj ET +BT /F1 10 Tf 56 707 Td (Hafenstra\337e 12) Tj ET +BT /F1 10 Tf 56 692 Td (20457 Hamburg) Tj ET +BT /F2 14 Tf 56 656 Td (Auftragsbest\344tigung) Tj ET +BT /F1 10 Tf 56 636 Td (Auftragsnummer: AB-2026-0815 Datum: 02.09.2026) Tj ET +BT /F1 10 Tf 56 621 Td (Ihr Angebot: ANG-2026-0342 Kundennummer: K-10042) Tj ET +BT /F1 10 Tf 56 606 Td (Bauvorhaben: Neubau B\374rogeb\344ude Speicherhof, Am Kaiserkai 30, 20457 Hamburg) Tj ET +BT /F1 10 Tf 56 591 Td (Ansprechpartnerin vor Ort: Jana K\366hler, Tel. 040 0000 2233, j.koehler@musterbau.example.org) Tj ET +BT /F2 10 Tf 56 576 Td (Ausf\374hrungszeitraum: 12.10.2026 bis 16.10.2026) Tj ET +BT /F1 10 Tf 56 552 Td (Sehr geehrte Frau K\366hler,) Tj ET +BT /F1 10 Tf 56 537 Td (vielen Dank f\374r Ihren Auftrag. Wir best\344tigen die Ausf\374hrung folgender Leistungen:) Tj ET +BT /F2 10 Tf 56 513 Td (Pos. Bezeichnung Art.-Nr. Menge Einheit) Tj ET +BT /F1 10 Tf 56 498 Td (1 Montage W\344rmepumpe Luft/Wasser 12 kW 1 Stk) Tj ET +BT /F1 10 Tf 56 483 Td (2 W\344rmepumpe Aerotherm 12 kW WP-AT-12 1 Stk) Tj ET +BT /F1 10 Tf 56 468 Td (3 Pufferspeicher 500 l PS-500 1 Stk) Tj ET +BT /F1 10 Tf 56 453 Td (4 Kupferrohr 22 mm CU-22 24 m) Tj ET +BT /F1 10 Tf 56 438 Td (5 Inbetriebnahme und Einweisung 4 Std) Tj ET +BT /F2 10 Tf 56 414 Td (Gesamtbetrag netto: 18.450,00 \200 zzgl. 19 % MwSt.: 3.505,50 \200 Gesamt: 21.955,50 \200) Tj ET +BT /F1 10 Tf 56 390 Td (Hinweise: Zufahrt \374ber Tor 2, Anmeldung beim Bauleiter. Kran ist bauseits zu stellen.) Tj ET +BT /F1 10 Tf 56 375 Td (Referenz: Ihre Bestellung BE-7781 vom 28.08.2026) Tj ET +BT /F1 10 Tf 56 345 Td (Mit freundlichen Gr\374\337en) Tj ET +BT /F1 10 Tf 56 330 Td (Kranich Haustechnik GmbH) Tj ET +endstream +endobj +6 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000218 00000 n +0000000320 00000 n +0000002593 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +2729 +%%EOF diff --git a/docs/craftvia/samples/02-elbblick-wartungsauftrag.pdf b/docs/craftvia/samples/02-elbblick-wartungsauftrag.pdf new file mode 100644 index 0000000..00ab6fc --- /dev/null +++ b/docs/craftvia/samples/02-elbblick-wartungsauftrag.pdf @@ -0,0 +1,54 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [6 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> +endobj +5 0 obj +<< /Length 1425 >> +stream +BT /F2 16 Tf 56 790 Td (Kranich Haustechnik GmbH) Tj ET +BT /F1 8 Tf 56 777 Td (Deichweg 4 \267 21029 Hamburg \267 Tel. 040 0000 1000 \267 info@kranich-haustechnik.example.org) Tj ET +BT /F1 10 Tf 56 737 Td (Elbblick Wohnen eG) Tj ET +BT /F1 10 Tf 56 722 Td (Hausverwaltung) Tj ET +BT /F1 10 Tf 56 707 Td (Neum\374hlen 7) Tj ET +BT /F1 10 Tf 56 692 Td (22763 Hamburg) Tj ET +BT /F2 14 Tf 56 656 Td (Auftrag Wartung Heizungsanlagen) Tj ET +BT /F1 10 Tf 56 636 Td (Auftrag Nr. W-26-117 \267 Kunden-Nr. K-20017 \267 Hamburg, den 01.09.2026) Tj ET +BT /F1 10 Tf 56 621 Td (Objekt: Wohnanlage Elbhang, \326velg\366nne 45, 22605 Hamburg) Tj ET +BT /F1 10 Tf 56 606 Td (Ansprechpartner: Herr Timo Brandt, Hausmeister, Tel. +49 40 0000 4545) Tj ET +BT /F1 10 Tf 56 586 Td (Termin: KW 41 nach Absprache mit dem Hausmeister) Tj ET +BT /F2 10 Tf 56 564 Td (Leistungsumfang:) Tj ET +BT /F1 10 Tf 56 549 Td (J\344hrliche Wartung von 2 Gas-Brennwertkesseln inkl. Abgasmessung und Protokoll.) Tj ET +BT /F1 10 Tf 56 534 Td (1 Wartung Gas-Brennwertkessel 2 Stk) Tj ET +BT /F1 10 Tf 56 519 Td (2 Wartungsset Dichtungen 2 Satz) Tj ET +BT /F1 10 Tf 56 504 Td (3 Abgasmessung mit Protokoll 2 Stk) Tj ET +BT /F1 10 Tf 56 482 Td (Besondere Hinweise: Heizungsraum im Keller, Schl\374ssel beim Hausmeister.) Tj ET +BT /F1 10 Tf 56 467 Td (R\374ckfragen bitte an verwaltung@elbblick.example.org) Tj ET +endstream +endobj +6 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000218 00000 n +0000000320 00000 n +0000001797 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +1933 +%%EOF diff --git a/docs/craftvia/samples/03-privatkunde-reparatur.pdf b/docs/craftvia/samples/03-privatkunde-reparatur.pdf new file mode 100644 index 0000000..d308750 --- /dev/null +++ b/docs/craftvia/samples/03-privatkunde-reparatur.pdf @@ -0,0 +1,51 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [6 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> +endobj +5 0 obj +<< /Length 1061 >> +stream +BT /F2 16 Tf 56 790 Td (Kranich Haustechnik GmbH) Tj ET +BT /F1 8 Tf 56 777 Td (Deichweg 4 \267 21029 Hamburg \267 Tel. 040 0000 1000 \267 info@kranich-haustechnik.example.org) Tj ET +BT /F1 10 Tf 56 737 Td (Frau) Tj ET +BT /F1 10 Tf 56 722 Td (Petra Sommer) Tj ET +BT /F1 10 Tf 56 707 Td (Lindenallee 9a) Tj ET +BT /F1 10 Tf 56 692 Td (2148 Hamburg) Tj ET +BT /F2 14 Tf 56 656 Td (Auftragsbest\344tigung Reparatur) Tj ET +BT /F1 10 Tf 56 636 Td (Auftrag: AB-2026-0901 Datum: 03.09.2026) Tj ET +BT /F1 10 Tf 56 621 Td (Einsatzort: wie oben) Tj ET +BT /F1 10 Tf 56 606 Td (Telefon: 0170 0000 987 E-Mail: petra.sommer\(at\)example.org) Tj ET +BT /F1 10 Tf 56 591 Td (Geplanter Termin: 18.09.2026, Ende 17.09.2026) Tj ET +BT /F1 10 Tf 56 569 Td (Leistung: Austausch defekter Thermostatkopf im Bad, Pr\374fung Heizk\366rperventile.) Tj ET +BT /F1 10 Tf 56 554 Td (1 Thermostatkopf Standard TK-100 1 Stk) Tj ET +BT /F1 10 Tf 56 539 Td (2 Arbeitszeit Monteur 1,5 Std) Tj ET +BT /F2 10 Tf 56 517 Td (Gesamt brutto: 189,40 \200) Tj ET +endstream +endobj +6 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000218 00000 n +0000000320 00000 n +0000001433 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +1569 +%%EOF diff --git a/messages/de/imports.json b/messages/de/imports.json new file mode 100644 index 0000000..b64c649 --- /dev/null +++ b/messages/de/imports.json @@ -0,0 +1,209 @@ +{ + "crumb": "Aufträge", + "title": "Auftragsimport", + "sub": "Auftragsbestätigung hochladen, erkannte Daten prüfen und den Auftrag anlegen. Ohne Bestätigung entsteht kein Auftrag.", + "noPermission": "Für den Auftragsimport fehlt die Berechtigung.", + "upload": { + "title": "Dokument hochladen", + "drop": "PDF, JPG oder PNG hierher ziehen", + "or": "oder", + "choose": "Datei auswählen", + "limits": "PDF bis 25 MB, Bilder bis 15 MB. Das Original bleibt am Auftrag gespeichert.", + "uploading": "Wird hochgeladen … {percent} %", + "done": "Hochgeladen. Daten werden erkannt.", + "errors": { + "file_type_not_allowed": "Dateityp nicht erlaubt. Erlaubt sind PDF, JPG und PNG.", + "file_too_large": "Datei ist zu groß.", + "file_type_mismatch": "Dateiinhalt passt nicht zum Dateityp.", + "file_empty": "Datei ist leer.", + "file_missing": "Keine Datei ausgewählt.", + "forbidden": "Keine Berechtigung.", + "unauthorized": "Sitzung abgelaufen. Bitte neu anmelden.", + "network": "Hochladen fehlgeschlagen. Verbindung prüfen.", + "error": "Hochladen fehlgeschlagen." + } + }, + "list": { + "title": "Importe", + "empty": "Noch keine Importe. Laden Sie eine Auftragsbestätigung hoch.", + "file": "Datei", + "uploadedAt": "Hochgeladen", + "uploadedBy": "Von", + "status": "Status", + "action": "Aktion", + "review": "Prüfen", + "open": "Ansehen", + "workOrder": "Auftrag {number}" + }, + "status": { + "uploaded": "Hochgeladen", + "processing": "Verarbeitung", + "review_required": "Prüfung erforderlich", + "confirmed": "Bestätigt", + "failed": "Fehlgeschlagen", + "discarded": "Verworfen" + }, + "actions": { + "retry": "Neu verarbeiten", + "retrying": "Wird gestartet …", + "discard": "Verwerfen", + "discardConfirm": "Import verwerfen? Es wird kein Auftrag angelegt. Das Originaldokument bleibt gespeichert.", + "confirm": "Bestätigen und Auftrag anlegen", + "confirming": "Auftrag wird angelegt …" + }, + "detail": { + "back": "Zurück zu den Importen", + "meta": "{file} · hochgeladen am {date} von {user}", + "model": "Erkannt mit {model}", + "preview": "Originaldokument", + "previewTitle": "Vorschau des Originaldokuments", + "openNewTab": "In neuem Tab öffnen", + "download": "Herunterladen", + "processing": "Das Dokument wird verarbeitet. Die Seite aktualisiert sich automatisch.", + "failed": "Die Verarbeitung ist fehlgeschlagen.", + "failedDetail": "Technischer Hinweis: {message}", + "confirmed": "Import bestätigt. Auftrag {number} wurde angelegt.", + "openWorkOrder": "Auftrag öffnen", + "corrections": "{count, plural, =0 {Keine Korrekturen} one {1 Feld korrigiert} other {# Felder korrigiert}}", + "discarded": "Import verworfen. Es wurde kein Auftrag angelegt.", + "extractedText": "Erkannter Text", + "noText": "Kein Text erkannt." + }, + "review": { + "title": "Erkannte Daten prüfen", + "intro": "Unsichere Felder sind markiert. Bitte prüfen und bei Bedarf korrigieren.", + "manualEntry": "Keine automatische Erkennung verfügbar. Bitte Daten manuell erfassen.", + "hintsTitle": "Hinweise zur Plausibilität", + "uncertain": "unsicher", + "confidence": "Sicherheit {percent} %", + "source": "Quelle: {text}", + "required": "Pflichtfeld", + "sections": { + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "order": "Auftrag", + "positions": "Positionen und Material" + }, + "customer": { + "existing": "Bestehenden Kunden verwenden", + "new": "Neuen Kunden anlegen", + "candidates": "Mögliche Treffer im Kundenstamm", + "noCandidates": "Keine passenden Kunden gefunden.", + "match": "Übereinstimmung {percent} %", + "merge": "Dubletten im Kundenstamm zusammenführen", + "search": "Kunden suchen", + "searchPlaceholder": "Name, Kundennummer oder Ort", + "searchButton": "Suchen", + "searchEmpty": "Keine Kunden gefunden.", + "select": "Auswählen" + }, + "site": { + "none": "Kein Objekt zuordnen", + "existing": "Bestehendes Objekt verwenden", + "new": "Neues Objekt anlegen", + "noSites": "Für den gewählten Kunden sind keine Objekte angelegt.", + "chooseCustomer": "Zuerst einen bestehenden Kunden auswählen.", + "suggested": "Gleiche Adresse" + }, + "reasons": { + "customer_number": "Kundennummer", + "company_name": "Firmenname", + "company_name_similar": "ähnlicher Firmenname", + "person_name": "Name", + "email": "E-Mail", + "phone": "Telefon", + "address": "Adresse", + "house_number": "Hausnummer", + "site_name": "Objektname" + }, + "fields": { + "customerNumber": "Kundennummer", + "companyName": "Firmenname", + "firstName": "Vorname", + "lastName": "Nachname", + "street": "Straße", + "houseNumber": "Hausnummer", + "postalCode": "PLZ", + "city": "Ort", + "country": "Land", + "phone": "Telefon", + "email": "E-Mail", + "name": "Name", + "siteName": "Objektbezeichnung", + "title": "Auftragstitel", + "externalOrderNumber": "Auftragsnummer (Dokument)", + "offerNumber": "Angebotsnummer", + "description": "Leistungsbeschreibung", + "plannedStart": "Geplanter Beginn", + "plannedEnd": "Geplantes Ende", + "notes": "Besondere Hinweise" + }, + "extractionFields": { + "orderNumber": "Auftragsnummer", + "offerNumber": "Angebotsnummer", + "customerNumber": "Kundennummer", + "companyName": "Firmenname", + "customerFirstName": "Vorname", + "customerLastName": "Nachname", + "customerAddress": "Kundenadresse", + "siteName": "Objekt", + "siteAddress": "Objektadresse", + "contactName": "Ansprechpartner", + "phone": "Telefon", + "email": "E-Mail", + "orderDate": "Auftragsdatum", + "documentDate": "Dokumentdatum", + "plannedStart": "Geplanter Beginn", + "plannedEnd": "Geplantes Ende", + "title": "Titel", + "description": "Leistungsbeschreibung", + "positions": "Positionen", + "notes": "Hinweise", + "totalAmount": "Gesamtbetrag", + "references": "Referenzen" + }, + "hints": { + "date_invalid": "{field}: Datum nicht lesbar.", + "date_implausible": "{field}: Datum liegt ungewöhnlich weit in Vergangenheit oder Zukunft.", + "postal_code_invalid": "{field}: Postleitzahl ist nicht 5-stellig.", + "email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.", + "phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.", + "end_before_start": "{field}: Ende liegt vor dem Beginn.", + "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen." + }, + "positions": { + "name": "Bezeichnung", + "articleNumber": "Artikelnr.", + "quantity": "Menge", + "unit": "Einheit", + "asMaterial": "als Materialvorgabe übernehmen", + "remove": "Position entfernen", + "add": "Position hinzufügen", + "empty": "Keine Positionen erkannt." + } + }, + "errors": { + "form_invalid": "Bitte die markierten Felder prüfen.", + "title_required": "Auftragstitel fehlt.", + "customer_required": "Bitte einen Kunden auswählen.", + "customer_name_required": "Firmenname oder Nachname angeben.", + "site_required": "Bitte ein Objekt auswählen.", + "site_name_required": "Objektbezeichnung oder Straße angeben.", + "site_requires_existing_customer": "Ein bestehendes Objekt setzt einen bestehenden Kunden voraus.", + "email_invalid": "E-Mail-Adresse hat kein gültiges Format.", + "postal_code_invalid": "Postleitzahl muss 5-stellig sein.", + "end_before_start": "Ende liegt vor dem Beginn.", + "date_invalid": "Datum nicht lesbar.", + "quantity_invalid": "Menge muss eine Zahl sein.", + "customer_number_taken": "Diese Kundennummer ist bereits vergeben. Bestehenden Kunden verwenden oder Nummer leeren.", + "import_not_reviewable": "Dieser Import wurde bereits bearbeitet.", + "import_not_discardable": "Dieser Import kann nicht mehr verworfen werden.", + "import_not_retryable": "Nur fehlgeschlagene Importe können neu verarbeitet werden.", + "not_found": "Datensatz nicht gefunden.", + "forbidden": "Keine Berechtigung für diese Aktion.", + "file_unavailable": "Originaldatei ist nicht verfügbar.", + "dispatch_failed": "Verarbeitung konnte nicht gestartet werden.", + "error": "Vorgang nicht möglich." + } +} diff --git a/messages/en/imports.json b/messages/en/imports.json new file mode 100644 index 0000000..abf4b56 --- /dev/null +++ b/messages/en/imports.json @@ -0,0 +1,209 @@ +{ + "crumb": "Work orders", + "title": "Order import", + "sub": "Upload an order confirmation, check the recognised data and create the work order. Nothing is created without confirmation.", + "noPermission": "You are not allowed to import orders.", + "upload": { + "title": "Upload document", + "drop": "Drop a PDF, JPG or PNG here", + "or": "or", + "choose": "Choose file", + "limits": "PDF up to 25 MB, images up to 15 MB. The original stays attached to the work order.", + "uploading": "Uploading … {percent} %", + "done": "Uploaded. Recognising data.", + "errors": { + "file_type_not_allowed": "File type not allowed. Allowed: PDF, JPG and PNG.", + "file_too_large": "File is too large.", + "file_type_mismatch": "File content does not match its type.", + "file_empty": "File is empty.", + "file_missing": "No file selected.", + "forbidden": "Not allowed.", + "unauthorized": "Session expired. Please sign in again.", + "network": "Upload failed. Check the connection.", + "error": "Upload failed." + } + }, + "list": { + "title": "Imports", + "empty": "No imports yet. Upload an order confirmation.", + "file": "File", + "uploadedAt": "Uploaded", + "uploadedBy": "By", + "status": "Status", + "action": "Action", + "review": "Review", + "open": "View", + "workOrder": "Work order {number}" + }, + "status": { + "uploaded": "Uploaded", + "processing": "Processing", + "review_required": "Review required", + "confirmed": "Confirmed", + "failed": "Failed", + "discarded": "Discarded" + }, + "actions": { + "retry": "Process again", + "retrying": "Starting …", + "discard": "Discard", + "discardConfirm": "Discard this import? No work order will be created. The original document is kept.", + "confirm": "Confirm and create work order", + "confirming": "Creating work order …" + }, + "detail": { + "back": "Back to imports", + "meta": "{file} · uploaded on {date} by {user}", + "model": "Recognised with {model}", + "preview": "Original document", + "previewTitle": "Preview of the original document", + "openNewTab": "Open in new tab", + "download": "Download", + "processing": "The document is being processed. This page refreshes automatically.", + "failed": "Processing failed.", + "failedDetail": "Technical detail: {message}", + "confirmed": "Import confirmed. Work order {number} was created.", + "openWorkOrder": "Open work order", + "corrections": "{count, plural, =0 {No corrections} one {1 field corrected} other {# fields corrected}}", + "discarded": "Import discarded. No work order was created.", + "extractedText": "Recognised text", + "noText": "No text recognised." + }, + "review": { + "title": "Check recognised data", + "intro": "Uncertain fields are marked. Please check and correct them if needed.", + "manualEntry": "Automatic recognition is not available. Please enter the data manually.", + "hintsTitle": "Plausibility notes", + "uncertain": "uncertain", + "confidence": "Confidence {percent} %", + "source": "Source: {text}", + "required": "Required", + "sections": { + "customer": "Customer", + "site": "Site", + "contact": "Contact person", + "order": "Work order", + "positions": "Line items and material" + }, + "customer": { + "existing": "Use existing customer", + "new": "Create new customer", + "candidates": "Possible matches", + "noCandidates": "No matching customers found.", + "match": "Match {percent} %", + "merge": "Merge duplicates in customer records", + "search": "Search customers", + "searchPlaceholder": "Name, customer number or city", + "searchButton": "Search", + "searchEmpty": "No customers found.", + "select": "Select" + }, + "site": { + "none": "No site", + "existing": "Use existing site", + "new": "Create new site", + "noSites": "The selected customer has no sites.", + "chooseCustomer": "Select an existing customer first.", + "suggested": "Same address" + }, + "reasons": { + "customer_number": "customer number", + "company_name": "company name", + "company_name_similar": "similar company name", + "person_name": "name", + "email": "e-mail", + "phone": "phone", + "address": "address", + "house_number": "house number", + "site_name": "site name" + }, + "fields": { + "customerNumber": "Customer number", + "companyName": "Company name", + "firstName": "First name", + "lastName": "Last name", + "street": "Street", + "houseNumber": "House number", + "postalCode": "Postal code", + "city": "City", + "country": "Country", + "phone": "Phone", + "email": "E-mail", + "name": "Name", + "siteName": "Site name", + "title": "Work order title", + "externalOrderNumber": "Order number (document)", + "offerNumber": "Offer number", + "description": "Description of services", + "plannedStart": "Planned start", + "plannedEnd": "Planned end", + "notes": "Special notes" + }, + "extractionFields": { + "orderNumber": "Order number", + "offerNumber": "Offer number", + "customerNumber": "Customer number", + "companyName": "Company name", + "customerFirstName": "First name", + "customerLastName": "Last name", + "customerAddress": "Customer address", + "siteName": "Site", + "siteAddress": "Site address", + "contactName": "Contact person", + "phone": "Phone", + "email": "E-mail", + "orderDate": "Order date", + "documentDate": "Document date", + "plannedStart": "Planned start", + "plannedEnd": "Planned end", + "title": "Title", + "description": "Description", + "positions": "Line items", + "notes": "Notes", + "totalAmount": "Total amount", + "references": "References" + }, + "hints": { + "date_invalid": "{field}: date not readable.", + "date_implausible": "{field}: date is unusually far in the past or future.", + "postal_code_invalid": "{field}: postal code does not have 5 digits.", + "email_invalid": "{field}: e-mail address format is invalid.", + "phone_invalid": "{field}: phone number format is invalid.", + "end_before_start": "{field}: end is before start.", + "manual_entry": "Automatic recognition is not available. Please enter manually." + }, + "positions": { + "name": "Description", + "articleNumber": "Article no.", + "quantity": "Quantity", + "unit": "Unit", + "asMaterial": "use as planned material", + "remove": "Remove line item", + "add": "Add line item", + "empty": "No line items recognised." + } + }, + "errors": { + "form_invalid": "Please check the marked fields.", + "title_required": "Work order title is missing.", + "customer_required": "Please select a customer.", + "customer_name_required": "Enter a company name or last name.", + "site_required": "Please select a site.", + "site_name_required": "Enter a site name or street.", + "site_requires_existing_customer": "An existing site requires an existing customer.", + "email_invalid": "E-mail address format is invalid.", + "postal_code_invalid": "Postal code must have 5 digits.", + "end_before_start": "End is before start.", + "date_invalid": "Date not readable.", + "quantity_invalid": "Quantity must be a number.", + "customer_number_taken": "This customer number is already taken. Use the existing customer or clear the number.", + "import_not_reviewable": "This import has already been handled.", + "import_not_discardable": "This import can no longer be discarded.", + "import_not_retryable": "Only failed imports can be processed again.", + "not_found": "Record not found.", + "forbidden": "Not allowed.", + "file_unavailable": "The original file is not available.", + "dispatch_failed": "Processing could not be started.", + "error": "Action not possible." + } +} diff --git a/scripts/make-sample-pdfs.ts b/scripts/make-sample-pdfs.ts new file mode 100644 index 0000000..f9eeec9 --- /dev/null +++ b/scripts/make-sample-pdfs.ts @@ -0,0 +1,155 @@ +/** + * Generates fictitious sample order confirmations for the import lane (L3) as text PDFs — + * handwritten minimal PDF writer (Helvetica, WinAnsiEncoding), no dependencies. + * + * Lauf: npx tsx scripts/make-sample-pdfs.ts → docs/craftvia/samples/*.pdf + * All companies, people, addresses and numbers are invented (example.org, 0000-numbers). + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "docs", "craftvia", "samples"); + +type Line = { text: string; size?: number; bold?: boolean; x?: number; gap?: number }; + +/** Latin-1/WinAnsi string literal for a PDF content stream. */ +function pdfString(s: string): string { + let out = ""; + for (const ch of s) { + if (ch === "€") out += "\\200"; + else if (ch === "–") out += "\\226"; + else if (ch === "(" || ch === ")" || ch === "\\") out += `\\${ch}`; + else { + const code = ch.charCodeAt(0); + out += code < 128 ? ch : `\\${code.toString(8).padStart(3, "0")}`; + } + } + return `(${out})`; +} + +export function buildPdf(pages: Line[][]): Buffer { + const objects: string[] = []; + const add = (body: string) => { + objects.push(body); + return objects.length; + }; + const catalogId = add(""); + const pagesId = add(""); + const fontId = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + const boldId = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"); + const pageIds: number[] = []; + + for (const lines of pages) { + let y = 800; + const ops: string[] = []; + for (const l of lines) { + const size = l.size ?? 10; + y -= l.gap ?? size + 5; + ops.push(`BT /${l.bold ? "F2" : "F1"} ${size} Tf ${l.x ?? 56} ${y} Td ${pdfString(l.text)} Tj ET`); + } + const stream = ops.join("\n"); + const contentId = add(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`); + pageIds.push( + add(`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 ${fontId} 0 R /F2 ${boldId} 0 R >> >> /Contents ${contentId} 0 R >>`), + ); + } + objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`; + objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageIds.length} >>`; + + let pdf = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"; + const offsets: number[] = []; + objects.forEach((body, i) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${i + 1} 0 obj\n${body}\nendobj\n`; + }); + const xref = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const o of offsets) pdf += `${String(o).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} + +const letterhead = (): Line[] => [ + { text: "Kranich Haustechnik GmbH", size: 16, bold: true, gap: 10 }, + { text: "Deichweg 4 · 21029 Hamburg · Tel. 040 0000 1000 · info@kranich-haustechnik.example.org", size: 8 }, +]; + +const SAMPLES: Record = { + "01-musterbau-auftragsbestaetigung.pdf": [ + [ + ...letterhead(), + { text: "Musterbau GmbH", gap: 40 }, + { text: "z. Hd. Frau Jana Köhler" }, + { text: "Hafenstraße 12" }, + { text: "20457 Hamburg" }, + { text: "Auftragsbestätigung", size: 14, bold: true, gap: 36 }, + { text: "Auftragsnummer: AB-2026-0815 Datum: 02.09.2026", gap: 20 }, + { text: "Ihr Angebot: ANG-2026-0342 Kundennummer: K-10042" }, + { text: "Bauvorhaben: Neubau Bürogebäude Speicherhof, Am Kaiserkai 30, 20457 Hamburg" }, + { text: "Ansprechpartnerin vor Ort: Jana Köhler, Tel. 040 0000 2233, j.koehler@musterbau.example.org" }, + { text: "Ausführungszeitraum: 12.10.2026 bis 16.10.2026", bold: true }, + { text: "Sehr geehrte Frau Köhler,", gap: 24 }, + { text: "vielen Dank für Ihren Auftrag. Wir bestätigen die Ausführung folgender Leistungen:" }, + { text: "Pos. Bezeichnung Art.-Nr. Menge Einheit", bold: true, gap: 24 }, + { text: "1 Montage Wärmepumpe Luft/Wasser 12 kW 1 Stk" }, + { text: "2 Wärmepumpe Aerotherm 12 kW WP-AT-12 1 Stk" }, + { text: "3 Pufferspeicher 500 l PS-500 1 Stk" }, + { text: "4 Kupferrohr 22 mm CU-22 24 m" }, + { text: "5 Inbetriebnahme und Einweisung 4 Std" }, + { text: "Gesamtbetrag netto: 18.450,00 € zzgl. 19 % MwSt.: 3.505,50 € Gesamt: 21.955,50 €", bold: true, gap: 24 }, + { text: "Hinweise: Zufahrt über Tor 2, Anmeldung beim Bauleiter. Kran ist bauseits zu stellen.", gap: 24 }, + { text: "Referenz: Ihre Bestellung BE-7781 vom 28.08.2026" }, + { text: "Mit freundlichen Grüßen", gap: 30 }, + { text: "Kranich Haustechnik GmbH" }, + ], + ], + "02-elbblick-wartungsauftrag.pdf": [ + [ + ...letterhead(), + { text: "Elbblick Wohnen eG", gap: 40 }, + { text: "Hausverwaltung" }, + { text: "Neumühlen 7" }, + { text: "22763 Hamburg" }, + { text: "Auftrag Wartung Heizungsanlagen", size: 14, bold: true, gap: 36 }, + { text: "Auftrag Nr. W-26-117 · Kunden-Nr. K-20017 · Hamburg, den 01.09.2026", gap: 20 }, + { text: "Objekt: Wohnanlage Elbhang, Övelgönne 45, 22605 Hamburg" }, + { text: "Ansprechpartner: Herr Timo Brandt, Hausmeister, Tel. +49 40 0000 4545" }, + { text: "Termin: KW 41 nach Absprache mit dem Hausmeister", gap: 20 }, + { text: "Leistungsumfang:", bold: true, gap: 22 }, + { text: "Jährliche Wartung von 2 Gas-Brennwertkesseln inkl. Abgasmessung und Protokoll." }, + { text: "1 Wartung Gas-Brennwertkessel 2 Stk" }, + { text: "2 Wartungsset Dichtungen 2 Satz" }, + { text: "3 Abgasmessung mit Protokoll 2 Stk" }, + { text: "Besondere Hinweise: Heizungsraum im Keller, Schlüssel beim Hausmeister.", gap: 22 }, + { text: "Rückfragen bitte an verwaltung@elbblick.example.org" }, + ], + ], + "03-privatkunde-reparatur.pdf": [ + [ + ...letterhead(), + { text: "Frau", gap: 40 }, + { text: "Petra Sommer" }, + { text: "Lindenallee 9a" }, + { text: "2148 Hamburg" }, + { text: "Auftragsbestätigung Reparatur", size: 14, bold: true, gap: 36 }, + { text: "Auftrag: AB-2026-0901 Datum: 03.09.2026", gap: 20 }, + { text: "Einsatzort: wie oben" }, + { text: "Telefon: 0170 0000 987 E-Mail: petra.sommer(at)example.org" }, + { text: "Geplanter Termin: 18.09.2026, Ende 17.09.2026" }, + { text: "Leistung: Austausch defekter Thermostatkopf im Bad, Prüfung Heizkörperventile.", gap: 22 }, + { text: "1 Thermostatkopf Standard TK-100 1 Stk" }, + { text: "2 Arbeitszeit Monteur 1,5 Std" }, + { text: "Gesamt brutto: 189,40 €", bold: true, gap: 22 }, + ], + ], +}; + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + mkdirSync(OUT_DIR, { recursive: true }); + for (const [name, pages] of Object.entries(SAMPLES)) { + const file = join(OUT_DIR, name); + writeFileSync(file, buildPdf(pages)); + console.log(`✔ ${file}`); + } +} diff --git a/scripts/test-import-flow.ts b/scripts/test-import-flow.ts new file mode 100644 index 0000000..2ab4b2b --- /dev/null +++ b/scripts/test-import-flow.ts @@ -0,0 +1,282 @@ +// Lane L3 (Auftragsimport) — service flow against the local DB: +// upload/validation, processing (fake provider), duplicate + site candidates, AiGeneration, +// "kein Auftrag ohne Bestätigung", confirm (existing vs. new customer) incl. document link, +// corrections and audit, double confirm, provider error → failed + retry, no provider → manual, +// discard, tenant isolation (B cannot read/change A) and roles (technician → forbidden). +// +// Lauf: npx tsx scripts/test-import-flow.ts + +import "dotenv/config"; +import type { WorkOrderExtraction } from "../src/server/ai/providers"; +import { dbForTenant, prisma } from "../src/server/db"; +import { ROLE_DEFS } from "../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../src/server/services/context"; +import { createImport } from "../src/server/services/imports/upload"; +import { processImport } from "../src/server/services/imports/process"; +import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm"; +import { getImportDetail, getImportFile, listImports, searchCustomers } from "../src/server/services/imports/queries"; +import { FakeExtractionProvider } from "../src/server/ai/extraction/fake"; +import { emptyExtraction } from "../src/lib/imports/extraction"; +import { extractionToForm } from "../src/lib/imports/review"; +import { buildPdf } from "./make-sample-pdfs"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; +async function code(p: Promise): Promise { + try { + await p; + return "ok"; + } catch (e) { + return e instanceof ServiceError ? e.code : `error:${(e as Error).message}`; + } +} + +const SLUG_A = "zz-import-a"; +const SLUG_B = "zz-import-b"; + +async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (!ids.length) return; + const w = { tenantId: { in: ids } }; + await prisma.auditLog.deleteMany({ where: w }); + await prisma.aiGeneration.deleteMany({ where: w }); + await prisma.materialPlan.deleteMany({ where: w }); + await prisma.workOrderStatusChange.deleteMany({ where: w }); + await prisma.document.updateMany({ where: w, data: { workOrderId: null, siteId: null, customerId: null } }); + await prisma.workOrder.deleteMany({ where: w }); + await prisma.importJob.deleteMany({ where: w }); + await prisma.document.deleteMany({ where: w }); + await prisma.site.deleteMany({ where: w }); + await prisma.contact.deleteMany({ where: w }); + await prisma.customer.deleteMany({ where: w }); + await prisma.numberSequence.deleteMany({ where: w }); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); +} + +const ctxFor = (tenantId: string, role: keyof typeof ROLE_DEFS, userId: string): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), +}); + +const PDF = buildPdf([[{ text: "Auftragsbestätigung AB-2026-0815" }, { text: "Musterbau GmbH, Hafenstraße 12, 20457 Hamburg" }]]); +const loadBytes = async () => PDF; +const noDispatch = async () => undefined; + +function musterbauExtraction(): Partial { + return { + orderNumber: { value: "AB-2026-0815", confidence: 0.97 }, + customerNumber: { value: "K-10042", confidence: 0.95 }, + companyName: { value: "Musterbau GmbH", confidence: 0.96 }, + customerAddress: { value: { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.93 }, + siteName: { value: "Speicherhof", confidence: 0.7, source: "Bauvorhaben: Speicherhof" }, + siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, + plannedStart: { value: "12.10.2026", confidence: 0.9 }, + plannedEnd: { value: "16.10.2026", confidence: 0.9 }, + title: { value: "Montage Wärmepumpe", confidence: 0.85 }, + positions: { + value: [ + { name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true }, + { name: "Montage", quantity: 8, unit: "Std", isMaterial: false }, + ], + confidence: 0.88, + }, + }; +} + +async function main() { + await cleanup(); + const tA = await prisma.tenant.create({ data: { name: "ZZ Import A", slug: SLUG_A } }); + const tB = await prisma.tenant.create({ data: { name: "ZZ Import B", slug: SLUG_B } }); + const boA = ctxFor(tA.id, "backoffice", "zz-user-bo-a"); + const techA = ctxFor(tA.id, "technician", "zz-user-tech-a"); + const leadA = ctxFor(tA.id, "team-lead", "zz-user-lead-a"); + const boB = ctxFor(tB.id, "backoffice", "zz-user-bo-b"); + + // Existing master data in A (duplicate + site candidate). + const existing = await prisma.customer.create({ + data: { tenantId: tA.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, + }); + const existingSite = await prisma.site.create({ + data: { tenantId: tA.id, customerId: existing.id, name: "Speicherhof", street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, + }); + await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-99999", companyName: "Andere Firma AG", postalCode: "80331", city: "München" } }); + const foreignCustomer = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", postalCode: "20457" } }); + + // ---------- upload ---------- + const dispatched: unknown[] = []; + const job1 = await createImport(boA, { bytes: PDF, fileName: "../AB 2026-0815.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) }); + const doc1 = await prisma.document.findUnique({ where: { id: job1.documentId } }); + ok(job1.status === "uploaded" && job1.importedById === boA.userId, "(U1) upload creates ImportJob uploaded with importing user"); + ok(doc1?.category === "order_confirmation" && doc1.visibility === "backoffice_only" && doc1.mimeType === "application/pdf", "(U2) document category/visibility/type"); + ok(doc1?.fileName === "AB 2026-0815.pdf" && doc1.checksum.length === 64, "(U3) file name normalised, SHA-256 stored"); + ok(dispatched.length === 1 && (dispatched[0] as { tenantId: string; entityId: string }).entityId === job1.id && (dispatched[0] as { tenantId: string }).tenantId === tA.id, "(U4) extraction job dispatched with tenant + import id"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import" } })) === 1, "(U5) upload audited"); + + ok((await code(createImport(boA, { bytes: Buffer.from("MZ not a pdf"), fileName: "x.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U6) wrong magic bytes rejected"); + const big = Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(25 * 1024 * 1024)]); + ok((await code(createImport(boA, { bytes: big, fileName: "big.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U7) file > 25 MB rejected"); + ok((await code(createImport(boA, { bytes: PDF, fileName: "x.png", mimeType: "image/png" }, { dispatch: noDispatch }))) === "invalid", "(U8) declared type must match content"); + ok((await code(createImport(boA, { bytes: PDF, fileName: "x.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, { dispatch: noDispatch }))) === "invalid", "(U9) non-allowlisted type rejected"); + + // ---------- processing ---------- + const provider = new FakeExtractionProvider({ extraction: musterbauExtraction(), text: "Auftragsbestätigung AB-2026-0815 Musterbau GmbH" }); + const processed = await processImport(boA, job1.id, { provider, loadBytes, now: new Date("2026-09-14T10:00:00Z") }); + ok(processed.status === "review_required", "(X1) processing → review_required"); + ok(provider.calls.length === 1 && provider.calls[0].mimeType === "application/pdf", "(X2) provider received the stored PDF"); + ok(processed.extractedText?.includes("AB-2026-0815") === true && processed.provider === "fake" && processed.extractionModel === "fake-extraction-1" && processed.extractionVersion !== null, "(X3) text, provider, model and version stored"); + const dups = processed.duplicateCandidates as Array<{ customerId: string; score: number; reasons: string[] }>; + ok(dups[0]?.customerId === existing.id && dups[0].reasons.includes("customer_number") && dups[0].reasons.includes("address"), "(X4) existing customer found via number + address (Straße/Strasse)"); + ok(!dups.some((d) => d.customerId === foreignCustomer.id), "(X5) duplicate check never returns customers of another tenant"); + const stored = processed.extraction as { siteCandidates: Array<{ siteId: string }>; fields: WorkOrderExtraction }; + ok(stored.siteCandidates[0]?.siteId === existingSite.id, "(X6) site candidate at same address found"); + ok(stored.fields.plannedStart.value === "2026-10-12", "(X7) plausibility normalised dates before storing"); + ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job1.id, kind: "import_extraction" } })) === 1, "(X8) AiGeneration recorded"); + ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 0, "(X9) no work order without confirmation"); + const again = await processImport(boA, job1.id, { provider, loadBytes }); + ok(again.status === "review_required" && provider.calls.length === 1, "(X10) processing is idempotent"); + + // ---------- roles ---------- + ok((await code(listImports(techA))) === "forbidden", "(R1) technician cannot list imports"); + ok((await code(getImportDetail(techA, job1.id))) === "forbidden", "(R2) technician cannot read an import"); + ok((await code(getImportFile(leadA, job1.id))) === "forbidden", "(R3) team lead cannot load the original document"); + ok((await code(createImport(techA, { bytes: PDF, fileName: "a.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "forbidden", "(R4) technician cannot upload"); + ok((await code(confirmImport(techA, job1.id, extractionToForm(stored.fields)))) === "forbidden", "(R5) technician cannot confirm"); + ok((await code(discardImport(techA, job1.id))) === "forbidden", "(R6) technician cannot discard"); + ok((await code(searchCustomers(techA, "Muster"))) === "forbidden", "(R7) technician cannot use the import customer search"); + + // ---------- tenant isolation ---------- + ok((await code(getImportDetail(boB, job1.id))) === "not_found", "(T1) tenant B cannot read A's import"); + ok((await code(getImportFile(boB, job1.id))) === "not_found", "(T2) tenant B cannot load A's document"); + ok((await code(confirmImport(boB, job1.id, extractionToForm(stored.fields)))) === "not_found", "(T3) tenant B cannot confirm A's import"); + ok((await code(discardImport(boB, job1.id))) === "not_found", "(T4) tenant B cannot discard A's import"); + ok((await code(retryImport(boB, job1.id, { dispatch: noDispatch }))) === "not_found", "(T5) tenant B cannot retry A's import"); + ok((await code(processImport(boB, job1.id, { provider, loadBytes }))) === "not_found", "(T6) tenant B context cannot process A's import"); + ok(!(await listImports(boB)).some((i) => i.id === job1.id), "(T7) A's import not listed for B"); + ok(!(await searchCustomers(boB, "Muster")).some((c) => c.id === existing.id), "(T8) customer search stays in tenant"); + ok((await prisma.importJob.findUnique({ where: { id: job1.id } }))?.status === "review_required", "(T9) A's import unchanged after B's attempts"); + + // B's own import may not reference A's customer/site. + const jobB = await createImport(boB, { bytes: PDF, fileName: "b.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); + await processImport(boB, jobB.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes }); + const formB = extractionToForm((await getImportDetail(boB, jobB.id)).extraction.fields, { customerId: existing.id, siteId: existingSite.id }); + ok((await code(confirmImport(boB, jobB.id, formB))) === "not_found", "(T10) B cannot assign A's customer to its import"); + const detailB = await getImportDetail(boB, jobB.id); + ok(detailB.customerCandidates.every((c) => c.customerId === foreignCustomer.id), "(T11) B's candidates only from B"); + + // ---------- confirm: existing customer ---------- + const detail1 = await getImportDetail(boA, job1.id); + ok(detail1.customerCandidates[0]?.customer.companyName === "Musterbau GmbH" && detail1.customerCandidates[0].customer.sites.length === 1, "(K1) detail enriches candidates with customer and sites"); + const form1 = extractionToForm(detail1.extraction.fields, { customerId: existing.id, siteId: existingSite.id }); + form1.order!.title = "Wärmepumpe Speicherhof montieren"; + ok((await code(confirmImport(boA, job1.id, { ...form1, customerId: "" }))) === "invalid", "(K2) invalid form rejected (existing customer without id)"); + const customersBefore = await prisma.customer.count({ where: { tenantId: tA.id } }); + const res1 = await confirmImport(boA, job1.id, form1); + const wo1 = await prisma.workOrder.findUnique({ where: { id: res1.workOrderId }, include: { statusHistory: { orderBy: { createdAt: "asc" } }, materialPlans: true } }); + ok(wo1?.status === "planned" && wo1.sourceImportId === job1.id && wo1.customerId === existing.id && wo1.siteId === existingSite.id, "(K3) work order planned, linked to import, customer and site"); + ok(wo1?.number.startsWith("A-") === true && wo1.externalOrderNumber === "AB-2026-0815" && wo1.plannedStart?.toISOString().startsWith("2026-10-12") === true, "(K4) number allocated, document order number and dates taken over"); + ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "review_required>planned", "(K5) status history review_required → planned"); + ok(wo1?.materialPlans.length === 1 && wo1.materialPlans[0].articleNumber === "WP-AT-12", "(K6) only positions marked as material become material plan"); + ok((await prisma.customer.count({ where: { tenantId: tA.id } })) === customersBefore, "(K7) no new customer when existing one is used"); + const docAfter = await prisma.document.findUnique({ where: { id: job1.documentId } }); + ok(docAfter?.workOrderId === wo1?.id && docAfter?.customerId === existing.id && docAfter?.siteId === existingSite.id, "(K8) original document linked to work order, customer and site"); + const job1After = await prisma.importJob.findUnique({ where: { id: job1.id } }); + const corr = (job1After?.corrections as { fields: Record }).fields; + ok(job1After?.status === "confirmed" && job1After.confirmedById === boA.userId && job1After.confirmedAt !== null, "(K9) import confirmed with user and time"); + ok(corr["order.title"]?.to === "Wärmepumpe Speicherhof montieren" && corr["order.title"]?.from === "Montage Wärmepumpe", "(K10) corrections stored as diff"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import", after: { path: ["status"], equals: "confirmed" } } })) === 1, "(K11) confirmation audited as import"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "work_order", entityId: res1.workOrderId, action: "create" } })) === 1, "(K12) work order creation audited"); + ok((await code(confirmImport(boA, job1.id, form1))) === "conflict", "(K13) second confirmation → conflict (no duplicate order)"); + ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 1, "(K14) still exactly one work order"); + ok((await code(discardImport(boA, job1.id))) === "conflict", "(K15) confirmed import cannot be discarded"); + + // ---------- confirm: new customer ---------- + const job2 = await createImport(boA, { bytes: PDF, fileName: "neu.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); + const ex2 = { + ...musterbauExtraction(), + customerNumber: { value: null, confidence: 0 }, + companyName: { value: "Neubau Kraft KG", confidence: 0.94 }, + customerAddress: { value: { street: "Ringstraße", houseNumber: "5", postalCode: "22761", city: "Hamburg" }, confidence: 0.9 }, + contactName: { value: "Olaf Kraft", confidence: 0.9 }, + email: { value: "o.kraft@example.org", confidence: 0.9 }, + siteName: { value: "Lagerhalle Nord", confidence: 0.9 }, + siteAddress: { value: { street: "Nordkanalstraße", houseNumber: "1", postalCode: "20097", city: "Hamburg" }, confidence: 0.9 }, + }; + await processImport(boA, job2.id, { provider: new FakeExtractionProvider({ extraction: ex2 }), loadBytes }); + const detail2 = await getImportDetail(boA, job2.id); + ok(detail2.customerCandidates.length === 0, "(N1) unknown customer → no duplicate candidates"); + const form2 = extractionToForm(detail2.extraction.fields); + ok(form2.customerMode === "new" && form2.siteMode === "new", "(N2) form proposes new customer and new site"); + const res2 = await confirmImport(boA, job2.id, form2); + const cust2 = await prisma.customer.findUnique({ where: { id: res2.customerId }, include: { contacts: true, sites: true } }); + ok(cust2?.companyName === "Neubau Kraft KG" && cust2.customerNumber?.startsWith("K-") === true && cust2.tenantId === tA.id, "(N3) new customer created in tenant with K- number"); + ok(cust2?.contacts[0]?.name === "Olaf Kraft" && res2.contactId === cust2.contacts[0].id, "(N4) contact created"); + ok(cust2?.sites[0]?.name === "Lagerhalle Nord" && res2.siteId === cust2.sites[0].id && cust2.sites[0].contactId === res2.contactId, "(N5) site created with contact"); + ok(Object.keys((await prisma.importJob.findUnique({ where: { id: job2.id } }))!.corrections as object).length > 0, "(N6) decisions/corrections stored"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: res2.customerId, action: "create" } })) === 1, "(N7) customer creation audited"); + + // New customer with a customer number that already exists → conflict, nothing created. + const job3 = await createImport(boA, { bytes: PDF, fileName: "dup.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); + await processImport(boA, job3.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes }); + const form3 = extractionToForm((await getImportDetail(boA, job3.id)).extraction.fields); + const woCount = await prisma.workOrder.count({ where: { tenantId: tA.id } }); + ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(N8) new customer with taken customer number → conflict"); + ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === woCount && (await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "review_required", "(N9) nothing created, import stays reviewable"); + + // ---------- discard ---------- + await discardImport(boA, job3.id); + ok((await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "discarded", "(D1) discard → discarded"); + ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(D2) discarded import cannot be confirmed"); + ok((await prisma.document.findUnique({ where: { id: job3.documentId } })) !== null, "(D3) original document kept after discard"); + + // ---------- provider failure + retry ---------- + const job4 = await createImport(boA, { bytes: PDF, fileName: "fail.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); + const failed = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ fail: new Error("Claude API error 529 (OverloadedError)") }), loadBytes }); + ok(failed.status === "failed" && failed.errorMessage?.includes("529") === true, "(F1) provider error → failed with message"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entityId: job4.id, after: { path: ["status"], equals: "failed" } } })) === 1, "(F2) failure audited"); + ok((await code(confirmImport(boA, job4.id, form3))) === "conflict", "(F3) failed import cannot be confirmed"); + const missing = await createImport(boA, { bytes: PDF, fileName: "missing.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); + const noBytes = await processImport(boA, missing.id, { provider, loadBytes: async () => null }); + ok(noBytes.status === "failed" && noBytes.errorMessage === "file_unavailable", "(F4) unavailable file → failed (file_unavailable)"); + const redispatched: unknown[] = []; + await retryImport(boA, job4.id, { dispatch: async (p) => void redispatched.push(p) }); + ok((await prisma.importJob.findUnique({ where: { id: job4.id } }))?.status === "uploaded" && redispatched.length === 1, "(F5) retry → uploaded + dispatched again"); + const retried = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes }); + ok(retried.status === "review_required" && retried.errorMessage === null, "(F6) retried processing succeeds"); + ok((await code(retryImport(boA, job4.id, { dispatch: noDispatch }))) === "conflict", "(F7) only failed imports can be retried"); + const dispatchFail = await createImport(boA, { bytes: PDF, fileName: "q.pdf", mimeType: "application/pdf" }, { dispatch: async () => { throw new Error("redis down"); } }); + ok((await prisma.importJob.findUnique({ where: { id: dispatchFail.id } }))?.status === "failed", "(F8) dispatch failure → failed (retryable)"); + + // ---------- no provider ---------- + const job5 = await createImport(boA, { bytes: PDF, fileName: "manual.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }); + const manual = await processImport(boA, job5.id, { provider: null, loadBytes }); + const manualStored = manual.extraction as { hints: Array<{ code: string }>; fields: WorkOrderExtraction }; + ok(manual.status === "review_required" && manualStored.hints.some((h) => h.code === "manual_entry"), "(M1) without provider → review_required with manual-entry hint"); + const emptyKeys = Object.keys(emptyExtraction()); + ok( + emptyKeys.every((k) => { + const f = (manualStored.fields as Record)[k]; + return f?.value === null && f.confidence === 0; + }) && manual.provider === null, + "(M2) empty extraction, no provider recorded", + ); + ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job5.id } })) === 0, "(M3) no AiGeneration without AI use"); + + await cleanup(); + if (failures === 0) console.log("\nOK — Importablauf, Mandantentrennung und Rollen erfüllt."); + else console.log(`\n${failures} FEHLER.`); + await prisma.$disconnect(); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch(async (e) => { + console.error(e); + await cleanup().catch(() => {}); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/scripts/test-import-live.ts b/scripts/test-import-live.ts new file mode 100644 index 0000000..ca4d04b --- /dev/null +++ b/scripts/test-import-live.ts @@ -0,0 +1,62 @@ +// Lane L3 (Auftragsimport) — optional live test of the Claude extraction against a generated +// sample order confirmation. Runs ONLY when ANTHROPIC_API_KEY is set (costs tokens); otherwise +// it skips with exit 0 so the default gate stays offline. +// +// Lauf: ANTHROPIC_API_KEY=… npx tsx scripts/test-import-live.ts + +import "dotenv/config"; +import { getExtractionProvider } from "../src/server/ai/extraction/anthropic"; +import { checkPlausibility } from "../src/lib/imports/plausibility"; +import { buildPdf } from "./make-sample-pdfs"; + +async function main() { + if (!process.env.ANTHROPIC_API_KEY?.trim()) { + console.log("↷ übersprungen: kein ANTHROPIC_API_KEY gesetzt (Live-Extraktion gegen Claude)."); + process.exit(0); + } + const provider = getExtractionProvider(); + if (!provider) { + console.log("↷ übersprungen: AI_EXTRACTION_PROVIDER ist nicht anthropic."); + process.exit(0); + } + + let failures = 0; + const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; + }; + + const pdf = buildPdf([ + [ + { text: "Kranich Haustechnik GmbH", size: 16, bold: true }, + { text: "Musterbau GmbH", gap: 40 }, + { text: "Hafenstraße 12" }, + { text: "20457 Hamburg" }, + { text: "Auftragsbestätigung", size: 14, bold: true, gap: 30 }, + { text: "Auftragsnummer: AB-2026-0815 Datum: 02.09.2026 Kundennummer: K-10042", gap: 20 }, + { text: "Bauvorhaben: Speicherhof, Am Kaiserkai 30, 20457 Hamburg" }, + { text: "Ausführung: 12.10.2026 bis 16.10.2026" }, + { text: "1 Wärmepumpe Aerotherm 12 kW WP-AT-12 1 Stk", gap: 20 }, + { text: "2 Kupferrohr 22 mm CU-22 24 m" }, + ], + ]); + + const started = Date.now(); + const result = await provider.extract({ bytes: pdf, mimeType: "application/pdf", fileName: "live-sample.pdf" }); + const { extraction } = checkPlausibility(result.extraction); + console.log(` Modell ${result.meta.model}, ${result.meta.inputTokens} in / ${result.meta.outputTokens} out, ${((Date.now() - started) / 1000).toFixed(1)} s`); + + ok(result.text.includes("AB-2026-0815"), "(L1) full text contains the order number"); + ok(extraction.companyName.value?.includes("Musterbau") === true, "(L2) customer company recognised (not the letterhead)"); + ok(extraction.orderNumber.value === "AB-2026-0815" && extraction.orderNumber.confidence > 0.5, "(L3) order number with confidence"); + ok(extraction.plannedStart.value === "2026-10-12", "(L4) German date normalised to ISO"); + ok(extraction.siteAddress.value?.street?.includes("Kaiserkai") === true, "(L5) site address separated from customer address"); + ok((extraction.positions.value ?? []).some((p) => p.quantity === 24 && p.isMaterial === true), "(L6) positions with quantity and material flag"); + + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/test-import-rules.ts b/scripts/test-import-rules.ts new file mode 100644 index 0000000..e345307 --- /dev/null +++ b/scripts/test-import-rules.ts @@ -0,0 +1,201 @@ +// Lane L3 (Auftragsimport) — pure logic without DB: plausibility rules, extraction parsing, +// JSON schema for structured outputs, mapping extraction → form, form validation, +// correction diff, duplicate normalisation, file sniffing. +// +// Lauf: npx tsx scripts/test-import-rules.ts + +import "dotenv/config"; +import type { WorkOrderExtraction } from "../src/server/ai/providers"; +import { emptyExtraction, extractionJsonSchema, parseExtraction, EXTRACTION_FIELDS } from "../src/lib/imports/extraction"; +import { checkPlausibility, parseDate, toIsoDate, VIOLATION_CONFIDENCE } from "../src/lib/imports/plausibility"; +import { computeCorrections, extractionToForm, formFieldMeta, reviewFormSchema } from "../src/lib/imports/review"; +import { normalizeCompany, normalizeStreet } from "../src/server/services/imports/duplicates-stub"; +import { normalizeFileName, sniffMime } from "../src/server/services/imports/document-store-stub"; +import { buildPdf } from "./make-sample-pdfs"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const NOW = new Date("2026-09-14T10:00:00Z"); + +function sample(): WorkOrderExtraction { + return { + ...emptyExtraction(), + orderNumber: { value: "AB-2026-0815", confidence: 0.97, source: "Auftragsnummer: AB-2026-0815" }, + customerNumber: { value: "K-10042", confidence: 0.95 }, + companyName: { value: "Musterbau GmbH", confidence: 0.96 }, + customerAddress: { value: { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.93 }, + siteName: { value: "Neubau Bürogebäude Speicherhof", confidence: 0.7, source: "Bauvorhaben: Neubau Bürogebäude" }, + siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, + contactName: { value: "Jana Köhler", confidence: 0.9 }, + phone: { value: "040 0000 2233", confidence: 0.9 }, + email: { value: "j.koehler@musterbau.example.org", confidence: 0.92 }, + orderDate: { value: "2026-09-02", confidence: 0.95 }, + plannedStart: { value: "12.10.2026", confidence: 0.9 }, + plannedEnd: { value: "2026-10-16", confidence: 0.9 }, + title: { value: "Montage Wärmepumpe", confidence: 0.85 }, + description: { value: "Montage Wärmepumpe inkl. Inbetriebnahme", confidence: 0.9 }, + positions: { + value: [ + { position: "1", name: "Montage Wärmepumpe", quantity: 1, unit: "Stk", isMaterial: false }, + { position: "2", name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true }, + { position: "4", name: "Kupferrohr 22 mm", articleNumber: "CU-22", quantity: 24, unit: "m", isMaterial: true }, + ], + confidence: 0.88, + }, + }; +} + +// ---------- plausibility ---------- +{ + const { extraction, hints } = checkPlausibility(sample(), NOW); + ok(hints.length === 0, "(P1) plausible extraction → no hints"); + ok(extraction.plannedStart.value === "2026-10-12", "(P2) German date 12.10.2026 normalised to ISO"); + ok(extraction.plannedStart.confidence === 0.9, "(P3) confidence unchanged without violation"); + + const bad = sample(); + bad.customerAddress.value = { ...bad.customerAddress.value!, postalCode: "2045" }; + bad.email.value = "j.koehler(at)musterbau"; + bad.phone.value = "call me"; + bad.plannedEnd.value = "2026-10-01"; + bad.orderDate.value = "31.02.2026"; + bad.documentDate.value = "1999-01-01"; + bad.documentDate.confidence = 0.99; + const r = checkPlausibility(bad, NOW); + const codes = new Set(r.hints.map((h) => `${h.field}:${h.code}`)); + ok(codes.has("customerAddress:postal_code_invalid"), "(P4) 4-digit German postal code flagged"); + ok(codes.has("email:email_invalid"), "(P5) invalid e-mail flagged"); + ok(codes.has("phone:phone_invalid"), "(P6) invalid phone flagged"); + ok(codes.has("plannedEnd:end_before_start"), "(P7) end before start flagged"); + ok(codes.has("orderDate:date_invalid"), "(P8) impossible date 31.02. flagged"); + ok(codes.has("documentDate:date_implausible"), "(P9) date 27 years back flagged as implausible"); + ok(r.extraction.documentDate.confidence <= VIOLATION_CONFIDENCE && r.extraction.email.confidence <= VIOLATION_CONFIDENCE, "(P10) violations lower confidence ≤ 0.4"); + ok(bad.email.confidence === 0.92, "(P11) input extraction is not mutated"); + + const foreign = sample(); + foreign.customerAddress.value = { street: "Hauptstrasse", postalCode: "8001", city: "Zürich", country: "CH" }; + ok(checkPlausibility(foreign, NOW).hints.length === 0, "(P12) non-German postal codes are not checked against the 5-digit rule"); + ok(parseDate("2026-02-29") === null && parseDate("29.02.2028") !== null, "(P13) leap-year aware date parsing"); + ok(toIsoDate("1.3.26") === "2026-03-01", "(P14) short German date 1.3.26 → 2026-03-01"); +} + +// ---------- extraction parsing + schema ---------- +{ + const raw = { + ...Object.fromEntries(EXTRACTION_FIELDS.map((k) => [k, { value: null, confidence: 0.3, source: null }])), + companyName: { value: " Musterbau GmbH ", confidence: 1.7, source: null }, + email: { value: "", confidence: 0.9, source: "E-Mail:" }, + positions: { value: [{ position: null, name: "Rohr", articleNumber: null, quantity: 3, unit: "m", isMaterial: true }], confidence: 0.8, source: null }, + }; + let parsed: WorkOrderExtraction | null = null; + try { + parsed = parseExtraction(raw); + } catch (e) { + console.error(e); + } + ok(parsed !== null, "(E1) provider output with nulls parses"); + ok(parsed?.companyName.value === "Musterbau GmbH", "(E2) values are trimmed"); + ok(parsed?.companyName.confidence === 0, "(E3) out-of-range confidence is rejected (→ 0, i.e. uncertain)"); + ok(parsed?.email.value === null && parsed?.email.confidence === 0, "(E4) empty string counts as not found"); + ok(parsed?.positions.value?.[0].articleNumber === undefined && parsed?.positions.value?.[0].quantity === 3, "(E5) nested nulls → undefined"); + let threw = false; + try { + parseExtraction({ foo: 1 }); + } catch { + threw = true; + } + ok(threw, "(E6) structurally invalid output throws"); + + const schema = extractionJsonSchema(); + const strict = (node: unknown): boolean => { + if (!node || typeof node !== "object") return true; + const n = node as Record; + if (n.type === "object") { + const props = Object.keys((n.properties as object) ?? {}); + const req = (n.required as string[]) ?? []; + if (n.additionalProperties !== false || props.length !== req.length || !props.every((p) => req.includes(p))) return false; + } + return Object.values(n).every((v) => (Array.isArray(v) ? v.every(strict) : strict(v))); + }; + ok(strict(schema), "(E7) JSON schema: every object has additionalProperties:false and all properties required"); + const exProps = ((schema.properties as Record).extraction.properties); + ok(EXTRACTION_FIELDS.every((k) => k in exProps), "(E8) JSON schema covers all 22 extraction fields"); + ok(!JSON.stringify(schema).match(/"(minimum|maximum|minLength|maxLength|pattern)"/), "(E9) no unsupported constraint keywords"); +} + +// ---------- mapping + validation + corrections ---------- +{ + const ex = checkPlausibility(sample(), NOW).extraction; + const form = extractionToForm(ex); + ok(form.customerMode === "new" && form.customer.companyName === "Musterbau GmbH", "(M1) customer fields mapped, default new customer"); + ok(form.siteMode === "new" && form.site.street === "Am Kaiserkai" && form.site.houseNumber === "30", "(M2) site address mapped"); + ok(form.contact.name === "Jana Köhler" && form.contact.email === "j.koehler@musterbau.example.org", "(M3) contact mapped"); + ok(form.order.externalOrderNumber === "AB-2026-0815" && form.order.plannedStart === "2026-10-12", "(M4) order number and dates mapped"); + ok(form.positions?.length === 3 && form.positions[0].asMaterial === false && form.positions[1].asMaterial === true, "(M5) positions mapped, material preselected"); + ok(extractionToForm(ex, { customerId: "c1", siteId: "s1" }).customerMode === "existing", "(M6) existing customer preselectable"); + + const noTitle = { ...ex, title: { value: null, confidence: 0 } }; + ok(extractionToForm(noTitle).order.title === "Montage Wärmepumpe inkl. Inbetriebnahme", "(M7) title falls back to first description line"); + ok(extractionToForm(emptyExtraction()).siteMode === "none", "(M8) empty extraction → no site"); + + const meta = formFieldMeta(ex); + ok(meta["site.name"].uncertain === true && meta["site.name"].source?.startsWith("Bauvorhaben") === true, "(M9) confidence 0.7 → uncertain with source snippet"); + ok(meta["customer.companyName"].uncertain === false, "(M10) confidence 0.96 → not uncertain"); + ok(meta["order.offerNumber"].uncertain === false, "(M11) missing value is not flagged as uncertain"); + + const valid = reviewFormSchema.safeParse(form); + ok(valid.success, "(V1) mapped form validates"); + const invalid = reviewFormSchema.safeParse({ + ...form, + customer: { ...form.customer, email: "nope", postalCode: "123" }, + order: { ...form.order, title: " ", plannedEnd: "2026-10-01" }, + positions: [{ name: "Rohr", quantity: "abc" }], + }); + const paths = invalid.success ? [] : invalid.error.issues.map((i) => `${i.path.join(".")}:${i.message}`); + ok(paths.includes("customer.email:email_invalid"), "(V2) invalid e-mail rejected"); + ok(paths.includes("customer.postalCode:postal_code_invalid"), "(V3) invalid postal code rejected"); + ok(paths.includes("order.title:title_required"), "(V4) empty title rejected"); + ok(paths.includes("order.plannedEnd:end_before_start"), "(V5) end before start rejected"); + ok(paths.includes("positions.0.quantity:quantity_invalid"), "(V6) non-numeric quantity rejected"); + const comma = reviewFormSchema.safeParse({ ...form, positions: [{ name: "Rohr", quantity: "2,5", unit: "m", asMaterial: true }] }); + ok(comma.success && comma.data.positions[0].quantity === 2.5, "(V7) German decimal comma accepted"); + const existingWithout = reviewFormSchema.safeParse({ ...form, customerMode: "existing", customerId: "" }); + ok(!existingWithout.success, "(V8) existing customer requires a selection"); + + const unchanged = reviewFormSchema.parse(form); + ok(Object.keys(computeCorrections(ex, unchanged)).length === 0, "(C1) unchanged form → no corrections"); + const edited = reviewFormSchema.parse({ + ...form, + order: { ...form.order, title: "Wärmepumpe montieren" }, + site: { ...form.site, postalCode: "20459" }, + positions: form.positions!.slice(1), + }); + const corr = computeCorrections(ex, edited); + ok(corr["order.title"]?.from === "Montage Wärmepumpe" && corr["order.title"]?.to === "Wärmepumpe montieren", "(C2) edited title recorded as from/to"); + ok(corr["site.postalCode"]?.to === "20459", "(C3) edited postal code recorded"); + ok("positions" in corr && Object.keys(corr).length === 3, "(C4) removed position recorded, nothing else"); + const onlyMaterialToggle = reviewFormSchema.parse({ ...form, positions: form.positions!.map((p) => ({ ...p, asMaterial: !p.asMaterial })) }); + ok(!("positions" in computeCorrections(ex, onlyMaterialToggle)), "(C5) material toggle is a decision, not a data correction"); +} + +// ---------- duplicates normalisation + files ---------- +{ + ok(normalizeCompany("Musterbau GmbH") === "musterbau" && normalizeCompany("MUSTERBAU G.m.b.H.") !== "", "(D1) legal form stripped from company names"); + ok(normalizeCompany("Elbblick Wohnen eG") === normalizeCompany("elbblick wohnen eg"), "(D2) company normalisation is case-insensitive"); + ok(normalizeStreet("Hafenstraße") === normalizeStreet("Hafenstr.") && normalizeStreet("Hafenstrasse") === normalizeStreet("Hafenstraße"), "(D3) street variants normalise equally"); + + const pdf = buildPdf([[{ text: "Auftragsbestätigung Größe 5 €" }]]); + ok(sniffMime(pdf) === "application/pdf", "(F1) generated sample is a PDF by magic bytes"); + ok(sniffMime(Buffer.from([0xff, 0xd8, 0xff, 0xe0])) === "image/jpeg", "(F2) JPEG magic bytes"); + ok(sniffMime(Buffer.from("MZ\x90\x00 fake exe", "latin1")) === null, "(F3) executable rejected"); + ok(normalizeFileName("../../etc/pass.pdf") === "pa_ss_wd_.pdf", "(F4) file name without path and control characters"); + const raw = pdf.toString("latin1"); + ok(raw.includes("(Auftragsbest\\344tigung Gr\\366\\337e 5 \\200)") && raw.trimEnd().endsWith("%%EOF"), "(F5) PDF writer encodes umlauts/€ as WinAnsi octal escapes and closes the file"); +} + +if (failures === 0) console.log("\nOK — Importregeln (Plausibilität, Mapping, Korrekturen) erfüllt."); +else console.log(`\n${failures} FEHLER.`); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/app/(app)/imports/[id]/file/route.ts b/src/app/(app)/imports/[id]/file/route.ts new file mode 100644 index 0000000..9455cef --- /dev/null +++ b/src/app/(app)/imports/[id]/file/route.ts @@ -0,0 +1,41 @@ +import { moduleGuard } from "@/server/action-guard"; +import { storage } from "@/server/storage/adapter"; +import { ctxFromGuard } from "@/server/services/context"; +import { getImportFile } from "@/server/services/imports/queries"; + +/** + * GET /imports/[id]/file — original document of an import for the review mask preview. + * Authorisation: session + DB-authoritative permission import:write + module + document + * visibility (getImportFile). Unknown/foreign ids → 404. `?download=1` forces a download. + * Inline delivery only for the allowlisted types stored by the import (PDF/JPEG/PNG) and + * only framable by the own origin. + */ +const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]); + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + let file: { storageKey: string; mimeType: string; fileName: string }; + try { + const ctx = ctxFromGuard(await moduleGuard("imports")("import:write")); + const { id } = await params; + file = await getImportFile(ctx, id); + } catch { + return new Response("Nicht gefunden.", { status: 404 }); + } + + const content = await storage.get(file.storageKey); + if (!content) return new Response("Datei nicht verfügbar.", { status: 404 }); + + const download = new URL(req.url).searchParams.get("download") === "1"; + const inline = !download && INLINE.has(file.mimeType); + const asciiName = file.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const headers = new Headers({ + "Content-Type": INLINE.has(file.mimeType) ? file.mimeType : "application/octet-stream", + "Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(file.fileName)}`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", + "Content-Security-Policy": "frame-ancestors 'self'", + "X-Frame-Options": "SAMEORIGIN", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); +} diff --git a/src/app/(app)/imports/[id]/page.tsx b/src/app/(app)/imports/[id]/page.tsx new file mode 100644 index 0000000..00a7586 --- /dev/null +++ b/src/app/(app)/imports/[id]/page.tsx @@ -0,0 +1,166 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ArrowLeft, CheckCircle2, Download, ExternalLink, Info, Loader2, XCircle } from "lucide-react"; +import { requireSession } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { hasPermission } from "@/server/rbac"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { getImportDetail } from "@/server/services/imports/queries"; +import { confirmImportAction, discardImportAction, retryImportAction, searchCustomersAction } from "@/server/actions/imports/imports"; +import { PageHead } from "@/components/mockup-ui"; +import { ImportStatusPill } from "@/components/imports/status-pill"; +import { AutoRefresh } from "@/components/imports/auto-refresh"; +import { DiscardImportButton, RetryImportButton } from "@/components/imports/job-actions"; +import { ImportReviewForm } from "@/components/imports/review-form"; +import { extractionToForm, formFieldMeta } from "@/lib/imports/review"; +import { IMPORT_IN_PROGRESS } from "@/lib/imports/status"; + +/** /imports/[id] — review mask: original document left, form right (spec §9.6, US-002/003). */ +export default async function ImportDetailPage({ params }: { params: Promise<{ id: string }> }) { + const session = await requireSession(); + if (!hasPermission(session, "import:write")) redirect("/imports"); + const { id } = await params; + const t = await getTranslations("imports"); + const locale = await getLocale(); + const ctx: ServiceCtx = { + db: dbForTenant(session.user.tenantId), + tenantId: session.user.tenantId, + userId: session.user.id, + permissions: new Set(session.user.permissions), + }; + + let detail; + try { + detail = await getImportDetail(ctx, id); + } catch (err) { + if (err instanceof ServiceError) notFound(); + throw err; + } + + const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }); + const fileUrl = `/imports/${detail.id}/file`; + const isPdf = detail.document?.mimeType === "application/pdf"; + const fields = detail.extraction.fields; + const top = detail.customerCandidates[0]; + const topSite = top ? detail.siteCandidates.find((s) => s.customerId === top.customerId) : undefined; + const inProgress = IMPORT_IN_PROGRESS.includes(detail.status); + const errorKey = detail.errorMessage && t.has(`errors.${detail.errorMessage}`) ? `errors.${detail.errorMessage}` : null; + const correctionCount = + detail.corrections && typeof detail.corrections === "object" && "fields" in detail.corrections + ? Object.keys((detail.corrections as { fields?: Record }).fields ?? {}).length + : 0; + + return ( +
+ + + {t("detail.back")} + + } + /> + +
+ {/* Original document */} +
+
+ + {isPdf ? ( +