Merge lane/import in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/pa | ||||