L8 Notdienst: Lane-Bericht
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
# Lane L8 – Notdienst
|
||||||
|
|
||||||
|
Branch `lane/notdienst` (Basis `d5c1221` auf `feature/craftvia-mvp`, Welle 1 L1–L6 integriert). Spec §19 komplett, §39, US-010, §7.3, ARCHITEKTUR §3/§4.1/§4.6.
|
||||||
|
**Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten.**
|
||||||
|
|
||||||
|
## 1. Umfang / erfüllte Spec-Punkte
|
||||||
|
|
||||||
|
| Spec | Umsetzung |
|
||||||
|
|---|---|
|
||||||
|
| §19.1 / US-010 Monteur legt Notdienst selbst an | `/m/emergency` (Modul `emergency`, Recht `emergency:create` + `field:execute`), jederzeit anlegbar (keine Geschäftszeitenprüfung, §19 Punkt 5 optional nicht umgesetzt) |
|
||||||
|
| §19.2 Erfassungsmaske | 3 Schritte mit Schrittanzeige: **1 Kunde** – Suche bestehender Kunden (Name/Ort/Kundennummer, Treffer zeigen nur Name · Nummer · Ort · „Vorläufig") oder „Neuer Kunde" (Firma/Name, Telefon Pflicht, E-Mail, Adresse); **2 Einsatzort** – Objekt des Kunden oder Einsatzadresse (Straße + Ort Pflicht, „Kundenadresse übernehmen"), Ansprechpartner vor Ort + Telefon Pflicht; **3 Grund** (Pflicht, Textfeld) + Sprachnotiz optional (lokal aufgenommen, nach dem Start hochgeladen und als `voice.attach` angehängt), Beginn (Default jetzt), Team (Default eigenes Team) und weitere Monteure des Teams (eigener User immer dabei). Große Felder (≥ 48 px), `type="tel"`/`inputMode="tel"` für Nummern, Status/Fehler immer mit Text + Icon. |
|
||||||
|
| §19.3 Vorläufige Datensätze | `createEmergencyOrder` in **einer** Transaktion (`inTransaction`): vorläufiger Kunde (`status provisional`, `isProvisional true`, **ohne Kundennummer**) bzw. bestehender Kunde → Ansprechpartner (wiederverwendet bei gleichem Namen + Telefon) → vorläufiges Objekt (`provisional`) bzw. bestehendes Objekt des Kunden → Auftrag über L2 `createWorkOrder` (`isEmergency`, Auftragsart `notdienst`, Nummer aus Sequenz `emergency` N-…, Priorität `urgent`, Status `in_progress`, `plannedStart` = Beginn) → Team/Teamleiter/Zuweisungen → L4 `startSession` (WorkSession + Arbeitszeit-Segment). Danach Event `emergency.created` (`startedAt`). Anschließend normale Einsatzseite `/m/orders/[id]` (Zeiten, Material, Fotos, Tätigkeiten, Bericht, Unterschrift aus L4/L5). |
|
||||||
|
| Offline (ARCHITEKTUR §4.6) | Sync-Op `emergency.create` mit `clientIds { workOrder, session, customer?, site? }`: Zod-Schema in `src/lib/sync/ops.ts`, Registry-Eintrag in `services/sync/external-ops.ts`. Idempotent doppelt: gleiche `clientOpId` → `duplicate` (L4), neue `clientOpId` mit gleicher Session-Client-ID → Replay liefert denselben Auftrag. Modul `emergency` wird in der Op geprüft (Sync-Route ist `field`-gegated). `idMap` bildet alle Client-IDs auf Server-IDs ab. Der Wizard sendet über `submitOp` (L7 kann die Outbox dahinter tauschen; die Client-IDs bleiben über Wiederholungen stabil). |
|
||||||
|
| §19.4 Benachrichtigung | Nach Einreichen des Abschlussberichts (v1) eines Notdienstauftrags → `emergency.completed` mit `number`, `technician` (Ersteller), `customer`, `startedAt` (erste Session), `endedAt` (letztes Session-Ende), `status` (i. d. R. `in_review`), `occurrenceId`. L6 versendet die Pflichtmail im Format §19.4 und In-App ans Backoffice. |
|
||||||
|
| §19.3 / §39 Backoffice-Nachbearbeitung | `/work-orders/emergency-review` (Recht `emergency:review`, Modul-Gate `emergency` + `work_orders`): Tabs „Zu prüfen" (vorläufiger Kunde/Objekt oder Status `technically_completed`/`signature_pending`/`in_review`) und „Alle Notdienste"; Karten mit Nummer, Status, Kunde/Objekt (Kennzeichnung „Vorläufig"), Monteur, Beginn, Fortschrittsbalken **„x von 5 Prüfschritten erledigt"**. Detail `/work-orders/emergency-review/[id]` mit den Prüfschritten: **1 Kunde** – bestätigen (Kundennummer wird jetzt vergeben, dann L1 `confirmProvisionalCustomer`) · bestehendem Kunden zuordnen (Kandidaten über L1 `findDuplicateCustomers` oder Kundennummer; Objekte, Ansprechpartner, Aufträge (Version +1), Dokumente werden umgehängt, vorläufiger Datensatz → `merged` + `mergedIntoId`) · Dublette zusammenführen (L1 `mergeCustomers`, `customer:merge` + Bestätigung); **2 Objekt** – korrigieren/bestätigen (L1 `updateSite`) · bestehendem Objekt zuordnen (L2 `updateWorkOrder`, ungenutztes vorläufiges Objekt → Soft Delete über L1 `deleteSite`); **3 Auftrag ergänzen** – Titel, Beschreibung, Auftragsart, Abrechnungsart (L2 `updateWorkOrder`, Versionsprüfung); **4 Bericht** – Berichtsliste mit Link zur L5-Freigabe; **5 Abrechnung** – L2 `releaseForBilling` (freigegebener Abschlussbericht), zusätzlich gesperrt solange Kunde/Objekt vorläufig (`blocked`, `master_data_open`). |
|
||||||
|
| Dashboard | Kachel „neue Notdiensteinsätze" verlinkt für Nutzer mit `emergency:review` auf `/work-orders/emergency-review` (sonst weiter auf die L2-Liste). |
|
||||||
|
| §7.3 Dubletten | Kandidaten in der Prüfung, niemals automatische Zusammenführung; Merge nur mit Bestätigung. |
|
||||||
|
|
||||||
|
Jede Mutation: Permission (Guard in der Action **und** Service) · Scope (`workOrderScope`, bei der Erfassung Team-/Zuweisungsprüfung) · Zod · `writeAuditLog` (before/after; zusätzlich Entität `emergency` je Prüfschritt) · Events über `emitEvent`. Fachdaten ausschließlich über `ctx.db`, Mehrschritt-Schreibvorgänge über `inTransaction`.
|
||||||
|
|
||||||
|
**Such-Service** `searchCustomersForEmergency(ctx, q)`: nur `emergency:create`, ab 2 Zeichen, max. 10 Treffer, nur aktive/vorläufige Kunden, Rückgabe `{ id, customerNumber, name, city, provisional }`; jeder Zugriff wird auditiert (`action "export"`, Entität `emergency_customer_search`, Suchbegriff + Treffer-IDs). `listSitesForEmergency` analog (`{ id, name, address }`, Entität `emergency_site_lookup`). Mandantentrennung über `dbForTenant`/RLS.
|
||||||
|
|
||||||
|
**Sichtbarkeit:** Ein vorläufiger Kunde ist für Monteure ausschließlich über einen sichtbaren Auftrag erreichbar – der bestehende `workOrderScope` enthält `isEmergency && createdById = userId` (ARCHITEKTUR §2), zusätzlich sehen Mitglieder/Teamleiter des gewählten Teams und Zugewiesene den Auftrag.
|
||||||
|
|
||||||
|
## 2. Dateien
|
||||||
|
|
||||||
|
**Neu (Ownership L8)**
|
||||||
|
- `src/lib/emergency/schemas.ts` – Zod `emergencyCreatePayload` (Sync-Payload = Service-Input), `emergencyOrderPatchSchema`, `REVIEW_STEPS`
|
||||||
|
- `src/server/services/emergency/create.ts` – `createEmergencyOrder(ctx, input, deps?)`
|
||||||
|
- `src/server/services/emergency/lookup.ts` – `searchCustomersForEmergency`, `listSitesForEmergency`, `emergencyTeamOptions`
|
||||||
|
- `src/server/services/emergency/completion.ts` – `buildEmergencyCompletedData`, `onCompletionReportSubmitted`
|
||||||
|
- `src/server/services/emergency/sync-ops.ts` – `applySyncOp` für `emergency.create`
|
||||||
|
- `src/server/services/emergency/review.ts` – `listEmergencyReviews`, `getEmergencyReview`, `reviewProgress`, `confirmEmergencyCustomer`, `assignEmergencyToCustomer`, `mergeEmergencyCustomer`, `correctEmergencySite`, `assignEmergencySite`, `completeEmergencyOrderData`, `releaseEmergencyForBilling`
|
||||||
|
- `src/server/actions/emergency/capture.ts` (Suche/Objekte, `moduleGuard("emergency")` + `guard("emergency:create")`), `src/server/actions/emergency/review.ts` (7 Actions, je `guard("emergency:review", …)`)
|
||||||
|
- `src/app/(field)/m/emergency/page.tsx` (Platzhalter ersetzt), `src/components/emergency/emergency-wizard.tsx`
|
||||||
|
- `src/app/(app)/work-orders/emergency-review/{layout.tsx,page.tsx,[id]/page.tsx}`, `src/components/emergency/{review-form,review-progress}.tsx`
|
||||||
|
- `messages/de/emergency.json`, `messages/en/emergency.json`
|
||||||
|
- `scripts/test-notdienst-flow.ts`, `scripts/test-notdienst-review.ts`
|
||||||
|
|
||||||
|
**Fremd-Eingriffe (Einzeiler, im Auftrag erlaubt bzw. zwingend)**
|
||||||
|
- `src/server/services/sync/external-ops.ts`: Registry-Zeile `emergency.create` aktiviert
|
||||||
|
- `src/lib/sync/ops.ts`: `"emergency.create": emergencyCreatePayload` (+ Import)
|
||||||
|
- `src/app/(app)/dashboard/page.tsx` (L2): `href` der Kachel `emergency_new` → `/work-orders/emergency-review` (bei `emergency:review`)
|
||||||
|
- `src/lib/nav.ts`: Eintrag „Notdienst-Prüfung" (`module: "emergency"`, `emergency:review`, Icon `Siren`) + Label `emergencyReview` in `messages/{de,en}/nav.json`
|
||||||
|
- `src/components/audit-trail.tsx`: Entity-Labels `emergency_customer_search`, `emergency_site_lookup`
|
||||||
|
- **`src/server/services/reports/submit.ts` (L5) – nicht in der Einzeiler-Liste, aber für Liefergegenstand 3 zwingend:** Import + ein Aufruf `await onCompletionReportSubmitted(ctx, wo.id, occurrenceId)` direkt nach `advanceOrder` für Abschlussbericht v1. Der Hook ist ein No-op für normale Aufträge und wirft nie. Alternative ohne L5-Eingriff wäre nur ein Event-Abonnement im Fundament (`emitEvent` auf `report.submitted`) – bitte beim Merge bestätigen.
|
||||||
|
|
||||||
|
## 3. Tests
|
||||||
|
|
||||||
|
`npm run gate` **grün**: prisma generate, tsc (0 Fehler), lint (0 Fehler, 2 Warnungen in fremden Dateien), build inkl. Modul-Guard-Check (29 Action-Dateien), **44/44 Testskripte grün** (davon 2 neu).
|
||||||
|
|
||||||
|
| Skript | Prüfungen | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `test-notdienst-flow.ts` | 69 | Erfassung mit vorläufigem Kunden (Nummer N-, `in_progress` + Historie, Auftragsart `notdienst`, Kunde/Objekt vorläufig ohne Kundennummer, Ansprechpartner, Default-Team/-Zuweisung, laufende WorkSession, Audit, `emergency.created` ans Backoffice, Akteur ausgenommen); **Transaktion**: Fehler nach dem Auftrags-Insert → kein Kunde/Objekt/Kontakt/Auftrag/Session, Nummernkreis ohne Lücke; **Sichtbarkeit**: vorläufiger Kunde für Ersteller sichtbar, anderer Monteur und Mandant B → `not_found`; **Such-Service**: fremde Kunden auffindbar, nur minimale Felder, Audit, Mandant B findet nichts, ohne `emergency:create` → `forbidden`, Objektabfrage minimal/auditiert/mandantengetrennt; bestehender Kunde + Objekt, Kontakt-Wiederverwendung, Objekt eines anderen Kunden/fremdes Team/fremder Kollege → `invalid`, Mandant B mit A-Kunden-ID → nichts angelegt, Backoffice → `forbidden`, Pflichtfelder; **Sync-Op**: applied mit idMap, gleiche `clientOpId` → `duplicate`, Replay mit neuer `clientOpId` → derselbe Auftrag ohne Doppelanlage, Offline-Flag, ungültige Payload → `rejected invalid`, Replay durch anderen Nutzer abgelehnt, Modul deaktiviert → `forbidden`; **Abschluss**: Session beenden → Abschlussbericht → Unterschrift „Kunde abwesend" → Absenden → Auftrag `in_review`, `emergency.completed` ans Backoffice, Daten Monteur/Kunde/Beginn ≤ Ende/Status, Pflichtmail protokolliert, normaler Auftrag ohne Notdienst-Event |
|
||||||
|
| `test-notdienst-review.ts` | 49 | **Rollen**: Monteur/Teamleiter → `forbidden` (Liste, Detail, bestätigen, zuordnen, Abrechnung, Merge); **Mandant B**: Liste leer, Detail/bestätigen/zuordnen/ergänzen → `not_found`, Daten unverändert; normaler Auftrag → `not_found`; Liste + Fortschritt 0/5 + Monteur; Dublettenkandidat über Telefon/Name; Beginn/Ende; **Zuordnen**: gleicher/fremder Kunde → `invalid`, Auftrag (Version +1), Objekt, Kontakt umgehängt, Quelle `merged`, Audit, erneut → `conflict`; **Objekt**: zuordnen + vorläufiges Objekt soft-gelöscht, fremdes Objekt → `invalid`, korrigieren + bestätigen; **Bestätigen**: K-Nummer vergeben, aktiv, zweites Mal → `conflict`; **Merge** ohne Bestätigung → `invalid`, Monteur → `forbidden`, mit Bestätigung umgehängt; **Auftrag ergänzen** inkl. Validierung und Versionskonflikt; **Abrechnung**: vorläufige Stammdaten → `blocked master_data_open`, ohne freigegebenen Bericht → `blocked`, nach Freigabe → `released_for_billing`, 5/5, aus „Zu prüfen" entfernt, unter „Alle" sichtbar, Prüfschritte auditiert |
|
||||||
|
|
||||||
|
Hinweis Lane-DB: `.env` des Worktrees (nicht eingecheckt) nutzt `craftvia_notdienst` für `DATABASE_URL` **und** `RLS_DATABASE_URL`.
|
||||||
|
|
||||||
|
## 4. Stubs / Abhängigkeiten
|
||||||
|
|
||||||
|
- **Keine Stubs.** Genutzt: L1 `findDuplicateCustomers`, `confirmProvisionalCustomer`, `mergeCustomers`, `updateSite`, `deleteSite`, `customerScope`; L2 `createWorkOrder`, `updateWorkOrder`, `releaseForBilling`, `ensureDefaultOrderTypes`, `workOrderScope`/`activeTeamIds`; L4 `startSession`, `submitOp`, `uploadFieldFile`, Sync-Registry; L5 Abschlussbericht/Unterschrift (unverändert) + Hook in `submit.ts`; L6 Empfänger/Pflichtmail für `emergency.*`.
|
||||||
|
- **L7 Offline:** Der Wizard ruft `submitOp({ opType: "emergency.create", … })`. Liefert die Outbox später ein Ergebnis ohne Server-ID, zeigt der Wizard „wird übertragen, sobald Verbindung besteht". Die Kundensuche braucht Verbindung; offline bleibt „Neuer Kunde". Die Sprachnotiz wird nur online nach dem Start hochgeladen.
|
||||||
|
|
||||||
|
## 5. Bekannte Lücken / Hinweise
|
||||||
|
|
||||||
|
1. **L5-Eingriff** (siehe §2): eine Zeile + Import in `services/reports/submit.ts`.
|
||||||
|
2. **Audit „Lesezugriff":** `AuditAction` kennt kein `read`; der Suchzugriff wird als `export` protokolliert (Fundament-Vorschlag: Aktion `read`/`access`).
|
||||||
|
3. `mergeCustomers` (L1) nutzt `ctx.db.$transaction([...])` direkt und kann daher nicht innerhalb von `inTransaction` laufen; der Merge-Schritt ruft ihn eigenständig auf (L1 ist in sich atomar, bei `RLS_ENFORCED=true` gilt der Fundament-Hinweis aus §4.8).
|
||||||
|
4. `writeAuditLog` schreibt über den Owner-Client außerhalb der Transaktion; eigene Audit-Einträge der Erfassung werden deshalb erst nach dem Commit geschrieben. `createWorkOrder`/`startSession` protokollieren innerhalb – bei einem Rollback bleiben deren Audit-Zeilen stehen (bekannt aus L2).
|
||||||
|
5. `assignEmergencySite` läuft in `inTransaction`; `updateWorkOrder` emittiert sein Event dabei im Transaktionskontext (pg meldet eine Deprecation-Warnung wegen paralleler Queries im Transaktions-Client, funktional ohne Auswirkung).
|
||||||
|
6. Globale Eindeutigkeit von `WorkSession.clientId` (Schema): ein Replay mit derselben Session-Client-ID in einem anderen Mandanten scheitert mit einem internen Fehler (nicht gespeichert, keine Datenwirkung).
|
||||||
|
7. Geschäftszeiten-Hinweis (optional) nicht umgesetzt.
|
||||||
|
8. `PII_REFERENCE_FIELDS` (Fundament): keine neuen Felder – genutzt werden bestehende (`Customer.createdById`, `WorkOrder.createdById`, `WorkSession.userId`).
|
||||||
|
|
||||||
|
## 6. Screens / Routen
|
||||||
|
|
||||||
|
| Route | Rolle | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `/m/emergency` | Monteur, Teamleiter (`emergency:create`) | 3-Schritt-Erfassung → „Einsatz starten" → `/m/orders/[id]` |
|
||||||
|
| `/work-orders/emergency-review` (`?filter=all`) | Backoffice, Admin (`emergency:review`) | Liste mit Fortschritt |
|
||||||
|
| `/work-orders/emergency-review/[id]` | Backoffice, Admin | Prüfschritte 1–5 |
|
||||||
|
| `POST /api/v1/sync` Op `emergency.create` | Monteur, Teamleiter | Offline-/Online-Anlage |
|
||||||
|
|
||||||
|
## 7. Smoke
|
||||||
|
|
||||||
|
HTTP-Smoke gegen den Dev-Server `:3108` (Lane-DB, Seed-Mandanten `demo`/`demo2`). Session-Cookies wurden lokal ohne Passworteingabe erzeugt (`finalizeIdentityLogin` + Auth.js `encode`). **15/16 Prüfungen grün:**
|
||||||
|
|
||||||
|
- ohne Session: `/m/emergency` → 307 Login
|
||||||
|
- Monteur: `/m/emergency` rendert Schritt 1 (bestehender/neuer Kunde); Backoffice sieht dort den Hinweis „nicht freigeschaltet"
|
||||||
|
- `POST /api/v1/sync` `emergency.create` → `applied` mit idMap; gleiche `clientOpId` → `duplicate`; fremder Origin → 403; Backoffice (ohne `emergency:create`) → abgelehnt
|
||||||
|
- Monteur: `/m/orders/[id]` des neuen Notdienstes rendert (N-Nummer, Grund)
|
||||||
|
- Backoffice: `/work-orders/emergency-review` (Fortschritt), `?filter=all`, Detail mit allen Prüfschritten → 200
|
||||||
|
- Mandant `demo2`: Detail → 404, Liste ohne Notdienste von `demo`
|
||||||
|
- Monteur/Teamleiter: Prüfseiten → 307 (nicht zugänglich)
|
||||||
|
- Navigation „Notdienst-Prüfung" wird gerendert
|
||||||
|
- ✗ **`/dashboard` → 500 im Dev-Server (vorbestehender L2-Fehler, nicht durch L8):** `dashboard/page.tsx` ruft `buttonCls()` aus der Client-Datei `components/work-orders/action-form.tsx` („use client") in einer Server-Komponente auf (Zeilen 134/137, schon auf `d5c1221` vorhanden). Die neue Kachel-Verlinkung ist deshalb nur im Code geprüft, nicht im gerenderten Dashboard. Fix-Vorschlag an L2/Architekt: `buttonCls` in eine Datei ohne „use client" verschieben.
|
||||||
|
|
||||||
|
Nicht durchgeführt: visuelle Prüfung im Browser (Responsive 375/768/1024 px) und Aufrufe der Review-Server-Actions über die UI (fachlich durch `test-notdienst-review.ts` abgedeckt). Der Smoke hinterlässt zwei Smoke-Notdienste im Mandanten `demo` der Lane-DB.
|
||||||
Reference in New Issue
Block a user