diff --git a/docs/craftvia/lanes/import.md b/docs/craftvia/lanes/import.md new file mode 100644 index 0000000..738b64d --- /dev/null +++ b/docs/craftvia/lanes/import.md @@ -0,0 +1,106 @@ +# Lane L3 – Auftragsimport + +Branch `lane/import` (von `feature/craftvia-mvp` @ `bf44567`). Spec §9 komplett, §7.3, §31, US-002, US-003, ARCHITEKTUR §4.4/§4.5. + +## Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §9.2 Dateien | PDF (Text und Scan: Claude liest PDF nativ als `document`-Block, inkl. Seitenbild → kein separates OCR), JPG, PNG. Allowlist + Magic Bytes + Größenlimit (PDF 25 MB, Bild 15 MB) | +| §9.3 1–2 Upload, Typprüfung | `services/imports/upload.ts#createImport` → Dokument (Kategorie `order_confirmation`, Sichtbarkeit `backoffice_only`, SHA-256) → `ImportJob uploaded` → `dispatchJob("import-extraction")` | +| §9.3 3–5 Texterkennung, Analyse, Extraktion | `ai/extraction/anthropic.ts` (Anthropic SDK 0.115, Modell `ANTHROPIC_MODEL`, Default `claude-opus-5`): Streaming + `finalMessage()`, adaptive thinking, Structured Output (`output_config.format` JSON-Schema aus `lib/imports/extraction.ts`), Volltext `text` + 22 Felder mit `value/confidence/source`. Prompt: nichts erfinden, unsichere Felder < 0.8, deutsche Datums-/Zahlenformate normalisieren, Kunde ≠ Briefkopf, Objekt ≠ Kundenadresse. Refusal/`max_tokens` → Fehler; bei Opus 5 serverseitiger Fallback (`fallbacks: "default"`, Beta `server-side-fallback-2026-07-01`) | +| §9.3 6 Plausibilität | `lib/imports/plausibility.ts`: Datum gültig und plausibel (Auftrags-/Dokumentdatum ≤ +1 Monat, Ausführung −2…+3 Jahre), PLZ 5-stellig (DE), E-Mail-/Telefonformat, Ende ≥ Beginn. Verstoß: Konfidenz ≤ 0.4 + Hinweis | +| §9.3 7 / §7.3 / US-003 Dubletten | Kandidaten über Kundennummer, Firmenname (Rechtsform-normalisiert), Personenname, E-Mail, Telefon, Adresse (Straße/Str./Strasse) mit Score + Gründen; Objekt-Kandidaten = Objekte der Kandidaten an gleicher Adresse. Keine automatische Zuordnung/Zusammenführung | +| §9.5 Vertrauenswerte | je Feld gespeichert (`ImportJob.extraction.fields`), Prüfmaske markiert < 0.8 mit Warnfarbe + Icon + Text „unsicher · Sicherheit n %“, Quelle als Tooltip und Hinweistext | +| §9.6 Manuelle Prüfung | `/imports/[id]`: links Original (PDF-iframe / Bild), rechts Formular Kunde · Objekt · Ansprechpartner · Auftrag · Positionen/Material; Kundenentscheidung (Kandidat mit Score/Gründen · Suche · neu anlegen · Link „Dubletten im Kundenstamm zusammenführen“ → L1), Objekt analog (keins/bestehend/neu), Positionen editierbar mit „als Materialvorgabe übernehmen“. **Kein Auftrag ohne Bestätigung** | +| §9.3 10 Bestätigung | `services/imports/confirm.ts#confirmImport`: eine Transaktion – atomarer Statuswechsel `review_required → confirmed` (verhindert Doppelaufträge), Kunde/Kontakt/Objekt anlegen oder zuordnen, `createWorkOrder` (Status-Historie `review_required → planned`, `sourceImportId`, Materialvorgabe), Originaldokument an Auftrag/Kunde/Objekt verknüpft, `corrections` (Diff Extraktion ↔ bestätigt + Entscheidungen). Audit: `import_job` (import), `work_order`/`customer`/`site`/`contact` (create) | +| §9.7 Originaldokument | bleibt dauerhaft (auch bei Verwerfen); gespeichert: Importdatum, importierender Nutzer, erkannter Text, Extraktion, Extraktionsversion, Modell, Provider, Korrekturen, `AiGeneration` | +| §31 Provider-Abstraktion | `DocumentExtractionProvider` (ARCHITEKTUR §4.5), `getExtractionProvider()` → `null` ohne Key bzw. bei `AI_EXTRACTION_PROVIDER≠anthropic`; `FakeExtractionProvider` für Tests | +| Graceful degradation | ohne Provider: `review_required` mit leerer Extraktion + Hinweis „manuell erfassen“ | +| Fehler | Provider-/Dateifehler → `failed` + `errorMessage` + Audit + Event `import.failed`; „Neu verarbeiten“ (`failed → uploaded` + Dispatch). Erfolg → Event `import.ready_for_review` | + +## Routen / Screens + +| Route | Inhalt | +|---|---| +| `/imports` | Upload (Drag & Drop + Dateiauswahl, Fortschrittsbalken per XHR), Liste mit Status (Pill = Farbe + Icon + Text), Aktionen Prüfen / Neu verarbeiten / Auftrag, Auto-Refresh während Verarbeitung | +| `/imports/[id]` | Prüfmaske bzw. Statusansicht (Verarbeitung, fehlgeschlagen + Neu verarbeiten/Verwerfen, bestätigt + Link zum Auftrag, verworfen), erkannter Text aufklappbar | +| `/imports/[id]/file` | Original für die Vorschau (inline, nur eigener Origin als Frame; `?download=1`) – Session + `import:write` (DB-autoritativ) + Modul + Dokument-Sichtbarkeit | +| `POST /api/v1/work-orders/import` | multipart `file` → `201 { id, status }` | +| `GET /api/v1/imports/[id]` | Status, Extraktion inkl. Konfidenzen, Kandidaten | +| `POST /api/v1/imports/[id]/confirm` | JSON = Prüfformular (`lib/imports/review.ts#reviewFormSchema`) → `{ workOrderId, workOrderNumber, customerId, siteId, contactId }` | + +Fehler-Mapping API: `not_found` 404, `forbidden` 403, `invalid` 400 (mit Feldpfaden), `conflict` 409, ohne Session 401. + +## Dateien + +- `src/lib/imports/` – `extraction.ts` (Felder, Zod, JSON-Schema, gespeicherte Form), `plausibility.ts`, `review.ts` (Formularschema, Mapping, Feld-Konfidenzen, Korrektur-Diff), `status.ts` +- `src/server/ai/extraction/` – `anthropic.ts` (Provider + `getExtractionProvider`), `fake.ts` +- `src/server/services/imports/` – `upload.ts`, `process.ts`, `confirm.ts` (inkl. Verwerfen/Neu verarbeiten), `queries.ts`, Stubs `document-store-stub.ts`, `duplicates-stub.ts`, `work-orders-stub.ts` +- `src/server/jobs/processors/import-extraction.ts` +- `src/server/actions/imports/imports.ts` (confirm, discard, retry, Kundensuche – je `moduleGuard("imports")` + `await guard(...)`) +- `src/app/(app)/imports/page.tsx`, `src/app/(app)/imports/[id]/page.tsx`, `src/app/(app)/imports/[id]/file/route.ts` +- `src/app/api/v1/imports/_context.ts`, `src/app/api/v1/imports/[id]/route.ts`, `src/app/api/v1/imports/[id]/confirm/route.ts`, `src/app/api/v1/work-orders/import/route.ts` +- `src/components/imports/` – `uploader.tsx`, `review-form.tsx`, `status-pill.tsx`, `job-actions.tsx`, `auto-refresh.tsx` +- `messages/de/imports.json`, `messages/en/imports.json` +- `scripts/make-sample-pdfs.ts` → `docs/craftvia/samples/01-musterbau-auftragsbestaetigung.pdf`, `02-elbblick-wartungsauftrag.pdf`, `03-privatkunde-reparatur.pdf` (fiktiv; Nr. 3 enthält absichtlich PLZ „2148“, E-Mail „(at)“ und Ende vor Beginn für die Plausibilitätshinweise) +- Tests: `scripts/test-import-rules.ts`, `scripts/test-import-flow.ts`, `scripts/test-import-live.ts` + +Fremd-Einzeiler: `src/server/jobs/processors/index.ts` (Processor-Registrierung). `src/lib/nav.ts` enthielt `/imports` bereits. + +## Tests + +| Skript | Prüfungen | Inhalt | +|---|---|---| +| `test-import-rules.ts` | 55 | Plausibilitätsregeln, Parsing der Provider-Ausgabe, JSON-Schema (strict-tauglich), Mapping Extraktion → Formular, Formularvalidierung, Korrektur-Diff, Normalisierung Dubletten, Magic Bytes/Dateiname, PDF-Generator | +| `test-import-flow.ts` | 75 | Upload/Validierung, Verarbeitung mit Fake-Provider, Dubletten-/Objektkandidaten, AiGeneration, kein Auftrag ohne Bestätigung, Idempotenz; **Rollen** (Monteur/Teamleiter → `forbidden`); **Mandantentrennung** (B kann A weder lesen, laden, bestätigen, verwerfen, neu verarbeiten noch A-Kunden zuordnen; Kandidaten/Suche nur im Mandanten); Bestätigung bestehender vs. neuer Kunde inkl. Dokumentverknüpfung, Korrekturen, Audit; Doppelbestätigung → conflict; vergebene Kundennummer → conflict ohne Teilanlage; Verwerfen; Provider-Fehler/Datei fehlt/Dispatch-Fehler → failed + Neu verarbeiten; ohne Provider → manuell | +| `test-import-live.ts` | 6 | nur mit `ANTHROPIC_API_KEY` (sonst übersprungen): echte Extraktion einer generierten Auftragsbestätigung | + +## Gate + +`npm run gate` grün (Lane-DB `craftvia_import`, `RLS_DATABASE_URL` auf dieselbe DB): prisma generate, `tsc` ohne Fehler, Lint 0 Fehler (2 Warnungen im Fundament-Platzhalter `services/notifications/handle-event.ts`), Build inkl. Modul-Guard-Check (14 Action-Dateien), **25/25 Testskripte grün**. `test-import-live.ts` ohne `ANTHROPIC_API_KEY` übersprungen (Exit 0) – die Live-Extraktion gegen Claude ist damit **nicht** verifiziert. + +## Smoke (Dev-Server :3103, curl mit Seed-Logins) + +| Prüfung | Ergebnis | +|---|---| +| ohne Session: `/imports`, `/imports/[id]`, `/imports/[id]/file`, `/api/v1/**` | 307 → `/login` | +| Backoffice `GET /imports` | 200, Upload-Bereich gerendert | +| Backoffice `POST /api/v1/work-orders/import` (Beispiel-PDF 01) | 201, ohne API-Key direkt `review_required` mit Hinweis „manuell erfassen“ | +| Upload `package.json` | 400 | +| `GET /api/v1/imports/[id]` / unbekannte ID | 200 / 404 | +| `GET /imports/[id]` | 200, Prüfmaske + Originaldokument gerendert | +| `GET /imports/[id]/file` | 200, `inline`, **aber `X-Frame-Options: DENY`** (siehe Bedarf Punkt 2) | +| `POST /api/v1/imports/[id]/confirm` mit `{}` | 400 mit Feldpfaden | +| Monteur: API / Datei / Upload | 403 / 404 / 403 | + +Keine visuelle Browser-Prüfung (Login-Maske würde Passworteingabe durch den Agenten erfordern) – Layout 1024/768/375 px noch manuell prüfen. Der Smoke hinterlässt einen Import im Mandanten `demo` der Lane-DB. + +## Stubs / Abhängigkeiten zu anderen Lanes + +| Stub (L3-Pfad) | Vertrag | Ablösen | +|---|---|---| +| `services/imports/document-store-stub.ts#storeFile` | ARCHITEKTUR §4.3 `services/documents/store.ts#storeFile` (auf dem Basis-Commit nicht vorhanden; keiner Lane zugeordnet) | Import in `upload.ts` umstellen; `readDocumentBytes` durch Service-Funktion ersetzen | +| `services/imports/duplicates-stub.ts#findDuplicateCustomers` | L1 `lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → { customerId, score, reasons[] }[]` | Import in `process.ts` umstellen (`findSiteCandidates` bleibt L3) | +| `services/imports/work-orders-stub.ts#createWorkOrder` + `CreateWorkOrderInput` | L2 `createWorkOrder(ctx, input)`; Stub schreibt Status-Historie direkt statt über `transitionWorkOrder` | Import in `confirm.ts` umstellen; L2-Service muss einen Transaktions-Client in `ctx.db` akzeptieren und `sourceImportId`, Status `planned` (Historie ab `review_required`) und Materialvorgabe unterstützen | +| `app/api/v1/imports/_context.ts#importsApiContext` | gemeinsames `requireApiContext` (in `services/context.ts` erwähnt, nicht vorhanden) | durch zentrale Funktion ersetzen | + +Links in andere Lanes: `/work-orders/[id]` (L2), `/customers/[id]` für das Zusammenführen (L1, Merge mit Bestätigung). +Hinweis Pfad: `src/app/api/v1/work-orders/import/route.ts` liegt im L2-Ordner `api/v1/work-orders` (vom Auftrag so gefordert) – neue Datei, kein Konflikt mit L2-Dateien zu erwarten. + +## Bedarf an Fundament / Architektur (nicht geändert) + +1. **Upload > 10 MB:** `src/proxy.ts` puffert Request-Bodies standardmäßig nur bis 10 MB (`proxyClientMaxBodySize`, danach wird der Body still abgeschnitten). Für die geforderten 25 MB in `next.config.ts` `experimental.proxyClientMaxBodySize: "26mb"` setzen. Server Actions werden für Uploads bewusst nicht genutzt (1-MB-Limit). +2. **PDF-Vorschau im iframe:** globale Header in `next.config.ts` setzen `X-Frame-Options: DENY` und `frame-ancestors 'none'` für alle Pfade. Die Datei-Route setzt `SAMEORIGIN`/`frame-ancestors 'self'`, **im Smoke verifiziert: der globale Header gewinnt (`X-Frame-Options: DENY`) → die iframe-Vorschau bleibt im Browser leer**; „In neuem Tab öffnen“ funktioniert. `object`/`embed` unterliegen derselben Regel, daher kein Umweg in L3. Fix im Fundament: in `next.config.ts` für `/imports/:id/file` `X-Frame-Options: SAMEORIGIN` und `frame-ancestors 'self'` statt `DENY`/`'none'` ausliefern. Außerdem braucht die Vorschau echte Bytes → `S3_*` muss gesetzt sein (ohne S3 speichert der Storage-Stub keine Bytes; Verarbeitung mit Provider endet dann mit `file_unavailable`). +3. **RLS_ENFORCED=true:** `dbForTenant` öffnet je Operation eine eigene Transaktion; innerhalb von `ctx.db.$transaction` (Bestätigung) ist das nicht atomar. Betrifft alle Lanes mit Transaktionen – zentral in `db.ts` lösen. +4. `documents/store.ts` (§4.3) ist keiner Lane zugeordnet – Zuständigkeit klären. +5. **Eigene Lane-Datenbank und RLS-Test:** `scripts/test-rls-enforcement.ts` legt Fixtures über `DATABASE_URL` an, verbindet `craftvia_app` aber ohne `RLS_DATABASE_URL` fest auf die DB `craftvia`. Mit `DATABASE_URL=…/craftvia_import` schlägt der Test deshalb fehl (0 Zeilen, FK-Verletzung) – kein Codefehler. Gate daher mit `RLS_DATABASE_URL=postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia_import?schema=public` ausgeführt; der Test ist damit grün. Vorschlag Fundament: Default-URL aus `DATABASE_URL` ableiten. + +## Bekannte Lücken + +- Keine Migration nötig: Hinweise und Objektkandidaten liegen in `ImportJob.extraction` (`{ fields, hints, siteCandidates }`), Dublettenkandidaten in `duplicate_candidates`. +- Malware-Scan nur Magic-Byte-/Typprüfung (ClamAV-Hook gehört zum Dokumentservice §4.3). +- Kostenlimit/Region/Aufbewahrung je Mandant (§31) nicht umgesetzt; Konfiguration nur per Env. +- Kundensuche in der Prüfmaske: Top 10 nach Name/Nummer/Ort/E-Mail; kein Paging. +- Keine Teamzuweisung/Auftragsart in der Prüfmaske (erfolgt danach im Auftrag, L2). +- Worker: mit `REDIS_URL` läuft die Extraktion in `npm run worker:craftvia`; ohne laufenden Worker bleibt ein Import auf „Hochgeladen“. diff --git a/messages/de/imports.json b/messages/de/imports.json new file mode 100644 index 0000000..b64c649 --- /dev/null +++ b/messages/de/imports.json @@ -0,0 +1,209 @@ +{ + "crumb": "Aufträge", + "title": "Auftragsimport", + "sub": "Auftragsbestätigung hochladen, erkannte Daten prüfen und den Auftrag anlegen. Ohne Bestätigung entsteht kein Auftrag.", + "noPermission": "Für den Auftragsimport fehlt die Berechtigung.", + "upload": { + "title": "Dokument hochladen", + "drop": "PDF, JPG oder PNG hierher ziehen", + "or": "oder", + "choose": "Datei auswählen", + "limits": "PDF bis 25 MB, Bilder bis 15 MB. Das Original bleibt am Auftrag gespeichert.", + "uploading": "Wird hochgeladen … {percent} %", + "done": "Hochgeladen. Daten werden erkannt.", + "errors": { + "file_type_not_allowed": "Dateityp nicht erlaubt. Erlaubt sind PDF, JPG und PNG.", + "file_too_large": "Datei ist zu groß.", + "file_type_mismatch": "Dateiinhalt passt nicht zum Dateityp.", + "file_empty": "Datei ist leer.", + "file_missing": "Keine Datei ausgewählt.", + "forbidden": "Keine Berechtigung.", + "unauthorized": "Sitzung abgelaufen. Bitte neu anmelden.", + "network": "Hochladen fehlgeschlagen. Verbindung prüfen.", + "error": "Hochladen fehlgeschlagen." + } + }, + "list": { + "title": "Importe", + "empty": "Noch keine Importe. Laden Sie eine Auftragsbestätigung hoch.", + "file": "Datei", + "uploadedAt": "Hochgeladen", + "uploadedBy": "Von", + "status": "Status", + "action": "Aktion", + "review": "Prüfen", + "open": "Ansehen", + "workOrder": "Auftrag {number}" + }, + "status": { + "uploaded": "Hochgeladen", + "processing": "Verarbeitung", + "review_required": "Prüfung erforderlich", + "confirmed": "Bestätigt", + "failed": "Fehlgeschlagen", + "discarded": "Verworfen" + }, + "actions": { + "retry": "Neu verarbeiten", + "retrying": "Wird gestartet …", + "discard": "Verwerfen", + "discardConfirm": "Import verwerfen? Es wird kein Auftrag angelegt. Das Originaldokument bleibt gespeichert.", + "confirm": "Bestätigen und Auftrag anlegen", + "confirming": "Auftrag wird angelegt …" + }, + "detail": { + "back": "Zurück zu den Importen", + "meta": "{file} · hochgeladen am {date} von {user}", + "model": "Erkannt mit {model}", + "preview": "Originaldokument", + "previewTitle": "Vorschau des Originaldokuments", + "openNewTab": "In neuem Tab öffnen", + "download": "Herunterladen", + "processing": "Das Dokument wird verarbeitet. Die Seite aktualisiert sich automatisch.", + "failed": "Die Verarbeitung ist fehlgeschlagen.", + "failedDetail": "Technischer Hinweis: {message}", + "confirmed": "Import bestätigt. Auftrag {number} wurde angelegt.", + "openWorkOrder": "Auftrag öffnen", + "corrections": "{count, plural, =0 {Keine Korrekturen} one {1 Feld korrigiert} other {# Felder korrigiert}}", + "discarded": "Import verworfen. Es wurde kein Auftrag angelegt.", + "extractedText": "Erkannter Text", + "noText": "Kein Text erkannt." + }, + "review": { + "title": "Erkannte Daten prüfen", + "intro": "Unsichere Felder sind markiert. Bitte prüfen und bei Bedarf korrigieren.", + "manualEntry": "Keine automatische Erkennung verfügbar. Bitte Daten manuell erfassen.", + "hintsTitle": "Hinweise zur Plausibilität", + "uncertain": "unsicher", + "confidence": "Sicherheit {percent} %", + "source": "Quelle: {text}", + "required": "Pflichtfeld", + "sections": { + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "order": "Auftrag", + "positions": "Positionen und Material" + }, + "customer": { + "existing": "Bestehenden Kunden verwenden", + "new": "Neuen Kunden anlegen", + "candidates": "Mögliche Treffer im Kundenstamm", + "noCandidates": "Keine passenden Kunden gefunden.", + "match": "Übereinstimmung {percent} %", + "merge": "Dubletten im Kundenstamm zusammenführen", + "search": "Kunden suchen", + "searchPlaceholder": "Name, Kundennummer oder Ort", + "searchButton": "Suchen", + "searchEmpty": "Keine Kunden gefunden.", + "select": "Auswählen" + }, + "site": { + "none": "Kein Objekt zuordnen", + "existing": "Bestehendes Objekt verwenden", + "new": "Neues Objekt anlegen", + "noSites": "Für den gewählten Kunden sind keine Objekte angelegt.", + "chooseCustomer": "Zuerst einen bestehenden Kunden auswählen.", + "suggested": "Gleiche Adresse" + }, + "reasons": { + "customer_number": "Kundennummer", + "company_name": "Firmenname", + "company_name_similar": "ähnlicher Firmenname", + "person_name": "Name", + "email": "E-Mail", + "phone": "Telefon", + "address": "Adresse", + "house_number": "Hausnummer", + "site_name": "Objektname" + }, + "fields": { + "customerNumber": "Kundennummer", + "companyName": "Firmenname", + "firstName": "Vorname", + "lastName": "Nachname", + "street": "Straße", + "houseNumber": "Hausnummer", + "postalCode": "PLZ", + "city": "Ort", + "country": "Land", + "phone": "Telefon", + "email": "E-Mail", + "name": "Name", + "siteName": "Objektbezeichnung", + "title": "Auftragstitel", + "externalOrderNumber": "Auftragsnummer (Dokument)", + "offerNumber": "Angebotsnummer", + "description": "Leistungsbeschreibung", + "plannedStart": "Geplanter Beginn", + "plannedEnd": "Geplantes Ende", + "notes": "Besondere Hinweise" + }, + "extractionFields": { + "orderNumber": "Auftragsnummer", + "offerNumber": "Angebotsnummer", + "customerNumber": "Kundennummer", + "companyName": "Firmenname", + "customerFirstName": "Vorname", + "customerLastName": "Nachname", + "customerAddress": "Kundenadresse", + "siteName": "Objekt", + "siteAddress": "Objektadresse", + "contactName": "Ansprechpartner", + "phone": "Telefon", + "email": "E-Mail", + "orderDate": "Auftragsdatum", + "documentDate": "Dokumentdatum", + "plannedStart": "Geplanter Beginn", + "plannedEnd": "Geplantes Ende", + "title": "Titel", + "description": "Leistungsbeschreibung", + "positions": "Positionen", + "notes": "Hinweise", + "totalAmount": "Gesamtbetrag", + "references": "Referenzen" + }, + "hints": { + "date_invalid": "{field}: Datum nicht lesbar.", + "date_implausible": "{field}: Datum liegt ungewöhnlich weit in Vergangenheit oder Zukunft.", + "postal_code_invalid": "{field}: Postleitzahl ist nicht 5-stellig.", + "email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.", + "phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.", + "end_before_start": "{field}: Ende liegt vor dem Beginn.", + "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen." + }, + "positions": { + "name": "Bezeichnung", + "articleNumber": "Artikelnr.", + "quantity": "Menge", + "unit": "Einheit", + "asMaterial": "als Materialvorgabe übernehmen", + "remove": "Position entfernen", + "add": "Position hinzufügen", + "empty": "Keine Positionen erkannt." + } + }, + "errors": { + "form_invalid": "Bitte die markierten Felder prüfen.", + "title_required": "Auftragstitel fehlt.", + "customer_required": "Bitte einen Kunden auswählen.", + "customer_name_required": "Firmenname oder Nachname angeben.", + "site_required": "Bitte ein Objekt auswählen.", + "site_name_required": "Objektbezeichnung oder Straße angeben.", + "site_requires_existing_customer": "Ein bestehendes Objekt setzt einen bestehenden Kunden voraus.", + "email_invalid": "E-Mail-Adresse hat kein gültiges Format.", + "postal_code_invalid": "Postleitzahl muss 5-stellig sein.", + "end_before_start": "Ende liegt vor dem Beginn.", + "date_invalid": "Datum nicht lesbar.", + "quantity_invalid": "Menge muss eine Zahl sein.", + "customer_number_taken": "Diese Kundennummer ist bereits vergeben. Bestehenden Kunden verwenden oder Nummer leeren.", + "import_not_reviewable": "Dieser Import wurde bereits bearbeitet.", + "import_not_discardable": "Dieser Import kann nicht mehr verworfen werden.", + "import_not_retryable": "Nur fehlgeschlagene Importe können neu verarbeitet werden.", + "not_found": "Datensatz nicht gefunden.", + "forbidden": "Keine Berechtigung für diese Aktion.", + "file_unavailable": "Originaldatei ist nicht verfügbar.", + "dispatch_failed": "Verarbeitung konnte nicht gestartet werden.", + "error": "Vorgang nicht möglich." + } +} diff --git a/messages/en/imports.json b/messages/en/imports.json new file mode 100644 index 0000000..abf4b56 --- /dev/null +++ b/messages/en/imports.json @@ -0,0 +1,209 @@ +{ + "crumb": "Work orders", + "title": "Order import", + "sub": "Upload an order confirmation, check the recognised data and create the work order. Nothing is created without confirmation.", + "noPermission": "You are not allowed to import orders.", + "upload": { + "title": "Upload document", + "drop": "Drop a PDF, JPG or PNG here", + "or": "or", + "choose": "Choose file", + "limits": "PDF up to 25 MB, images up to 15 MB. The original stays attached to the work order.", + "uploading": "Uploading … {percent} %", + "done": "Uploaded. Recognising data.", + "errors": { + "file_type_not_allowed": "File type not allowed. Allowed: PDF, JPG and PNG.", + "file_too_large": "File is too large.", + "file_type_mismatch": "File content does not match its type.", + "file_empty": "File is empty.", + "file_missing": "No file selected.", + "forbidden": "Not allowed.", + "unauthorized": "Session expired. Please sign in again.", + "network": "Upload failed. Check the connection.", + "error": "Upload failed." + } + }, + "list": { + "title": "Imports", + "empty": "No imports yet. Upload an order confirmation.", + "file": "File", + "uploadedAt": "Uploaded", + "uploadedBy": "By", + "status": "Status", + "action": "Action", + "review": "Review", + "open": "View", + "workOrder": "Work order {number}" + }, + "status": { + "uploaded": "Uploaded", + "processing": "Processing", + "review_required": "Review required", + "confirmed": "Confirmed", + "failed": "Failed", + "discarded": "Discarded" + }, + "actions": { + "retry": "Process again", + "retrying": "Starting …", + "discard": "Discard", + "discardConfirm": "Discard this import? No work order will be created. The original document is kept.", + "confirm": "Confirm and create work order", + "confirming": "Creating work order …" + }, + "detail": { + "back": "Back to imports", + "meta": "{file} · uploaded on {date} by {user}", + "model": "Recognised with {model}", + "preview": "Original document", + "previewTitle": "Preview of the original document", + "openNewTab": "Open in new tab", + "download": "Download", + "processing": "The document is being processed. This page refreshes automatically.", + "failed": "Processing failed.", + "failedDetail": "Technical detail: {message}", + "confirmed": "Import confirmed. Work order {number} was created.", + "openWorkOrder": "Open work order", + "corrections": "{count, plural, =0 {No corrections} one {1 field corrected} other {# fields corrected}}", + "discarded": "Import discarded. No work order was created.", + "extractedText": "Recognised text", + "noText": "No text recognised." + }, + "review": { + "title": "Check recognised data", + "intro": "Uncertain fields are marked. Please check and correct them if needed.", + "manualEntry": "Automatic recognition is not available. Please enter the data manually.", + "hintsTitle": "Plausibility notes", + "uncertain": "uncertain", + "confidence": "Confidence {percent} %", + "source": "Source: {text}", + "required": "Required", + "sections": { + "customer": "Customer", + "site": "Site", + "contact": "Contact person", + "order": "Work order", + "positions": "Line items and material" + }, + "customer": { + "existing": "Use existing customer", + "new": "Create new customer", + "candidates": "Possible matches", + "noCandidates": "No matching customers found.", + "match": "Match {percent} %", + "merge": "Merge duplicates in customer records", + "search": "Search customers", + "searchPlaceholder": "Name, customer number or city", + "searchButton": "Search", + "searchEmpty": "No customers found.", + "select": "Select" + }, + "site": { + "none": "No site", + "existing": "Use existing site", + "new": "Create new site", + "noSites": "The selected customer has no sites.", + "chooseCustomer": "Select an existing customer first.", + "suggested": "Same address" + }, + "reasons": { + "customer_number": "customer number", + "company_name": "company name", + "company_name_similar": "similar company name", + "person_name": "name", + "email": "e-mail", + "phone": "phone", + "address": "address", + "house_number": "house number", + "site_name": "site name" + }, + "fields": { + "customerNumber": "Customer number", + "companyName": "Company name", + "firstName": "First name", + "lastName": "Last name", + "street": "Street", + "houseNumber": "House number", + "postalCode": "Postal code", + "city": "City", + "country": "Country", + "phone": "Phone", + "email": "E-mail", + "name": "Name", + "siteName": "Site name", + "title": "Work order title", + "externalOrderNumber": "Order number (document)", + "offerNumber": "Offer number", + "description": "Description of services", + "plannedStart": "Planned start", + "plannedEnd": "Planned end", + "notes": "Special notes" + }, + "extractionFields": { + "orderNumber": "Order number", + "offerNumber": "Offer number", + "customerNumber": "Customer number", + "companyName": "Company name", + "customerFirstName": "First name", + "customerLastName": "Last name", + "customerAddress": "Customer address", + "siteName": "Site", + "siteAddress": "Site address", + "contactName": "Contact person", + "phone": "Phone", + "email": "E-mail", + "orderDate": "Order date", + "documentDate": "Document date", + "plannedStart": "Planned start", + "plannedEnd": "Planned end", + "title": "Title", + "description": "Description", + "positions": "Line items", + "notes": "Notes", + "totalAmount": "Total amount", + "references": "References" + }, + "hints": { + "date_invalid": "{field}: date not readable.", + "date_implausible": "{field}: date is unusually far in the past or future.", + "postal_code_invalid": "{field}: postal code does not have 5 digits.", + "email_invalid": "{field}: e-mail address format is invalid.", + "phone_invalid": "{field}: phone number format is invalid.", + "end_before_start": "{field}: end is before start.", + "manual_entry": "Automatic recognition is not available. Please enter manually." + }, + "positions": { + "name": "Description", + "articleNumber": "Article no.", + "quantity": "Quantity", + "unit": "Unit", + "asMaterial": "use as planned material", + "remove": "Remove line item", + "add": "Add line item", + "empty": "No line items recognised." + } + }, + "errors": { + "form_invalid": "Please check the marked fields.", + "title_required": "Work order title is missing.", + "customer_required": "Please select a customer.", + "customer_name_required": "Enter a company name or last name.", + "site_required": "Please select a site.", + "site_name_required": "Enter a site name or street.", + "site_requires_existing_customer": "An existing site requires an existing customer.", + "email_invalid": "E-mail address format is invalid.", + "postal_code_invalid": "Postal code must have 5 digits.", + "end_before_start": "End is before start.", + "date_invalid": "Date not readable.", + "quantity_invalid": "Quantity must be a number.", + "customer_number_taken": "This customer number is already taken. Use the existing customer or clear the number.", + "import_not_reviewable": "This import has already been handled.", + "import_not_discardable": "This import can no longer be discarded.", + "import_not_retryable": "Only failed imports can be processed again.", + "not_found": "Record not found.", + "forbidden": "Not allowed.", + "file_unavailable": "The original file is not available.", + "dispatch_failed": "Processing could not be started.", + "error": "Action not possible." + } +} diff --git a/src/app/(app)/imports/[id]/file/route.ts b/src/app/(app)/imports/[id]/file/route.ts new file mode 100644 index 0000000..9455cef --- /dev/null +++ b/src/app/(app)/imports/[id]/file/route.ts @@ -0,0 +1,41 @@ +import { moduleGuard } from "@/server/action-guard"; +import { storage } from "@/server/storage/adapter"; +import { ctxFromGuard } from "@/server/services/context"; +import { getImportFile } from "@/server/services/imports/queries"; + +/** + * GET /imports/[id]/file — original document of an import for the review mask preview. + * Authorisation: session + DB-authoritative permission import:write + module + document + * visibility (getImportFile). Unknown/foreign ids → 404. `?download=1` forces a download. + * Inline delivery only for the allowlisted types stored by the import (PDF/JPEG/PNG) and + * only framable by the own origin. + */ +const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]); + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + let file: { storageKey: string; mimeType: string; fileName: string }; + try { + const ctx = ctxFromGuard(await moduleGuard("imports")("import:write")); + const { id } = await params; + file = await getImportFile(ctx, id); + } catch { + return new Response("Nicht gefunden.", { status: 404 }); + } + + const content = await storage.get(file.storageKey); + if (!content) return new Response("Datei nicht verfügbar.", { status: 404 }); + + const download = new URL(req.url).searchParams.get("download") === "1"; + const inline = !download && INLINE.has(file.mimeType); + const asciiName = file.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const headers = new Headers({ + "Content-Type": INLINE.has(file.mimeType) ? file.mimeType : "application/octet-stream", + "Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(file.fileName)}`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", + "Content-Security-Policy": "frame-ancestors 'self'", + "X-Frame-Options": "SAMEORIGIN", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); +} diff --git a/src/app/(app)/imports/[id]/page.tsx b/src/app/(app)/imports/[id]/page.tsx new file mode 100644 index 0000000..00a7586 --- /dev/null +++ b/src/app/(app)/imports/[id]/page.tsx @@ -0,0 +1,166 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ArrowLeft, CheckCircle2, Download, ExternalLink, Info, Loader2, XCircle } from "lucide-react"; +import { requireSession } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { hasPermission } from "@/server/rbac"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { getImportDetail } from "@/server/services/imports/queries"; +import { confirmImportAction, discardImportAction, retryImportAction, searchCustomersAction } from "@/server/actions/imports/imports"; +import { PageHead } from "@/components/mockup-ui"; +import { ImportStatusPill } from "@/components/imports/status-pill"; +import { AutoRefresh } from "@/components/imports/auto-refresh"; +import { DiscardImportButton, RetryImportButton } from "@/components/imports/job-actions"; +import { ImportReviewForm } from "@/components/imports/review-form"; +import { extractionToForm, formFieldMeta } from "@/lib/imports/review"; +import { IMPORT_IN_PROGRESS } from "@/lib/imports/status"; + +/** /imports/[id] — review mask: original document left, form right (spec §9.6, US-002/003). */ +export default async function ImportDetailPage({ params }: { params: Promise<{ id: string }> }) { + const session = await requireSession(); + if (!hasPermission(session, "import:write")) redirect("/imports"); + const { id } = await params; + const t = await getTranslations("imports"); + const locale = await getLocale(); + const ctx: ServiceCtx = { + db: dbForTenant(session.user.tenantId), + tenantId: session.user.tenantId, + userId: session.user.id, + permissions: new Set(session.user.permissions), + }; + + let detail; + try { + detail = await getImportDetail(ctx, id); + } catch (err) { + if (err instanceof ServiceError) notFound(); + throw err; + } + + const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }); + const fileUrl = `/imports/${detail.id}/file`; + const isPdf = detail.document?.mimeType === "application/pdf"; + const fields = detail.extraction.fields; + const top = detail.customerCandidates[0]; + const topSite = top ? detail.siteCandidates.find((s) => s.customerId === top.customerId) : undefined; + const inProgress = IMPORT_IN_PROGRESS.includes(detail.status); + const errorKey = detail.errorMessage && t.has(`errors.${detail.errorMessage}`) ? `errors.${detail.errorMessage}` : null; + const correctionCount = + detail.corrections && typeof detail.corrections === "object" && "fields" in detail.corrections + ? Object.keys((detail.corrections as { fields?: Record }).fields ?? {}).length + : 0; + + return ( +
+ + + {t("detail.back")} + + } + /> + +
+ {/* Original document */} +
+
+ + {isPdf ? ( +