From ceab79b7f81feb55a609ad7fdef8ce35805572ae Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:31:29 +0200 Subject: [PATCH] L12 Zeiterfassung: Lane-Bericht und Abnahme-Entscheidung Arbeitszeitkorrektur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/craftvia/lanes/zeiterfassung.md (Umfang, Dateien, Tests, Gate/RLS, Lücken), ABNAHME §4 „Arbeitszeitkorrektur“ auf die Freigaberegel aktualisiert. Co-Authored-By: Claude Opus 5 --- docs/craftvia/ABNAHME.md | 2 +- docs/craftvia/lanes/zeiterfassung.md | 94 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 docs/craftvia/lanes/zeiterfassung.md diff --git a/docs/craftvia/ABNAHME.md b/docs/craftvia/ABNAHME.md index e23bdeb..4e7774b 100644 --- a/docs/craftvia/ABNAHME.md +++ b/docs/craftvia/ABNAHME.md @@ -69,7 +69,7 @@ | Berichtsvorlage | generische Vorlage L5 mit Mandantenlogo/-namen | Layout/Pflichtinhalte des Pilotkunden | | Pflichtfelder je Auftragsart | über Checklisten-Vorlagen je Auftragsart konfigurierbar | konkrete Pflichtfelder | | Fotoarten | Standard-Pflichtfotos §14.2 als Vorschläge | verbindliche Liste | -| Arbeitszeitkorrektur | nur mit `field:correct_time` (Teamleiter/Admin), Grund Pflicht, Audit | Genehmigungsprozess? | +| Arbeitszeitkorrektur | **Entschieden (L12 Zeiterfassung, 2026-09-15):** Monteure tragen eigene Zeiten der letzten 7 Tage nach bzw. schlagen Korrekturen vor (`field:record_own_time`), Begründung Pflicht, Kennzeichen „manuell“; Freigabe durch Teamleiter (eigene Teams) oder Backoffice (`time:approve`), nie durch die erfassende Person selbst; bis zur Freigabe zählen Nachträge nicht für Bericht/Summen, Korrekturvorschläge lassen den alten Wert gelten; Abrechnungsfreigabe gesperrt, solange Zeiten offen sind. Direktkorrektur/Nachtrag für Teammitglieder weiterhin mit `field:correct_time` (sofort freigegeben). Grund, Audit before/after, Events `time.approval_requested/approved/rejected` | Freigabefrist/Eskalation, Export an Lohnabrechnung | | Aufbewahrungsfristen | Soft Delete, keine automatische Löschung; KI-Protokoll: `AI_GENERATION_RETENTION_DAYS` (Default 180): täglicher Worker-Job leert Ein-/Ausgaben und Personenbezug im KI-Protokoll; monatliches Token-Kontingent je Mandant (`/settings/lotse`) | Fristen je Dokumentart | | Empfänger Abrechnungsbenachrichtigung | `/settings/email` Abrechnungsempfänger je Mandant | Adressen | | OCR-/KI-Anbieter | Claude (Anthropic) für Extraktion + Lotse, Whisper-kompatibel für Transkription (Entscheidung 2026-09-14) | Vertrag/AVV, Region | diff --git a/docs/craftvia/lanes/zeiterfassung.md b/docs/craftvia/lanes/zeiterfassung.md new file mode 100644 index 0000000..f97cf79 --- /dev/null +++ b/docs/craftvia/lanes/zeiterfassung.md @@ -0,0 +1,94 @@ +# Lane L12 – Zeiterfassung (`lane/zeiterfassung`) + +Stand: 2026-09-15 · Basis `d3bc7f2` (`feature/craftvia-mvp`, L1–L11 integriert) + +Ziel: Zeiterfassung der Monteure deutlich einfacher – laufende Uhr überall, „Für heute beenden“, Auto-Wechsel zwischen Aufträgen, Zeit nachtragen + Tagesübersicht. **Entscheidung des Nutzers:** Monteure tragen eigene Zeiten nach / schlagen Korrekturen vor – mit Freigabe durch Teamleiter (eigene Teams) oder Backoffice; bis zur Freigabe zählen sie nicht für Bericht und Abrechnung. + +## 1. Umfang + +| Punkt | Umsetzung | +|---|---| +| Datenmodell | Migration `20260915120000_zeiterfassung_freigabe` (nur additive Spalten, keine neue Tabelle → keine RLS-/TENANT_MODELS-Änderung): `TimeEntry.source` (`tracked`/`manual`), `approvalStatus` (`approved`/`pending`/`rejected`, Default approved → Bestand unverändert), `approvedById` (entscheidende Person, auch bei Ablehnung), `approvedAt`, `rejectionReason`, `pendingChange` (Json: vorgeschlagene Korrektur `{startedAt, endedAt, type, reason, requestedAt, requestedById}`), `note`; Index `(tenant_id, approval_status)`. `WorkSession.manual` (Boolean). Begründung eines Nachtrags liegt in `correctionReason`. PII: `TimeEntry.approvedById` in `pii-fields.ts`. | +| Manuelle Session (Begründung) | Nachträge hängen an einer „manuellen“ Session je User + Auftrag + lokalem Tag (`manual = true`, Status `ended`, Start/Ende = min/max der Einträge). So bleiben alle bestehenden Lesepfade (Auftrag → Sessions → Einträge: Bericht, Zeiten-Tab, Bundle, Lotse, Abschluss-Blocker) ohne Sonderfälle korrekt; `ended` löst keine Blocker/aktive-Session-Logik aus. Eine eigene Tabelle hätte jeden Lesepfad verdoppelt. | +| Rechte | `field:record_own_time` → technician, team-lead; `time:approve` → team-lead, backoffice, tenant-admin (Katalog). `scripts/sync-role-permissions.ts` zieht bestehende Mandanten nach (lokal ausgeführt: +12 Zuordnungen). | +| Services `services/field/time-entries.ts` | `addManualTimeEntry` (Scope, Status nachtragbar = Feldstatus + `in_review`, ≤ 7 Tage zurück ab Tagesbeginn Mandanten-TZ, nicht Zukunft, Ende > Beginn, ≤ 16 h, keine Überlappung mit eigenen nicht abgelehnten Einträgen → `invalid`/`overlap`; eigener Eintrag → pending + Event; `forUserId` mit `field:correct_time` nur für aktive Mitglieder geführter Teams bzw. Backoffice → approved; idempotent über `clientId`), `proposeTimeCorrection` (nur eigener, abgeschlossener Eintrag ≤ 7 Tage; approved bleibt gültig, `pendingChange`; bei noch offenem Nachtrag werden die offenen Werte direkt geändert), `approveTimeEntry`/`approveTimeEntries`/`rejectTimeEntry` (`time:approve`, Team-Scope, nie eigene Einträge, Überlappungsprüfung bei Freigabe, Korrektur → Werte übernommen + `corrected`), `listPendingTimeEntries`, `countPendingTimeEntries`, `pendingTimeOfOrder`, `getMyTimeOverview`, `listRecordableOrders`, `listRecordableUsers`. Alle Mehrschritt-Writes in `inTransaction`, Audit before/after, Events nach Commit. | +| Team-Scope | Backoffice (`work_order:read_all`) → alle; Teamleiter → Einträge auf Aufträgen seiner geführten aktiven Teams, Aufträge mit `teamLeadUserId = er` oder von aktiven Mitgliedern seiner Teams. Außerhalb → `not_found`. | +| Sessions `services/field/sessions.ts` | Alle Session-Mutationen jetzt in `inTransaction`. **Auto-Wechsel:** `startSession`/`resumeSession` werfen `conflict other_session_running {workOrderId, number, title}`, wenn der Nutzer auf einem anderen Auftrag eine laufende/Anfahrts-Session hat; mit `switchFromOther: true` wird diese in derselben Transaktion pausiert (Pause-Segment, Auftrag `paused`, falls sonst niemand arbeitet). `stopForToday` (Session beendet, Auftrag bleibt offen → `paused` über `transitionWorkOrder`, kein Abschluss). `switchSegment` (`work`/`return_travel`/`material_procurement`). `getMyActiveSession` (laufende vor pausierter, Segmentstart, aufsummierte Dauer ohne Pausen). `correctTimeEntry`: eigene Einträge → `forbidden own_entry`, räumt offene Vorschläge ab. | +| Zählen nur freigegeben | `reports/build-content.ts` (Summen nur approved, neues optionales Feld `time.pendingMinutes` im Snapshot), Backoffice Zeiten-Tab (`workMinutes` approved, `pendingMinutes`), mobile Auftragsdetail/Zeiten, Lotse-Vollständigkeit, Meine Zeiten. **Abrechnungsfreigabe** (`completion.ts#transitionBlockers`) → `blocked` mit `missing_field: pending_time_entries`, solange Nachträge oder Korrekturvorschläge offen sind. Bericht einreichen warnt nur (Hinweis auf `/m/orders/[id]/report`). | +| Sync/Offline | Ops `session.stop_day`, `session.segment`, `time.add_manual`, `time.propose_correction` (Envelope + Zod in `lib/sync/ops.ts`, Dispatch in `sync/apply.ts`), `session.start`/`session.resume` mit `switchFromOther?`. Outbox ohne Sonderlogik; `VERSION_CHANGING_OPS` um stop_day/segment ergänzt (verkettete baseVersion). Optimistische Ansicht (`bundle-core.ts`): stop_day → Uhr aus, Auftrag pausiert; add_manual → `local.manualTimes` mit pending. Abgelehnte Ops im Klartext auf `/m/sync` (`offline.problem.timeOverlap`, `timeWindow`, `otherSession`). Konfliktmeldung des Auto-Wechsels trägt die Auftragsdaten (`other_session_running:{…}`). | +| Events/Benachrichtigungen | `time.approval_requested` → Teamleiter des Auftrags-Teams bzw. der Teams des Monteurs (mit `time:approve`, ohne Antragsteller), sonst Backoffice (`read_all` + `time:approve`); In-App + E-Mail (über Einstellungen abbestellbar). `time.approved`/`time.rejected` → Monteur, nur In-App, Ablehnungsgrund im Text. Links: Backoffice `/work-orders/time-approvals`, Teamleiter `/m/approvals`, Monteur `/m/time`. | + +## 2. Screens / Routen + +| Route | Inhalt | +|---|---| +| Mobile-Shell (alle `/m`-Seiten) | **Laufende-Uhr-Leiste** über der Bottom-Nav: Status (Text + Punkt), „A-00042 · Titel · 1:23 h“ (Sekunden-Tick), Tipp → Auftrag, Pause/Weiter, „Für heute beenden“ (zweistufig), Link „Meine Zeiten“; offline aus lokalem Zustand (ohne Dauer). Badge-Zähler offener Freigaben am „Profil“. | +| `/m`, `/m/orders` | je Karte direkter Button Arbeit starten / Pause / Weiter; laufende Karte mit breiter Statuskante + Text „Zeit läuft“/„Pausiert“. | +| Auto-Wechsel | Bottom-Sheet „A-00041 läuft noch. Pausieren und A-00042 starten?“ [Wechseln] [Abbrechen] – aus Karte, Auftragsdetail und Uhr-Leiste. | +| `/m/orders/[id]` | laufend: primär „Pause“, sekundär „Für heute beenden“ (mit Hinweis), Textlink „Auftrag abschließen …“ (bestehender Flow mit Bestätigung/Blockern); kleinere Segment-Aktionen „Rückfahrt starten“, „Material holen“, „Zurück zur Arbeit“. | +| `/m/orders/[id]/time` | Badges manuell/zur Freigabe/abgelehnt/Korrektur beantragt, freigegebene + offene Summe, eigene Einträge „Korrektur vorschlagen“, fremde (Teamleiter) Direktkorrektur, „Zeit nachtragen“ für den Auftrag. | +| `/m/time` | **Meine Zeiten:** Tages-Chips (Heute, Gestern, weitere 6 Tage), Summen freigegeben/offen, Einträge je Auftrag mit Art, von–bis, Dauer, Badges, „Korrektur vorschlagen“, nicht übertragene lokale Nachträge. | +| `/m/time/new` | **Zeit nachtragen:** Auftrag (letzte 7 Tage/laufend), optional „Für“ (Teammitglied, nur `field:correct_time`), Art-Chips, Datum, Von–Bis oder Dauer (15/30/60 min), Begründung Pflicht mit Vorschlägen, Notiz, Hinweis „Wird nach Freigabe durch Teamleiter oder Büro gezählt“. | +| `/m/approvals` | Teamleiter (`time:approve`): offene Nachträge/Korrekturen (alt → neu), große Buttons Freigeben / Ablehnen mit Grund. | +| `/work-orders/time-approvals` | Backoffice: Tabelle Mitarbeiter, Auftrag, Datum, von–bis (alt → neu), Dauer, Art, Begründung; Einzel- und Sammelfreigabe, Ablehnen mit Grund. Navigation „Zeiten zur Freigabe“, Dashboard-Kachel. | +| `/work-orders/[id]?tab=times` | Summe freigegeben + offen, Badges, Freigabe/Ablehnung inline. | + +## 3. Dateien + +**Neu** +- `prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql` +- `src/server/services/field/time-entries.ts`, `src/lib/field/time-rules.ts` +- `src/server/actions/work_orders/time-approvals.ts` +- `src/components/field/`: `running-clock-bar.tsx`, `switch-session-sheet.tsx`, `card-time-button.tsx`, `time-entry-badges.tsx`, `correction-proposal-form.tsx`, `manual-time-form.tsx`, `local-pending-times.tsx`, `approval-actions.tsx`, `pending-time-notice.tsx` +- `src/components/work-orders/time-approval-forms.tsx` +- `src/app/(field)/m/(core)/time/page.tsx`, `time/new/page.tsx`, `approvals/page.tsx`, `src/app/(app)/work-orders/time-approvals/page.tsx` +- `scripts/test-zeiterfassung-service.ts`, `scripts/test-zeiterfassung-sync.ts` + +**Geändert (Field/Sync, L4/L7-Pfade)** +- `src/server/services/field/{sessions,time-correction,queries}.ts`, `src/server/actions/field/time.ts` +- `src/components/field/{primary-action,order-card,bottom-nav}.tsx`, `src/app/(field)/m/layout.tsx`, `(core)/page.tsx`, `(core)/orders/page.tsx`, `(core)/orders/[id]/{page,time/page,report/page}.tsx`, `(core)/profile/page.tsx` +- `src/lib/sync/{envelope,ops}.ts`, `src/server/services/sync/apply.ts` +- `src/lib/offline/{outbox-core,bundle-core}.ts`, `src/components/offline/offline-view.tsx` (Aktion „Für heute beenden“) + +**Geändert außerhalb field/sync** +- `prisma/schema.prisma`; `src/server/rbac.ts` (2 Rechte + Rollen); `src/server/dsgvo/pii-fields.ts` (1 Zeile) +- `src/lib/events.ts` (3 Events, entityType `time_entry`); `src/server/services/notifications/{recipients,handle-event}.ts` (Empfänger, Link, Grund im Text) +- `src/server/services/reports/build-content.ts`, `src/lib/reports/content.ts` (optional `pendingMinutes`) +- `src/server/services/work-orders/{completion,detail,dashboard}.ts`, `src/components/work-orders/detail-tabs.tsx` (Zeiten-Tab), `src/app/(app)/dashboard/page.tsx` (Kachel), `src/lib/nav.ts` (1 Eintrag) +- `src/server/services/lotse/completeness.ts` (nur freigegebene Arbeitszeit) +- `messages/{de,en}/{field,offline,notifications,nav,dashboard,workOrders}.json` +- `scripts/test-security-roles.ts` (Matrix-Proben für die neuen Rechte), `scripts/smoke-auth.ts` +- `docs/craftvia/ABNAHME.md` §4 „Arbeitszeitkorrektur“ + +Keine neuen npm-Abhängigkeiten, keine neuen Tabellen, keine Stubs. + +## 4. Tests + +- `scripts/test-zeiterfassung-service.ts` – **78 Prüfungen**: Validierung (Überlappung → `invalid overlap`, > 7 Tage, Zukunft, > 16 h, Ende vor Beginn, ohne Grund, ohne Zuweisung → not_found, Backoffice ohne Recht, Monteur für Kollegen), manuelle Session, Events (Teamleiter statt Monteur/Backoffice; eigener Teamleiter-Nachtrag → Backoffice) + Audit, pending zählt nicht (Tagesbericht, Zeiten-Tab, Meine Zeiten), Freigabe-Rollen (Monteur nie, Teamleiter nicht eigene, fremder Teamleiter not_found, Backoffice alle, Sammelfreigabe, Direktkorrektur eigener Einträge forbidden), Listen/Badge/Dashboard, Freigabe → zählt + `time.approved`, Ablehnung mit Grund + `time.rejected` (Text, Link) + abgelehnter Zeitraum frei, Korrekturvorschlag (alter Wert bis Freigabe, danach neu + corrected; abgelehnter Vorschlag lässt Eintrag gültig), Direktanlage für Teammitglied (approved) bzw. Nicht-Mitglied forbidden, Abrechnungsfreigabe blocked `pending_time_entries` und nach Freigabe möglich, „Für heute beenden“ (Session ended, Auftrag paused, kein Blocker), Auto-Wechsel (conflict mit Nummer, nichts angelegt, fehlgeschlagener Wechsel lässt andere Session laufen, mit Flag pausiert + genau eine laufende Uhr, Weiter ebenfalls geprüft), Segmente + `getMyActiveSession`, Mandantentrennung (B kann weder freigeben, ablehnen, sehen, nachtragen noch vorschlagen). +- `scripts/test-zeiterfassung-sync.ts` – **29 Prüfungen**: Payload-Schemas, 4 neue Ops in einem Batch applied (idMap, entityVersion), gleiche clientOpIds → duplicate ohne Doppelanlage, Event über Sync, Überlappung → rejected mit Klartext-Schlüssel (`timeOverlap`/`timeWindow`), Korrekturvorschlag über Sync, Auto-Wechsel-Konflikt mit Auftragsnummer (`otherSession`) und Wechsel mit Flag, verkettete baseVersion, optimistische Ansicht (stop_day, add_manual pending), Mandant B / Monteur ohne Zuweisung → not_found. +- Bestehend angepasst: `test-security-roles.ts` (Proben `field:record_own_time`, `time:approve`). +- Gate/RLS: siehe §7. + +**HTTP-Smoke** (Dev-Server :3113, `scripts/smoke-auth.ts`, Demo-Seed, Rechte per `sync-role-permissions` nachgezogen): **105/105 grün** – neu: Backoffice `/work-orders/time-approvals`, Dashboard-Kachel; Teamleiter `/m/time`, `/m/approvals`, Profil-Link; Monteur Uhr-Leiste auf `/m` („Laufende Zeiterfassung“, „Für heute beenden“), `/m/time` (heute/gestern), `/m/time/new`, `/m/approvals` ohne Recht → Hinweis, Zeiten-Seite des Auftrags. Visuelle Browserprüfung nicht durchgeführt (Login würde Passworteingabe/Token-Einschleusen durch den Agenten erfordern). + +## 5. Bekannte Lücken / Hinweise + +1. **Demo-/Bestandsdaten mit zwei laufenden Sessions** eines Nutzers (vor L12 möglich, z. B. Seed: `monteur@` auf A-00009 und N-00001) bleiben bestehen; die Uhr-Leiste zeigt die neueste, der nächste Start/Weiter erzwingt den Wechsel. +2. **Backoffice hat kein `field:correct_time`** (Rollenfilter schließt `field:*` aus): Direktanlage für Mitarbeiter mobil nur für Teamleiter/Mandantenadmin; Backoffice gibt frei/lehnt ab. Falls gewünscht, Recht im Katalog ergänzen. +3. **Offline-Uhr** kennt keine Dauer (Bundle ohne Einträge) – offline nur Auftrag + Status. Auto-Wechsel-Dialog erscheint offline erst beim Sync (Op wird dann auf `/m/sync` im Klartext abgelehnt); der optimistische Wechsel pausiert die andere Auftragsansicht lokal nicht. +4. **Zeitzone**: Formular nutzt die Gerätezeit, Server prüft das 7-Tage-Fenster in der Mandanten-Zeitzone (`TenantSettings.timezone`). +5. **Transaktionen + Events**: `transitionWorkOrder` emittiert Events innerhalb der Session-Transaktion (bestehendes Verhalten, ABNAHME §5); pg meldet dabei eine Deprecation-Warnung für parallele Queries auf dem Transaktions-Client (harmlos). +6. **Nachträge auf abgerechneten/stornierten Aufträgen** nicht möglich (`work_order_status`); nach Abrechnungsfreigabe erfasste Korrekturen gibt es damit nicht. +7. Freigabefrist/Eskalation und Lohn-Export nicht umgesetzt (ABNAHME §4 „zu klären“). + +## 6. Migration + +`20260915120000_zeiterfassung_freigabe` – rein additiv (2 Enums, 7 Spalten `time_entries`, 1 Spalte `work_sessions`, 1 Index). Bestehende Zeilen: `tracked` + `approved` → unverändertes Verhalten. Deploy: `prisma migrate deploy` + `scripts/sync-role-permissions.ts` (Nutzer neu anmelden). + +## 7. Gate + +`npm run gate` **grün**: prisma generate, tsc, lint (0 Fehler, 3 bestehende Warnungen außerhalb L12), build inkl. Modul-Guard-Check (33 Action-Dateien), **67/67 Testskripte** (davon neu `test-zeiterfassung-service` 78 ✓, `test-zeiterfassung-sync` 29 ✓). + +Im ersten Gate-Lauf schlugen `test-notdienst-flow`/`test-notdienst-review` fehl: Die Notdienst-Erfassung startet eine Session, während der Monteur noch auf einem anderen Auftrag arbeitet → neue Regel „eine laufende Uhr“ lieferte `other_session_running`. Behoben in `services/emergency/create.ts` (`switchFromOther: true` – ein Notdiensteinsatz pausiert die laufende Uhr automatisch). + +**RLS-Lauf** `RLS_ENFORCED=true npm run test` (RLS_DATABASE_URL auf die Lane-DB `craftvia_zeiterfassung`): **67/67 Testskripte grün** – alle Session-/Freigabe-Writes laufen über `inTransaction` und sind damit auch unter scharfer RLS atomar.