L4 Einsatz mobil: Lane-Bericht

docs/craftvia/lanes/einsatz.md: Umfang, Routen, Dateien, Tests, Stubs und
Abhängigkeiten, bekannte Lücken, Gate- und Smoke-Ergebnis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:30:03 +02:00
co-authored by Claude Opus 5
parent 95f41b29f5
commit edded26b21
+110
View File
@@ -0,0 +1,110 @@
# Lane L4 – Einsatz mobil (`lane/einsatz`)
Stand: 2026-09-14 · Basis `bf44567` (`feature/craftvia-mvp`)
## 1. Umfang / erfüllte Spec-Punkte
| Spec | Umsetzung |
|---|---|
| §11.2 Auftragsansicht | `/m` Heute (heute geplant + laufend + pausiert), `/m/orders` Tabs Kommend · Laufend · Abzuschließen · Vergangen; große Karten mit Nummer, Kunde, Objektadresse + Routenlink, Zeitfenster, Statusgruppe (Text + Icon + Statuskante), Primärbutton |
| §12.1 Einsatzstart | `session.start` speichert Startzeit, Benutzer, Team, Auftrag, optional Standort, Gerätestatus (User-Agent), Offline-Flag; optional „Losfahren“ (Anfahrt) vor Arbeitsbeginn |
| §12.2 Arbeitszeiten | Session aus TimeEntry-Segmenten (Anfahrt → Arbeit ⇄ Pause), Start/Pause/Weiter/Ende, Dauerberechnung; Korrektur nur mit `field:correct_time`, Grund Pflicht, `corrected`-Flag, Audit before/after |
| §12.3 Tätigkeitsdokumentation | Notizen mit Art (9 große Chips) + Text; Entwurf bleibt bei Verbindungsabbruch lokal erhalten (US-006); Sprachnotiz |
| §12.4 Checklisten | Abhaken mit großem Schalter + Kommentar |
| §13.2/§13.3 Material | Planposition vollständig/teilweise/nicht verwendet mit Stepper-Menge; Begründung bei Abweichung Pflicht (UI + Server, gemeinsame Regeln); Zusatzmaterial mit Bezeichnung, Artikelnummer, Menge, Einheiten-Vorschlägen, Grund |
| §14.1/§14.3/§14.4 Fotos | Kamera (`capture="environment"`) + Galerie; Phase Vorher/Während/Nachher, Pflichtfoto (Kategorie), Checklistenpunkt, Kommentar, optional Standort; Kompression im Browser (max. 2560 px, JPEG 0.82, Vorschaubild 400 px, EXIF-Orientierung über `imageOrientation: "from-image"`), Upload-Fortschritt; Metadaten: Auftrag, Einsatz, Benutzer, Zeit, MIME (Magic Bytes), Größe, Prüfsumme, Uploadstatus; serverseitiges Vorschaubild per Job, falls der Client keines liefert |
| §15.1 Sprachnotizen | MediaRecorder (webm/opus, mp4 auf iOS), max. 5 min, Upload → VoiceNote `pending` → `dispatchJob("transcription")`; ohne registrierten Processor (L9) Status `disabled` |
| §22 Mobile Ansicht | eigene Shell ohne Sidebar, Bottom-Nav Heute · Aufträge · Notdienst · Sync · Profil, Online/Offline-Badge, Touch-Ziele ≥ 48 px, eine Primäraktion je Zustand, Kamera in 2 Taps (Karte → Foto) |
| US-005 | Dokumente am Auftrag + Objekt (nur erlaubte Sichtbarkeit, `backoffice_only` verborgen), freigegebene Berichte der Objekt-Historie; Auslieferung über autorisierte Route |
| US-006 | Einsatz starten/beenden, Fotos, Material bestätigen/ändern, Zusatzmaterial, Notizen mit lokalem Entwurf |
| ARCHITEKTUR §4.6 (Server) | `src/lib/sync/ops.ts`, `applyOperations`, `POST /api/v1/sync`, `POST /api/v1/uploads`, `GET /api/v1/field/bundle?since=` |
Primäraktion je Zustand (`components/field/primary-action.tsx`): `assigned` → Annehmen · `accepted` → Losfahren (sekundär: Arbeit starten) · `en_route` → Arbeit starten · in Arbeit mit laufender eigener Session → Abschließen (sekundär Pause) · pausiert → Weiter · `technically_completed`/`signature_pending` → Hinweis „Bericht und Unterschrift folgen“. Abschließen beendet die eigene Session und ruft `work_order.transition → technically_completed`; offene Pflichtpunkte werden vorher als Liste angezeigt und der Button gesperrt.
## 2. Routen / Screens
| Route | Inhalt |
|---|---|
| `/m` | Heute |
| `/m/orders?tab=` | Auftragsliste mit Tabs |
| `/m/orders/[id]` | Kopf (Status, Zeitfenster, Primäraktion), Schnellaktionen (Foto direkt · Notiz · Material · Checkliste · Zeiten), Hinweise für Monteure, Objekt (Zugang/Parken/Sicherheit/Technik hervorgehoben, Route), Kunde/Ansprechpartner (`tel:`/`mailto:`), Auftrag, Dokumente, Objekt-Historie, Zusammenfassungen |
| `/m/orders/[id]/photos` · `/notes` · `/materials` · `/checklist` · `/time` | Unterseiten (max. 2 Ebenen unter der Liste) |
| `/m/profile` | Name, Betrieb, Team, Rolle, Sprache, Abmelden (Link ins Büro für Teamleiter/Backoffice) |
| `/m/sync` | **Platzhalter** für L7 (Verbindungsstatus, „Änderungen werden sofort übertragen“) |
| `/m/emergency` | unverändert Platzhalter von L8 (nur verschoben) |
| `POST /api/v1/sync` | Batch-Ops → `SyncResponse` |
| `POST /api/v1/uploads` | multipart `file`, `clientId`, `workOrderId`, `kind` (`photo`/`voice_note`), optional `preview` → `{ documentId, duplicate }` |
| `GET /api/v1/field/bundle?since=` | Offline-Pull |
| `GET /api/v1/field/documents/[id]?variant=preview` | autorisierte Dokument-Auslieferung (Sichtbarkeit + Scope) |
Route-Group-Umzug: `src/app/(app)/m/**` → `src/app/(field)/m/**`. `(field)/m/layout.tsx` = Shell (Zugriffsprüfung), `(field)/m/(core)/layout.tsx` = Modul-Gate `field`, `emergency/layout.tsx` = Modul-Gate `emergency` (unverändert). Die Zugriffsprüfungen aus `(app)/layout.tsx` sind nach `src/server/app-access.ts#requireAppAccess` extrahiert und werden von beiden Shells genutzt (erlaubter Fundament-Eingriff, ARCHITEKTUR §5).
Header-Slot für L6: im Mobile-Header (`(field)/m/layout.tsx`) ist links neben dem Online-Badge Platz für `<NotificationBell variant="mobile" />` markiert.
## 3. Dateien
**Neu (Ownership L4)**
- `src/app/(field)/m/layout.tsx`, `(core)/layout.tsx` (verschoben), `(core)/page.tsx`, `(core)/orders/page.tsx`, `(core)/orders/[id]/{page.tsx,load.ts}`, `(core)/orders/[id]/{photos,notes,materials,checklist,time}/page.tsx`, `(core)/profile/page.tsx`, `sync/page.tsx` (Platzhalter L7)
- `src/app/api/v1/sync/route.ts`, `src/app/api/v1/uploads/route.ts`, `src/app/api/v1/field/bundle/route.ts`, `src/app/api/v1/field/documents/[id]/route.ts`
- `src/server/services/field/`: `common.ts`, `sessions.ts`, `time-correction.ts`, `checklist.ts`, `materials.ts`, `notes.ts`, `photos.ts`, `voice.ts`, `uploads.ts`, `documents.ts`, `queries.ts`, `page-context.ts`, `stubs/{work-order-transition,documents-store,site-history}.ts`
- `src/server/services/sync/apply.ts`, `src/server/services/sync/api-context.ts`, `src/server/services/sync/external-ops.ts` (Registry für Ops anderer Lanes)
- `src/server/actions/field/time.ts` (Zeitkorrektur, `moduleGuard("field")`)
- `src/server/jobs/processors/image-derivatives.ts`
- `src/lib/sync/ops.ts`, `src/lib/field/{client-ops,upload,image,format,material-rules}.ts`
- `src/components/field/*` (Shell-Navigation, Online-Badge, Statusbadge, Auftragskarte, Primäraktion, Foto, Sprachnotiz, Notiz, Material, Stepper, Checkliste, Zeitkorrektur, UI-Klassen)
- `messages/de/field.json`, `messages/en/field.json`
- `scripts/test-einsatz-field.ts`, `scripts/test-einsatz-sync.ts`, `scripts/lib/einsatz-fixture.ts`
**Fundament / Fremd-Einzeiler**
- `src/server/app-access.ts` (neu, extrahiert) + `src/components/account-inactive-notice.tsx` (Hinweis „Konto deaktiviert“ aus dem Layout herausgelöst, Text unverändert) + `src/app/(app)/layout.tsx` nutzt beides
- `src/app/page.tsx`: rollenabhängige Startseite (`landingPath`: `field:execute` ohne `work_order:read_all` → `/m`, sonst `/dashboard`)
- `src/app/login/page.tsx`, `src/app/login/mfa/page.tsx`: Default-Redirect nach Login `/dashboard` → `/` (je 1 Zeile, + „bereits angemeldet“-Redirect)
- `src/server/jobs/processors/index.ts`: Registrierung `image-derivatives` (1 Zeile)
- `src/components/audit-trail.tsx`: Entity-Labels für `work_session`, `time_entry`, `checklist_item`, `material_usage`, `photo`, `voice_note`, `activity_note`, `sync_operation` (1 Zeile)
Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten (`sharp` ist über Next vorhanden).
## 4. Tests
- `scripts/test-einsatz-field.ts` – Session-Zustände (keine doppelte laufende Session, Pause-Segmente, Ende berechnet Dauer), Abschluss-Guards, Zeitkorrektur (Recht, Grund, Ende ≥ Beginn, Audit before/after), Material (Abweichung ohne Grund → invalid, Zusatzmaterial, Idempotenz), Checkliste/Notiz, Scope (Monteur ohne Zuweisung → not_found; ohne `field:execute` → forbidden), Sichtbarkeit/Bundle, Mandantentrennung.
- `scripts/test-einsatz-sync.ts` – Idempotenz (gleiche clientOpId → duplicate, keine Doppelanlage, je Mandant), Konflikt bei veralteter baseVersion (nichts überschrieben, SyncOperation `conflict`), Scope über Sync (fremder Auftrag → not_found, kein Versions-Leak), Ops fremder Lanes (rejected invalid, nicht gespeichert), Uploads (Idempotenz, Magic Bytes, Mandantentrennung beim Hochladen/Öffnen/Anhängen, `backoffice_only` verborgen), Foto/Sprachnotiz (Status `disabled`), image-derivatives-Processor inkl. Fremdmandant.
Ergebnis: siehe Abschnitt 7 (Gate).
## 5. Stubs & Abhängigkeiten zu anderen Lanes
| Stub (in L4-Pfad) | Vertrag | Ersetzen durch |
|---|---|---|
| `services/field/stubs/work-order-transition.ts` | `transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version }`; `completionBlockers(ctx, workOrderId) → CompletionBlocker[]`; wirft `ServiceError` inkl. `blocked` mit Blockern | L2 `services/work-orders/transition.ts` (Importe in `sessions.ts`, `queries.ts`, `sync/apply.ts`, Test) |
| `services/field/stubs/documents-store.ts` | `storeFile(ctx, { bytes, fileName, declaredMime, category, visibility, links, lineageId? }) → Document` (§4.3) inkl. Magic-Byte-Prüfung/Limits/SHA-256 | gemeinsames `services/documents/store.ts` (Import in `uploads.ts`) |
| `services/field/stubs/site-history.ts` | `getSiteHistory(ctx, siteId, { onlyApproved, limit? }) → SiteHistoryEntry[]` | L1 (Importe in `queries.ts`) |
| `app/(field)/m/sync/page.tsx` | Platzhalter | L7 ersetzt die Seite und `lib/field/client-ops.ts#submitOp` (Signatur stabil) |
| Sync-Ops `report.save_draft`, `report.submit`, `signature.capture` | Lazy-Import-Registry `services/sync/external-ops.ts` (auskommentierte Einzeiler) → `services/reports/sync-ops.ts#applySyncOp(ctx, op) → { idMap?, entityVersion? }` | L5 liefert das Modul und aktiviert je Op eine Zeile in `external-ops.ts`; bis dahin `rejected invalid` („not available yet“), nicht gespeichert |
| Sync-Op `emergency.create` | Registry-Einzeiler in `services/sync/external-ops.ts` → `services/emergency/sync-ops.ts#applySyncOp` | L8 |
| Transkription | Processor `transcription` in `jobs/processors/index.ts` | L9; bis dahin VoiceNote `disabled` |
| `/m/orders/[id]/{report,sign}` | noch nicht angelegt | Komponenten von L5, Einbindung durch L4 nach Merge |
## 6. Bekannte Lücken / Hinweise an den Architekten
0. **Registry statt berechnetem `import()`**: Ein Import mit berechnetem Pfad (`import(`../${dir}/sync-ops`)`) kann Turbopack nicht auflösen (Build-Warnung, Modul wäre nach Merge nicht ladbar). Daher explizite Registry `services/sync/external-ops.ts` – L5/L8 brauchen dort je Op einen Einzeiler.
1. **Offline**: Ops werden sofort gesendet; ohne Verbindung wird nichts zwischengespeichert (außer Notiz-Entwurf). Outbox/Service Worker = L7.
2. **Bundle-Löschungen**: `bundle?since=` liefert nur geänderte/offene Aufträge, keine Tombstones für entzogene/abgeschlossene Aufträge (Client muss Vollabgleich ohne `since` machen).
3. **Jobs (Fundament-Bug)**: `enqueueJob` erzeugt `jobId` mit `:` – BullMQ lehnt das ab („Custom Id cannot contain :“), `dispatchJob` fällt deshalb immer auf Inline-Ausführung zurück. Fix in `src/server/jobs/queues.ts` (z. B. `-` statt `:`) nötig.
4. **`requireApiContext`** wird in `services/context.ts` erwähnt, existiert im Fundament aber nicht → lane-lokal in `services/sync/api-context.ts` (nutzt `moduleGuard`, Same-Origin-Prüfung, Fehler-Mapping). Kandidat für das Fundament.
5. **DSGVO `pii-fields.ts`** (Fundament): Personenreferenzen der Einsatzmodelle sind noch nicht eingetragen: `WorkSession.userId`, `TimeEntry.userId`, `TimeEntry.correctedById`, `ActivityNote.authorId`, `Photo.takenById`, `VoiceNote.recordedById`, `MaterialUsage.recordedById`, `ChecklistItem.checkedById`, `Document.uploadedById`, `SyncOperation.userId`/`resolvedById`.
6. **`/files/[...key]`** prüft weiterhin nur das Mandantenpräfix (TODO documents); die Mobile-App nutzt deshalb `/api/v1/field/documents/[id]` mit Sichtbarkeits- und Scope-Prüfung.
7. **Transaktionen**: Session-Operationen (Segment schließen/öffnen + Statuswechsel) laufen sequenziell ohne DB-Transaktion; bei parallelen Doppelstarts desselben Users ist eine zweite aktive Session theoretisch möglich (kein Unique-Index). Idempotenz über `clientId`/`clientOpId` deckt Wiederholungen ab.
8. **„Heute“** wird in der Server-Zeitzone berechnet, Anzeige in `Europe/Berlin` (`lib/field/format.ts`); Mandanten-Zeitzone fehlt im Modell.
9. **Passkey-Login** und Mandantenwechsel (`tenant-switch.ts`, `/select-tenant`) leiten weiter fest auf `/dashboard` – Feldrollen landen dort erst über die Startseite `/`, nicht direkt auf `/m`.
10. **HEIC** ohne Browser-Dekodierung: Upload scheitert mit Hinweis (keine serverseitige Konvertierung).
## 7. Gate & Smoke
**Tests:** `test-einsatz-field.ts` 48 Prüfungen, `test-einsatz-sync.ts` 38 Prüfungen – alle grün (S3/Garage-Pfad aktiv; ohne `S3_ENDPOINT` werden nur die Byte-Abrufe übersprungen).
**Gate:** `npm run gate` grün – prisma generate, tsc, lint (0 Fehler, 2 Warnungen im Fundament-Platzhalter `handle-event.ts`), build inkl. Modul-Guard-Check (14 Action-Dateien), 24/24 Testskripte.
**HTTP-Smoke** (Dev-Server :3104, Demo-Mandant mit Smoke-Auftrag, Session-Cookies für `monteur@`, `multi@` (Monteur ohne Zuweisung) und `admin2@` (Mandant demo2)): 32/32 Prüfungen grün – Startseite `/` → `/m` (Monteur) bzw. `/dashboard` (Admin), alle Mobile-Seiten 200 mit erwarteten Inhalten, fremder/zugriffsloser Auftrag → 404, Backoffice-Dashboard rendert weiterhin, Sync (not_found für fremd, applied+duplicate, conflict bei veralteter Version, 400 bei leerem Batch, 403 bei Cross-Origin, ohne Session → Login-Redirect), Bundle, Upload 201/200 (idempotent)/404 (fremder Mandant), Dokument-Original + Vorschau 200 bzw. 404 für fremd, `photo.attach` applied.
**Mobile-UX 375×812** (Browser-Pane): große Karten mit Statuskante, Status als Text + Icon, eine orange Primäraktion, Schnellaktionen mit Kamera direkt (Heute → Karte → Foto = 2 Taps), Bottom-Nav 64 px, Buttons ≥ 48 px, max. 2 Ebenen unter der Liste; Klickpfad Annehmen → Losfahren über die UI geprüft.