L3 Auftragsimport: Prüfmaske, Upload, API und Lane-Bericht
- /imports: Upload per Drag & Drop/Dateiauswahl mit Fortschritt, Liste mit Status, Neu verarbeiten
- /imports/[id]: Originaldokument + Prüfmaske (Kunde, Objekt, Ansprechpartner, Auftrag, Positionen),
unsichere Felder markiert, Kunden-/Objektentscheidung, Bestätigen/Verwerfen
- Datei-Route für die Vorschau, Server Actions (moduleGuard("imports"))
- API: POST /api/v1/work-orders/import, GET /api/v1/imports/[id], POST /api/v1/imports/[id]/confirm
- Texte messages/{de,en}/imports.json, Bericht docs/craftvia/lanes/import.md
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“.
|
||||
Reference in New Issue
Block a user