L13 Planung: Lane-Bericht und Abnahme-Matrix (Kalender, Live-Lage, Verzug, Standortdaten)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:13:57 +02:00
co-authored by Claude Opus 5
parent ee17134863
commit edee395b2e
2 changed files with 150 additions and 2 deletions
+5 -2
View File
@@ -56,7 +56,10 @@
| MFA | erfüllt | Fundament TOTP + Passkeys, Mandanten-MFA-Pflicht |
| Kartenlink zur Einsatzadresse | erfüllt | L1 OpenStreetMap-Link, L4 Routenlink |
| Push-Benachrichtigungen | offen | nach MVP (Spec §20.2 „später") |
| Kalenderansicht | offen | optional (§11.2) |
| Kalenderansicht | erfüllt (Backoffice) | L13 Menüpunkt „Planung“ → `/planning`: Standard „Heute + nächste 4 Werktage“ (Teams = Kolonnen als Zeilen, heutige Spalte mit Live-Status je Kolonne), Umschalter Heute (Stundenraster 6–20 Uhr, Kolonnen als parallele Spalten) · 5 Tage · Woche · Nächste Woche; Auslastung der Kolonnenkapazität je Tag, Konflikte (überbucht, Überschneidung, Person doppelt eingeplant, außerhalb der Arbeitstage) mit roter Kante + Icon + Text, Hinweis „Kolonne unvollständig“, Drag & Drop mit Bestätigung und Tastatur-Alternative, ungeplante Aufträge in der Seitenleiste; Dashboard-Kacheln „Planung heute“ und „Konflikte diese Woche“; Teamleiter lesend für eigene Kolonnen; `test-planung-core`. Mobile Kalenderansicht für Monteure weiterhin offen (§11.2 optional) |
| Live-Lage der Monteure | erfüllt (ohne GPS) | L13 `/planning/live`: ein Marker/Eintrag je Kolonne am Objekt des laufenden Auftrags, Mitglieder mit individuellem Status aus ihrer WorkSession, Monteure ohne Team einzeln; Karte (OpenStreetMap) + Liste, 30-s-Aktualisierung; keine Geräte-Koordinaten; `test-planung-live` |
| Verzugswarnungen / früher fertig | erfüllt (Hinweis) | L13: erfasste Kolonnenzeit (vereinigte Arbeitssegmente) ≥ 80 % → „Verzug droht“, ≥ 100 % → Meldung `planning.overrun` (erneut je +50 %), gefährdeter Folgeauftrag (Restzeit + geschätzte Fahrzeit bzw. Tageskapazität) → Markierung + `planning.followup_at_risk`, früher fertig → „früher fertig“ + Vorzieh-/Umkreis-Vorschläge + `planning.capacity_freed`; Meldungen nur In-App an Backoffice + Teamleiter, dedupliziert, aus dem 5-Minuten-Job `planning-watch`; nie automatische Umplanung; `test-planung-watch` |
| Einsatz-Empfehlungen | erfüllt (Vorschlag) | L13: Teams mit Auftrag in der Nähe (Luftlinie) und freier Kapazität, Begründung als Text, Übernehmen nur mit Bestätigung; nahe ungeplante Aufträge; Geocoding über Nominatim-Job mit Cache; `test-planung-recommend`, `test-planung-geocode`. Keine Routen-/Fahrzeitberechnung (Spec §35 „automatische Tourenoptimierung“ bleibt außerhalb) |
| Standorterfassung beim Einsatzstart | erfüllt | L4 optional beim Session-Start |
| Barcode/QR-Code | offen | Version 2 |
| Kundenspezifische Statusmodelle | offen | Statusmodell global, Labels gruppiert (Brandbook §12.3) |
@@ -76,7 +79,7 @@
| E-Mail-Dienst | SMTP (Mail-Worker, Queue) | Anbieter, SPF/DKIM/DMARC |
| Hostingstandort | Coolify/Docker (aus Certvia übernommen) | Rechenzentrum/Region |
| MFA-Regelung | optional je Nutzer, Mandanten-Pflicht einschaltbar | Pflicht für Admins? |
| Standortdaten | optional beim Einsatzstart/Foto, kein Dauertracking | Einwilligung/Betriebsvereinbarung |
| Standortdaten | optional beim Einsatzstart/Foto, kein Dauertracking. **Live-Lage (L13) nutzt keine Geräte-Standorte:** angezeigt wird der Einsatzort (Objektadresse) des laufenden Auftrags, Status aus der Zeiterfassung; Start-Koordinaten der WorkSession werden weder gelesen noch ausgeliefert. Objektadressen werden serverseitig über OpenStreetMap Nominatim verortet (nur Adresse, keine Personendaten, max. 1 Anfrage/s, Ergebnis am Objekt gespeichert); Kartenkacheln lädt der Browser von `tile.openstreetmap.org` (IP-Adresse des Nutzers wird dabei an OSM übertragen) | Einwilligung/Betriebsvereinbarung für die Anzeige „wer arbeitet wo“; Datenschutzhinweis zur Kachel-/Geocoding-Nutzung bzw. eigener Karten-/Geocoding-Dienst (EU-Hosting) für Produktion |
| Maximale Offline-Dauer | `OFFLINE_MAX_DAYS=7` | Wert |
| Maximale Dateigrößen | Bild 15 MB, PDF 25 MB, Audio 20 MB | Werte |
| Buchhaltungssoftware | keine Schnittstelle (MVP-Abgrenzung §35) | Zielsystem für V2 |
+145
View File
@@ -0,0 +1,145 @@
# Lane L13 – Planung (`lane/planung`)
Stand: 2026-09-15 · Basis `d3bc7f2` (`feature/craftvia-mvp`, L1–L11 integriert) · Spec §11, §21, §35/§36, §44 · Brandbook §12
Menüpunkt „Planung“ mit Plantafel (Standard: heute + nächste 4 Werktage) und Live-Lage (ohne GPS), Kolonnenkapazität, Konflikte, Verzugswarnungen, „früher fertig“ mit Vorzieh-Vorschlägen, Einsatz-Empfehlungen nach Luftlinie + Terminlage, Geocoding über OpenStreetMap Nominatim. **Alles sind Hinweise und Vorschläge – es wird nie automatisch umgeplant.**
## 1. Umfang / umgesetzte Nutzerentscheidungen
| Punkt | Umsetzung |
|---|---|
| **Teams = Kolonnen** | Ein Team fährt und arbeitet gemeinsam (2+ Personen). Kapazität = `Team.dailyCapacityMinutes` (Kolonnen-Arbeitstag, Default 480) an `Team.workingDays` (Bitmaske Mo=1 … So=64, Default Mo–Fr) – **nicht** Personenstunden; ohne aktive Mitglieder 0. Auftragsdauer ist Kolonnenzeit. Auslastung = Summe geplanter Kolonnendauern / Kolonnenkapazität. Popup „Teamkapazität“ auf der Plantafel (`team:manage`, Audit). |
| Dauer | `WorkOrder.plannedDurationMinutes` → Beginn/Ende am selben Tag (mit Uhrzeit) → `OrderType.defaultDurationMinutes` → 120 min. Mehrtägig: explizite Dauer gleichmäßig verteilt, sonst je Tag Auftragsart-Standard bzw. 480 min. Ende exakt um Mitternacht zählt zum Vortag. |
| Konflikte | `overbooked` (Minuten über Kapazität), `overlap` (zwei Aufträge derselben Kolonne mit Uhrzeit überlappen), `assignee_double_booked` (Person in zwei Kolonnen/Aufträgen gleichzeitig; in beiden Zeilen sichtbar), `outside_working_days`; **Hinweis** `crew_incomplete` (an Arbeitstagen < 2 aktive Mitglieder; `severity: hint`, gelb mit Info-Icon, zählt nicht als Konflikt). |
| **Menü „Planung“** | Top-Level direkt unter „Dashboard“ mit Unterpunkten „Plantafel“ (Standard, `/planning`) und „Live-Lage“ (`/planning/live`); sichtbar für `work_order:read_all` und Teamleiter (`report:approve_team`). |
| **Plantafel** `/planning` | Standard **„Heute + nächste 4 Werktage“** (5 Spalten, erste Spalte „Heute“ hervorgehoben), Kolonnen als Zeilen mit Name, Mitglieder-Kurzliste und Kolonnenkapazität; je Zelle Auftragskarten (Nummer, Kunde, Ort, Uhrzeit, Dauer, Statusgruppe mit Icon, Priorität, Notdienst), Auslastungsbalken („6 h / 8 h · 75 %“), Konflikt-/Hinweis-Liste, Verzugs- und Gefährdungs-Badges; heutige Spalte zusätzlich **Live-Status je Kolonne** (Unterwegs/In Arbeit/Pause/frei + aktive Personen, Verzug, „früher fertig“). Umschalter **Heute** (Stundenraster 6–20 Uhr, **Kolonnen als parallele Spalten**, Zeit vertikal, überlappende Aufträge nebeneinander) · **5 Tage** · **Woche** · **Nächste Woche**; vor/zurück/heute. Filter Team/Auftragsart/Priorität (wirken auf Karten, nicht auf die Auslastung). Seitenleiste „Ungeplante Aufträge“ mit „Vorschläge“. Panel „Früher fertig“ mit „Vorziehen“. Unter 1024 px Hinweis + einfache Liste. |
| Drag & Drop | `@dnd-kit/core` (Pointer- und Keyboard-Sensor am Griff): Ablegen auf Tag (Uhrzeit bleibt bzw. 08:00) oder Stunde (Heute-Ansicht) → Bestätigungs-Popover (Team, Datum, Beginn, „ohne Uhrzeit“, Dauer vorbelegt) → `POST /api/v1/planning/schedule`. **Tastatur-Alternative:** „Einplanen …“/„Verschieben …“ je Karte öffnet dasselbe Popover (Fokus hinein, Escape schließt, Fokus zurück). Ergebnis in `role="status"` inkl. Konfliktanzahl; Versionskonflikt → Meldung + Neuladen. |
| `scheduleWorkOrder` | `inTransaction`: `baseVersion` Pflicht → `conflict` „Auftrag wurde zwischenzeitlich geändert“; nur Status draft/review_required/planned/assigned/accepted; **L2 `assignWorkOrder`** (Teamwechsel/Erstzuweisung, Event `work_order.assigned`; Einzelzuweisungen bleiben nur als aktive Mitglieder des Zielteams) + **L2 `updateWorkOrder`** (Beginn/Ende) + Dauer (`writeWithVersion`); Audit „planning“ (before/after); Länge bleibt beim Verschieben ohne neues Ende erhalten; Antwort mit Konflikten des Zieltages. Rechte `work_order:assign` + `work_order:write`. |
| **Live-Lage ohne GPS** `/planning/live` | Status aus der aktiven WorkSession je Person (`en_route` → Unterwegs, `running` → In Arbeit, `paused` → Pause, keine → frei), „seit“ aus dem offenen Zeitabschnitt. **Ein Marker je Kolonne** am Objekt des laufenden Auftrags (Auftrag des aktivsten Mitglieds), Popup/Liste mit Mitgliedern und deren individuellem Status (inkl. abweichendem Auftrag); Monteure ohne Team einzeln. Farbe + inline-SVG-Icon + Text, roter Ring bei Verzug. Karte (Leaflet, OSM-Kacheln, Namensnennung) + Liste, mobil Tabs; Filter Team/Status; Polling 30 s + „Zuletzt aktualisiert“; „Heute ohne Beginn“; „Früher fertig“ mit Vorzieh-Links. Hinweis „Standort = Einsatzort des laufenden Auftrags, keine GPS-Ortung.“ |
| **Früher fertig** | `getFreedCapacity(ctx, { date })`: Aufträge des Tages mit Uhrzeit, Status technically_completed/signature_pending/in_review/released_for_billing/billed, Ende der letzten (Uhr-)Session < geplanter Beginn + Dauer (≥ 15 min) → freie Minuten je Kolonne (früher fertig gesamt, ab jetzt verfügbar), tatsächliche Dauer aus freigegebenen Zeiten. Vorschläge (nur heute): spätere Aufträge derselben Kolonne heute/nächste 5 Tage, die in die freie Zeit passen („Vorziehen“), und ungeplante Aufträge im Umkreis 15 km (Luftlinie). Badge „Team Nord · 2 h früher fertig“ in Live-Lage und Plantafel; „Vorziehen“ öffnet das Einplan-Popover vorbelegt. |
| **Verzugswarnungen** | `evaluateDelays`: erfasste Kolonnen-Arbeitszeit des laufenden Auftrags = **zeitliche Vereinigung** der `work`-Segmente aller Sessions des Auftrags (nicht über Personen summiert) vs. geplante Dauer: ≥ 80 % → „Verzug droht“, ≥ 100 % → „Dauer überschritten“ (Live-Lage Marker/Liste, Plantafel Karte, Text + Icon). **Folgeauftrag gefährdet:** nächster geplanter Auftrag derselben Kolonne heute; Restzeit (≥ 0) + Fahrzeit (Luftlinie × 1,3 / 50 km/h, mind. 10 min) → Beginn (mit Uhrzeit) überschritten oder Überziehung + Fahrzeit sprengt die Tageskapazität → Karte „gefährdet durch Verzug A-00041“. |
| **Meldungen** | Job `planning-watch` (BullMQ Job-Scheduler alle 5 min, alle Mandanten): `planning.overrun` (dedupe je Auftrag, erneut je weitere +50 %), `planning.followup_at_risk` (je Folgeauftrag/Tag), `planning.capacity_freed` (je Kolonne/Tag/Auftrag) → **nur In-App** an Backoffice (`work_order:read_all` + `report:approve`) + Teamleiter der Kolonne. Dedupe-Ledger: append-only Audit-Log (`entity = planning_alert`, Schlüssel je Mandant). Anzeige wird bei jedem Laden serverseitig berechnet (auch ohne Redis); Events entstehen nur im Job. |
| **Empfehlungen** | `recommendAssignments(ctx, { workOrderId, days = 10, radiusKm = 25 })`: Zielobjekt braucht Koordinaten (sonst Hinweis „Adresse nicht verortet“ + Geocoding-Job; `not_found` eigener Hinweis). Je Arbeitstag und Kolonne: nächster geplanter Auftrag (Luftlinie) + **freie Kolonnenkapazität**; ohne freie Kapazität ausgeschlossen, zu wenig → „knapp“. Score 0,6 Distanz + 0,2 Kapazität + Datum (Gewicht nach Priorität) − 0,25 knapp; Top 5 mit Text („3,0 km von A-00001 · Team Nord hat am Mo 28.09. noch 4 h frei“) und vorgeschlagenem Beginn. `findNearbyUnplanned` (5 km). UI in der Seitenleiste und im Auftragsdetail; „Übernehmen“ nur vorbelegt, **kein Auto-Speichern**. |
| **Geocoding** | `services/geo`: `GeocodingProvider`, `nominatim.ts` (strukturiert, Timeout 8 s, `Accept-Language: de`, `countrycodes`, User-Agent `Craftvia/<version> (+APP_BASE_URL)` bzw. `GEOCODING_USER_AGENT`, Limiter 1/s je Prozess), `none`. Job `geocode-site` (Concurrency 1 + BullMQ-Limiter 1/s, `failed` → Retry). Cache `Site.latitude/longitude` + `geocodeQuery` + `geocodeStatus` + `geocodedAt`; unveränderte Adresse nie erneut; manuelle Koordinaten nie überschrieben. Auslöser Objekt anlegen/Adressänderung/Import-Bestätigung – nur Queue, nie inline. `scripts/geocode-backfill.ts`. Ohne Netz/Provider: „ohne Ortsangabe“, nie Fehler. |
| Dashboard | Kacheln „Planung heute“ (Kolonnen im Einsatz / gesamt, Konflikte heute, ungeplante Aufträge) und „Konflikte diese Woche“ – additiv. |
## 2. Datenschutz – Live-Lage (verbindliche Entscheidung)
- **Keine Standortdaten des Geräts.** Angezeigt wird ausschließlich der **Einsatzort des laufenden Auftrags** (Objektadresse bzw. deren Koordinaten). `WorkSession.startLat/startLng`, `deviceInfo` und Foto-Koordinaten werden nicht selektiert und sind nie Teil einer Antwort (per Test über das serialisierte Ergebnis aller Sichten geprüft).
- Status und Verzug stammen aus der Zeiterfassung (aktive Uhr-Sessions, getrackte Arbeitssegmente). Kein Tracking, keine Historie der Live-Lage, keine zusätzlich gespeicherten Personendaten; das Dedupe-Ledger enthält nur Auftrags-/Team-IDs und Prozentwerte.
- Sichtbarkeit: Backoffice sieht alle Kolonnen des Mandanten, Teamleiter nur ihre geleiteten Kolonnen (Aufträge außerhalb des Scopes als „Einsatz außerhalb Ihres Bereichs“), Monteure keinen Zugriff. Verzugs-/Früher-fertig-Meldungen gehen nur an Backoffice und den Teamleiter der Kolonne.
- Empfehlung vor Produktivbetrieb (Spec §44 „Umgang mit Standortdaten“): Information der Beschäftigten / ggf. Betriebsvereinbarung, da sichtbar ist, wer gerade an welchem Einsatzort arbeitet und ob ein Einsatz länger dauert.
- Geocoding überträgt nur Objektadressen an den Geocoding-Dienst; Kartenkacheln lädt der Browser vom Kachelserver (IP-Adresse des Nutzers wird übertragen) → Datenschutzhinweis bzw. eigener Kacheldienst.
## 3. OpenStreetMap-Nutzungsrichtlinien & Produktionsempfehlung
- **Nominatim** (<https://operations.osmfoundation.org/policies/nominatim/>): nur serverseitig (Worker/Skript), max. 1 Anfrage/s (Limiter + BullMQ-Limiter + Concurrency 1), eindeutiger User-Agent mit Kontakt-URL, Ergebnisse am Objekt gecacht, kein Massen-Geocoding im Request, keine Autocomplete-Suche. Limiter gilt je Prozess – bei mehreren Worker-Replikas nur eine mit `GEOCODING_PROVIDER=nominatim`.
- **Kacheln** (<https://operations.osmfoundation.org/policies/tiles/>): Namensnennung sichtbar, Browser-Referrer, kein Vorabladen; der öffentliche Kachelserver ist nicht für hohe Last gedacht.
- **Produktion:** eigener bzw. vertraglich gebundener Geocoding- und Kacheldienst (selbst gehostetes Nominatim/Photon + Tile-Server oder EU-Anbieter mit AVV). Umschalten per `GEOCODING_URL`/`GeocodingProvider`, `MAP_TILE_URL` + `MAP_ATTRIBUTION`. **`MAP_TILE_URL` muss beim `next build` gesetzt sein** (Kachel-Host in CSP `img-src`).
- Env (in `.env.example`): `GEOCODING_PROVIDER=nominatim|none`, `GEOCODING_URL`, `GEOCODING_USER_AGENT`, `MAP_TILE_URL` (Default `https://tile.openstreetmap.org/{z}/{x}/{y}.png`), `MAP_ATTRIBUTION` (Default „© OpenStreetMap-Mitwirkende“).
## 4. Datenmodell – Migration `20260915120000_planung`
Nur Spalten (RLS unverändert, keine neue Tabelle, keine TENANT_MODELS-/PII-Änderung):
- `Team.dailyCapacityMinutes Int @default(480)` (Kolonnen-Arbeitstag), `Team.workingDays Int @default(31)`.
- `OrderType.defaultDurationMinutes Int?` + Backfill der Standardarten (Montage 480, Reparatur 180, Wartung 120, Störung 120, Notdienst 120, Besichtigung 60, Abnahme 60, Nacharbeit 120); neue Mandanten über `DEFAULT_ORDER_TYPES`.
- `WorkOrder.plannedDurationMinutes Int?`.
- `Site.geocodedAt DateTime?`, `Site.geocodeStatus String?` (`ok|not_found|failed|skipped`), `Site.geocodeQuery String?`.
**Zeiterfassung (L12):** keine Schemaänderung durch L13. `services/planning/time-tracking.ts` nutzt `TimeEntry.source = tracked` (Live/Verzug), `TimeEntry.approvalStatus = approved` (tatsächliche Dauer abgeschlossener Aufträge) und `WorkSession.manual = false` – die Filter schalten sich automatisch ein, sobald der generierte Prisma-Client die L12-Felder kennt (vorher wirkungslos, damit die Lane gegen beide Schemata kompiliert). WorkSessions/TimeEntries werden nur gelesen.
## 5. Dateien
**Neu (Lane-Pfade):**
- `src/lib/geo/distance.ts`, `src/lib/planning/{days,capacity,text}.ts`
- `src/server/services/geo/{config,provider,nominatim,rate-limit,normalize,geocode-site,dispatch}.ts`
- `src/server/services/planning/{access,data,board,schedule,recommend,live,watch,summary,team-settings,time-tracking}.ts`
- `src/server/jobs/processors/{geocode-site,planning-watch}.ts`
- `src/server/actions/work_orders/planning.ts` (Kolonnenkapazität, `moduleGuard("work_orders")` + `guard("team:manage")`)
- `src/app/(app)/planning/{layout,page}.tsx`, `src/app/(app)/planning/live/page.tsx`
- `src/app/api/v1/planning/{board,schedule,recommendations,live}/route.ts`
- `src/components/planning/{planning-board,schedule-dialog,recommendations-panel,live-situation,live-map,live-tone,capacity-form,suggestions-section,conflicts-tile}.tsx|ts`
- `messages/{de,en}/planning.json`
- `prisma/migrations/20260915120000_planung/migration.sql`
- `scripts/test-planung-{core,recommend,live,geocode,watch}.ts`, `scripts/geocode-backfill.ts`
**Eingriffe außerhalb planning/geo (minimal, additiv):**
| Datei | Änderung |
|---|---|
| `prisma/schema.prisma` | 7 Spalten (s. §4) |
| `src/lib/work-orders/defaults.ts` | `defaultDurationMinutes` je Standard-Auftragsart |
| `src/server/services/sites/sites.ts` | je 1 Zeile nach Anlage/Adressänderung `requestSiteGeocoding` (+ 2 Importe) |
| `src/server/services/imports/confirm.ts` | 1 Zeile nach Bestätigung mit neuem Objekt (+ 1 Import) |
| `src/server/jobs/queues.ts` | Queue-Namen `geocode-site`, `planning-watch` + Job-Scheduler „alle 5 min“ in `scheduleRecurringJobs` |
| `src/server/jobs/processors/index.ts` | 2 Registrierungen |
| `scripts/craftvia-worker.ts` | Concurrency 1 + Limiter 1/s für `geocode-site` |
| `src/lib/events.ts` | Event-Typen `planning.capacity_freed`, `planning.overrun`, `planning.followup_at_risk` |
| `src/server/services/notifications/recipients.ts` | Regel für die 3 Events (Backoffice + Teamleiter, `mailToUsers: false`) + 3 Facts |
| `src/server/services/notifications/handle-event.ts` | Textvariablen `team`, `minutes`, `percent`, `blocker` |
| `messages/{de,en}/notifications.json` | Typ-Labels + Texte der 3 Events, Audit-Objektart `planning_alert` |
| `next.config.ts` | CSP `img-src` um Kachel-Host(s) aus `MAP_TILE_URL` (Default `https://tile.openstreetmap.org https://*.tile.openstreetmap.org`) |
| `src/lib/nav.ts`, `messages/{de,en}/nav.json` | „Planung“ + Unterpunkte „Plantafel“/„Live-Lage“; `NavItem.sub`/`exact` (optional) |
| `src/components/nav-link.tsx` | optionales `exact` (Unterpunkt nicht auf Unterpfaden aktiv) |
| `src/app/(app)/layout.tsx` | Einrückung für `sub`, eindeutiger `key`, `exact` durchgereicht (Hauptnavigation) |
| `src/app/(app)/dashboard/page.tsx` | Kacheln „Planung heute“ + „Konflikte diese Woche“ (1 Import + 1 Zeile) |
| `src/app/(app)/work-orders/[id]/page.tsx` | Abschnitt „Einsatz-Vorschläge“ auf der Übersicht (2 Importe + 1 Zeile) |
| `src/lib/api/openapi.ts`, `docs/craftvia/API.md` | 4 Planungs-Endpunkte + Tag „Planung“ |
| `scripts/smoke-auth.ts` | Planungsseiten/-API je Rolle |
| `.env.example`, `docs/craftvia/ABNAHME.md` | Env-Block; §3 Kalender/Live-Lage/Verzug/Empfehlungen, §4 Standortdaten |
| `package.json`, `package-lock.json` | neue Abhängigkeiten (s. §7) |
Nicht angefasst: `services/field/**`, `components/field/**`, `app/(field)/m/**`, `TimeEntry`/`WorkSession`-Schema, Zeit-Freigaben, `time.*`-Events (L12), `src/server/rbac.ts` (keine neuen Rechte).
## 6. Tests
| Skript | ✓ | Inhalt |
|---|---|---|
| `test-planung-core` | 92 | Haversine/Formatierung; Tage (Bitmaske, Zeitumstellung, heute + nächste Werktage, Werktage vor/zurück); Dauerregeln inkl. mehrtägig/Mitternacht; **Kolonnenkapazität** (aktive Mitglieder gültig ab/bis/inaktiv/doppelt, Kapazität nicht × Personen, 0 ohne Mitglieder, Wochenende); Auslastung; Konflikte rein + DB (overbooked 10 h bei 225 %, overlap, anschließend ≠ overlap, assignee_double_booked in beiden Zeilen, outside_working_days); **crew_incomplete** als Hinweis (nicht im Konfliktzähler, färbt keine Karte); Vereinigung von Arbeitssegmenten, Fahrzeitschätzung (50 km → 78 min, min. 10), Meldestufen 100/150/200 %; Mitgliedschaft „gültig bis“; Kolonnenkapazität setzen (Wirkung, Audit, Teamleiter forbidden, Mandant B not_found, invalid); Filter; Rollen (Teamleiter nur eigene Kolonne lesend, Monteur forbidden) und **Mandantentrennung**; Zeitraum-Validierung; Dashboard-Zähler; `scheduleWorkOrder`: Zuweisung + Termin + Dauer, Version, Konflikte im Ergebnis, **Event** `work_order.assigned`, **Audit**, Länge beim Verschieben, **Versionskonflikt** mit Klartext, **atomar** (Zuweisung, Statushistorie, Audit zurückgerollt), Rechte/Scope/Mandant B, ungültiges Datum, laufender Auftrag |
| `test-planung-recommend` | 27 | nächste Kolonne mit freier Kolonnenzeit (3 km, 4 h frei, Text, Beginn nach letztem Auftrag), Kolonne ohne Kapazität trotz größerer Nähe ausgeschlossen, außerhalb Radius ausgeschlossen, **nächster Kandidat mit Kapazität gewinnt**, „knapp“, Top 5, englischer Text, Radius-Hinweis; **ohne Koordinaten** → Hinweis, `not_found`, ohne Objekt; nahe ungeplante Aufträge; Rollen; **Mandantentrennung** (identische Koordinaten in B nie in A-Vorschlägen, B → not_found) |
| `test-planung-live` | 26 | Status je Person aus Session, „seit“, beendete Session zählt nicht, Standort = Objekt, ohne Ortsangabe, heute noch geplant, „Heute ohne Beginn“; **ein Eintrag je Kolonne** mit individuellen Mitgliederstatus und abweichendem Auftrag, Kolonne Süd Pause, Monteure gehören zur Kolonne; **keine Geräte-Koordinaten/Geräteinfo** im Ergebnis; Filter; Teamleiter nur eigene Kolonne; Monteur forbidden; Mandant B |
| `test-planung-watch` | 41 | Job/Queue registriert, L12-Adapter je Schema; **Kolonnenzeit vereinigt** (100 statt 140 min), 50 % ok, **83 % „Verzug droht“**, Folgeauftrag rechtzeitig (10:31 < 10:45) bzw. **gefährdet** (10:51 > 10:45, Fahrzeit 31 min für 20 km); Plantafel-Karten (Verzug, Dauer überschritten, gefährdet), Live-Lage Kolonne mit Verzug; **Meldungen**: keine bei 83 %, `planning.overrun` bei 108 %, **Dedupe**, erneut bei 150 %, nicht vor 200 %, `followup_at_risk` einmal je Tag, Ledger-Einträge; **Empfänger** Backoffice + Teamleiter der Kolonne, nicht Monteure/andere Teamleiter, **nur In-App**, Texte; Gefährdung durch **Tageskapazität** (Folgeauftrag ohne Uhrzeit); **früher fertig**: 2 h früher, 90 min ab jetzt, Vorziehen (passend ja, zu lang nein), Umkreis (1 km ja, fern nein), vorbelegter Beginn, **keine automatische Änderung**, Live-Lage-Anzeige, `capacity_freed` einmal + Dedupe + Text; Rollen (Monteur forbidden, anderer Teamleiter sieht nichts); **Mandantentrennung** (B: keine Auswertung/Meldungen/Ledger, Dedupe-Schlüssel in B blockiert A nicht) |
| `test-planung-geocode` | 39 | Normalisierung/Änderungserkennung; Nominatim-URL, User-Agent-Default/Override, Provider none, Kachel-Defaults; Provider mit injiziertem `fetch` (Treffer, Header, Limiter, not_found, HTTP-Fehler); **Drosselung** mit Fake-Uhr; `geocodeSite` mit Fake-Provider (ok + Audit, **Cache**, Neuverortung, **not_found**, failed → Retry, manuelle Koordinaten, none, unvollständig, Mandant B); Processor; Hook nie inline; **Nominatim nie aufgerufen** |
**Gesamt: 225 Prüfungen in 5 neuen Skripten.** Gate/RLS/Smoke: §8.
## 7. Neue Abhängigkeiten (begründet)
| Paket | Lizenz | Größe | Begründung |
|---|---|---|---|
| `leaflet` 1.9.4 | BSD-2-Clause | ~42 KB min+gz JS, 4 KB CSS | Kartenanzeige der Live-Lage (Nutzervorgabe); nur auf `/planning/live` per `next/dynamic` (`ssr: false`); Styles lokal, Marker inline SVG, keine CDN-Assets. |
| `@dnd-kit/core` 6.3.1 | MIT | ~15 KB min+gz | Drag & Drop der Plantafel (Nutzervorgabe, war nicht vorhanden); Pointer- und Keyboard-Sensor, Screenreader-Ansagen; nur auf `/planning`. |
| `@types/leaflet` 1.9.22 (dev) | MIT | – | Typen für tsc. |
## 8. Gate, RLS, Smoke
- **`npm run gate` grün** (Vordergrund): prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in `scripts/test-betrieb-api.ts`, `src/app/(app)/layout.tsx` `CraftviaLogo`, `src/server/services/field/mime.ts`), build inkl. Modul-Guard-Check (33 Action-Dateien), **70/70 Testskripte** (davon 5 neu, 225 Prüfungen).
- **`RLS_ENFORCED=true npm run test` grün: 70/70** (Lane-DB `craftvia_planung`, `RLS_DATABASE_URL` auf dieselbe DB).
- **Smoke** `BASE=http://localhost:3114 npx tsx scripts/smoke-auth.ts` gegen den Produktions-Build (`next start -p 3114`): **OK — 111 Prüfungen** (alle Rollen). Neu: Backoffice `/planning` (Standard 5 Tage: `data-planning-view="days"`, `data-day-count="5"`, „Heute“, Team Nord + Team Süd, Seitenleiste), `?view=today` (Kolonnen als Spalten, „Ganztägig“), `?view=week` (7 Tage), Prefill `?schedule=`, `/planning/live` (Datenschutzhinweis, OSM-Namensnennung, keine Start-Koordinaten), `GET /api/v1/planning/{board,live,recommendations}` (live ohne `startLat/startLng/deviceInfo`), Dashboard „Planung heute“ + „Konflikte diese Woche“; Teamleiter `/planning` + `/planning/live` nur Team Nord, „Nur Lesezugriff“; Monteur `/planning*` → 404; Mandant demo2 ohne Demo-Teams/-Monteure, Empfehlungen für Demo-Auftrag → 404. Renderzeiten im Produktions-Build 30–170 ms.
- Hinweis Dev-Server: `next dev` im Worktree blieb beim zweiten Start beim Kompilieren von `/dashboard` hängen (Turbopack wählt wegen mehrerer Lockfiles das Hauptrepo inkl. aller Worktrees als Workspace-Root); der erste Dev-Lauf renderte alle Planungsseiten fehlerfrei, der finale Smoke lief daher gegen `next start`.
- **Nicht durchgeführt:** visuelle Prüfung im Browser (Drag & Drop, Karte, Tagesansicht mit parallelen Kolonnen, 1024/768/375 px) – erfordert eine angemeldete Browsersitzung (Passwort-/Token-Eingabe durch den Agenten). Bitte manuell mit `backoffice@demo.example` prüfen. Demo-Objekte haben ohne Geocoding keine Koordinaten: `npx tsx scripts/geocode-backfill.ts --tenant=demo` (≈ 1 s je Objekt) oder Koordinaten am Objekt eintragen.
## 9. Bekannte Lücken / offene Punkte
1. **L12-Abgleich beim Merge:** Feldnamen `TimeEntry.source`/`approvalStatus`, `WorkSession.manual` sind gegen die Vorgabe implementiert (Adapter aktiviert sich automatisch); tatsächliche Enum-Werte (`tracked`, `approved`) und „eine Uhr je Nutzer“ beim Merge prüfen. Die Live-Lage liest `WorkSession.status` (`en_route|running|paused|ended`) – ändert L12 die Semantik, `SESSION_STATUS` in `services/planning/live.ts` nachziehen.
2. **Dedupe-Ledger im Audit-Log** (append-only, je Mandant): bei parallel laufenden Worker-Replikas sind vereinzelte Doppelmeldungen möglich (kein Unique-Index). Meldungen erscheinen im Audit-Viewer als „Planungshinweis“.
3. **Früher fertig/Verzug** nur für Aufträge mit Uhrzeit; „Folgeauftrag gefährdet“ betrachtet nur den nächsten Auftrag derselben Kolonne am selben Tag. Die Kolonnen-Marker zeigen den Auftrag des aktivsten Mitglieds; arbeitet eine Kolonne getrennt an zwei Orten, stehen abweichende Aufträge nur im Popup/in der Liste.
4. **Events in Transaktionen** (bekannt aus L10b): `scheduleWorkOrder` ruft `assignWorkOrder`/`updateWorkOrder` innerhalb `inTransaction`; In-App-Benachrichtigungen rollen mit zurück, eine eingereihte E-Mail kann trotzdem versendet werden.
5. **Auftragsart-Dauer** nicht in `/settings/order-types` editierbar (L2-Seite); Kolonnenkapazität nur im Plantafel-Popup, nicht im Teams-Formular (L1).
6. **Nominatim-Limiter je Prozess** (s. §3); Notdienst-Objekte (L8) und Altbestände werden nicht automatisch verortet → Backfill bzw. Adressänderung. Manuelle Koordinaten werden nie ersetzt (zum Neuverorten leeren).
7. **Mehrtägige Aufträge:** Kapazitätsanteil heuristisch; keine Überschneidungsprüfung für mehrtägige/ganztägige Aufträge. Heute-Ansicht: Aufträge vor 6 bzw. nach 20 Uhr am Rand. „5 Tage“ blendet Wochenendtage aus (Aufträge am Wochenende in der Wochenansicht).
8. **Ungeplant** umfasst zusätzlich zugewiesene/angenommene Aufträge ohne Termin. Seitenleiste max. 200, Plantafel max. 3 000 Aufträge je Zeitraum.
9. **Empfehlungen** nur Luftlinie (keine Routen, Spec §35) und nur Kolonnen mit geplantem Auftrag in der Nähe.
10. CSP-Erweiterung wird beim Build ausgewertet (`MAP_TILE_URL` vor `next build`). Dashboard-Kachel „Planung heute“ berechnet Plantafel + Live-Lage je Aufruf (für MVP-Größen unkritisch).
## 10. Routen
| Route | Inhalt |
|---|---|
| `/planning` | Plantafel: Standard 5 Werktage ab heute; `?view=today` (Kolonnen als Spalten), `?view=week`, `&date=YYYY-MM-DD`; Filter `teamId`/`orderTypeId`/`priority`; `?capacity=<teamId>` Popup; `?schedule=<id>&scheduleTeam=&day=&time=` vorbelegtes Einplan-Popover |
| `/planning/live` | Live-Lage (Kolonnen-Marker, Liste, früher fertig, Filter, Polling) |
| `GET /api/v1/planning/board` | Plantafel-Daten |
| `POST /api/v1/planning/schedule` | Einplanen |
| `GET /api/v1/planning/recommendations?workOrderId=` | Empfehlungen + nahe ungeplante Aufträge |
| `GET /api/v1/planning/live` | Live-Lage inkl. Kolonnen und „früher fertig“ |
| Job `planning-watch` | alle 5 min: Verzug/Überschreitung, gefährdete Folgeaufträge, früher fertig → Meldungen |