Merge lane/import in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:07 +02:00
co-authored by Claude Opus 5
38 changed files with 4204 additions and 4 deletions
+106
View File
@@ -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“.
@@ -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
@@ -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
@@ -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
+209
View File
@@ -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."
}
}
+209
View File
@@ -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."
}
}
+155
View File
@@ -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<string, Line[][]> = {
"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}`);
}
}
+282
View File
@@ -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<unknown>): Promise<string> {
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<WorkOrderExtraction> {
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<string, { from: unknown; to: unknown }> }).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<string, { value: unknown; confidence: number }>)[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);
});
+62
View File
@@ -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);
});
+201
View File
@@ -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<string, unknown>;
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<string, { properties: object }>).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<wd>.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);
+41
View File
@@ -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 });
}
+166
View File
@@ -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<string, unknown> }).fields ?? {}).length
: 0;
return (
<main className="flex-1 p-4 sm:p-6">
<AutoRefresh active={inProgress} />
<Link href="/imports" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
</Link>
<PageHead
crumb={t("title")}
title={detail.document?.fileName ?? t("title")}
sub={t("detail.meta", { file: detail.document?.fileName ?? "—", date: fmt.format(detail.createdAt), user: detail.importedByName ?? "—" })}
actions={<ImportStatusPill status={detail.status} label={t(`status.${detail.status}`)} />}
/>
<div className="grid gap-5 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
{/* Original document */}
<section aria-labelledby="imp-preview" className="lg:sticky lg:top-20 lg:self-start">
<div className="shadow-card rounded-xl border bg-card p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-1">
<h2 id="imp-preview" className="font-heading text-[15px] font-semibold">{t("detail.preview")}</h2>
<div className="flex gap-3 text-[12.5px] font-semibold">
<a href={fileUrl} target="_blank" rel="noreferrer" className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
<ExternalLink className="size-3.5" aria-hidden /> {t("detail.openNewTab")}
</a>
<a href={`${fileUrl}?download=1`} className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
<Download className="size-3.5" aria-hidden /> {t("detail.download")}
</a>
</div>
</div>
{isPdf ? (
<iframe src={fileUrl} title={t("detail.previewTitle")} className="h-[55vh] w-full rounded-lg border bg-muted lg:h-[calc(100vh-11rem)]" />
) : (
// eslint-disable-next-line @next/next/no-img-element -- authenticated same-origin file, no optimisation
<img src={fileUrl} alt={t("detail.previewTitle")} className="max-h-[calc(100vh-11rem)] w-full rounded-lg border object-contain" />
)}
{detail.extractionModel && <p className="mt-2 px-1 text-[12px] text-muted-foreground">{t("detail.model", { model: detail.extractionModel })}</p>}
</div>
<details className="shadow-card mt-3 rounded-xl border bg-card p-3">
<summary className="min-h-11 cursor-pointer content-center px-1 font-heading text-sm font-semibold">{t("detail.extractedText")}</summary>
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap px-1 text-[12px]">{detail.extractedText || t("detail.noText")}</pre>
</details>
</section>
{/* Status / review mask */}
<section aria-labelledby="imp-review">
<h2 id="imp-review" className="mb-1 font-heading text-[17px] font-semibold">{t("review.title")}</h2>
{inProgress && (
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
<Loader2 className="mt-0.5 size-5 shrink-0 animate-spin text-[var(--info)]" aria-hidden />
<p className="text-sm">{t("detail.processing")}</p>
</div>
)}
{detail.status === "failed" && (
<div className="shadow-card space-y-3 rounded-xl border border-[var(--risk)] bg-card p-5" role="alert">
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t("detail.failed")}
</p>
<p className="text-[13px] text-muted-foreground">
{errorKey ? t(errorKey) : t("detail.failedDetail", { message: detail.errorMessage ?? "—" })}
</p>
<div className="flex flex-wrap gap-2">
<RetryImportButton action={retryImportAction.bind(null, detail.id)} />
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
</div>
</div>
)}
{detail.status === "confirmed" && (
<div className="shadow-card space-y-2 rounded-xl border border-[var(--ok)] bg-card p-5" role="status">
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden /> {t("detail.confirmed", { number: detail.createdWorkOrder?.number ?? "—" })}
</p>
<p className="text-[13px] text-muted-foreground">{t("detail.corrections", { count: correctionCount })}</p>
{detail.createdWorkOrder && (
<Link href={`/work-orders/${detail.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
{t("detail.openWorkOrder")}
</Link>
)}
</div>
)}
{detail.status === "discarded" && (
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
<Info className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<p className="text-sm">{t("detail.discarded")}</p>
</div>
)}
{detail.status === "review_required" && (
<>
<p className="mb-3 text-[13px] text-muted-foreground">
{detail.extraction.hints.some((h) => h.code === "manual_entry") ? t("review.manualEntry") : t("review.intro")}
</p>
<ImportReviewForm
initial={extractionToForm(fields, { customerId: top && top.score >= 0.6 ? top.customerId : null, siteId: top && top.score >= 0.6 ? topSite?.siteId ?? null : null })}
meta={formFieldMeta(fields)}
hints={detail.extraction.hints.filter((h) => h.code !== "manual_entry")}
customerCandidates={detail.customerCandidates}
siteCandidates={detail.siteCandidates}
confirmAction={confirmImportAction.bind(null, detail.id)}
searchAction={searchCustomersAction}
/>
<div className="mt-3">
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
</div>
</>
)}
</section>
</div>
</main>
);
}
+105 -3
View File
@@ -1,5 +1,107 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { listImports } from "@/server/services/imports/queries";
import { retryImportAction } from "@/server/actions/imports/imports";
import { PageHead } from "@/components/mockup-ui";
import { ImportUploader } from "@/components/imports/uploader";
import { ImportStatusPill } from "@/components/imports/status-pill";
import { AutoRefresh } from "@/components/imports/auto-refresh";
import { RetryImportButton } from "@/components/imports/job-actions";
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
export default function Page() {
return <ModulePlaceholder moduleKey="imports" />;
/** /imports — upload + list of imports with status (spec §9, US-002). */
export default async function ImportsPage() {
const session = await requireSession();
const t = await getTranslations("imports");
const locale = await getLocale();
if (!hasPermission(session, "import:write")) {
return (
<main className="flex-1 p-6">
<PageHead crumb={t("crumb")} title={t("title")} />
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("noPermission")}</p>
</main>
);
}
const ctx: ServiceCtx = {
db: dbForTenant(session.user.tenantId),
tenantId: session.user.tenantId,
userId: session.user.id,
permissions: new Set(session.user.permissions),
};
const imports = await listImports(ctx);
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const inProgress = imports.some((i) => IMPORT_IN_PROGRESS.includes(i.status));
return (
<main className="flex-1 p-4 sm:p-6">
<AutoRefresh active={inProgress} />
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<section aria-labelledby="imp-upload" className="max-w-3xl">
<h2 id="imp-upload" className="sr-only">{t("upload.title")}</h2>
<ImportUploader />
</section>
<section aria-labelledby="imp-list" className="mt-6">
<h2 id="imp-list" className="mb-3 font-heading text-[15px] font-semibold">{t("list.title")}</h2>
{imports.length === 0 ? (
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("list.empty")}</p>
) : (
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[640px] text-left text-[13px]">
<thead className="border-b text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.file")}</th>
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedAt")}</th>
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedBy")}</th>
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.status")}</th>
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.action")}</th>
</tr>
</thead>
<tbody>
{imports.map((imp) => (
<tr key={imp.id} className="border-b last:border-0">
<td className="max-w-[280px] truncate px-4 py-2.5 font-semibold">
<Link href={`/imports/${imp.id}`} className="hover:underline">
{imp.document?.fileName ?? "—"}
</Link>
</td>
<td className="px-4 py-2.5 whitespace-nowrap">{fmt.format(imp.createdAt)}</td>
<td className="px-4 py-2.5">{imp.importedByName ?? "—"}</td>
<td className="px-4 py-2.5">
<ImportStatusPill status={imp.status} label={t(`status.${imp.status}`)} />
</td>
<td className="px-4 py-2">
{imp.status === "review_required" && (
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
{t("list.review")}
</Link>
)}
{imp.status === "failed" && <RetryImportButton action={retryImportAction.bind(null, imp.id)} size="sm" />}
{imp.status === "confirmed" && imp.createdWorkOrder && (
<Link href={`/work-orders/${imp.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
{t("list.workOrder", { number: imp.createdWorkOrder.number })}
</Link>
)}
{(imp.status === "uploaded" || imp.status === "processing" || imp.status === "discarded") && (
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
{t("list.open")}
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
@@ -0,0 +1,22 @@
import { confirmImport } from "@/server/services/imports/confirm";
import { apiError, importsApiContext } from "../../_context";
/**
* POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts
* `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3.
*/
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await importsApiContext("import:write", "work_order:write");
const { id } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "invalid", message: "json_required" }, { status: 400 });
}
return Response.json(await confirmImport(ctx, id, body));
} catch (err) {
return apiError(err);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { getImportDetail } from "@/server/services/imports/queries";
import { apiError, importsApiContext } from "../_context";
/** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await importsApiContext("import:write");
const { id } = await params;
return Response.json(await getImportDetail(ctx, id));
} catch (err) {
return apiError(err);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { moduleGuard } from "@/server/action-guard";
import { ForbiddenError, type Permission } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative
* guard as the server actions (session → account/identity status → permissions → module).
* Not a route: files starting with "_" are ignored by the App Router.
* TODO(architecture): replace with a shared `requireApiContext` once it exists.
*/
const guard = moduleGuard("imports");
export async function importsApiContext(...permissions: Permission[]): Promise<ServiceCtx> {
return ctxFromGuard(await guard(...permissions));
}
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 };
/** Map service/guard errors to JSON responses without leaking internals. */
export function apiError(err: unknown): Response {
if (err instanceof ServiceError) {
return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] });
}
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
console.error("[api/imports]", err);
return Response.json({ error: "internal" }, { status: 500 });
}
@@ -0,0 +1,30 @@
import { createImport } from "@/server/services/imports/upload";
import { apiError, importsApiContext } from "../../imports/_context";
/**
* POST /api/v1/work-orders/import — multipart upload of an order document (field `file`).
* Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background.
* Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report).
*/
export async function POST(req: Request) {
try {
const ctx = await importsApiContext("import:write");
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 });
const job = await createImport(ctx, {
bytes: Buffer.from(await file.arrayBuffer()),
fileName: file.name,
mimeType: file.type,
});
const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } });
return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 });
} catch (err) {
return apiError(err);
}
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/** Re-renders the server page periodically while an import is being processed. */
export function AutoRefresh({ active, intervalMs = 4000 }: { active: boolean; intervalMs?: number }) {
const router = useRouter();
useEffect(() => {
if (!active) return;
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [active, intervalMs, router]);
return null;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { RotateCcw, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { SimpleState } from "@/server/actions/imports/imports";
type Action = (prev: SimpleState, formData: FormData) => Promise<SimpleState>;
function ErrorText({ state }: { state: SimpleState }) {
const t = useTranslations("imports.errors");
if (state.status !== "error") return null;
return (
<span role="alert" className="text-[12.5px] font-semibold text-[var(--risk)]">
{t.has(state.code) ? t(state.code) : t("error")}
</span>
);
}
export function RetryImportButton({ action, size = "default" }: { action: Action; size?: "default" | "sm" }) {
const t = useTranslations("imports.actions");
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
return (
<form action={formAction} className="inline-flex flex-wrap items-center gap-2">
<Button type="submit" variant="outline" size={size} disabled={pending} className={size === "sm" ? "h-9" : "h-11 px-4"}>
<RotateCcw aria-hidden /> {pending ? t("retrying") : t("retry")}
</Button>
<ErrorText state={state} />
</form>
);
}
export function DiscardImportButton({ action }: { action: Action }) {
const t = useTranslations("imports.actions");
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
return (
<form
action={formAction}
onSubmit={(e) => {
if (!window.confirm(t("discardConfirm"))) e.preventDefault();
}}
className="inline-flex flex-wrap items-center gap-2"
>
<Button type="submit" variant="outline" disabled={pending} className="h-11 px-4">
<Trash2 aria-hidden /> {t("discard")}
</Button>
<ErrorText state={state} />
</form>
);
}
+383
View File
@@ -0,0 +1,383 @@
"use client";
import { useActionState, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { AlertTriangle, Info, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { FieldMeta, FormFieldPath, ReviewFormInput } from "@/lib/imports/review";
import type { PlausibilityHint } from "@/lib/imports/extraction";
import type { ConfirmState } from "@/server/actions/imports/imports";
export type SiteOption = { id: string; name: string; street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null };
export type CustomerOption = {
id: string;
customerNumber: string | null;
companyName: string | null;
firstName: string | null;
lastName: string | null;
postalCode: string | null;
city: string | null;
sites: SiteOption[];
};
export type CustomerCandidateView = { customerId: string; score: number; reasons: string[]; customer: CustomerOption };
export type SiteCandidateView = { siteId: string; customerId: string; score: number; reasons: string[] };
type Props = {
initial: ReviewFormInput;
meta: Record<FormFieldPath, FieldMeta>;
hints: PlausibilityHint[];
customerCandidates: CustomerCandidateView[];
siteCandidates: SiteCandidateView[];
confirmAction: (prev: ConfirmState, formData: FormData) => Promise<ConfirmState>;
searchAction: (q: string) => Promise<CustomerOption[]>;
};
type Section = "customer" | "site" | "contact" | "order";
type Position = NonNullable<ReviewFormInput["positions"]>[number];
const customerLabel = (c: CustomerOption) =>
[c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—", c.customerNumber ? `(${c.customerNumber})` : null].filter(Boolean).join(" ");
const siteLabel = (s: SiteOption) => [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
/**
* Review mask (spec §9.6): editable form in sections, uncertain fields (< 0.8) marked with
* warning colour + icon + text, source snippet as tooltip; customer/site decisions; editable
* line items with "als Materialvorgabe übernehmen". Submits the form as JSON to the confirm action.
*/
export function ImportReviewForm({ initial, meta, hints, customerCandidates, siteCandidates, confirmAction, searchAction }: Props) {
const t = useTranslations("imports.review");
const te = useTranslations("imports.errors");
const ta = useTranslations("imports.actions");
const [form, setForm] = useState<ReviewFormInput>(initial);
const [state, formAction, pending] = useActionState(confirmAction, { status: "idle" } as ConfirmState);
const [query, setQuery] = useState("");
const [results, setResults] = useState<CustomerOption[] | null>(null);
const [searching, startSearch] = useTransition();
const issues = useMemo(() => {
const map = new Map<string, string>();
if (state.status === "error") for (const i of state.issues ?? []) if (!map.has(i.path)) map.set(i.path, i.message);
return map;
}, [state]);
const knownCustomers = useMemo(() => {
const map = new Map<string, CustomerOption>();
for (const c of customerCandidates) map.set(c.customer.id, c.customer);
for (const c of results ?? []) map.set(c.id, c);
return map;
}, [customerCandidates, results]);
const selectedCustomer = form.customerMode === "existing" && form.customerId ? knownCustomers.get(form.customerId) ?? null : null;
const set = <S extends Section>(section: S, key: keyof NonNullable<ReviewFormInput[S]>, value: string) =>
setForm((f) => ({ ...f, [section]: { ...(f[section] as object), [key]: value } }));
const setPosition = (index: number, patch: Partial<Position>) =>
setForm((f) => ({ ...f, positions: (f.positions ?? []).map((p, i) => (i === index ? { ...p, ...patch } : p)) }));
const errorText = (path: string) => {
const code = issues.get(path);
return code ? (te.has(code) ? te(code) : te("form_invalid")) : null;
};
function field(section: Section, key: string, labelKey: string, opts: { type?: string; multiline?: boolean; required?: boolean; className?: string; inputMode?: "numeric" | "tel" | "email" } = {}) {
const path = `${section}.${key}`;
const m = (meta as Record<string, FieldMeta | undefined>)[path];
const value = String(((form[section] as Record<string, unknown>)[key] as string | undefined) ?? "");
const id = `rv-${section}-${key}`;
const err = errorText(path);
const tip = m?.source ? t("source", { text: m.source }) : undefined;
const common = {
id,
value,
title: tip,
"aria-invalid": err ? true : undefined,
"aria-describedby": m?.uncertain || err ? `${id}-note` : undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => set(section, key as never, e.target.value),
className: cn("mt-1 min-h-11", m?.uncertain && "border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_7%,transparent)]"),
};
return (
<div className={opts.className}>
<Label htmlFor={id} className="flex flex-wrap items-center gap-x-2">
<span>
{t(`fields.${labelKey}`)}
{opts.required && <span aria-label={t("required")}> *</span>}
</span>
{m?.uncertain && (
<span className="inline-flex items-center gap-1 text-[11.5px] font-bold text-[var(--warn)]" title={tip}>
<AlertTriangle className="size-3.5" aria-hidden />
{t("uncertain")} · {t("confidence", { percent: Math.round(m.confidence * 100) })}
</span>
)}
</Label>
{opts.multiline ? (
<Textarea {...common} rows={4} />
) : (
<Input {...common} type={opts.type ?? "text"} inputMode={opts.inputMode} required={opts.required} />
)}
<div id={`${id}-note`}>
{m?.uncertain && m.source && <p className="mt-0.5 truncate text-[11.5px] text-muted-foreground">{tip}</p>}
{err && (
<p role="alert" className="mt-0.5 text-[12px] font-semibold text-[var(--risk)]">
{err}
</p>
)}
</div>
</div>
);
}
const radio = (name: string, value: string, checked: boolean, onChange: () => void, label: React.ReactNode, extra?: React.ReactNode) => (
<label className={cn("flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border p-3 text-[13.5px]", checked ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)]" : "border-border")}>
<input type="radio" name={name} value={value} checked={checked} onChange={onChange} className="mt-1 size-4" />
<span className="min-w-0 flex-1">
<span className="font-semibold">{label}</span>
{extra}
</span>
</label>
);
const sectionClass = "shadow-card rounded-xl border bg-card p-4 sm:p-5";
const h2 = "mb-3 font-heading text-[15px] font-semibold";
const siteOptions = selectedCustomer?.sites ?? [];
const siteScore = new Map(siteCandidates.map((s) => [s.siteId, s]));
return (
<form action={formAction} className="space-y-4" noValidate>
<input type="hidden" name="payload" value={JSON.stringify(form)} />
{hints.length > 0 && (
<div className="rounded-xl border border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_8%,transparent)] p-4" role="status">
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--warn)]">
<AlertTriangle className="size-4" aria-hidden /> {t("hintsTitle")}
</p>
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-[13px]">
{hints.map((h, i) => (
<li key={i}>{t(`hints.${h.code}`, { field: h.field ? t(`extractionFields.${h.field}`) : "" })}</li>
))}
</ul>
</div>
)}
{/* Kunde */}
<section className={sectionClass} aria-labelledby="rv-h-customer">
<h2 id="rv-h-customer" className={h2}>{t("sections.customer")}</h2>
<fieldset className="grid gap-2 sm:grid-cols-2">
<legend className="sr-only">{t("sections.customer")}</legend>
{radio("customerMode", "existing", form.customerMode === "existing", () => setForm((f) => ({ ...f, customerMode: "existing", customerId: f.customerId || customerCandidates[0]?.customerId || "" })), t("customer.existing"))}
{radio("customerMode", "new", form.customerMode === "new", () => setForm((f) => ({ ...f, customerMode: "new", siteMode: f.siteMode === "existing" ? "new" : f.siteMode })), t("customer.new"))}
</fieldset>
{form.customerMode === "existing" && (
<div className="mt-4 space-y-3">
<p className="text-[12.5px] font-semibold text-muted-foreground">{t("customer.candidates")}</p>
{customerCandidates.length === 0 && <p className="text-[13px] text-muted-foreground">{t("customer.noCandidates")}</p>}
<div className="space-y-2">
{customerCandidates.map((c) =>
radio(
"customerId",
c.customerId,
form.customerId === c.customerId,
() => setForm((f) => ({ ...f, customerId: c.customerId, siteId: "" })),
customerLabel(c.customer),
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">
{[c.customer.postalCode, c.customer.city].filter(Boolean).join(" ")} · {t("customer.match", { percent: Math.round(c.score * 100) })} · {c.reasons.map((r) => (t.has(`reasons.${r}`) ? t(`reasons.${r}`) : r)).join(", ")}
{" · "}
<a href={`/customers/${c.customerId}`} target="_blank" rel="noreferrer" className="font-semibold text-[var(--ui-primary)] underline-offset-2 hover:underline">
{t("customer.merge")}
</a>
</span>,
),
)}
</div>
<div className="border-t pt-3">
<Label htmlFor="rv-search">{t("customer.search")}</Label>
<div className="mt-1 flex gap-2">
<Input
id="rv-search"
value={query}
placeholder={t("customer.searchPlaceholder")}
className="min-h-11"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
startSearch(async () => setResults(await searchAction(query)));
}
}}
/>
<Button type="button" variant="outline" className="h-11 px-4" disabled={searching || query.trim().length < 2} onClick={() => startSearch(async () => setResults(await searchAction(query)))}>
{searching ? <Loader2 className="animate-spin" aria-hidden /> : <Search aria-hidden />} {t("customer.searchButton")}
</Button>
</div>
{results && results.length === 0 && <p className="mt-2 text-[13px] text-muted-foreground">{t("customer.searchEmpty")}</p>}
<div className="mt-2 space-y-2">
{(results ?? [])
.filter((r) => !customerCandidates.some((c) => c.customerId === r.id))
.map((r) =>
radio(
"customerId",
r.id,
form.customerId === r.id,
() => setForm((f) => ({ ...f, customerId: r.id, siteId: "" })),
customerLabel(r),
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">{[r.postalCode, r.city].filter(Boolean).join(" ")}</span>,
),
)}
</div>
</div>
{errorText("customerId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("customerId")}</p>}
</div>
)}
{form.customerMode === "new" && (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
{field("customer", "companyName", "companyName", { className: "sm:col-span-2 lg:col-span-4" })}
{field("customer", "customerNumber", "customerNumber", { className: "lg:col-span-2" })}
{field("customer", "firstName", "firstName", { className: "lg:col-span-3" })}
{field("customer", "lastName", "lastName", { className: "lg:col-span-3" })}
{field("customer", "street", "street", { className: "lg:col-span-4" })}
{field("customer", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
{field("customer", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
{field("customer", "city", "city", { className: "lg:col-span-3" })}
{field("customer", "country", "country", { className: "lg:col-span-1" })}
{field("customer", "phone", "phone", { type: "tel", inputMode: "tel", className: "lg:col-span-3" })}
{field("customer", "email", "email", { type: "email", inputMode: "email", className: "lg:col-span-3" })}
</div>
)}
</section>
{/* Objekt */}
<section className={sectionClass} aria-labelledby="rv-h-site">
<h2 id="rv-h-site" className={h2}>{t("sections.site")}</h2>
<fieldset className="grid gap-2 sm:grid-cols-3">
<legend className="sr-only">{t("sections.site")}</legend>
{radio("siteMode", "none", form.siteMode === "none", () => setForm((f) => ({ ...f, siteMode: "none" })), t("site.none"))}
{radio("siteMode", "existing", form.siteMode === "existing", () => setForm((f) => ({ ...f, siteMode: "existing" })), t("site.existing"))}
{radio("siteMode", "new", form.siteMode === "new", () => setForm((f) => ({ ...f, siteMode: "new" })), t("site.new"))}
</fieldset>
{form.siteMode === "existing" && (
<div className="mt-3 space-y-2">
{!selectedCustomer && <p className="text-[13px] text-muted-foreground">{t("site.chooseCustomer")}</p>}
{selectedCustomer && siteOptions.length === 0 && <p className="text-[13px] text-muted-foreground">{t("site.noSites")}</p>}
{siteOptions.map((s) => {
const cand = siteScore.get(s.id);
return (
<div key={s.id}>
{radio(
"siteId",
s.id,
form.siteId === s.id,
() => setForm((f) => ({ ...f, siteId: s.id })),
siteLabel(s),
cand ? (
<span className="mt-0.5 flex items-center gap-1 text-[12.5px] font-semibold text-[var(--ok)]">
<Info className="size-3.5" aria-hidden /> {t("site.suggested")} · {t("customer.match", { percent: Math.round(cand.score * 100) })}
</span>
) : null,
)}
</div>
);
})}
{errorText("siteId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("siteId")}</p>}
</div>
)}
{form.siteMode === "new" && (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
{field("site", "name", "siteName", { className: "sm:col-span-2 lg:col-span-6" })}
{field("site", "street", "street", { className: "lg:col-span-4" })}
{field("site", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
{field("site", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
{field("site", "city", "city", { className: "lg:col-span-3" })}
{field("site", "country", "country", { className: "lg:col-span-1" })}
</div>
)}
</section>
{/* Ansprechpartner */}
<section className={sectionClass} aria-labelledby="rv-h-contact">
<h2 id="rv-h-contact" className={h2}>{t("sections.contact")}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{field("contact", "name", "name")}
{field("contact", "phone", "phone", { type: "tel", inputMode: "tel" })}
{field("contact", "email", "email", { type: "email", inputMode: "email" })}
</div>
</section>
{/* Auftrag */}
<section className={sectionClass} aria-labelledby="rv-h-order">
<h2 id="rv-h-order" className={h2}>{t("sections.order")}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{field("order", "title", "title", { required: true, className: "sm:col-span-2" })}
{field("order", "externalOrderNumber", "externalOrderNumber")}
{field("order", "offerNumber", "offerNumber")}
{field("order", "plannedStart", "plannedStart", { type: "date" })}
{field("order", "plannedEnd", "plannedEnd", { type: "date" })}
{field("order", "description", "description", { multiline: true, className: "sm:col-span-2" })}
{field("order", "notes", "notes", { multiline: true, className: "sm:col-span-2" })}
</div>
</section>
{/* Positionen */}
<section className={sectionClass} aria-labelledby="rv-h-positions">
<h2 id="rv-h-positions" className={h2}>{t("sections.positions")}</h2>
{(form.positions ?? []).length === 0 && <p className="text-[13px] text-muted-foreground">{t("positions.empty")}</p>}
<div className="space-y-3">
{(form.positions ?? []).map((p, i) => (
<div key={i} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-12">
<div className="sm:col-span-5">
<Label htmlFor={`rv-pos-${i}-name`}>{t("positions.name")}</Label>
<Input id={`rv-pos-${i}-name`} className="mt-1 min-h-11" value={p.name} onChange={(e) => setPosition(i, { name: e.target.value })} />
</div>
<div className="sm:col-span-3">
<Label htmlFor={`rv-pos-${i}-art`}>{t("positions.articleNumber")}</Label>
<Input id={`rv-pos-${i}-art`} className="mt-1 min-h-11" value={p.articleNumber ?? ""} onChange={(e) => setPosition(i, { articleNumber: e.target.value })} />
</div>
<div className="sm:col-span-2">
<Label htmlFor={`rv-pos-${i}-qty`}>{t("positions.quantity")}</Label>
<Input id={`rv-pos-${i}-qty`} className="mt-1 min-h-11" inputMode="decimal" value={String(p.quantity ?? "")} onChange={(e) => setPosition(i, { quantity: e.target.value })} />
</div>
<div className="sm:col-span-2">
<Label htmlFor={`rv-pos-${i}-unit`}>{t("positions.unit")}</Label>
<Input id={`rv-pos-${i}-unit`} className="mt-1 min-h-11" value={p.unit ?? ""} onChange={(e) => setPosition(i, { unit: e.target.value })} />
</div>
<div className="flex flex-wrap items-center justify-between gap-2 sm:col-span-12">
<label className="flex min-h-11 items-center gap-2 text-[13px]">
<input type="checkbox" className="size-4" checked={Boolean(p.asMaterial)} onChange={(e) => setPosition(i, { asMaterial: e.target.checked })} />
{t("positions.asMaterial")}
</label>
<Button type="button" variant="ghost" className="h-11 px-3" aria-label={t("positions.remove")} onClick={() => setForm((f) => ({ ...f, positions: (f.positions ?? []).filter((_, j) => j !== i) }))}>
<Trash2 aria-hidden /> <span className="sm:sr-only">{t("positions.remove")}</span>
</Button>
</div>
{errorText(`positions.${i}.quantity`) && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)] sm:col-span-12">{errorText(`positions.${i}.quantity`)}</p>}
</div>
))}
</div>
<Button
type="button"
variant="outline"
className="mt-3 h-11 px-4"
onClick={() => setForm((f) => ({ ...f, positions: [...(f.positions ?? []), { name: "", articleNumber: "", quantity: "", unit: "", asMaterial: false }] }))}
>
<Plus aria-hidden /> {t("positions.add")}
</Button>
</section>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={pending} className="h-11 bg-[var(--ui-accent)] px-5 text-[var(--ui-accent-foreground)]">
{pending && <Loader2 className="animate-spin" aria-hidden />}
{pending ? ta("confirming") : ta("confirm")}
</Button>
{state.status === "error" && (
<p role="alert" className="text-[13px] font-semibold text-[var(--risk)]">
{te.has(state.code) ? te(state.code) : te("error")}
</p>
)}
</div>
</form>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { AlertTriangle, Ban, CheckCircle2, Loader2, Upload, XCircle } from "lucide-react";
import { Pill } from "@/components/mockup-ui";
import { IMPORT_STATUS_TONE, type ImportStatusKey } from "@/lib/imports/status";
const ICONS: Record<ImportStatusKey, typeof Upload> = {
uploaded: Upload,
processing: Loader2,
review_required: AlertTriangle,
confirmed: CheckCircle2,
failed: XCircle,
discarded: Ban,
};
/** Import status as pill: colour + icon + text (never colour alone). */
export function ImportStatusPill({ status, label }: { status: ImportStatusKey; label: string }) {
const Icon = ICONS[status];
return (
<Pill tone={IMPORT_STATUS_TONE[status]}>
<Icon className={status === "processing" ? "size-3.5 animate-spin" : "size-3.5"} aria-hidden />
{label}
</Pill>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { CheckCircle2, FileUp, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { IMPORT_IMAGE_MAX_BYTES, IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
type UploadState =
| { kind: "idle" }
| { kind: "uploading"; percent: number; name: string }
| { kind: "done"; name: string; id: string }
| { kind: "error"; code: string };
const KNOWN_ERRORS = new Set(["file_type_not_allowed", "file_too_large", "file_type_mismatch", "file_empty", "file_missing", "forbidden", "unauthorized", "network"]);
/**
* Drag & drop / file picker upload with progress (XHR upload events). Posts to
* POST /api/v1/work-orders/import, then refreshes the server-rendered list.
*/
export function ImportUploader() {
const t = useTranslations("imports.upload");
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [state, setState] = useState<UploadState>({ kind: "idle" });
function upload(file: File) {
const type = file.type === "image/jpg" ? "image/jpeg" : file.type;
if (type && !(IMPORT_MIME_TYPES as readonly string[]).includes(type)) return setState({ kind: "error", code: "file_type_not_allowed" });
const limit = type === "application/pdf" ? IMPORT_MAX_BYTES : IMPORT_IMAGE_MAX_BYTES;
if (file.size > limit) return setState({ kind: "error", code: "file_too_large" });
if (file.size === 0) return setState({ kind: "error", code: "file_empty" });
const body = new FormData();
body.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/work-orders/import");
xhr.responseType = "json";
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name });
};
xhr.onload = () => {
const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string };
if (xhr.status === 201 && res.id) {
setState({ kind: "done", name: file.name, id: res.id });
router.refresh();
} else {
const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error";
setState({ kind: "error", code });
}
if (inputRef.current) inputRef.current.value = "";
};
xhr.onerror = () => setState({ kind: "error", code: "network" });
setState({ kind: "uploading", percent: 0, name: file.name });
xhr.send(body);
}
const busy = state.kind === "uploading";
return (
<div
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file && !busy) upload(file);
}}
className={cn(
"shadow-card rounded-xl border-2 border-dashed bg-card p-6 text-center transition-colors",
dragging ? "border-[var(--ui-accent)] bg-[var(--ui-primary-soft)]" : "border-border",
)}
>
<FileUp className="mx-auto size-8 text-[var(--ui-accent)]" aria-hidden />
<p className="mt-2 font-heading text-sm font-semibold">{t("drop")}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t("or")}</p>
<input
ref={inputRef}
id="import-file"
type="file"
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
className="sr-only"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
}}
/>
<Button type="button" className="mt-2 h-11 px-5" disabled={busy} onClick={() => inputRef.current?.click()}>
{t("choose")}
</Button>
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("limits")}</p>
<div aria-live="polite" className="mt-3 min-h-6">
{state.kind === "uploading" && (
<div className="mx-auto max-w-md text-left">
<div className="flex justify-between text-[12.5px]">
<span className="truncate">{state.name}</span>
<span>{t("uploading", { percent: state.percent })}</span>
</div>
<div className="mt-1 h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuenow={state.percent} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full bg-[var(--ui-accent)] transition-[width]" style={{ width: `${state.percent}%` }} />
</div>
</div>
)}
{state.kind === "done" && (
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden /> {state.name}: {t("done")}
</p>
)}
{state.kind === "error" && (
<p role="alert" className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t(`errors.${state.code}`)}
</p>
)}
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { z } from "zod";
import type { ExtractedField, WorkOrderExtraction } from "@/server/ai/providers";
/**
* Client-safe extraction model for the order import (spec §9.4/§9.5).
*
* - `EXTRACTION_FIELDS`: canonical field order (review mask, corrections, tests).
* - `workOrderExtractionSchema`: Zod validation of provider output (null → undefined for sub-objects).
* - `extractionJsonSchema()`: JSON schema for Claude structured outputs (all keys required,
* nullable via anyOf, additionalProperties:false — the structured-output subset).
* - `StoredExtraction`: shape persisted in ImportJob.extraction.
*/
export const EXTRACTION_VERSION = "craftvia-import-v1";
export const EXTRACTION_FIELDS = [
"orderNumber",
"offerNumber",
"customerNumber",
"companyName",
"customerFirstName",
"customerLastName",
"customerAddress",
"siteName",
"siteAddress",
"contactName",
"phone",
"email",
"orderDate",
"documentDate",
"plannedStart",
"plannedEnd",
"title",
"description",
"positions",
"notes",
"totalAmount",
"references",
] as const satisfies readonly (keyof WorkOrderExtraction)[];
export type ExtractionFieldKey = (typeof EXTRACTION_FIELDS)[number];
export type Address = { street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string };
export type Position = { position?: string; name: string; articleNumber?: string; quantity?: number; unit?: string; isMaterial?: boolean };
const confidence = z.number().min(0).max(1).catch(0);
const source = z.string().nullish().transform((v) => v ?? undefined);
const optStr = z.string().nullish().transform((v) => (v == null || v.trim() === "" ? undefined : v.trim()));
function field<T extends z.ZodTypeAny>(value: T) {
return z.object({ value: value.nullable().catch(null), confidence, source });
}
const addressSchema = z.object({
street: optStr,
houseNumber: optStr,
postalCode: optStr,
city: optStr,
country: optStr,
});
const positionSchema = z.object({
position: optStr,
name: z.string().min(1),
articleNumber: optStr,
quantity: z.number().nullish().transform((v) => (v == null || Number.isNaN(v) ? undefined : v)),
unit: optStr,
isMaterial: z.boolean().nullish().transform((v) => v ?? undefined),
});
const str = z.string().transform((v) => v.trim());
export const workOrderExtractionSchema = z.object({
orderNumber: field(str),
offerNumber: field(str),
customerNumber: field(str),
companyName: field(str),
customerFirstName: field(str),
customerLastName: field(str),
customerAddress: field(addressSchema),
siteName: field(str),
siteAddress: field(addressSchema),
contactName: field(str),
phone: field(str),
email: field(str),
orderDate: field(str),
documentDate: field(str),
plannedStart: field(str),
plannedEnd: field(str),
title: field(str),
description: field(str),
positions: field(z.array(positionSchema)),
notes: field(str),
totalAmount: field(z.number()),
references: field(z.array(z.string())),
});
/** Parse arbitrary provider output into a WorkOrderExtraction (throws on structurally invalid input). */
export function parseExtraction(raw: unknown): WorkOrderExtraction {
const parsed = workOrderExtractionSchema.parse(raw);
// Empty strings count as "not found".
const out = {} as Record<string, ExtractedField<unknown>>;
for (const key of EXTRACTION_FIELDS) {
const f = parsed[key] as ExtractedField<unknown>;
const empty = f.value === null || (typeof f.value === "string" && f.value === "");
out[key] = { value: empty ? null : f.value, confidence: empty ? 0 : f.confidence, ...(f.source ? { source: f.source } : {}) };
}
return out as unknown as WorkOrderExtraction;
}
/** Extraction with every field empty (no provider / manual entry). */
export function emptyExtraction(): WorkOrderExtraction {
const out = {} as Record<string, ExtractedField<unknown>>;
for (const key of EXTRACTION_FIELDS) out[key] = { value: null, confidence: 0 };
return out as unknown as WorkOrderExtraction;
}
// ---------- JSON schema for structured outputs ----------
type Json = Record<string, unknown>;
const nullable = (schema: Json): Json => ({ anyOf: [schema, { type: "null" }] });
const obj = (properties: Record<string, Json>): Json => ({
type: "object",
properties,
required: Object.keys(properties),
additionalProperties: false,
});
const addressJson = obj({
street: nullable({ type: "string" }),
houseNumber: nullable({ type: "string" }),
postalCode: nullable({ type: "string" }),
city: nullable({ type: "string" }),
country: nullable({ type: "string", description: "ISO 3166-1 alpha-2, e.g. DE" }),
});
const positionJson = obj({
position: nullable({ type: "string" }),
name: { type: "string" },
articleNumber: nullable({ type: "string" }),
quantity: nullable({ type: "number" }),
unit: nullable({ type: "string" }),
isMaterial: nullable({ type: "boolean" }),
});
const fieldJson = (value: Json, description: string): Json => ({
...obj({
value: nullable(value),
confidence: { type: "number", description: "0..1 — how certain the value is correct" },
source: nullable({ type: "string", description: "short verbatim snippet from the document" }),
}),
description,
});
const FIELD_DESCRIPTIONS: Record<ExtractionFieldKey, [Json, string]> = {
orderNumber: [{ type: "string" }, "Order / confirmation number of the issuer (Auftragsnummer)"],
offerNumber: [{ type: "string" }, "Offer number (Angebotsnummer)"],
customerNumber: [{ type: "string" }, "Customer number (Kundennummer)"],
companyName: [{ type: "string" }, "Company name of the customer (the ordering party, NOT the craft business)"],
customerFirstName: [{ type: "string" }, "First name if the customer is a private person"],
customerLastName: [{ type: "string" }, "Last name if the customer is a private person"],
customerAddress: [addressJson, "Billing / postal address of the customer"],
siteName: [{ type: "string" }, "Name of the site / construction site (Objekt, Baustelle)"],
siteAddress: [addressJson, "Address where the work is carried out"],
contactName: [{ type: "string" }, "Contact person"],
phone: [{ type: "string" }, "Phone number of the customer or contact"],
email: [{ type: "string" }, "E-mail address of the customer or contact"],
orderDate: [{ type: "string" }, "Order date, ISO 8601 (YYYY-MM-DD)"],
documentDate: [{ type: "string" }, "Document date, ISO 8601 (YYYY-MM-DD)"],
plannedStart: [{ type: "string" }, "Planned start of execution, ISO 8601 (YYYY-MM-DD)"],
plannedEnd: [{ type: "string" }, "Planned end of execution, ISO 8601 (YYYY-MM-DD)"],
title: [{ type: "string" }, "Short title of the job (max. 80 characters)"],
description: [{ type: "string" }, "Description of services (Leistungsbeschreibung)"],
positions: [{ type: "array", items: positionJson }, "Line items with quantities; isMaterial=true for material/articles"],
notes: [{ type: "string" }, "Special notes (access, deadlines, safety)"],
totalAmount: [{ type: "number" }, "Total gross amount in EUR as a number"],
references: [{ type: "array", items: { type: "string" } }, "Other references (project no., purchase order no.)"],
};
/** JSON schema of the complete provider answer: full text + structured fields. */
export function extractionJsonSchema(): Json {
const fields: Record<string, Json> = {};
for (const key of EXTRACTION_FIELDS) {
const [value, description] = FIELD_DESCRIPTIONS[key];
fields[key] = fieldJson(value, description);
}
return obj({
text: { type: "string", description: "Full recognised text of the document (reading order)" },
extraction: obj(fields),
});
}
// ---------- persisted shape ----------
export type PlausibilityHintCode =
| "date_invalid"
| "date_implausible"
| "postal_code_invalid"
| "email_invalid"
| "phone_invalid"
| "end_before_start"
| "manual_entry";
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
export type DuplicateCandidate = { customerId: string; score: number; reasons: string[] };
export type SiteCandidate = { siteId: string; customerId: string; name: string; score: number; reasons: string[] };
export type StoredExtraction = {
fields: WorkOrderExtraction;
hints: PlausibilityHint[];
siteCandidates: SiteCandidate[];
};
export function readStoredExtraction(raw: unknown): StoredExtraction {
const r = (raw ?? {}) as Partial<StoredExtraction>;
let fields: WorkOrderExtraction;
try {
fields = r.fields ? parseExtraction(r.fields) : emptyExtraction();
} catch {
fields = emptyExtraction();
}
return {
fields,
hints: Array.isArray(r.hints) ? r.hints : [],
siteCandidates: Array.isArray(r.siteCandidates) ? r.siteCandidates : [],
};
}
/** Fields below this confidence are marked "unsicher" in the review mask. */
export const LOW_CONFIDENCE = 0.8;
+109
View File
@@ -0,0 +1,109 @@
import type { WorkOrderExtraction } from "@/server/ai/providers";
import type { ExtractionFieldKey, PlausibilityHint } from "./extraction";
/**
* Plausibility check of an extraction (spec §9.3 step 6). Pure and client-safe.
* A violation never removes the value — it lowers the confidence (so the review mask marks
* the field as uncertain) and adds a hint the UI explains.
*/
/** Confidence ceiling for fields that violate a rule. */
export const VIOLATION_CONFIDENCE = 0.4;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// Digits with common separators, optional leading +; 6..20 digits.
const PHONE_RE = /^\+?[\d\s()/.-]+$/;
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
const GERMAN_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
export function isValidEmail(v: string): boolean {
return EMAIL_RE.test(v.trim());
}
export function isValidPhone(v: string): boolean {
const digits = v.replace(/\D/g, "");
return PHONE_RE.test(v.trim()) && digits.length >= 6 && digits.length <= 20;
}
export function isValidGermanPostalCode(v: string): boolean {
return /^\d{5}$/.test(v.trim());
}
/** Accepts ISO (YYYY-MM-DD) and German (TT.MM.JJJJ) dates; returns a UTC date or null. */
export function parseDate(v: string): Date | null {
const s = v.trim();
let y: number, m: number, d: number;
const iso = ISO_DATE_RE.exec(s.slice(0, 10));
const de = GERMAN_DATE_RE.exec(s);
if (iso) {
[y, m, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])];
} else if (de) {
[d, m, y] = [Number(de[1]), Number(de[2]), Number(de[3])];
if (y < 100) y += 2000;
} else {
return null;
}
const date = new Date(Date.UTC(y, m - 1, d));
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
return date;
}
/** Normalise a date string to YYYY-MM-DD, or null. */
export function toIsoDate(v: string | null | undefined): string | null {
if (!v) return null;
const d = parseDate(v);
return d ? d.toISOString().slice(0, 10) : null;
}
const DATE_FIELDS = ["orderDate", "documentDate", "plannedStart", "plannedEnd"] as const;
const MS_YEAR = 365 * 24 * 3600 * 1000;
export function checkPlausibility(
input: WorkOrderExtraction,
now: Date = new Date(),
): { extraction: WorkOrderExtraction; hints: PlausibilityHint[] } {
const extraction = structuredClone(input);
const hints: PlausibilityHint[] = [];
const flag = (field: ExtractionFieldKey, code: PlausibilityHint["code"]) => {
hints.push({ field, code });
const f = extraction[field];
f.confidence = Math.min(f.confidence, VIOLATION_CONFIDENCE);
};
const dates: Partial<Record<(typeof DATE_FIELDS)[number], Date>> = {};
for (const key of DATE_FIELDS) {
const f = extraction[key];
if (!f.value) continue;
const parsed = parseDate(f.value);
if (!parsed) {
flag(key, "date_invalid");
continue;
}
f.value = parsed.toISOString().slice(0, 10); // normalise German formats
dates[key] = parsed;
// Order/document dates lie in the past (≤ 1 month ahead); execution within −2 … +3 years.
const diff = parsed.getTime() - now.getTime();
const plausible =
key === "orderDate" || key === "documentDate"
? diff <= MS_YEAR / 12 && diff >= -10 * MS_YEAR
: diff >= -2 * MS_YEAR && diff <= 3 * MS_YEAR;
if (!plausible) flag(key, "date_implausible");
}
if (dates.plannedStart && dates.plannedEnd && dates.plannedEnd < dates.plannedStart) {
flag("plannedEnd", "end_before_start");
}
for (const key of ["customerAddress", "siteAddress"] as const) {
const addr = extraction[key].value;
if (!addr?.postalCode) continue;
const country = (addr.country ?? "DE").toUpperCase();
if ((country === "DE" || country === "DEUTSCHLAND") && !isValidGermanPostalCode(addr.postalCode)) {
flag(key, "postal_code_invalid");
}
}
if (extraction.email.value && !isValidEmail(extraction.email.value)) flag("email", "email_invalid");
if (extraction.phone.value && !isValidPhone(extraction.phone.value)) flag("phone", "phone_invalid");
return { extraction, hints };
}
+254
View File
@@ -0,0 +1,254 @@
import { z } from "zod";
import type { WorkOrderExtraction } from "@/server/ai/providers";
import { type ExtractionFieldKey, LOW_CONFIDENCE } from "./extraction";
import { isValidEmail, isValidGermanPostalCode, toIsoDate } from "./plausibility";
/**
* Review mask model (spec §9.6): mapping extraction → form, the form schema used by the
* confirm action/API, and the correction diff (extraction ↔ confirmed values, spec §9.7).
* Client-safe.
*/
// ---------- form schema ----------
const text = (max: number) =>
z
.string()
.max(max)
.transform((v) => v.trim())
.optional()
.default("");
const dateStr = z
.string()
.optional()
.default("")
.refine((v) => v.trim() === "" || toIsoDate(v) !== null, { message: "date_invalid" })
.transform((v) => toIsoDate(v) ?? "");
export const reviewPositionSchema = z.object({
name: z.string().trim().min(1).max(300),
articleNumber: text(100),
quantity: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return null;
const n = typeof v === "number" ? v : Number(String(v).replace(",", "."));
return Number.isFinite(n) ? n : Number.NaN;
})
.refine((v) => v === null || (!Number.isNaN(v) && v >= 0), { message: "quantity_invalid" }),
unit: text(30),
asMaterial: z.boolean().optional().default(false),
});
export const reviewFormSchema = z
.object({
customerMode: z.enum(["existing", "new"]),
customerId: z.string().optional().default(""),
customer: z.object({
customerNumber: text(50),
companyName: text(200),
firstName: text(100),
lastName: text(100),
street: text(200),
houseNumber: text(20),
postalCode: text(10),
city: text(100),
country: text(2).transform((v) => (v ? v.toUpperCase() : "DE")),
phone: text(50),
email: text(200),
}),
siteMode: z.enum(["existing", "new", "none"]),
siteId: z.string().optional().default(""),
site: z.object({
name: text(200),
street: text(200),
houseNumber: text(20),
postalCode: text(10),
city: text(100),
country: text(2).transform((v) => (v ? v.toUpperCase() : "DE")),
}),
contact: z.object({ name: text(200), phone: text(50), email: text(200) }),
order: z.object({
title: z.string().trim().min(1, { message: "title_required" }).max(200),
externalOrderNumber: text(100),
offerNumber: text(100),
description: text(10_000),
plannedStart: dateStr,
plannedEnd: dateStr,
notes: text(10_000),
}),
positions: z.array(reviewPositionSchema).max(500).default([]),
})
.superRefine((v, ctx) => {
if (v.customerMode === "existing" && !v.customerId) ctx.addIssue({ code: "custom", path: ["customerId"], message: "customer_required" });
if (v.customerMode === "new" && !v.customer.companyName && !v.customer.lastName) {
ctx.addIssue({ code: "custom", path: ["customer", "companyName"], message: "customer_name_required" });
}
if (v.siteMode === "existing" && !v.siteId) ctx.addIssue({ code: "custom", path: ["siteId"], message: "site_required" });
if (v.siteMode === "new" && !v.site.name && !v.site.street) ctx.addIssue({ code: "custom", path: ["site", "name"], message: "site_name_required" });
for (const [path, email] of [[["customer", "email"], v.customer.email], [["contact", "email"], v.contact.email]] as const) {
if (email && !isValidEmail(email)) ctx.addIssue({ code: "custom", path: [...path], message: "email_invalid" });
}
for (const [path, pc, country] of [
[["customer", "postalCode"], v.customer.postalCode, v.customer.country],
[["site", "postalCode"], v.site.postalCode, v.site.country],
] as const) {
if (pc && country === "DE" && !isValidGermanPostalCode(pc)) ctx.addIssue({ code: "custom", path: [...path], message: "postal_code_invalid" });
}
if (v.order.plannedStart && v.order.plannedEnd && v.order.plannedEnd < v.order.plannedStart) {
ctx.addIssue({ code: "custom", path: ["order", "plannedEnd"], message: "end_before_start" });
}
});
export type ReviewFormInput = z.input<typeof reviewFormSchema>;
export type ReviewForm = z.output<typeof reviewFormSchema>;
// ---------- form field ↔ extraction field ----------
/** Form path → extraction field that fed it (for confidence badges and corrections). */
export const FORM_FIELD_SOURCE = {
"customer.customerNumber": "customerNumber",
"customer.companyName": "companyName",
"customer.firstName": "customerFirstName",
"customer.lastName": "customerLastName",
"customer.street": "customerAddress",
"customer.houseNumber": "customerAddress",
"customer.postalCode": "customerAddress",
"customer.city": "customerAddress",
"customer.country": "customerAddress",
"customer.phone": "phone",
"customer.email": "email",
"site.name": "siteName",
"site.street": "siteAddress",
"site.houseNumber": "siteAddress",
"site.postalCode": "siteAddress",
"site.city": "siteAddress",
"site.country": "siteAddress",
"contact.name": "contactName",
"contact.phone": "phone",
"contact.email": "email",
"order.title": "title",
"order.externalOrderNumber": "orderNumber",
"order.offerNumber": "offerNumber",
"order.description": "description",
"order.plannedStart": "plannedStart",
"order.plannedEnd": "plannedEnd",
"order.notes": "notes",
} as const satisfies Record<string, ExtractionFieldKey>;
export type FormFieldPath = keyof typeof FORM_FIELD_SOURCE;
export type FieldMeta = { confidence: number; source?: string; uncertain: boolean };
/** Confidence/source per form field; `uncertain` = value present-or-expected but < 0.8. */
export function formFieldMeta(ex: WorkOrderExtraction): Record<FormFieldPath, FieldMeta> {
const out = {} as Record<FormFieldPath, FieldMeta>;
for (const [path, key] of Object.entries(FORM_FIELD_SOURCE) as [FormFieldPath, ExtractionFieldKey][]) {
const f = ex[key];
out[path] = { confidence: f.confidence, source: f.source, uncertain: f.value !== null && f.confidence < LOW_CONFIDENCE };
}
return out;
}
function deriveTitle(ex: WorkOrderExtraction): string {
if (ex.title.value) return ex.title.value.slice(0, 200);
const firstLine = ex.description.value?.split(/\r?\n/)[0]?.trim();
if (firstLine) return firstLine.slice(0, 80);
return "";
}
/** Initial review form from an extraction (existing customer/site preselected if given). */
export function extractionToForm(
ex: WorkOrderExtraction,
opts: { customerId?: string | null; siteId?: string | null } = {},
): ReviewFormInput {
const ca = ex.customerAddress.value ?? {};
const sa = ex.siteAddress.value ?? {};
const hasSite = Boolean(ex.siteName.value || sa.street || sa.city);
return {
customerMode: opts.customerId ? "existing" : "new",
customerId: opts.customerId ?? "",
customer: {
customerNumber: ex.customerNumber.value ?? "",
companyName: ex.companyName.value ?? "",
firstName: ex.customerFirstName.value ?? "",
lastName: ex.customerLastName.value ?? "",
street: ca.street ?? "",
houseNumber: ca.houseNumber ?? "",
postalCode: ca.postalCode ?? "",
city: ca.city ?? "",
country: (ca.country ?? "DE").slice(0, 2).toUpperCase(),
phone: ex.phone.value ?? "",
email: ex.email.value ?? "",
},
siteMode: opts.siteId ? "existing" : hasSite ? "new" : "none",
siteId: opts.siteId ?? "",
site: {
name: ex.siteName.value ?? (sa.street ? [sa.street, sa.houseNumber].filter(Boolean).join(" ") : ""),
street: sa.street ?? "",
houseNumber: sa.houseNumber ?? "",
postalCode: sa.postalCode ?? "",
city: sa.city ?? "",
country: (sa.country ?? "DE").slice(0, 2).toUpperCase(),
},
contact: {
name: ex.contactName.value ?? "",
phone: ex.contactName.value ? ex.phone.value ?? "" : "",
email: ex.contactName.value ? ex.email.value ?? "" : "",
},
order: {
title: deriveTitle(ex),
externalOrderNumber: ex.orderNumber.value ?? "",
offerNumber: ex.offerNumber.value ?? "",
description: ex.description.value ?? "",
plannedStart: toIsoDate(ex.plannedStart.value) ?? "",
plannedEnd: toIsoDate(ex.plannedEnd.value) ?? "",
notes: ex.notes.value ?? "",
},
positions: (ex.positions.value ?? []).map((p) => ({
name: p.name,
articleNumber: p.articleNumber ?? "",
quantity: p.quantity ?? "",
unit: p.unit ?? "",
asMaterial: Boolean(p.isMaterial && p.quantity != null),
})),
};
}
// ---------- corrections ----------
export type Corrections = Record<string, { from: unknown; to: unknown }>;
function get(obj: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), obj);
}
const norm = (v: unknown) => (v === undefined || v === null ? "" : typeof v === "string" ? v.trim() : v);
/**
* Diff between the form proposed from the extraction and the confirmed form. Only entered
* data counts (customer/site decisions are recorded separately) — a field that was edited
* shows up as `{ from, to }` under its form path; positions are compared as a whole.
*/
export function computeCorrections(ex: WorkOrderExtraction, confirmed: ReviewForm): Corrections {
const proposed = extractionToForm(ex);
const out: Corrections = {};
for (const path of Object.keys(FORM_FIELD_SOURCE)) {
const from = norm(get(proposed, path));
const to = norm(get(confirmed, path));
if (from !== to) out[path] = { from, to };
}
const simplify = (ps: Array<{ name: string; articleNumber?: string; quantity?: unknown; unit?: string }>) =>
ps.map((p) => ({
name: p.name.trim(),
articleNumber: norm(p.articleNumber),
quantity: p.quantity === "" || p.quantity == null ? null : Number(p.quantity),
unit: norm(p.unit),
}));
const fromPos = simplify(proposed.positions ?? []);
const toPos = simplify(confirmed.positions);
if (JSON.stringify(fromPos) !== JSON.stringify(toPos)) out.positions = { from: fromPos, to: toPos };
return out;
}
+19
View File
@@ -0,0 +1,19 @@
/** Import status → UI tone/label key (client-safe). Status is never shown by colour alone. */
export const IMPORT_STATUSES = ["uploaded", "processing", "review_required", "confirmed", "failed", "discarded"] as const;
export type ImportStatusKey = (typeof IMPORT_STATUSES)[number];
export const IMPORT_STATUS_TONE: Record<ImportStatusKey, "info" | "warn" | "ok" | "risk" | "mut"> = {
uploaded: "info",
processing: "info",
review_required: "warn",
confirmed: "ok",
failed: "risk",
discarded: "mut",
};
/** Statuses in which the page should poll for progress. */
export const IMPORT_IN_PROGRESS: readonly ImportStatusKey[] = ["uploaded", "processing"];
export const IMPORT_MAX_BYTES = 25 * 1024 * 1024;
export const IMPORT_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
export const IMPORT_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png"] as const;
+77
View File
@@ -0,0 +1,77 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ForbiddenError } from "@/server/rbac";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { confirmImport, discardImport, retryImport } from "@/server/services/imports/confirm";
import { searchCustomers } from "@/server/services/imports/queries";
/**
* Server actions of the import module (thin adapters: guard → service → revalidate).
* Uploads do NOT go through a server action (1 MB body limit) but through
* POST /api/v1/work-orders/import with upload progress.
*/
const guard = moduleGuard("imports");
export type ConfirmState =
| { status: "idle" }
| { status: "error"; code: string; issues?: Array<{ path: string; message: string }> };
export type SimpleState = { status: "idle" } | { status: "error"; code: string };
function errorCode(err: unknown): string {
if (err instanceof ServiceError) return err.code === "invalid" || err.code === "conflict" ? err.message : err.code;
if (err instanceof ForbiddenError) return "forbidden";
console.error("[actions/imports]", err);
return "error";
}
export async function confirmImportAction(importId: string, _prev: ConfirmState, formData: FormData): Promise<ConfirmState> {
const ctx = ctxFromGuard(await guard("import:write", "work_order:write"));
let payload: unknown;
try {
payload = JSON.parse(String(formData.get("payload") ?? ""));
} catch {
return { status: "error", code: "form_invalid" };
}
try {
await confirmImport(ctx, importId, payload);
} catch (err) {
const issues = err instanceof ServiceError && err.code === "invalid" ? (err.details as Array<{ path: string; message: string }> | undefined) : undefined;
return { status: "error", code: errorCode(err), issues };
}
revalidatePath("/imports");
redirect(`/imports/${importId}`);
}
// Bound as (prev, formData) action for useActionState; the extra arguments are not needed.
export async function discardImportAction(importId: string): Promise<SimpleState> {
const ctx = ctxFromGuard(await guard("import:write"));
try {
await discardImport(ctx, importId);
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/imports");
redirect("/imports");
}
export async function retryImportAction(importId: string): Promise<SimpleState> {
const ctx = ctxFromGuard(await guard("import:write"));
try {
await retryImport(ctx, importId);
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/imports");
revalidatePath(`/imports/${importId}`);
return { status: "idle" };
}
export async function searchCustomersAction(q: string) {
const ctx = ctxFromGuard(await guard("import:write"));
const rows = await searchCustomers(ctx, String(q ?? "").slice(0, 100));
return rows;
}
+132
View File
@@ -0,0 +1,132 @@
import Anthropic from "@anthropic-ai/sdk";
import { AI_MODEL, getAnthropic } from "@/server/ai/client";
import type { DocumentExtractionProvider, WorkOrderExtraction, ProviderMeta } from "@/server/ai/providers";
import { extractionJsonSchema, parseExtraction } from "@/lib/imports/extraction";
/**
* Claude-based document extraction (spec §9.4, ARCHITEKTUR §4.5).
*
* - PDF is sent natively as a base64 `document` block (text layer + page images, so scanned
* PDFs work without a separate OCR step); JPG/PNG as `image` block.
* - Structured output via `output_config.format` (JSON schema) — the response is guaranteed to
* match the schema; we still validate with Zod (`parseExtraction`).
* - Streaming + `finalMessage()`: the full text of long documents can be large.
* - Refusals are handled explicitly; for Claude Opus 5 / Fable 5.1 the server-side fallback
* (`fallbacks: "default"`) re-runs a declined request on the recommended fallback model.
*/
const SUPPORTED_IMAGE = new Set(["image/jpeg", "image/png"]);
const FALLBACK_BETA = "server-side-fallback-2026-07-01";
const MAX_TOKENS = 64_000;
const SYSTEM_PROMPT = `You extract structured data from order documents (Auftragsbestätigungen, Aufträge, Bestellungen) of German craft and installation businesses.
Rules:
- Return the full recognised text of the document in "text" (reading order, line breaks preserved).
- Extract each field only from what is written in the document. Never invent, guess or complete values. If a field is not present, set value to null and confidence to 0.
- confidence is 0..1 and reflects how certain you are that the value is correct and belongs to this field. Use values below 0.8 whenever the value is ambiguous, hard to read (scan quality, handwriting), inferred from context, or could belong to a different party. Use values of 0.95 and above only for values printed clearly and labelled unambiguously.
- source: a short verbatim snippet (max. 120 characters) from the document that the value was taken from.
- The customer is the ordering party (Auftraggeber / Rechnungsempfänger), not the business issuing the document (letterhead, footer, bank details).
- The site (Objekt / Baustelle / Einsatzort / Lieferadresse) is where the work is carried out. If the document names no separate site, leave siteName and siteAddress null.
- Normalise German formats: dates to ISO 8601 (YYYY-MM-DD; "15.03.2026" → "2026-03-15"), numbers with decimal comma to JSON numbers ("1.234,50" → 1234.5), country to ISO 3166-1 alpha-2 (default "DE" only if the address is clearly German).
- Split street and house number ("Hafenstraße 12a" → street "Hafenstraße", houseNumber "12a").
- A planned execution period like "KW 12/2026" or "ab 03.04." may be converted to dates only if the year is unambiguous; otherwise keep the value null and mention it in notes.
- positions: every line item with quantity and unit; set isMaterial=true for physical material/articles, false for labour/services.
- title: a short, factual job title in German (max. 80 characters) derived from the document subject or main service.`;
export class AnthropicExtractionProvider implements DocumentExtractionProvider {
readonly name = "anthropic";
readonly model: string;
constructor(
private readonly client: Anthropic,
model: string = AI_MODEL,
) {
this.model = model;
}
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
text: string;
extraction: WorkOrderExtraction;
meta: ProviderMeta;
}> {
const data = input.bytes.toString("base64");
let fileBlock: Anthropic.Beta.BetaContentBlockParam;
if (input.mimeType === "application/pdf") {
fileBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data }, title: input.fileName };
} else if (SUPPORTED_IMAGE.has(input.mimeType)) {
fileBlock = { type: "image", source: { type: "base64", media_type: input.mimeType as "image/jpeg" | "image/png", data } };
} else {
throw new Error(`unsupported mime type for extraction: ${input.mimeType}`);
}
const useFallback = /^claude-(opus-5|fable-5-1|mythos-5-1)/.test(this.model);
const today = new Date().toISOString().slice(0, 10);
let message: Anthropic.Beta.BetaMessage;
try {
const stream = this.client.beta.messages.stream({
model: this.model,
max_tokens: MAX_TOKENS,
thinking: { type: "adaptive" },
system: SYSTEM_PROMPT,
output_config: { format: { type: "json_schema", schema: extractionJsonSchema() } },
messages: [
{
role: "user",
content: [
fileBlock,
{ type: "text", text: `Extract the order data from this document. Today is ${today} (for resolving incomplete dates).` },
],
},
],
...(useFallback ? { betas: [FALLBACK_BETA], fallbacks: "default" as const } : {}),
});
message = await stream.finalMessage();
} catch (err) {
if (err instanceof Anthropic.APIError) {
// No document content in the message — only status/type for the import job's error field.
throw new Error(`Claude API error ${err.status ?? "?"} (${err.name})`);
}
throw err;
}
if (message.stop_reason === "refusal") {
throw new Error("Claude declined to process the document (refusal)");
}
if (message.stop_reason === "max_tokens") {
throw new Error("Claude response truncated (max_tokens)");
}
const textBlock = message.content.find((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text");
if (!textBlock) throw new Error("Claude response contained no text block");
let parsed: { text?: unknown; extraction?: unknown };
try {
parsed = JSON.parse(textBlock.text) as typeof parsed;
} catch {
throw new Error("Claude response was not valid JSON");
}
return {
text: typeof parsed.text === "string" ? parsed.text : "",
extraction: parseExtraction(parsed.extraction),
meta: {
provider: this.name,
model: message.model ?? this.model,
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
},
};
}
}
/**
* Configured extraction provider or `null` (graceful degradation → manual entry).
* `AI_EXTRACTION_PROVIDER` may be unset or "anthropic"; any other value disables extraction.
*/
export function getExtractionProvider(): DocumentExtractionProvider | null {
const configured = process.env.AI_EXTRACTION_PROVIDER?.trim().toLowerCase();
if (configured && configured !== "anthropic") return null;
const client = getAnthropic();
return client ? new AnthropicExtractionProvider(client) : null;
}
+34
View File
@@ -0,0 +1,34 @@
import type { DocumentExtractionProvider, ProviderMeta, WorkOrderExtraction } from "@/server/ai/providers";
import { emptyExtraction } from "@/lib/imports/extraction";
/**
* Deterministic provider for tests and demos: returns a fixed extraction (merged over an empty
* one) or throws the configured error. Records every call.
*/
export class FakeExtractionProvider implements DocumentExtractionProvider {
readonly name = "fake";
readonly model = "fake-extraction-1";
readonly calls: Array<{ mimeType: string; fileName: string; size: number }> = [];
constructor(
private readonly opts: {
extraction?: Partial<WorkOrderExtraction>;
text?: string;
fail?: Error;
} = {},
) {}
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
text: string;
extraction: WorkOrderExtraction;
meta: ProviderMeta;
}> {
this.calls.push({ mimeType: input.mimeType, fileName: input.fileName, size: input.bytes.byteLength });
if (this.opts.fail) throw this.opts.fail;
return {
text: this.opts.text ?? "",
extraction: { ...emptyExtraction(), ...structuredClone(this.opts.extraction ?? {}) },
meta: { provider: this.name, model: this.model, inputTokens: 100, outputTokens: 50 },
};
}
}
@@ -0,0 +1,25 @@
import { dbForTenant } from "@/server/db";
import type { JobPayload } from "@/server/jobs/queues";
import type { ServiceCtx } from "@/server/services/context";
import { getExtractionProvider } from "@/server/ai/extraction/anthropic";
import { processImport } from "@/server/services/imports/process";
import { readDocumentBytes } from "@/server/services/imports/document-store-stub";
/**
* BullMQ processor for "import-extraction" (ARCHITEKTUR §4.4). System context: tenant from the
* payload (dbForTenant), no user permissions — processImport performs no permission-gated steps.
* Errors are recorded on the job (status failed) instead of being rethrown, so a failed
* extraction is retried by the user ("Neu verarbeiten"), not blindly by the queue.
*/
export async function process(payload: JobPayload): Promise<void> {
const ctx: ServiceCtx = {
db: dbForTenant(payload.tenantId),
tenantId: payload.tenantId,
userId: payload.actorId ?? "",
permissions: new Set<string>(),
};
await processImport(ctx, payload.entityId, {
provider: getExtractionProvider(),
loadBytes: (doc) => readDocumentBytes(doc.storageKey),
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export type JobProcessor = (payload: JobPayload) => Promise<void>;
* Lazy imports keep the app bundle free of worker-only dependencies (Playwright etc.).
*/
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
"import-extraction": () => import("./import-extraction").then((m) => m.process),
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
+228
View File
@@ -0,0 +1,228 @@
import type { Prisma } from "@prisma/client";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { nextNumber } from "@/server/services/numbering";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { readStoredExtraction } from "@/lib/imports/extraction";
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
// TODO(L3→L2): replace with the L2 work order service after merge (same input type).
import { createWorkOrder } from "./work-orders-stub";
import { startExtraction, type Dispatch } from "./upload";
import { dispatchJob } from "@/server/jobs/dispatch";
export type ConfirmResult = { workOrderId: string; workOrderNumber: string; customerId: string; siteId: string | null; contactId: string | null };
const blankToNull = (v: string | null | undefined) => (v && v.trim() !== "" ? v.trim() : null);
function positionsAsScope(form: ReviewForm): string | null {
if (!form.positions.length) return null;
return form.positions
.map((p) => [p.quantity != null ? `${String(p.quantity).replace(".", ",")} ${p.unit}`.trim() : null, p.name, p.articleNumber ? `(${p.articleNumber})` : null].filter(Boolean).join(" "))
.join("\n");
}
/**
* Confirm a reviewed import (spec §9.6/§9.3 step 10): one transaction creates or assigns
* customer, contact and site, creates the work order (status planned, sourceImportId, material
* plan from positions marked as material), links the original document and stores the
* corrections. Only allowed from `review_required` (atomic status switch → no double orders).
*/
export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm: unknown): Promise<ConfirmResult> {
assertCan(ctx, "import:write");
assertCan(ctx, "work_order:write");
const parsed = reviewFormSchema.safeParse(rawForm);
if (!parsed.success) {
throw new ServiceError("invalid", "form_invalid", parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })));
}
const form = parsed.data;
const job = await ctx.db.importJob.findFirst({ where: { id: importId } });
if (!job) throw new ServiceError("not_found", "import not found");
if (job.status !== "review_required") throw new ServiceError("conflict", "import_not_reviewable");
// Decisions are validated before the transaction (all lookups tenant-scoped via ctx.db).
if (form.customerMode === "existing") {
const exists = await ctx.db.customer.findFirst({ where: { id: form.customerId, deletedAt: null, status: { not: "merged" } }, select: { id: true } });
if (!exists) throw new ServiceError("not_found", "customer not found");
} else {
assertCan(ctx, "customer:write");
if (form.customer.customerNumber) {
const taken = await ctx.db.customer.findFirst({ where: { customerNumber: form.customer.customerNumber }, select: { id: true } });
if (taken) throw new ServiceError("conflict", "customer_number_taken");
}
}
if (form.siteMode === "existing") {
if (form.customerMode !== "existing") throw new ServiceError("invalid", "site_requires_existing_customer");
const site = await ctx.db.site.findFirst({ where: { id: form.siteId, customerId: form.customerId, deletedAt: null }, select: { id: true } });
if (!site) throw new ServiceError("not_found", "site not found");
} else if (form.siteMode === "new") {
assertCan(ctx, "site:write");
}
if (form.contact.name && form.customerMode === "existing") assertCan(ctx, "customer:write");
const stored = readStoredExtraction(job.extraction);
const corrections = computeCorrections(stored.fields, form);
const now = new Date();
const result = await ctx.db.$transaction(async (tx) => {
const db = tx as unknown as TenantDb;
const txCtx: ServiceCtx = { ...ctx, db };
const switched = await db.importJob.updateMany({
where: { id: job.id, status: "review_required" },
data: {
status: "confirmed",
confirmedById: ctx.userId,
confirmedAt: now,
corrections: { fields: corrections, decisions: { customerMode: form.customerMode, siteMode: form.siteMode } } as unknown as Prisma.InputJsonValue,
},
});
if (switched.count !== 1) throw new ServiceError("conflict", "import_not_reviewable");
let customerId = form.customerId;
let createdCustomer = false;
if (form.customerMode === "new") {
const c = form.customer;
const customer = await db.customer.create({
data: {
tenantId: ctx.tenantId,
customerNumber: c.customerNumber || (await nextNumber(db, ctx.tenantId, "customer")),
companyName: blankToNull(c.companyName),
firstName: blankToNull(c.firstName),
lastName: blankToNull(c.lastName),
street: blankToNull(c.street),
houseNumber: blankToNull(c.houseNumber),
postalCode: blankToNull(c.postalCode),
city: blankToNull(c.city),
country: c.country || "DE",
phone: blankToNull(c.phone),
email: blankToNull(c.email),
status: "active",
createdById: ctx.userId,
},
});
customerId = customer.id;
createdCustomer = true;
}
let contactId: string | null = null;
if (form.contact.name) {
const contact = await db.contact.create({
data: {
tenantId: ctx.tenantId,
customerId,
name: form.contact.name,
phone: blankToNull(form.contact.phone),
email: blankToNull(form.contact.email),
},
});
contactId = contact.id;
}
let siteId: string | null = form.siteMode === "existing" ? form.siteId : null;
let createdSite = false;
if (form.siteMode === "new") {
const s = form.site;
const site = await db.site.create({
data: {
tenantId: ctx.tenantId,
customerId,
name: s.name || [s.street, s.houseNumber].filter(Boolean).join(" "),
street: blankToNull(s.street),
houseNumber: blankToNull(s.houseNumber),
postalCode: blankToNull(s.postalCode),
city: blankToNull(s.city),
country: s.country || "DE",
contactId,
},
});
siteId = site.id;
createdSite = true;
}
const wo = await createWorkOrder(txCtx, {
customerId,
siteId,
contactId,
title: form.order.title,
description: blankToNull(form.order.description),
scope: positionsAsScope(form),
externalOrderNumber: blankToNull(form.order.externalOrderNumber),
offerNumber: blankToNull(form.order.offerNumber),
plannedStart: form.order.plannedStart ? new Date(`${form.order.plannedStart}T00:00:00.000Z`) : null,
plannedEnd: form.order.plannedEnd ? new Date(`${form.order.plannedEnd}T00:00:00.000Z`) : null,
internalNotes: blankToNull(form.order.notes),
sourceImportId: job.id,
status: "planned",
materials: form.positions
.filter((p) => p.asMaterial && p.quantity != null)
.map((p) => ({ name: p.name, articleNumber: blankToNull(p.articleNumber), plannedQuantity: p.quantity as number, unit: p.unit || "Stk" })),
});
// Original document stays with the order forever (spec §9.7).
await db.document.update({ where: { id: job.documentId }, data: { workOrderId: wo.id, customerId, siteId } });
return { workOrderId: wo.id, workOrderNumber: wo.number, customerId, siteId, contactId, createdCustomer, createdSite };
});
const base = { tenantId: ctx.tenantId, actorId: ctx.userId };
if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } });
if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } });
await writeAuditLog({
...base,
action: "create",
entity: "work_order",
entityId: result.workOrderId,
after: { number: result.workOrderNumber, status: "planned", customerId: result.customerId, siteId: result.siteId, sourceImportId: job.id },
});
await writeAuditLog({
...base,
action: "import",
entity: "import_job",
entityId: job.id,
before: { status: job.status },
after: {
status: "confirmed",
workOrderId: result.workOrderId,
customerId: result.customerId,
siteId: result.siteId,
customerMode: form.customerMode,
siteMode: form.siteMode,
correctedFields: Object.keys(corrections),
},
});
return {
workOrderId: result.workOrderId,
workOrderNumber: result.workOrderNumber,
customerId: result.customerId,
siteId: result.siteId,
contactId: result.contactId,
};
}
/** Discard an import (no order is created; the original document is kept). */
export async function discardImport(ctx: ServiceCtx, importId: string): Promise<void> {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({ where: { id: importId }, select: { id: true, status: true } });
if (!job) throw new ServiceError("not_found", "import not found");
const res = await ctx.db.importJob.updateMany({
where: { id: job.id, status: { in: ["uploaded", "review_required", "failed"] } },
data: { status: "discarded" },
});
if (res.count !== 1) throw new ServiceError("conflict", "import_not_discardable");
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "import_job", entityId: job.id, before: { status: job.status }, after: { status: "discarded" } });
}
/** Re-run the extraction of a failed import. */
export async function retryImport(ctx: ServiceCtx, importId: string, opts: { dispatch?: Dispatch } = {}): Promise<void> {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({ where: { id: importId }, select: { id: true, status: true } });
if (!job) throw new ServiceError("not_found", "import not found");
const res = await ctx.db.importJob.updateMany({ where: { id: job.id, status: "failed" }, data: { status: "uploaded", errorMessage: null } });
if (res.count !== 1) throw new ServiceError("conflict", "import_not_retryable");
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "import_job", entityId: job.id, before: { status: "failed" }, after: { status: "uploaded", retry: true } });
await startExtraction(ctx, job, opts.dispatch ?? ((p) => dispatchJob("import-extraction", p)));
}
@@ -0,0 +1,101 @@
import { createHash, randomUUID } from "node:crypto";
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L3) for the document service contract ARCHITEKTUR §4.3
* `src/server/services/documents/store.ts#storeFile` — not present on the base commit.
* Same signature; replace the import in services/imports/upload.ts once the real service exists.
*
* Implements the parts the import needs: allowlist (PDF/JPEG/PNG), size limit per type
* (PDF 25 MB, images 15 MB), magic-byte check, normalised file name, SHA-256, storage.put,
* Document row with lineage.
*/
export type StoreFileInput = {
bytes: Buffer;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
lineageId?: string;
};
const LIMITS: Record<string, number> = {
"application/pdf": 25 * 1024 * 1024,
"image/jpeg": 15 * 1024 * 1024,
"image/png": 15 * 1024 * 1024,
};
/** Detect the real type from magic bytes (null = not allowed). */
export function sniffMime(bytes: Buffer): string | null {
if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-") return "application/pdf";
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
return null;
}
export function normalizeFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
const cleaned = base.normalize("NFC").replace(/[\x00-\x1f<>:"|?*]+/g, "_").replace(/\s+/g, " ").trim();
return (cleaned || "datei").slice(0, 180);
}
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
if (input.bytes.byteLength === 0) throw new ServiceError("invalid", "file_empty");
const sniffed = sniffMime(input.bytes);
if (!sniffed) throw new ServiceError("invalid", "file_type_not_allowed");
const declared = input.declaredMime === "image/jpg" ? "image/jpeg" : input.declaredMime;
// Browsers sometimes send application/octet-stream — the magic bytes are authoritative,
// but a declared allowed type must match them.
if (declared in LIMITS && declared !== sniffed) throw new ServiceError("invalid", "file_type_mismatch");
if (input.bytes.byteLength > LIMITS[sniffed]) throw new ServiceError("invalid", "file_too_large");
const fileName = normalizeFileName(input.fileName);
const checksum = createHash("sha256").update(input.bytes).digest("hex");
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed, bytes: input.bytes });
let version = 1;
const lineageId = input.lineageId ?? randomUUID();
if (input.lineageId) {
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
version = (last?.version ?? 0) + 1;
}
return ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
customerId: input.links.customerId ?? null,
siteId: input.links.siteId ?? null,
workOrderId: input.links.workOrderId ?? null,
category: input.category,
title: fileName,
fileName,
storageKey: stored.storageKey,
mimeType: sniffed,
fileSize: input.bytes.byteLength,
checksum,
version,
lineageId,
visibility: input.visibility,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
}
/** Read the bytes of a stored document (null if the backend keeps no bytes, e.g. stub storage). */
export async function readDocumentBytes(storageKey: string): Promise<Buffer | null> {
const content = await storage.get(storageKey);
if (!content) return null;
const chunks: Uint8Array[] = [];
const reader = content.stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return Buffer.concat(chunks);
}
@@ -0,0 +1,142 @@
import type { TenantDb } from "@/server/db";
import type { DuplicateCandidate, SiteCandidate } from "@/lib/imports/extraction";
/**
* STUB (lane L3) for the L1 contract `src/lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → Candidate[]`
* (ARCHITEKTUR §6, spec §7.3). Same signature and result shape `{ customerId, score, reasons[] }`;
* replace the import in services/imports/process.ts after the L1 merge.
*
* Compares customer number, company/person name, address, e-mail and phone. Never merges —
* it only proposes candidates; the backoffice decides (US-003).
*/
export type CustomerCandidateInput = {
customerNumber?: string | null;
companyName?: string | null;
firstName?: string | null;
lastName?: string | null;
street?: string | null;
houseNumber?: string | null;
postalCode?: string | null;
city?: string | null;
email?: string | null;
phone?: string | null;
};
const LEGAL_FORMS = /\b(gmbh|mbh|ag|kg|ohg|gbr|ug|e\.?\s?k|e\.?\s?v|co|haftungsbeschränkt|und|&)\b/g;
export function normalizeCompany(v: string | null | undefined): string {
return (v ?? "")
.toLowerCase()
.replace(/[.,;:()"'`´+/-]/g, " ")
.replace(LEGAL_FORMS, " ")
.replace(/\s+/g, " ")
.trim();
}
export function normalizeStreet(v: string | null | undefined): string {
return (v ?? "")
.toLowerCase()
.replace(/ß/g, "ss")
.replace(/strasse|str\./g, "str")
.replace(/[^a-z0-9äöü]/g, "");
}
const digits = (v: string | null | undefined) => (v ?? "").replace(/\D/g, "").replace(/^49/, "0").replace(/^00/, "0");
const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase();
export async function findDuplicateCustomers(db: TenantDb, candidate: CustomerCandidateInput): Promise<DuplicateCandidate[]> {
const company = normalizeCompany(candidate.companyName);
const firstWord = company.split(" ").find((w) => w.length >= 3);
const or: object[] = [];
if (candidate.customerNumber) or.push({ customerNumber: candidate.customerNumber.trim() });
if (candidate.email) or.push({ email: { equals: candidate.email.trim(), mode: "insensitive" } });
if (firstWord) or.push({ companyName: { contains: firstWord, mode: "insensitive" } });
if (candidate.lastName) or.push({ lastName: { equals: candidate.lastName.trim(), mode: "insensitive" } });
if (candidate.postalCode) or.push({ postalCode: candidate.postalCode.trim() });
if (candidate.phone) or.push({ phone: { not: null } });
if (or.length === 0) return [];
const rows = await db.customer.findMany({
where: { deletedAt: null, status: { not: "merged" }, OR: or },
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, email: true, phone: true, mobile: true },
take: 200,
});
const out: DuplicateCandidate[] = [];
for (const c of rows) {
let score = 0;
const reasons: string[] = [];
if (candidate.customerNumber && c.customerNumber && c.customerNumber.trim() === candidate.customerNumber.trim()) {
score += 0.6;
reasons.push("customer_number");
}
const cc = normalizeCompany(c.companyName);
if (company && cc) {
if (cc === company) {
score += 0.4;
reasons.push("company_name");
} else if (cc.includes(company) || company.includes(cc)) {
score += 0.25;
reasons.push("company_name_similar");
}
}
if (candidate.lastName && lc(c.lastName) === lc(candidate.lastName) && (!candidate.firstName || lc(c.firstName) === lc(candidate.firstName))) {
score += 0.3;
reasons.push("person_name");
}
if (candidate.email && lc(c.email) === lc(candidate.email)) {
score += 0.3;
reasons.push("email");
}
const phone = digits(candidate.phone);
if (phone.length >= 6 && [c.phone, c.mobile].some((p) => digits(p) === phone)) {
score += 0.2;
reasons.push("phone");
}
if (
candidate.postalCode &&
c.postalCode === candidate.postalCode.trim() &&
normalizeStreet(c.street) !== "" &&
normalizeStreet(c.street) === normalizeStreet(candidate.street)
) {
score += 0.25;
reasons.push("address");
}
if (score >= 0.25) out.push({ customerId: c.id, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
/** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */
export async function findSiteCandidates(
db: TenantDb,
customerIds: string[],
address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null,
): Promise<SiteCandidate[]> {
if (!customerIds.length || !address || (!address.street && !address.name)) return [];
const sites = await db.site.findMany({
where: { customerId: { in: customerIds }, deletedAt: null },
select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true },
take: 200,
});
const out: SiteCandidate[] = [];
for (const s of sites) {
let score = 0;
const reasons: string[] = [];
if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) {
score += 0.6;
reasons.push("address");
if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) {
score += 0.3;
reasons.push("house_number");
}
}
if (address.name && lc(s.name) === lc(address.name)) {
score += 0.3;
reasons.push("site_name");
}
if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
+145
View File
@@ -0,0 +1,145 @@
import type { ImportJob, Prisma } from "@prisma/client";
import { writeAuditLog } from "@/server/audit";
import { emitEvent } from "@/server/events";
import type { DocumentExtractionProvider, WorkOrderExtraction } from "@/server/ai/providers";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import {
emptyExtraction,
EXTRACTION_VERSION,
type PlausibilityHint,
type StoredExtraction,
} from "@/lib/imports/extraction";
import { checkPlausibility } from "@/lib/imports/plausibility";
// TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface).
import { findDuplicateCustomers, findSiteCandidates } from "./duplicates-stub";
export type ProcessDeps = {
provider: DocumentExtractionProvider | null;
loadBytes: (doc: { storageKey: string }) => Promise<Buffer | null>;
now?: Date;
};
/**
* Extraction pipeline (spec §9.3 steps 3–7): processing → provider (text + fields) →
* plausibility → duplicate customers + site candidates → review_required.
* Without provider: review_required with empty extraction and hint "manual_entry".
* Any error: failed + event import.failed. Never creates customers or work orders.
* Idempotent: only jobs in uploaded/processing are processed.
*/
export async function processImport(ctx: ServiceCtx, importId: string, deps: ProcessDeps): Promise<ImportJob> {
const job = await ctx.db.importJob.findFirst({ where: { id: importId } });
if (!job) throw new ServiceError("not_found", "import not found");
if (job.status !== "uploaded" && job.status !== "processing") return job;
const actorId = job.importedById ?? undefined;
await ctx.db.importJob.update({ where: { id: job.id }, data: { status: "processing", errorMessage: null } });
try {
const doc = await ctx.db.document.findFirst({ where: { id: job.documentId } });
if (!doc) throw new Error("document_missing");
let fields: WorkOrderExtraction;
let hints: PlausibilityHint[] = [];
let text: string | null = null;
let providerName: string | null = null;
let model: string | null = null;
if (!deps.provider) {
fields = emptyExtraction();
hints = [{ field: null, code: "manual_entry" }];
} else {
const bytes = await deps.loadBytes({ storageKey: doc.storageKey });
if (!bytes) throw new Error("file_unavailable");
const result = await deps.provider.extract({ bytes, mimeType: doc.mimeType, fileName: doc.fileName });
const checked = checkPlausibility(result.extraction, deps.now);
fields = checked.extraction;
hints = checked.hints;
text = result.text;
providerName = result.meta.provider;
model = result.meta.model;
await ctx.db.aiGeneration.create({
data: {
tenantId: ctx.tenantId,
kind: "import_extraction",
provider: result.meta.provider,
model: result.meta.model,
entityType: "import_job",
entityId: job.id,
input: { documentId: doc.id, fileName: doc.fileName, mimeType: doc.mimeType, fileSize: doc.fileSize, checksum: doc.checksum },
output: { extraction: result.extraction, hints } as unknown as Prisma.InputJsonValue,
inputTokens: result.meta.inputTokens ?? null,
outputTokens: result.meta.outputTokens ?? null,
createdById: actorId ?? null,
},
});
}
const address = fields.customerAddress.value ?? {};
const duplicates = await findDuplicateCustomers(ctx.db, {
customerNumber: fields.customerNumber.value,
companyName: fields.companyName.value,
firstName: fields.customerFirstName.value,
lastName: fields.customerLastName.value,
street: address.street,
houseNumber: address.houseNumber,
postalCode: address.postalCode,
city: address.city,
email: fields.email.value,
phone: fields.phone.value,
});
const siteAddress = fields.siteAddress.value ?? (fields.siteName.value ? {} : null);
const siteCandidates = await findSiteCandidates(
ctx.db,
duplicates.map((d) => d.customerId),
siteAddress ? { ...siteAddress, name: fields.siteName.value } : null,
);
const stored: StoredExtraction = { fields, hints, siteCandidates };
const updated = await ctx.db.importJob.update({
where: { id: job.id },
data: {
status: "review_required",
errorMessage: null,
extractedText: text,
extraction: stored as unknown as Prisma.InputJsonValue,
extractionVersion: EXTRACTION_VERSION,
extractionModel: model,
provider: providerName,
duplicateCandidates: duplicates as unknown as Prisma.InputJsonValue,
},
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId,
action: "update",
entity: "import_job",
entityId: job.id,
before: { status: job.status },
after: { status: updated.status, provider: providerName, model, hints: hints.length, duplicateCandidates: duplicates.length },
});
await emitEvent(ctx, {
type: "import.ready_for_review",
entityType: "import_job",
entityId: job.id,
data: { manual: !deps.provider, duplicateCandidates: duplicates.length },
});
return updated;
} catch (err) {
const message = ((err as Error).message || "extraction_failed").slice(0, 500);
console.error(`[imports] processing ${job.id} failed:`, message);
const failed = await ctx.db.importJob.update({ where: { id: job.id }, data: { status: "failed", errorMessage: message } });
await writeAuditLog({
tenantId: ctx.tenantId,
actorId,
action: "update",
entity: "import_job",
entityId: job.id,
before: { status: job.status },
after: { status: "failed", errorMessage: message },
});
await emitEvent(ctx, { type: "import.failed", entityType: "import_job", entityId: job.id, data: { reason: message.slice(0, 120) } });
return failed;
}
}
+153
View File
@@ -0,0 +1,153 @@
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { allowedDocumentVisibility, customerScope } from "@/server/services/work-orders/visibility";
import { readStoredExtraction, type DuplicateCandidate, type StoredExtraction } from "@/lib/imports/extraction";
/**
* Read models of the import module. Imports are backoffice data: every read requires
* `import:write` (technicians/team leads never see them) and stays inside the tenant (ctx.db).
* Unknown or foreign ids → not_found (no existence oracle).
*/
export async function listImports(ctx: ServiceCtx, opts: { take?: number } = {}) {
assertCan(ctx, "import:write");
const jobs = await ctx.db.importJob.findMany({
orderBy: { createdAt: "desc" },
take: Math.min(opts.take ?? 100, 500),
select: {
id: true,
status: true,
errorMessage: true,
createdAt: true,
confirmedAt: true,
documentId: true,
importedById: true,
provider: true,
createdWorkOrder: { select: { id: true, number: true } },
},
});
const [docs, users] = await Promise.all([
ctx.db.document.findMany({ where: { id: { in: jobs.map((j) => j.documentId) } }, select: { id: true, fileName: true, mimeType: true, fileSize: true } }),
ctx.db.user.findMany({ where: { id: { in: jobs.map((j) => j.importedById).filter((v): v is string => !!v) } }, select: { id: true, name: true } }),
]);
const docById = new Map(docs.map((d) => [d.id, d]));
const userById = new Map(users.map((u) => [u.id, u.name]));
return jobs.map((j) => ({
...j,
document: docById.get(j.documentId) ?? null,
importedByName: j.importedById ? userById.get(j.importedById) ?? null : null,
}));
}
export type ImportListItem = Awaited<ReturnType<typeof listImports>>[number];
export async function getImportDetail(ctx: ServiceCtx, importId: string) {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({
where: { id: importId },
include: { createdWorkOrder: { select: { id: true, number: true, status: true } } },
});
if (!job) throw new ServiceError("not_found", "import not found");
const document = await ctx.db.document.findFirst({
where: { id: job.documentId },
select: { id: true, fileName: true, mimeType: true, fileSize: true, visibility: true, createdAt: true },
});
const stored: StoredExtraction = readStoredExtraction(job.extraction);
const duplicates = (Array.isArray(job.duplicateCandidates) ? job.duplicateCandidates : []) as unknown as DuplicateCandidate[];
const scope = await customerScope(ctx);
const customerIds = [...new Set([...duplicates.map((d) => d.customerId), ...stored.siteCandidates.map((s) => s.customerId)])];
const customers = customerIds.length
? await ctx.db.customer.findMany({
where: { AND: [{ id: { in: customerIds } }, scope] },
select: {
id: true,
customerNumber: true,
companyName: true,
firstName: true,
lastName: true,
street: true,
houseNumber: true,
postalCode: true,
city: true,
email: true,
phone: true,
sites: { where: { deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true }, orderBy: { name: "asc" }, take: 50 },
},
})
: [];
const customerById = new Map(customers.map((c) => [c.id, c]));
const importer = job.importedById ? await ctx.db.user.findFirst({ where: { id: job.importedById }, select: { name: true } }) : null;
return {
id: job.id,
status: job.status,
errorMessage: job.errorMessage,
createdAt: job.createdAt,
confirmedAt: job.confirmedAt,
provider: job.provider,
extractionModel: job.extractionModel,
extractionVersion: job.extractionVersion,
extractedText: job.extractedText,
importedByName: importer?.name ?? null,
document,
extraction: stored,
corrections: job.corrections,
createdWorkOrder: job.createdWorkOrder,
customerCandidates: duplicates
.map((d) => ({ ...d, customer: customerById.get(d.customerId) ?? null }))
.filter((d): d is typeof d & { customer: NonNullable<typeof d.customer> } => d.customer !== null),
siteCandidates: stored.siteCandidates.filter((s) => customerById.has(s.customerId)),
};
}
export type ImportDetail = Awaited<ReturnType<typeof getImportDetail>>;
/** Customer search for the review mask ("bestehenden Kunden verwenden" without a candidate). */
export async function searchCustomers(ctx: ServiceCtx, q: string) {
assertCan(ctx, "import:write");
const term = q.trim();
if (term.length < 2) return [];
const scope = await customerScope(ctx);
return ctx.db.customer.findMany({
where: {
AND: [
scope,
{ status: { not: "merged" } },
{
OR: [
{ customerNumber: { contains: term, mode: "insensitive" } },
{ companyName: { contains: term, mode: "insensitive" } },
{ lastName: { contains: term, mode: "insensitive" } },
{ city: { contains: term, mode: "insensitive" } },
{ email: { contains: term, mode: "insensitive" } },
],
},
],
},
select: {
id: true,
customerNumber: true,
companyName: true,
firstName: true,
lastName: true,
postalCode: true,
city: true,
sites: { where: { deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true }, orderBy: { name: "asc" }, take: 50 },
},
orderBy: { companyName: "asc" },
take: 10,
});
}
/** Original file for the preview/download route; enforces document visibility. */
export async function getImportFile(ctx: ServiceCtx, importId: string) {
assertCan(ctx, "import:write");
const job = await ctx.db.importJob.findFirst({ where: { id: importId }, select: { documentId: true } });
if (!job) throw new ServiceError("not_found", "import not found");
const doc = await ctx.db.document.findFirst({
where: { id: job.documentId, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
select: { storageKey: true, mimeType: true, fileName: true },
});
if (!doc) throw new ServiceError("not_found", "document not found");
return doc;
}
+65
View File
@@ -0,0 +1,65 @@
import type { ImportJob } from "@prisma/client";
import { writeAuditLog } from "@/server/audit";
import { dispatchJob } from "@/server/jobs/dispatch";
import type { JobPayload } from "@/server/jobs/queues";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
// TODO(L3→documents): replace with "@/server/services/documents/store" once available (ARCHITEKTUR §4.3).
import { storeFile } from "./document-store-stub";
export type UploadFile = { bytes: Buffer; fileName: string; mimeType: string };
export type Dispatch = (payload: JobPayload) => Promise<unknown>;
const defaultDispatch: Dispatch = (payload) => dispatchJob("import-extraction", payload);
/**
* Upload an order document (spec §9.3 steps 1–2): file check via the document service
* (category order_confirmation, visibility backoffice_only), ImportJob `uploaded`, extraction job.
* No work order is created here — only after confirmation (spec §9.6).
*/
export async function createImport(ctx: ServiceCtx, file: UploadFile, opts: { dispatch?: Dispatch } = {}): Promise<ImportJob> {
assertCan(ctx, "import:write");
if (file.bytes.byteLength > IMPORT_MAX_BYTES) throw new ServiceError("invalid", "file_too_large");
const declared = file.mimeType === "image/jpg" ? "image/jpeg" : file.mimeType;
if (declared && declared !== "application/octet-stream" && !(IMPORT_MIME_TYPES as readonly string[]).includes(declared)) {
throw new ServiceError("invalid", "file_type_not_allowed");
}
const document = await storeFile(ctx, {
bytes: file.bytes,
fileName: file.fileName,
declaredMime: declared,
category: "order_confirmation",
visibility: "backoffice_only",
links: {},
});
const job = await ctx.db.importJob.create({
data: { tenantId: ctx.tenantId, documentId: document.id, status: "uploaded", importedById: ctx.userId },
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "import",
entity: "import_job",
entityId: job.id,
after: { status: job.status, documentId: document.id, fileName: document.fileName, mimeType: document.mimeType, fileSize: document.fileSize, checksum: document.checksum },
});
await startExtraction(ctx, job, opts.dispatch ?? defaultDispatch);
return job;
}
/** Queue (or run inline) the extraction; a dispatch failure marks the job failed (retryable). */
export async function startExtraction(ctx: ServiceCtx, job: Pick<ImportJob, "id">, dispatch: Dispatch): Promise<void> {
try {
await dispatch({ tenantId: ctx.tenantId, entityId: job.id, actorId: ctx.userId });
} catch (err) {
console.error(`[imports] dispatch for ${job.id} failed:`, (err as Error).message);
await ctx.db.importJob.updateMany({
where: { id: job.id, status: { in: ["uploaded", "processing"] } },
data: { status: "failed", errorMessage: "dispatch_failed" },
});
}
}
@@ -0,0 +1,87 @@
import type { WorkOrder } from "@prisma/client";
import { nextNumber } from "@/server/services/numbering";
import { assertCan, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L3) for the L2 contract `src/server/services/work-orders/*#createWorkOrder(ctx, input)`
* (ARCHITEKTUR §6: "Bestätigung → … Auftrag (über L2-Service createWorkOrder)").
* Exported input type + function; after the L2 merge, services/imports/confirm.ts imports the
* real service instead. Kept minimal on purpose: number allocation, status history
* (review_required → planned), material plan. No events/audit here — the caller audits.
*
* `ctx.db` may be a transaction client (the confirm transaction passes one).
*/
export type CreateWorkOrderInput = {
customerId: string;
siteId?: string | null;
contactId?: string | null;
title: string;
description?: string | null;
scope?: string | null;
externalOrderNumber?: string | null;
offerNumber?: string | null;
plannedStart?: Date | null;
plannedEnd?: Date | null;
internalNotes?: string | null;
sourceImportId?: string | null;
/** Initial status; imports pass "planned" after confirmation (history: review_required → planned). */
status?: "draft" | "review_required" | "planned";
materials?: Array<{ name: string; articleNumber?: string | null; plannedQuantity: number; unit: string }>;
};
export async function createWorkOrder(ctx: ServiceCtx, input: CreateWorkOrderInput): Promise<WorkOrder> {
assertCan(ctx, "work_order:write");
const number = await nextNumber(ctx.db, ctx.tenantId, "work_order");
const status = input.status ?? "draft";
const wo = await ctx.db.workOrder.create({
data: {
tenantId: ctx.tenantId,
number,
customerId: input.customerId,
siteId: input.siteId ?? null,
contactId: input.contactId ?? null,
title: input.title,
description: input.description ?? null,
scope: input.scope ?? null,
externalOrderNumber: input.externalOrderNumber ?? null,
offerNumber: input.offerNumber ?? null,
plannedStart: input.plannedStart ?? null,
plannedEnd: input.plannedEnd ?? null,
internalNotes: input.internalNotes ?? null,
sourceImportId: input.sourceImportId ?? null,
status,
createdById: ctx.userId,
},
});
const history: Array<{ fromStatus: WorkOrder["status"] | null; toStatus: WorkOrder["status"] }> =
status === "planned" && input.sourceImportId
? [
{ fromStatus: null, toStatus: "review_required" },
{ fromStatus: "review_required", toStatus: "planned" },
]
: [{ fromStatus: null, toStatus: status }];
for (const h of history) {
await ctx.db.workOrderStatusChange.create({
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: h.fromStatus, toStatus: h.toStatus, actorId: ctx.userId },
});
}
let sort = 0;
for (const m of input.materials ?? []) {
await ctx.db.materialPlan.create({
data: {
tenantId: ctx.tenantId,
workOrderId: wo.id,
name: m.name,
articleNumber: m.articleNumber ?? null,
plannedQuantity: m.plannedQuantity,
unit: m.unit,
sortOrder: sort++,
},
});
}
return wo;
}