diff --git a/.env.example b/.env.example index a81f2c4..bf2fbe5 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,18 @@ AI_GENERATION_RETENTION_DAYS=180 # Import-Extraktion ab. 0 = unbegrenzt. AI_MONTHLY_TOKEN_LIMIT=0 +# --- Craftvia: Planung – Karten & Geocoding (L13) --- +# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s), +# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte +# erscheinen „ohne Ortsangabe“). Für Produktion eigenen/vertraglichen Dienst verwenden. +GEOCODING_PROVIDER=nominatim +# GEOCODING_URL=https://nominatim.openstreetmap.org +# Eindeutiger User-Agent (Nominatim-Richtlinie); leer = "Craftvia/ (+APP_BASE_URL)". +# GEOCODING_USER_AGENT= +# Kartenkacheln der Live-Lage; der Host wird beim Build in die CSP (img-src) übernommen. +# MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png +# MAP_ATTRIBUTION=© OpenStreetMap-Mitwirkende + # --- Optionale Fundament-Variablen (Default leer) --- # MFA_ENC_KEY: Schlüssel für TOTP-Secrets at-rest (leer = aus AUTH_SECRET abgeleitet). # ⚠ Nach dem Setzen nicht mehr ändern. diff --git a/docs/craftvia/ABNAHME.md b/docs/craftvia/ABNAHME.md index 4e7774b..7f23d14 100644 --- a/docs/craftvia/ABNAHME.md +++ b/docs/craftvia/ABNAHME.md @@ -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 | diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md index 02b2096..5ab762a 100644 --- a/docs/craftvia/API.md +++ b/docs/craftvia/API.md @@ -91,4 +91,8 @@ Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit | POST | `/uploads` | field | `field:execute` | Foto/Sprachnotiz hochladen → `documentId` | | GET | `/field/bundle` | field | `field:execute` | Offline-Pull (`?since=`, max. 200 Aufträge) | | GET | `/field/documents/{id}` | field | Service: `document:read` + Sichtbarkeit/Scope | Dokument für die Mobile-App (`?variant=preview`) | +| GET | `/planning/board` | work_orders | Service: `work_order:read_all` oder Teamleiter (eigene Teams) | Plantafel: Aufträge je Team/Tag, Kapazität, Auslastung, Konflikte (`from`, `to` ≤ 42 Tage, `teamId`, `orderTypeId`, `priority`) | +| POST | `/planning/schedule` | work_orders | `work_order:assign` + Service: `work_order:write` | Einplanen (Team + Termin + Dauer, atomar, `baseVersion` Pflicht, 409 „Auftrag wurde zwischenzeitlich geändert“) | +| GET | `/planning/recommendations` | work_orders | `work_order:assign` | Einsatz-Empfehlungen (Luftlinie + freie Kapazität, Top 5) + nahe ungeplante Aufträge; nur Vorschläge | +| GET | `/planning/live` | work_orders | Service: `work_order:read_all` oder Teamleiter | Live-Lage: Status aus aktiver WorkSession, Standort = Objekt des Auftrags, keine Geräte-Koordinaten | | GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument | diff --git a/docs/craftvia/lanes/planung.md b/docs/craftvia/lanes/planung.md new file mode 100644 index 0000000..86b4aa0 --- /dev/null +++ b/docs/craftvia/lanes/planung.md @@ -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/ (+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** (): 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** (): 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=` Popup; `?schedule=&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 | diff --git a/messages/de/nav.json b/messages/de/nav.json index f49d785..59e2a72 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -1,6 +1,9 @@ { "dashboard": "Dashboard", "workOrders": "Aufträge", + "planning": "Planung", + "planningBoard": "Plantafel", + "planningLive": "Live-Lage", "emergencyReview": "Notdienst-Prüfung", "imports": "Import", "customers": "Kunden", diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 5685b8c..e87df6f 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -48,7 +48,10 @@ "sync_failed": "Synchronisationsfehler", "time_approval_requested": "Zeit zur Freigabe", "time_approved": "Zeit freigegeben", - "time_rejected": "Zeit abgelehnt" + "time_rejected": "Zeit abgelehnt", + "planning_capacity_freed": "Früher fertig", + "planning_overrun": "Geplante Dauer überschritten", + "planning_followup_at_risk": "Folgeauftrag gefährdet" }, "events": { "work_order_assigned": { @@ -130,6 +133,18 @@ "time_rejected": { "title": "Zeit abgelehnt: {number}", "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}" + }, + "planning_capacity_freed": { + "title": "{team} früher fertig: {number}", + "message": "{title} – {customer}: {minutes} min vor dem geplanten Ende abgeschlossen. Freie Zeit auf der Plantafel nutzen." + }, + "planning_overrun": { + "title": "Geplante Dauer überschritten: {number}", + "message": "{team} bei {customer}: {percent} % der geplanten Dauer erfasst." + }, + "planning_followup_at_risk": { + "title": "Folgeauftrag gefährdet: {number}", + "message": "{title} – {customer} ({team}) ist gefährdet durch Verzug {blocker}." } }, "fallback": { @@ -244,7 +259,8 @@ "voice_note": "Sprachnotiz", "notification": "Benachrichtigung", "notification_settings": "Benachrichtigungseinstellungen", - "sync_operation": "Synchronisation" + "sync_operation": "Synchronisation", + "planning_alert": "Planungshinweis" } } } diff --git a/messages/de/planning.json b/messages/de/planning.json new file mode 100644 index 0000000..62e8dc6 --- /dev/null +++ b/messages/de/planning.json @@ -0,0 +1,199 @@ +{ + "crumb": "Planung", + "weekday": { "0": "Mo", "1": "Di", "2": "Mi", "3": "Do", "4": "Fr", "5": "Sa", "6": "So" }, + "board": { + "title": "Plantafel", + "sub": "Aufträge je Team und Tag einplanen – Auslastung und Überschneidungen auf einen Blick.", + "readOnly": "Nur Lesezugriff – Sie sehen Ihre eigenen Teams.", + "viewLabel": "Ansicht", + "view": { "today": "Heute", "days": "5 Tage", "week": "Woche", "nextWeek": "Nächste Woche" }, + "navLabel": "Zeitraum wechseln", + "prevWeek": "Vorige Woche", + "nextWeek": "Nächste Woche", + "prevDay": "Voriger Tag", + "nextDay": "Nächster Tag", + "prevDays": "Vorige 5 Werktage", + "nextDays": "Nächste 5 Werktage", + "todayLabel": "Heute · {day}", + "time": "Uhrzeit", + "today": "Heute", + "range": "{from} – {to}", + "team": "Team", + "noTeams": "Keine aktiven Teams in dieser Auswahl.", + "showMembers": "Mitglieder von {team} anzeigen", + "hideMembers": "Mitglieder von {team} ausblenden", + "memberHint": "Einzeln zugewiesen", + "load": "{planned} / {capacity} · {percent} %", + "loadNoCapacity": "{planned} · keine Kapazität", + "notWorkingDay": "Kein Arbeitstag", + "allDay": "Ganztägig", + "free": "frei", + "narrowHint": "Die Plantafel ist für Bildschirme ab 1024 px ausgelegt. Hier sehen Sie eine einfache Liste.", + "capacity": "Kapazität", + "capacityOf": "Kapazität von {team} bearbeiten", + "move": "Verschieben …", + "schedule": "Einplanen …", + "dragHint": "Karten mit der Maus auf einen Tag ziehen oder über „Einplanen …“ bzw. „Verschieben …“ per Tastatur einplanen.", + "open": "Auftrag öffnen", + "saved": "{number} eingeplant: {team}, {day}.", + "savedWithConflicts": "{number} eingeplant: {team}, {day}. Achtung: {count} Konflikt(e) am Zieltag.", + "priority": "Priorität: {priority}", + "emergency": "Notdienst", + "multiDay": "mehrtägig", + "duration": "Dauer {duration}", + "listDayEmpty": "Nichts geplant", + "crewCapacity": "Kolonne · {capacity} je Arbeitstag · {count} Personen", + "delayRisk": "Verzug droht · {percent} %", + "delayOverrun": "Dauer überschritten · {percent} %", + "atRisk": "gefährdet durch Verzug {number}" + }, + "freed": { + "title": "Früher fertig", + "badge": "{team} · {minutes} früher fertig", + "available": "{minutes} frei ab {time}", + "pullForward": "Vorziehen", + "kindPullForward": "Geplanter Auftrag von {team}", + "kindNearby": "Ungeplant, {distance} entfernt", + "noSuggestions": "Kein passender Auftrag für die freie Zeit.", + "hint": "Nur Hinweis – nichts wird automatisch verschoben." + }, + "conflicts": { + "label": "Konflikt", + "count": "{count, plural, =0 {Keine Konflikte} one {# Konflikt} other {# Konflikte}}", + "overbooked": "Überbucht um {over}", + "overlap": "Überschneidung: {a} und {b}", + "assignee_double_booked": "{user} doppelt eingeplant: {a} und {b}", + "outside_working_days": "Außerhalb der Arbeitstage", + "crew_incomplete": "Kolonne unvollständig: {count} aktive Person(en)", + "hintLabel": "Hinweis", + "short": { + "overbooked": "Überbucht", + "overlap": "Überschneidung", + "assignee_double_booked": "Doppelt eingeplant", + "outside_working_days": "Kein Arbeitstag", + "crew_incomplete": "Kolonne unvollständig" + } + }, + "filters": { + "title": "Filter", + "team": "Team", + "orderType": "Auftragsart", + "priority": "Priorität", + "any": "Alle", + "apply": "Anwenden", + "reset": "Zurücksetzen" + }, + "unplanned": { + "title": "Ungeplante Aufträge", + "empty": "Keine ungeplanten Aufträge.", + "noTeam": "ohne Team", + "noDate": "ohne Termin", + "noLocation": "ohne Ortsangabe", + "suggestions": "Vorschläge" + }, + "dialog": { + "title": "Auftrag einplanen", + "team": "Team", + "date": "Datum", + "time": "Beginn", + "noTime": "Ohne Uhrzeit (ganztägig)", + "duration": "Dauer in Minuten", + "durationHint": "Vorbelegt aus Auftrag bzw. Auftragsart ({duration}).", + "save": "Speichern", + "saving": "Speichert …", + "cancel": "Abbrechen", + "close": "Schließen" + }, + "errors": { + "conflict": "Auftrag wurde zwischenzeitlich geändert. Die Ansicht wurde aktualisiert – bitte erneut einplanen.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "not_found": "Auftrag nicht gefunden.", + "invalid": "Bitte die Eingaben prüfen.", + "planned_end_before_start": "Das Ende liegt vor dem Beginn.", + "not_schedulable": "Dieser Auftrag läuft bereits und wird hier nicht verschoben.", + "team_not_found": "Team nicht gefunden oder nicht aktiv.", + "not_editable": "Dieser Auftrag kann nicht mehr geändert werden.", + "network": "Keine Verbindung. Bitte erneut versuchen.", + "generic": "Speichern war nicht möglich. Bitte erneut versuchen." + }, + "recommend": { + "title": "Vorschläge", + "sub": "Nur Vorschläge nach Luftlinie und Terminlage – die Zuweisung entscheiden Sie.", + "loading": "Vorschläge werden berechnet …", + "reason": "{distance} von {number} · {team} hat am {day} noch {free} frei", + "reasonTight": "{distance} von {number} · {team} am {day} knapp: {free} frei, {required} nötig", + "card": "{day} · {team} · {distance} von {number} · {free} frei", + "tight": "knapp", + "take": "Übernehmen", + "noCoordinates": "Adresse nicht verortet – die Verortung wurde angestoßen. Bitte später erneut öffnen.", + "addressNotFound": "Adresse nicht verortet – die Adresse wurde nicht gefunden. Bitte am Objekt prüfen.", + "notSchedulable": "Dieser Auftrag ist bereits in Ausführung.", + "none": "Kein Team hat in den nächsten {days} Tagen einen Auftrag im Umkreis von {radius} km mit freier Kapazität.", + "nearbyTitle": "In der Nähe, ebenfalls ungeplant", + "nearbyQuestion": "Diese Aufträge liegen im Umkreis von {radius} km – zusammen einplanen?", + "nearbyEmpty": "Keine weiteren ungeplanten Aufträge in der Nähe.", + "planTogether": "Gemeinsam einplanen", + "close": "Vorschläge schließen", + "failed": "Vorschläge konnten nicht geladen werden.", + "sectionTitle": "Einsatz-Vorschläge", + "openBoard": "Zur Plantafel" + }, + "live": { + "title": "Live-Lage", + "sub": "Wer ist gerade in welchem Einsatz?", + "privacy": "Standort = Einsatzort des laufenden Auftrags, keine GPS-Ortung.", + "status": { "en_route": "Unterwegs", "working": "In Arbeit", "paused": "Pause", "free": "Frei" }, + "freeHint": "kein laufender Einsatz", + "since": "seit {time}", + "updated": "Zuletzt aktualisiert: {time}", + "refresh": "Aktualisieren", + "refreshing": "Aktualisiert …", + "updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.", + "tabsLabel": "Darstellung", + "tabs": { "map": "Karte", "list": "Liste" }, + "filterTeam": "Team", + "filterStatus": "Status", + "all": "Alle", + "noLocation": "ohne Ortsangabe", + "hiddenOrder": "Einsatz außerhalb Ihres Bereichs", + "next": "Heute noch geplant", + "nothingNext": "Heute nichts weiter geplant", + "attentionTitle": "Heute ohne Beginn", + "attentionEmpty": "Alle heutigen Aufträge sind begonnen oder später geplant.", + "reason": { "not_accepted": "Nicht angenommen", "overdue": "Beginn überschritten" }, + "empty": "Keine Monteure in dieser Auswahl.", + "markerCount": "{count} Personen an diesem Ort", + "mapLabel": "Karte der laufenden Einsätze", + "mapLoading": "Karte wird geladen …", + "withoutLocation": "{count} laufende Einsätze ohne Ortsangabe (nur in der Liste)", + "noTeam": "ohne Team", + "crewsTitle": "Kolonnen", + "soloTitle": "Ohne Team", + "crewMembers": "Mitglieder", + "memberAt": "bei {number}", + "delayRisk": "Verzug droht · {percent} %", + "delayOverrun": "Dauer überschritten · {percent} %", + "worked": "{worked} von {planned} erfasst" + }, + "capacity": { + "title": "Teamkapazität", + "sub": "Grundlage für Auslastung und Vorschläge", + "minutes": "Arbeitstag der Kolonne in Minuten", + "minutesHint": "Die Kolonne arbeitet gemeinsam: Kapazität je Arbeitstag, nicht je Person.", + "days": "Arbeitstage", + "save": "Speichern", + "saving": "Speichert …", + "errors": { + "invalid": "Bitte die Eingaben prüfen.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "not_found": "Team nicht gefunden.", + "generic": "Speichern war nicht möglich." + } + }, + "dashboardTile": { + "title": "Konflikte diese Woche", + "hint": "Überbuchung, Überschneidung, Doppelbuchung", + "todayTitle": "Planung heute", + "todayHint": "Teams im Einsatz · {conflicts} Konflikt(e) · {unplanned} ungeplante Aufträge" + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index 5e97423..765663c 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -1,6 +1,9 @@ { "dashboard": "Dashboard", "workOrders": "Work orders", + "planning": "Planning", + "planningBoard": "Planning board", + "planningLive": "Live situation", "emergencyReview": "Emergency review", "imports": "Import", "customers": "Customers", diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 13ca202..310a3ce 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -48,7 +48,10 @@ "sync_failed": "Sync failed", "time_approval_requested": "Time to approve", "time_approved": "Time approved", - "time_rejected": "Time rejected" + "time_rejected": "Time rejected", + "planning_capacity_freed": "Finished early", + "planning_overrun": "Planned duration exceeded", + "planning_followup_at_risk": "Follow-up order at risk" }, "events": { "work_order_assigned": { @@ -130,6 +133,18 @@ "time_rejected": { "title": "Time rejected: {number}", "message": "{actor} rejected your time for {number}: {reason}" + }, + "planning_capacity_freed": { + "title": "{team} finished early: {number}", + "message": "{title} – {customer}: completed {minutes} min before the planned end. Use the free time on the planning board." + }, + "planning_overrun": { + "title": "Planned duration exceeded: {number}", + "message": "{team} at {customer}: {percent} % of the planned duration recorded." + }, + "planning_followup_at_risk": { + "title": "Follow-up order at risk: {number}", + "message": "{title} – {customer} ({team}) is at risk due to the delay of {blocker}." } }, "fallback": { @@ -244,7 +259,8 @@ "voice_note": "Voice note", "notification": "Notification", "notification_settings": "Notification settings", - "sync_operation": "Sync" + "sync_operation": "Sync", + "planning_alert": "Planning alert" } } } diff --git a/messages/en/planning.json b/messages/en/planning.json new file mode 100644 index 0000000..aafe3b3 --- /dev/null +++ b/messages/en/planning.json @@ -0,0 +1,199 @@ +{ + "crumb": "Planning", + "weekday": { "0": "Mon", "1": "Tue", "2": "Wed", "3": "Thu", "4": "Fri", "5": "Sat", "6": "Sun" }, + "board": { + "title": "Planning board", + "sub": "Schedule orders per team and day – utilization and overlaps at a glance.", + "readOnly": "Read-only – you see your own teams.", + "viewLabel": "View", + "view": { "today": "Today", "days": "5 days", "week": "Week", "nextWeek": "Next week" }, + "navLabel": "Change period", + "prevWeek": "Previous week", + "nextWeek": "Next week", + "prevDay": "Previous day", + "nextDay": "Next day", + "prevDays": "Previous 5 working days", + "nextDays": "Next 5 working days", + "todayLabel": "Today · {day}", + "time": "Time", + "today": "Today", + "range": "{from} – {to}", + "team": "Team", + "noTeams": "No active teams in this selection.", + "showMembers": "Show members of {team}", + "hideMembers": "Hide members of {team}", + "memberHint": "Individually assigned", + "load": "{planned} / {capacity} · {percent} %", + "loadNoCapacity": "{planned} · no capacity", + "notWorkingDay": "Not a working day", + "allDay": "All day", + "free": "free", + "narrowHint": "The planning board is designed for screens from 1024 px. Here you see a simple list.", + "capacity": "Capacity", + "capacityOf": "Edit capacity of {team}", + "move": "Move …", + "schedule": "Schedule …", + "dragHint": "Drag cards onto a day with the mouse or use “Schedule …” / “Move …” with the keyboard.", + "open": "Open order", + "saved": "{number} scheduled: {team}, {day}.", + "savedWithConflicts": "{number} scheduled: {team}, {day}. Attention: {count} conflict(s) on the target day.", + "priority": "Priority: {priority}", + "emergency": "Emergency", + "multiDay": "multi-day", + "duration": "Duration {duration}", + "listDayEmpty": "Nothing planned", + "crewCapacity": "Crew · {capacity} per working day · {count} people", + "delayRisk": "Delay likely · {percent} %", + "delayOverrun": "Duration exceeded · {percent} %", + "atRisk": "at risk due to delay of {number}" + }, + "freed": { + "title": "Finished early", + "badge": "{team} · {minutes} early", + "available": "{minutes} free from {time}", + "pullForward": "Pull forward", + "kindPullForward": "Planned order of {team}", + "kindNearby": "Unplanned, {distance} away", + "noSuggestions": "No suitable order for the free time.", + "hint": "Hint only – nothing is moved automatically." + }, + "conflicts": { + "label": "Conflict", + "count": "{count, plural, =0 {No conflicts} one {# conflict} other {# conflicts}}", + "overbooked": "Overbooked by {over}", + "overlap": "Overlap: {a} and {b}", + "assignee_double_booked": "{user} double-booked: {a} and {b}", + "outside_working_days": "Outside working days", + "crew_incomplete": "Crew incomplete: {count} active person(s)", + "hintLabel": "Hint", + "short": { + "overbooked": "Overbooked", + "overlap": "Overlap", + "assignee_double_booked": "Double-booked", + "outside_working_days": "Not a working day", + "crew_incomplete": "Crew incomplete" + } + }, + "filters": { + "title": "Filter", + "team": "Team", + "orderType": "Order type", + "priority": "Priority", + "any": "All", + "apply": "Apply", + "reset": "Reset" + }, + "unplanned": { + "title": "Unplanned orders", + "empty": "No unplanned orders.", + "noTeam": "no team", + "noDate": "no date", + "noLocation": "no location", + "suggestions": "Suggestions" + }, + "dialog": { + "title": "Schedule order", + "team": "Team", + "date": "Date", + "time": "Start", + "noTime": "No time (all day)", + "duration": "Duration in minutes", + "durationHint": "Prefilled from the order or order type ({duration}).", + "save": "Save", + "saving": "Saving …", + "cancel": "Cancel", + "close": "Close" + }, + "errors": { + "conflict": "The order was changed in the meantime. The view was refreshed – please schedule again.", + "forbidden": "You are not allowed to do this.", + "not_found": "Order not found.", + "invalid": "Please check your input.", + "planned_end_before_start": "The end is before the start.", + "not_schedulable": "This order is already in progress and is not moved here.", + "team_not_found": "Team not found or not active.", + "not_editable": "This order can no longer be changed.", + "network": "No connection. Please try again.", + "generic": "Saving was not possible. Please try again." + }, + "recommend": { + "title": "Suggestions", + "sub": "Suggestions only, by straight-line distance and schedule – you decide the assignment.", + "loading": "Calculating suggestions …", + "reason": "{distance} from {number} · {team} still has {free} free on {day}", + "reasonTight": "{distance} from {number} · {team} on {day} tight: {free} free, {required} needed", + "card": "{day} · {team} · {distance} from {number} · {free} free", + "tight": "tight", + "take": "Use", + "noCoordinates": "Address not located – geocoding was requested. Please open again later.", + "addressNotFound": "Address not located – the address was not found. Please check the site.", + "notSchedulable": "This order is already in progress.", + "none": "No team has an order within {radius} km with free capacity in the next {days} days.", + "nearbyTitle": "Nearby, also unplanned", + "nearbyQuestion": "These orders are within {radius} km – schedule them together?", + "nearbyEmpty": "No other unplanned orders nearby.", + "planTogether": "Schedule together", + "close": "Close suggestions", + "failed": "Suggestions could not be loaded.", + "sectionTitle": "Assignment suggestions", + "openBoard": "Open planning board" + }, + "live": { + "title": "Live situation", + "sub": "Who is currently on which job?", + "privacy": "Location = site of the running order, no GPS tracking.", + "status": { "en_route": "En route", "working": "Working", "paused": "Break", "free": "Free" }, + "freeHint": "no running job", + "since": "since {time}", + "updated": "Last updated: {time}", + "refresh": "Refresh", + "refreshing": "Refreshing …", + "updateFailed": "Update failed – showing the last state.", + "tabsLabel": "Display", + "tabs": { "map": "Map", "list": "List" }, + "filterTeam": "Team", + "filterStatus": "Status", + "all": "All", + "noLocation": "no location", + "hiddenOrder": "Job outside your area", + "next": "Still planned today", + "nothingNext": "Nothing else planned today", + "attentionTitle": "Today without start", + "attentionEmpty": "All of today's orders have started or are planned later.", + "reason": { "not_accepted": "Not accepted", "overdue": "Start time passed" }, + "empty": "No technicians in this selection.", + "markerCount": "{count} people at this location", + "mapLabel": "Map of running jobs", + "mapLoading": "Loading map …", + "withoutLocation": "{count} running jobs without location (list only)", + "noTeam": "no team", + "crewsTitle": "Crews", + "soloTitle": "Without team", + "crewMembers": "Members", + "memberAt": "at {number}", + "delayRisk": "Delay likely · {percent} %", + "delayOverrun": "Duration exceeded · {percent} %", + "worked": "{worked} of {planned} recorded" + }, + "capacity": { + "title": "Team capacity", + "sub": "Basis for utilization and suggestions", + "minutes": "Crew working day in minutes", + "minutesHint": "The crew works together: capacity per working day, not per person.", + "days": "Working days", + "save": "Save", + "saving": "Saving …", + "errors": { + "invalid": "Please check your input.", + "forbidden": "You are not allowed to do this.", + "not_found": "Team not found.", + "generic": "Saving was not possible." + } + }, + "dashboardTile": { + "title": "Conflicts this week", + "hint": "Overbooking, overlap, double booking", + "todayTitle": "Planning today", + "todayHint": "Teams on the job · {conflicts} conflict(s) · {unplanned} unplanned orders" + } +} diff --git a/next.config.ts b/next.config.ts index fe81c08..ee17c7d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,15 @@ import createNextIntlPlugin from "next-intl/plugin"; // zum Dev-Server. Diese Lockerungen gelten NUR in der Entwicklung, nie im Build. const isDev = process.env.NODE_ENV !== "production"; +// L13 Planung: map tiles of the live map (MAP_TILE_URL, default OpenStreetMap). Only the tile host(s) +// are added to img-src — evaluated at build time, so set MAP_TILE_URL before `next build`. +function mapTileSources(): string { + const raw = process.env.MAP_TILE_URL?.trim(); + if (!raw) return "https://tile.openstreetmap.org https://*.tile.openstreetmap.org"; + const m = /^(https?):\/\/([^/?#]+)/i.exec(raw); + return m ? `${m[1].toLowerCase()}://${m[2].replace(/\{[a-z]\}/gi, "*")}` : ""; +} + // Content-Security-Policy (F-07). // // Hinweis Skripte: 'unsafe-inline' ist ein bewusster Zwischenstand. Next.js liefert @@ -16,7 +25,7 @@ const csp = [ // Next.js und Tailwind v4 setzen Inline-Styles. "style-src 'self' 'unsafe-inline'", // data: für den MFA-QR-Code; blob: für clientseitige Foto-Vorschauen (Einsatz-Fotos). - "img-src 'self' data: blob:", + `img-src 'self' data: blob: ${mapTileSources()}`.trim(), // blob: für lokale Wiedergabe von Sprachnotizen vor dem Upload. "media-src 'self' blob:", "font-src 'self' data:", diff --git a/package-lock.json b/package-lock.json index 62be9a7..31746df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@anthropic-ai/sdk": "^0.115.0", "@aws-sdk/client-s3": "^3.1103.0", "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -21,6 +22,7 @@ "clsx": "^2.1.1", "dotenv": "^17.4.2", "ioredis": "^5.11.1", + "leaflet": "^1.9.4", "lucide-react": "^1.23.0", "next": "16.2.12", "next-auth": "5.0.0-beta.32", @@ -38,6 +40,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", "@types/node": "^20", "@types/nodemailer": "^8.0.1", "@types/qrcode": "^1.5.6", @@ -896,6 +899,45 @@ } } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -4832,6 +4874,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4846,6 +4895,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -9567,6 +9626,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 50fd72b..9570f39 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@anthropic-ai/sdk": "^0.115.0", "@aws-sdk/client-s3": "^3.1103.0", "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -30,6 +31,7 @@ "clsx": "^2.1.1", "dotenv": "^17.4.2", "ioredis": "^5.11.1", + "leaflet": "^1.9.4", "lucide-react": "^1.23.0", "next": "16.2.12", "next-auth": "5.0.0-beta.32", @@ -47,6 +49,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", "@types/node": "^20", "@types/nodemailer": "^8.0.1", "@types/qrcode": "^1.5.6", diff --git a/prisma/migrations/20260915120000_planung/migration.sql b/prisma/migrations/20260915120000_planung/migration.sql new file mode 100644 index 0000000..f48a6d3 --- /dev/null +++ b/prisma/migrations/20260915120000_planung/migration.sql @@ -0,0 +1,32 @@ +-- L13 Planung: capacity planning + geocoding cache. Columns only (no new table → RLS unchanged, +-- no TENANT_MODELS change). No personal references (no DSGVO pii-fields change). + +-- AlterTable +ALTER TABLE "order_types" ADD COLUMN "default_duration_minutes" INTEGER; + +-- AlterTable +ALTER TABLE "sites" ADD COLUMN "geocode_query" TEXT, +ADD COLUMN "geocode_status" TEXT, +ADD COLUMN "geocoded_at" TIMESTAMP(3); + +-- AlterTable +-- Team = crew (Kolonne): capacity is the crew's working day in minutes, not person-hours. +ALTER TABLE "teams" ADD COLUMN "daily_capacity_minutes" INTEGER NOT NULL DEFAULT 480, +ADD COLUMN "working_days" INTEGER NOT NULL DEFAULT 31; + +-- AlterTable +ALTER TABLE "work_orders" ADD COLUMN "planned_duration_minutes" INTEGER; + +-- Backfill: default durations of the standard order types (spec §10.2) for existing tenants. +UPDATE "order_types" SET "default_duration_minutes" = CASE "key" + WHEN 'montage' THEN 480 + WHEN 'reparatur' THEN 180 + WHEN 'wartung' THEN 120 + WHEN 'stoerung' THEN 120 + WHEN 'notdienst' THEN 120 + WHEN 'besichtigung' THEN 60 + WHEN 'abnahme' THEN 60 + WHEN 'nacharbeit' THEN 120 + END +WHERE "default_duration_minutes" IS NULL + AND "key" IN ('montage', 'reparatur', 'wartung', 'stoerung', 'notdienst', 'besichtigung', 'abnahme', 'nacharbeit'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0f5a6d..41b4dfa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -472,6 +472,8 @@ model OrderType { active Boolean @default(true) signatureRequired Boolean @default(true) @map("signature_required") sortOrder Int @default(0) @map("sort_order") + // L13 Planung: default duration for capacity planning when the order has no own duration + defaultDurationMinutes Int? @map("default_duration_minutes") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -597,6 +599,11 @@ model Site { status SiteStatus @default(active) latitude Float? longitude Float? + // L13 Planung: geocoding cache (services/geo) — status ok | not_found | failed | skipped, + // geocodeQuery = normalized address the coordinates belong to (change detection) + geocodedAt DateTime? @map("geocoded_at") + geocodeStatus String? @map("geocode_status") + geocodeQuery String? @map("geocode_query") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -627,6 +634,10 @@ model Team { vehicle String? area String? notes String? + // L13 Planung: a team is a crew (Kolonne) working together — capacity = crew working day in minutes + // (not person-hours); workingDays = bit mask Mon=1 … Sun=64 (31 = Mo–Fr) + dailyCapacityMinutes Int @default(480) @map("daily_capacity_minutes") + workingDays Int @default(31) @map("working_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -700,7 +711,9 @@ model WorkOrder { scope String? // Leistungsumfang plannedStart DateTime? @map("planned_start") plannedEnd DateTime? @map("planned_end") - assignedTeamId String? @map("assigned_team_id") + // L13 Planung: explicit duration; fallback plannedStart/End → OrderType.defaultDurationMinutes → 120 + plannedDurationMinutes Int? @map("planned_duration_minutes") + assignedTeamId String? @map("assigned_team_id") teamLeadUserId String? @map("team_lead_user_id") signatureRequired Boolean @default(true) @map("signature_required") billingType String? @map("billing_type") // fixed | time_material | maintenance_contract | warranty diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts index 649e67e..f2a53de 100644 --- a/scripts/craftvia-worker.ts +++ b/scripts/craftvia-worker.ts @@ -18,8 +18,9 @@ async function main() { continue; } const processor = await load(); - const concurrency = name === JOB_QUEUES.reportPdf ? 2 : 4; - const w = new Worker(name, async (job) => processor(job.data), { connection, concurrency }); + const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s + const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4; + const w = new Worker(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) }); w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message)); workers.push(w); console.info(`[worker] listening on ${name}`); diff --git a/scripts/geocode-backfill.ts b/scripts/geocode-backfill.ts new file mode 100644 index 0000000..0d68ab6 --- /dev/null +++ b/scripts/geocode-backfill.ts @@ -0,0 +1,81 @@ +/** + * L13 Planung — geocode sites that have no (or outdated) coordinates, respecting the OSM Nominatim + * usage policy: strictly sequential, max. 1 request per second (process-wide limiter in the + * provider), identifying User-Agent, results cached on the site (unchanged addresses are skipped). + * Manually entered coordinates are never overwritten. + * + * Usage: + * npx tsx scripts/geocode-backfill.ts # all tenants + * npx tsx scripts/geocode-backfill.ts --tenant=demo --limit=50 + * npx tsx scripts/geocode-backfill.ts --dry-run # only list what would be geocoded + * npx tsx scripts/geocode-backfill.ts --retry-not-found # also retry addresses marked not_found + * + * For larger data sets use a self-hosted or contracted geocoding service (GEOCODING_URL). + */ +import "dotenv/config"; +import { isValidLatLng } from "../src/lib/geo/distance"; +import { dbForTenant, prisma } from "../src/server/db"; +import { geocodingConfig } from "../src/server/services/geo/config"; +import { geocodeSite, type GeocodeOutcome } from "../src/server/services/geo/geocode-site"; +import { geocodeQueryFor } from "../src/server/services/geo/normalize"; + +const arg = (name: string) => process.argv.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]; +const flag = (name: string) => process.argv.includes(`--${name}`); + +async function main() { + const cfg = geocodingConfig(); + const dryRun = flag("dry-run"); + const retryNotFound = flag("retry-not-found"); + const limit = Number(arg("limit") ?? Infinity); + const slug = arg("tenant"); + if (cfg.provider === "none" && !dryRun) { + console.log("GEOCODING_PROVIDER=none — nichts zu tun."); + return; + } + console.log(`Provider ${cfg.provider} (${cfg.url}), User-Agent "${cfg.userAgent}", ≤ 1 Anfrage/s${dryRun ? " — Probelauf" : ""}`); + + const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true }, orderBy: { slug: "asc" } }); + const totals: Record = {}; + let done = 0; + for (const tenant of tenants) { + const db = dbForTenant(tenant.id); + const sites = await db.site.findMany({ + where: { deletedAt: null }, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, country: true, latitude: true, longitude: true, geocodedAt: true, geocodeStatus: true, geocodeQuery: true }, + orderBy: { createdAt: "asc" }, + }); + const pending = sites.filter((s) => { + const query = geocodeQueryFor(s); + if (!query) return false; + if (isValidLatLng(s) && !s.geocodedAt) return false; // manual coordinates + if (s.geocodeQuery !== query) return true; + if (s.geocodeStatus === "ok") return false; + if (s.geocodeStatus === "not_found") return retryNotFound; + return true; + }); + if (!pending.length) continue; + console.log(`\n${tenant.slug}: ${pending.length} Objekt(e)`); + for (const site of pending) { + if (done >= limit) break; + done++; + if (dryRun) { + console.log(` · ${site.name} (${[site.postalCode, site.city].filter(Boolean).join(" ")})`); + continue; + } + if (retryNotFound && site.geocodeStatus === "not_found") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: null } }); + const outcome: GeocodeOutcome = await geocodeSite(tenant.id, site.id); + totals[outcome] = (totals[outcome] ?? 0) + 1; + console.log(` ${outcome.padEnd(9)} ${site.name}`); + } + } + console.log(`\nFertig: ${done} Objekt(e) ${JSON.stringify(totals)}`); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index d2fab2e..b7a802a 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -102,6 +102,17 @@ function plans(x: Awaited>): Plan[] { { path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true }, { path: "/teams" }, { path: "/settings/users", expect: [200, 307, 403] }, + // L13 Planung + { path: "/planning", mustContain: ["Plantafel", 'data-planning-view="days"', 'data-day-count="5"', "Heute", "Team Nord", "Team Süd", "Ungeplante Aufträge"], perf: true }, + { path: "/planning?view=today", mustContain: ['data-planning-view="today"', "Ganztägig", "Team Nord", "Team Süd"] }, + { path: "/planning?view=week", mustContain: ['data-planning-view="week"', 'data-day-count="7"'] }, + { path: "/dashboard", mustContain: ["Planung heute", "Teams im Einsatz"] }, + { path: `/planning?schedule=${x.d07.id}`, mustContain: "Plantafel" }, + { path: "/planning/live", mustContain: ["Live-Lage", "keine GPS-Ortung", "OpenStreetMap"], mustNotContain: ["startLat", "startLng"], perf: true }, + { path: "/api/v1/planning/board", mustContain: ['"teams"', '"conflictCount"'] }, + { path: "/api/v1/planning/live", mustContain: '"technicians"', mustNotContain: ["startLat", "startLng", "deviceInfo"] }, + { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, mustContain: '"recommendations"' }, + { path: "/dashboard", mustContain: "Konflikte diese Woche" }, ], }, { @@ -120,6 +131,9 @@ function plans(x: Awaited>): Plan[] { { path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] }, { path: "/m/approvals", mustContain: "Zeiten freigeben" }, { path: "/m/profile", mustContain: "Zeiten freigeben" }, + // L13 Planung: team lead read-only, own team only + { path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] }, + { path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] }, ], }, { @@ -155,6 +169,8 @@ function plans(x: Awaited>): Plan[] { { path: m(x.d08), expect: [404] }, { path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] }, { path: "/dashboard", expect: [307, 308], redirectTo: "/m" }, + { path: "/planning", expect: [404] }, + { path: "/planning/live", expect: [404] }, ], }, { @@ -181,6 +197,9 @@ function plans(x: Awaited>): Plan[] { ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []), ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []), ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []), + { path: "/planning", mustNotContain: ["Team Nord", x.d07.number] }, + { path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] }, + { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] }, ], }, ]; diff --git a/scripts/test-planung-core.ts b/scripts/test-planung-core.ts new file mode 100644 index 0000000..07a88b1 --- /dev/null +++ b/scripts/test-planung-core.ts @@ -0,0 +1,302 @@ +// L13 Planung — Kern: Haversine, Arbeitstage/Kapazität (Mitgliedschaft gültig ab/bis), Auslastung, +// Konflikte (overbooked, overlap, assignee_double_booked, outside_working_days), Plantafel mit Rollen +// und Mandantentrennung, scheduleWorkOrder (Version, Scope, Recht, Event, Audit, atomar). +// Lauf: npx tsx scripts/test-planung-core.ts (eigene zz-Mandanten, räumt auf) + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; +import { formatDistance, haversineKm, isValidLatLng } from "../src/lib/geo/distance"; +import { addDays, dayBounds, dayKeyOf, isDayKey, isWorkingDay, startOfWeek, weekdayIndex } from "../src/lib/planning/days"; +import { + activeMemberCount, + assigneeDoubleBookings, + computeTeamDay, + effectiveDurationMinutes, + estimateTravelMinutes, + isConflict, + minutesOnDay, + overrunBucket, + teamCapacityMinutes, + unionMinutes, + utilizationPercent, + type PlanningOrder, +} from "../src/lib/planning/capacity"; +import { addWorkingDays, nextWorkingDays } from "../src/lib/planning/days"; +import { formatDayShort, formatMinutes } from "../src/lib/planning/text"; +import { wallTimeToUtc } from "../src/lib/work-orders/time"; +import { ServiceError } from "../src/server/services/context"; +import { countWeekConflicts, getPlanningBoard } from "../src/server/services/planning/board"; +import { scheduleWorkOrder, VERSION_CONFLICT_MESSAGE } from "../src/server/services/planning/schedule"; +import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { ensureDefaultOrderTypes } from "../src/server/services/work-orders/settings"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-core-a"; +const SLUG_B = "zz-planung-core-b"; +const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!; +const po = (p: Partial & { id: string }): PlanningOrder => ({ + teamId: null, + plannedStart: null, + plannedEnd: null, + plannedDurationMinutes: null, + orderTypeDefaultMinutes: null, + assigneeIds: [], + ...p, +}); + +runSuite("L13 Planung – Kern", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; + + section("Haversine & Formatierung"); + const hh = { latitude: 53.5503, longitude: 9.992 }; + const ber = { latitude: 52.5163, longitude: 13.3777 }; + const d = haversineKm(hh, ber); + ok(d > 250 && d < 262, `Hamburg–Berlin ≈ 255 km (${d.toFixed(1)} km)`); + ok(haversineKm(hh, hh) === 0, "gleicher Punkt → 0 km"); + ok(Math.abs(haversineKm(hh, ber) - haversineKm(ber, hh)) < 1e-9, "symmetrisch"); + const eq = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 }); + ok(Math.abs(eq - 111.195) < 0.1, `1° Länge am Äquator ≈ 111,2 km (${eq.toFixed(3)})`); + ok(formatDistance(3.24, "de") === "3,2 km" && formatDistance(0.85, "de") === "850 m" && formatDistance(3.24, "en") === "3.2 km", "„3,2 km“ / „850 m“ / en „3.2 km“"); + ok(!isValidLatLng({ latitude: null, longitude: 9 }) && !isValidLatLng({ latitude: 91, longitude: 0 }) && isValidLatLng(hh), "Koordinaten-Validierung"); + ok(formatMinutes(240, "de") === "4 h" && formatMinutes(270, "de") === "4,5 h" && formatMinutes(45, "de") === "45 min", "Dauer „4 h“ / „4,5 h“ / „45 min“"); + ok(formatDayShort("2026-09-15", "de") === "Di 15.09.", `Tag kurz „Di 15.09.“ (${formatDayShort("2026-09-15", "de")})`); + + section("Tage, Dauer, Kapazität, Konflikte (reine Regeln)"); + const mon = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14)); + const tue = addDays(mon, 1); + const wed = addDays(mon, 2); + const sat = addDays(mon, 5); + ok(weekdayIndex(mon) === 0 && weekdayIndex(addDays(mon, 6)) === 6, "Wochentag Mo = 0 … So = 6"); + ok(isWorkingDay(31, mon) && !isWorkingDay(31, sat) && isWorkingDay(31 | 32, sat), "Arbeitstage-Bitmaske (Mo–Fr, mit Sa)"); + ok(isDayKey("2026-02-28") && !isDayKey("2026-02-30") && !isDayKey("28.02.2026"), "Tagesschlüssel validiert"); + const dst = dayBounds("2026-10-25", TZ); + ok(dst.end.getTime() - dst.start.getTime() === 25 * 3600_000, "Zeitumstellung: 25-Stunden-Tag"); + + ok(effectiveDurationMinutes(po({ id: "a", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "09:00"), plannedDurationMinutes: 90, orderTypeDefaultMinutes: 480 }), TZ) === 90, "Dauer: explizit vor Beginn/Ende und Auftragsart"); + ok(effectiveDurationMinutes(po({ id: "b", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "11:30"), orderTypeDefaultMinutes: 480 }), TZ) === 210, "Dauer: aus Beginn/Ende (3,5 h)"); + ok(effectiveDurationMinutes(po({ id: "c", plannedStart: at(mon, "08:00"), orderTypeDefaultMinutes: 180 }), TZ) === 180, "Dauer: Standard der Auftragsart"); + ok(effectiveDurationMinutes(po({ id: "d", plannedStart: at(mon, "08:00") }), TZ) === 120, "Dauer: Fallback 120 min"); + const multi = po({ id: "e", plannedStart: at(mon, "08:00"), plannedEnd: at(tue, "16:00") }); + ok(minutesOnDay(multi, mon, TZ) === 480 && minutesOnDay(multi, tue, TZ) === 480 && minutesOnDay(multi, wed, TZ) === 0, "mehrtägig ohne Dauer: je Tag 480 min"); + ok(minutesOnDay({ ...multi, plannedDurationMinutes: 600 }, tue, TZ) === 300, "mehrtägig mit Dauer: gleichmäßig verteilt"); + const dateOnly = po({ id: "f", plannedStart: at(mon, "00:00"), plannedEnd: at(tue, "00:00") }); + ok(minutesOnDay(dateOnly, mon, TZ) === 120 && minutesOnDay(dateOnly, tue, TZ) === 0, "Ende um Mitternacht gehört zum Vortag"); + + const longAgo = new Date(Date.now() - 400 * 86400_000); + const team = { + id: "t", + dailyCapacityMinutes: 480, + workingDays: 31, + members: [ + { userId: "u1", validFrom: longAgo, validTo: null, active: true }, + { userId: "u2", validFrom: longAgo, validTo: dayBounds(mon, TZ).start, active: true }, // endet vor Montag + { userId: "u3", validFrom: dayBounds(tue, TZ).start, validTo: null, active: true }, // ab Dienstag + { userId: "u4", validFrom: longAgo, validTo: null, active: false }, // deaktiviert + { userId: "u1", validFrom: longAgo, validTo: null, active: true }, // doppelte Zeile zählt einmal + ], + }; + ok(activeMemberCount(team, mon, TZ) === 1 && activeMemberCount(team, tue, TZ) === 2, "aktive Mitglieder: gültig ab/bis, inaktiv, doppelt (Mo 1, Di 2)"); + ok(teamCapacityMinutes(team, mon, TZ) === 480 && teamCapacityMinutes(team, tue, TZ) === 480, "Kolonnenkapazität = Arbeitstag der Kolonne (480), nicht × Personen"); + ok(teamCapacityMinutes(team, sat, TZ) === 0, "Kapazität Sa: kein Arbeitstag → 0"); + ok(teamCapacityMinutes({ ...team, members: [] }, tue, TZ) === 0, "Kolonne ohne aktive Mitglieder → 0"); + ok(utilizationPercent(360, 960) === 38 && utilizationPercent(60, 0) === null, "Auslastung 6 h / 16 h = 38 %; ohne Kapazität null"); + const hintMon = computeTeamDay(team, mon, [], TZ).conflicts.find((c) => c.kind === "crew_incomplete"); + ok(!!hintMon && hintMon.severity === "hint" && hintMon.memberCount === 1, "crew_incomplete: Hinweis bei weniger als 2 aktiven Personen"); + ok(!computeTeamDay(team, tue, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete") && !computeTeamDay(team, sat, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete"), "kein Hinweis bei 2 Personen bzw. an Nicht-Arbeitstagen"); + ok(nextWorkingDays("2026-09-18", 5).join(",") === "2026-09-18,2026-09-21,2026-09-22,2026-09-23,2026-09-24" && nextWorkingDays("2026-09-19", 2).join(",") === "2026-09-19,2026-09-21", "Standardansicht: heute + nächste Werktage (Wochenende übersprungen)"); + ok(addWorkingDays("2026-09-21", -5) === "2026-09-14" && addWorkingDays("2026-09-18", 1) === "2026-09-21", "Werktage vor/zurück"); + + const crew = { ...team, dailyCapacityMinutes: 960 }; + const orders = [ + po({ id: "o1", teamId: "t", plannedStart: at(tue, "08:00"), plannedEnd: at(tue, "12:00") }), + po({ id: "o2", teamId: "t", plannedStart: at(tue, "11:00"), plannedEnd: at(tue, "13:00") }), + po({ id: "o3", teamId: "t", plannedStart: at(tue, "13:00"), plannedDurationMinutes: 600 }), + ]; + const td = computeTeamDay(crew, tue, orders, TZ); + ok(td.plannedMinutes === 960 && td.capacityMinutes === 960 && td.utilization === 100, "Tageslast 16 h bei 16 h Kolonnenkapazität = 100 %"); + ok(!td.conflicts.some((c) => c.kind === "overbooked"), "genau ausgelastet ist nicht überbucht"); + const overlaps = td.conflicts.filter((c) => c.kind === "overlap"); + ok(overlaps.length === 1 && overlaps[0].orderIds.includes("o1") && overlaps[0].orderIds.includes("o2"), "Überschneidung o1/o2; o3 beginnt genau am Ende von o2 → keine"); + const td2 = computeTeamDay(crew, tue, [...orders, po({ id: "o4", teamId: "t", plannedStart: at(tue, "00:00"), plannedDurationMinutes: 30 })], TZ); + ok(td2.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 30), "überbucht um 30 min"); + ok(td2.conflicts.filter((c) => c.kind === "overlap").length === 1, "Auftrag ohne Uhrzeit erzeugt keine Überschneidung"); + const tdSat = computeTeamDay(crew, sat, [po({ id: "o5", teamId: "t", plannedStart: at(sat, "09:00") })], TZ); + ok(tdSat.conflicts.length === 1 && tdSat.conflicts[0].kind === "outside_working_days", "Auftrag am Samstag → outside_working_days (nicht zusätzlich überbucht)"); + const dbl = assigneeDoubleBookings( + [ + po({ id: "x1", teamId: "t", plannedStart: at(tue, "09:00"), plannedEnd: at(tue, "10:00"), assigneeIds: ["u1"] }), + po({ id: "x2", teamId: "s", plannedStart: at(tue, "09:30"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u1"] }), + po({ id: "x3", teamId: "s", plannedStart: at(tue, "10:00"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u3"] }), + po({ id: "x4", teamId: "s", plannedStart: at(wed, "09:00"), plannedEnd: at(wed, "11:00"), assigneeIds: ["u1"] }), + ], + tue, + TZ, + ); + ok(dbl.length === 1 && dbl[0].userId === "u1" && dbl[0].teamIds.length === 2, "Person in zwei Kolonnen gleichzeitig → assignee_double_booked (nur überlappende Zeitfenster)"); + + section("Kolonnenzeit, Fahrzeit, Überschreitungsstufen (rein)"); + const hm = (v: string) => at(tue, v).getTime(); + ok(unionMinutes([{ start: hm("08:00"), end: hm("10:00") }, { start: hm("09:00"), end: hm("11:00") }, { start: hm("12:00"), end: hm("13:00") }]) === 240, "Arbeitssegmente zeitlich vereinigt (3 h + 1 h), nicht über Personen summiert"); + ok(unionMinutes([]) === 0 && unionMinutes([{ start: hm("10:00"), end: hm("09:00") }]) === 0, "leere/ungültige Segmente → 0"); + ok(estimateTravelMinutes(null) === 10 && estimateTravelMinutes(1) === 10 && estimateTravelMinutes(50) === 78 && estimateTravelMinutes(20) === 31, "Fahrzeit = Luftlinie × 1,3 / 50 km/h, mind. 10 min (50 km → 78 min)"); + ok(overrunBucket(0.99) === -1 && overrunBucket(1) === 0 && overrunBucket(1.49) === 0 && overrunBucket(1.5) === 1 && overrunBucket(2) === 2, "Meldestufen: 100 %, danach je weitere +50 %"); + + section("Plantafel (Datenbank)"); + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + await ensureDefaultOrderTypes(bo.db, A.tenantId); + const montage = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "montage" } }); + const besichtigung = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "besichtigung" } }); + ok(montage.defaultDurationMinutes === 480 && besichtigung.defaultDurationMinutes === 60, "Standard-Auftragsarten mit Dauer (Montage 480, Besichtigung 60)"); + + const mk = (title: string, extra: Record = {}) => createWorkOrder(bo, { title, customerId: A.customerId, siteId: A.siteId, ...extra }); + const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version; + const place = async (id: string, teamId: string, start: Date, extra: { plannedEnd?: Date | null; plannedDurationMinutes?: number | null } = {}) => + scheduleWorkOrder(bo, { workOrderId: id, teamId, plannedStart: start, baseVersion: await version(id), ...extra }); + + const w1 = await mk("Planung 1"); + await assignWorkOrder(bo, { workOrderId: w1.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + await place(w1.id, A.teamId, at(mon, "08:00"), { plannedEnd: at(mon, "12:00") }); + const w2 = await mk("Planung 2"); + await place(w2.id, A.teamId, at(mon, "10:00"), { plannedEnd: at(mon, "14:00") }); + const w3 = await mk("Planung 3"); + await place(w3.id, A.teamId, at(mon, "14:00"), { plannedDurationMinutes: 600 }); + const w4 = await mk("Planung Samstag"); + await place(w4.id, A.team2Id, at(sat, "09:00"), { plannedEnd: at(sat, "11:00") }); + const w5 = await mk("Planung Süd mit Max", { priority: "high" }); + await assignWorkOrder(bo, { workOrderId: w5.id, teamId: A.team2Id, userIds: [A.users.tech.id] }); + await place(w5.id, A.team2Id, at(mon, "09:00"), { plannedEnd: at(mon, "11:00") }); + const draft = await mk("Noch ungeplant"); + + const board = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6) }); + const nord = board.teams.find((t) => t.id === A.teamId)!; + const sued = board.teams.find((t) => t.id === A.team2Id)!; + const nMon = nord.days.find((x) => x.day === mon)!; + ok(board.days.length === 7 && board.timeZone === TZ, "Woche Mo–So in Mandanten-Zeitzone"); + ok(nMon.capacityMinutes === 480 && nMon.plannedMinutes === 1080 && nMon.utilization === 225 && nMon.memberCount === 2, `Kolonne Nord Mo: 18 h / 8 h · 225 % (${nMon.plannedMinutes}/${nMon.capacityMinutes}/${nMon.utilization})`); + ok(nMon.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 600), "Kolonne Nord Mo überbucht um 10 h"); + ok( + !nMon.conflicts.some((c) => c.kind === "crew_incomplete") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "crew_incomplete" && c.severity === "hint" && c.memberCount === 1), + "Hinweis „Kolonne unvollständig“ nur bei Team Süd (1 Person)", + ); + ok(nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w1.id) && c.orderIds.includes(w2.id)), "Überschneidung zweier Aufträge desselben Teams"); + ok(!nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w3.id)), "anschließender Auftrag (14:00) überschneidet nicht"); + ok(sued.days.find((x) => x.day === sat)!.conflicts.some((c) => c.kind === "outside_working_days" && c.orderIds.includes(w4.id)), "Samstag bei Team Süd → außerhalb der Arbeitstage"); + const dblC = board.conflicts.find((c) => c.kind === "assignee_double_booked"); + ok(!!dblC && dblC.userId === A.users.tech.id && dblC.orderIds.includes(w1.id) && dblC.orderIds.includes(w5.id) && dblC.teamIds.length === 2, "Monteur Nord + Einzelzuweisung Süd gleichzeitig → doppelt eingeplant"); + ok( + nMon.conflicts.some((c) => c.kind === "assignee_double_booked") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "assignee_double_booked"), + "Doppelbuchung in beiden Teamzeilen sichtbar", + ); + ok(board.orders[w1.id]?.conflictKinds.includes("overlap") && board.orders[w1.id].conflictKinds.includes("assignee_double_booked"), "Auftragskarte trägt ihre Konfliktarten"); + ok(nMon.orderIds.length === 3 && board.orders[w3.id].durationMinutes === 600 && board.orders[w1.id].assigneeIds.includes(A.users.tech.id), "Zelle zeigt Aufträge mit Dauer und Einzelzuweisung"); + ok(nord.days.find((x) => x.day === tue)!.utilization === 0 && nord.members.length === 2, "Di ohne Aufträge: 0 %; Mitglieder aufklappbar (2)"); + ok(board.unplanned.some((o) => o.id === draft.id) && !board.unplanned.some((o) => o.id === w1.id), "Seitenleiste: ungeplanter Entwurf ja, eingeplanter Auftrag nein"); + ok(board.conflictCount >= 4 && board.conflictCount === board.conflicts.filter(isConflict).length && board.conflicts.some((c) => c.severity === "hint"), `Konfliktzähler ohne Hinweise (${board.conflictCount})`); + ok(!board.orders[w4.id].conflictKinds.includes("crew_incomplete"), "Hinweise färben keine Auftragskarte rot"); + ok(board.access.all && board.access.canSchedule, "Backoffice: alle Teams, darf einplanen"); + + await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: dayBounds(tue, TZ).start } }); + const b2 = await getPlanningBoard(bo, { from: mon, to: tue, teamIds: [A.teamId] }); + ok( + b2.teams.length === 1 && b2.teams[0].days[0].memberCount === 2 && b2.teams[0].days[1].memberCount === 1 && b2.teams[0].days[1].capacityMinutes === 480 && b2.teams[0].days[1].conflicts.some((c) => c.kind === "crew_incomplete"), + "Mitgliedschaft „gültig bis Di“ → Di 1 Person: Kapazität bleibt, Hinweis Kolonne unvollständig", + ); + await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: null } }); + + const settings = await updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 360, workingDays: 63 }); + const b3 = await getPlanningBoard(bo, { from: sat, to: sat, teamIds: [A.team2Id] }); + ok(settings.workingDays === 63 && b3.teams[0].days[0].capacityMinutes === 360 && !b3.teams[0].days[0].conflicts.some((c) => c.kind === "outside_working_days"), "Kolonnenkapazität 6 h + Samstag als Arbeitstag wirkt sofort"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "team", entityId: A.team2Id, action: "update" } })) === 1, "Kolonnenkapazität auditiert"); + await expectCode(() => updateTeamPlanningSettings(A.ctx.lead, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "forbidden", "Kolonnenkapazität: Teamleiter → forbidden"); + await expectCode(() => updateTeamPlanningSettings(B.ctx.admin, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "not_found", "Kolonnenkapazität: Mandant B → not_found"); + await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 480, workingDays: 200 }), "invalid", "Kolonnenkapazität: ungültige Bitmaske → invalid"); + + const bf = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6), priority: "high" }); + ok(!!bf.orders[w5.id] && !bf.orders[w1.id] && bf.teams.find((t) => t.id === A.teamId)!.days[0].plannedMinutes === 1080, "Filter Priorität blendet Karten aus, Auslastung bleibt vollständig"); + ok(!bf.unplanned.some((o) => o.id === draft.id), "Filter wirkt auch auf die Seitenleiste"); + + section("Plantafel: Rollen & Mandantentrennung"); + const leadBoard = await getPlanningBoard(A.ctx.lead, { from: mon, to: addDays(mon, 6) }); + ok(leadBoard.teams.length === 1 && leadBoard.teams[0].id === A.teamId && !leadBoard.access.canSchedule, "Teamleiter: nur eigenes Team, nur lesend"); + ok(!!leadBoard.orders[w1.id] && !leadBoard.orders[w5.id] && !leadBoard.unplanned.some((o) => o.id === draft.id), "Teamleiter: keine Aufträge anderer Teams, keine teamlosen Aufträge"); + await expectCode(() => getPlanningBoard(A.ctx.tech, { from: mon, to: mon }), "forbidden", "Monteur → forbidden"); + const bB = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: addDays(mon, 6) }); + ok( + bB.teams.every((t) => t.id !== A.teamId && t.id !== A.team2Id) && Object.keys(bB.orders).length === 0 && !bB.unplanned.some((o) => [w1.id, draft.id].includes(o.id)), + "Mandant B sieht keine Teams und Aufträge von A", + ); + const bBforeign = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: mon, teamIds: [A.teamId] }); + ok(bBforeign.teams.length === 0, "Mandant B: fremde Team-ID liefert nichts"); + await expectCode(() => getPlanningBoard(bo, { from: mon, to: addDays(mon, 60) }), "invalid", "Zeitraum > 42 Tage → invalid"); + await expectCode(() => getPlanningBoard(bo, { from: tue, to: mon }), "invalid", "Ende vor Beginn → invalid"); + ok((await countWeekConflicts(B.ctx.backoffice))?.count === 0, "Dashboard-Zähler Mandant B: 0 Konflikte diese Woche"); + ok((await countWeekConflicts(A.ctx.tech)) === null, "Dashboard-Zähler für Monteur: keine Kachel"); + + section("scheduleWorkOrder"); + const s1 = await mk("Einplanen Entwurf"); + const s1before = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } }); + const r1 = await scheduleWorkOrder(bo, { workOrderId: s1.id, teamId: A.teamId, plannedStart: at(mon, "15:00"), plannedDurationMinutes: 60, baseVersion: s1before.version }); + const s1after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } }); + ok( + s1after.status === "assigned" && s1after.assignedTeamId === A.teamId && s1after.plannedStart?.getTime() === at(mon, "15:00").getTime() && s1after.plannedDurationMinutes === 60, + "Entwurf → zugewiesen mit Team, Beginn und Dauer", + ); + ok(r1.version === s1after.version && s1after.version > s1before.version, "neue Version zurückgegeben"); + ok(r1.conflicts.some((c) => c.kind === "overbooked") && r1.conflicts.every((c) => c.teamIds.includes(A.teamId)), "Antwort enthält Konflikte des Zielteams am Zieltag"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, entityId: s1.id, type: "work_order.assigned" } })) > 0, "Event work_order.assigned → Benachrichtigung ans Team"); + const audits = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s1.id } }); + const planningAudit = audits.find((a) => (a.after as { source?: string } | null)?.source === "planning"); + ok(!!planningAudit && planningAudit.actorId === A.users.backoffice.id && (planningAudit.before as { assignedTeamId?: string | null }).assignedTeamId === null, "Audit „planning“ mit before/after und Akteur"); + + const s2 = await mk("Verschieben"); + await place(s2.id, A.teamId, at(tue, "08:00"), { plannedEnd: at(tue, "10:30") }); + const v2 = await version(s2.id); + await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.team2Id, plannedStart: at(wed, "13:00"), baseVersion: v2 }); + const s2after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } }); + ok(s2after.assignedTeamId === A.team2Id && s2after.plannedEnd?.getTime() === at(wed, "15:30").getTime(), "Verschieben in anderes Team/Tag behält die Länge (2,5 h)"); + + let conflictErr: unknown = null; + try { + await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: v2 }); + } catch (err) { + conflictErr = err; + } + ok(conflictErr instanceof ServiceError && conflictErr.code === "conflict" && conflictErr.message === VERSION_CONFLICT_MESSAGE, "veraltete Version → conflict „Auftrag wurde zwischenzeitlich geändert“"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } })).assignedTeamId === A.team2Id, "bei Konflikt nichts überschrieben"); + + const s3 = await mk("Atomar"); + await expectCode( + () => scheduleWorkOrder(bo, { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), plannedEnd: at(tue, "09:00"), baseVersion: s3.version }), + "invalid", + "Ende vor Beginn → invalid (Prüfung im Auftragsservice nach der Zuweisung)", + ); + const s3after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } }); + ok(s3after.status === "draft" && s3after.assignedTeamId === null && s3after.version === s3.version, "atomar: Zuweisung zurückgerollt (Entwurf, ohne Team, gleiche Version)"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s3.id, action: "update" } })) === 0, "atomar: kein Audit aus dem zurückgerollten Versuch"); + ok((await prisma.workOrderStatusChange.count({ where: { tenantId: A.tenantId, workOrderId: s3.id, toStatus: "assigned" } })) === 0, "atomar: keine Statushistorie „zugewiesen“"); + + const s3input = { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), baseVersion: s3.version }; + await expectCode(() => scheduleWorkOrder(A.ctx.lead, s3input), "forbidden", "Teamleiter darf nicht einplanen"); + await expectCode(() => scheduleWorkOrder(A.ctx.tech, s3input), "forbidden", "Monteur darf nicht einplanen"); + await expectCode(() => scheduleWorkOrder(B.ctx.backoffice, s3input), ["not_found", "invalid"], "Mandant B: Auftrag von A → abgewiesen"); + const bOrder = await createWorkOrder(B.ctx.backoffice, { title: "B-Auftrag", customerId: B.customerId, siteId: B.siteId }); + await expectCode( + () => scheduleWorkOrder(B.ctx.backoffice, { workOrderId: bOrder.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: bOrder.version }), + "invalid", + "Mandant B: Team von A → invalid (team_not_found)", + ); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } })).status === "draft", "Auftrag von A unverändert"); + await expectCode(() => scheduleWorkOrder(bo, { ...s3input, plannedStart: "kein Datum" }), "invalid", "ungültiges Datum → invalid"); + + const s4 = await mk("Läuft schon"); + await place(s4.id, A.teamId, at(tue, "08:00")); + await transitionWorkOrder(A.ctx.tech, { workOrderId: s4.id, to: "in_progress" }); + const v4 = await version(s4.id); + await expectCode(() => scheduleWorkOrder(bo, { workOrderId: s4.id, teamId: A.team2Id, plannedStart: at(wed, "08:00"), baseVersion: v4 }), "invalid", "laufender Auftrag → invalid (not_schedulable)"); +}); diff --git a/scripts/test-planung-geocode.ts b/scripts/test-planung-geocode.ts new file mode 100644 index 0000000..8f834dc --- /dev/null +++ b/scripts/test-planung-geocode.ts @@ -0,0 +1,178 @@ +// L13 Planung — Geocoding: Normalisierung/Änderungserkennung, Nominatim-Anfrage (URL, User-Agent, +// Accept-Language, Auswertung, Fehler) mit injiziertem fetch, Drosselung 1/s mit Fake-Uhr, Processor mit +// Fake-Provider (Cache bei unveränderter Adresse, not_found, failed → Retry, manuelle Koordinaten, +// Provider none, Mandantentrennung), Hook beim Anlegen ohne Inline-Aufruf. Nominatim wird NIE aufgerufen. +// Lauf: npx tsx scripts/test-planung-geocode.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture"; +import { geocodingConfig, mapConfig } from "../src/server/services/geo/config"; +import { requestSiteGeocoding } from "../src/server/services/geo/dispatch"; +import { geocodeSite } from "../src/server/services/geo/geocode-site"; +import { buildNominatimUrl, createNominatimProvider } from "../src/server/services/geo/nominatim"; +import { addressChanged, geocodeQueryFor } from "../src/server/services/geo/normalize"; +import { GeocodingError, setGeocodingProvider, type GeocodeAddress, type GeocodingProvider } from "../src/server/services/geo/provider"; +import { createIntervalLimiter } from "../src/server/services/geo/rate-limit"; +import { process as geocodeProcessor } from "../src/server/jobs/processors/geocode-site"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { createSite } from "../src/server/services/sites/sites"; + +const SLUG_A = "zz-planung-geo-a"; +const SLUG_B = "zz-planung-geo-b"; + +// Guard: any request to a Nominatim host fails the suite. +let nominatimHits = 0; +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (/nominatim|openstreetmap/i.test(url)) { + nominatimHits++; + throw new Error("Nominatim must never be called from tests"); + } + return realFetch(input, init); +}) as typeof fetch; + +function fakeProvider(answer: (a: GeocodeAddress) => { status: "ok"; latitude: number; longitude: number } | { status: "not_found" } | "throw") { + const calls: GeocodeAddress[] = []; + const provider: GeocodingProvider = { + name: "fake", + async geocode(a) { + calls.push(a); + const r = answer(a); + if (r === "throw") throw new GeocodingError("http 503"); + return r; + }, + }; + return { provider, calls }; +} + +runSuite("L13 Planung – Geocoding", [SLUG_A, SLUG_B], async () => { + section("Normalisierung & Änderungserkennung"); + const addr = { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }; + ok(geocodeQueryFor(addr) === "hafenstraße 12|20457|hamburg|de", `normierte Adresse (${geocodeQueryFor(addr)})`); + ok(geocodeQueryFor({ ...addr, street: " HAFENSTRASSE ".replace("SS", "ß"), city: "hamburg " }) === geocodeQueryFor(addr), "Groß-/Kleinschreibung und Leerzeichen egal"); + ok(geocodeQueryFor({ street: "Hafenstraße", houseNumber: "12" }) === null, "ohne PLZ und Ort → nicht verortbar"); + ok(!addressChanged(addr, { ...addr, city: "HAMBURG" }) && addressChanged(addr, { ...addr, houseNumber: "14" }), "nur relevante Adressänderungen zählen"); + + section("Nominatim-Anfrage (ohne Netz)"); + const url = buildNominatimUrl("https://nominatim.example/", addr); + ok( + url.pathname === "/search" && url.searchParams.get("format") === "jsonv2" && url.searchParams.get("limit") === "1" && url.searchParams.get("street") === "12 Hafenstraße" && url.searchParams.get("postalcode") === "20457" && url.searchParams.get("city") === "Hamburg" && url.searchParams.get("countrycodes") === "de", + `strukturierte Suche (${url.search})`, + ); + const cfg = geocodingConfig({ APP_BASE_URL: "https://app.craftvia.example" }); + ok(/^Craftvia\/\d+\.\d+\.\d+ \(\+https:\/\/app\.craftvia\.example\)$/.test(cfg.userAgent) && cfg.url === "https://nominatim.openstreetmap.org" && cfg.provider === "nominatim", `Standard-User-Agent „${cfg.userAgent}“`); + ok(geocodingConfig({ GEOCODING_USER_AGENT: "Betrieb/1 (+mailto:it@example.org)", GEOCODING_PROVIDER: "none", GEOCODING_URL: "https://geo.example/" }).userAgent === "Betrieb/1 (+mailto:it@example.org)", "User-Agent per GEOCODING_USER_AGENT überschreibbar"); + ok(geocodingConfig({ GEOCODING_PROVIDER: "none" }).provider === "none" && geocodingConfig({ GEOCODING_URL: "https://geo.example/" }).url === "https://geo.example", "Provider none / eigene GEOCODING_URL"); + ok(mapConfig({}).tileUrl === "https://tile.openstreetmap.org/{z}/{x}/{y}.png" && mapConfig({}).attribution.includes("OpenStreetMap"), "Kartenkacheln: OSM-Standard mit Namensnennung"); + + const seen: { url: string; headers: Headers }[] = []; + let reply: () => Response = () => Response.json([{ lat: "53.5413", lon: "9.9849" }]); + const acquired: number[] = []; + const nominatim = createNominatimProvider( + { url: "https://nominatim.example", userAgent: "Craftvia/test (+https://app.example)", timeoutMs: 1000 }, + { + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), headers: new Headers(init?.headers) }); + return reply(); + }) as typeof fetch, + limiter: { acquire: async () => (acquired.push(Date.now()), Date.now()) }, + }, + ); + const hit = await nominatim.geocode(addr); + ok(hit.status === "ok" && hit.latitude === 53.5413 && hit.longitude === 9.9849, "Treffer ausgewertet"); + ok(seen[0]?.headers.get("user-agent") === "Craftvia/test (+https://app.example)" && seen[0].headers.get("accept-language") === "de", "User-Agent und Accept-Language de gesetzt"); + ok(acquired.length === 1, "jede Anfrage läuft über den Drosselungs-Limiter"); + reply = () => Response.json([]); + ok((await nominatim.geocode(addr)).status === "not_found", "leere Antwort → not_found"); + reply = () => new Response("busy", { status: 503 }); + let threw = false; + try { + await nominatim.geocode(addr); + } catch (err) { + threw = err instanceof GeocodingError; + } + ok(threw, "HTTP-Fehler → GeocodingError (wiederholbar)"); + + section("Drosselung 1 Anfrage/s (Fake-Uhr)"); + let clock = 10_000; + const sleeps: number[] = []; + const limiter = createIntervalLimiter(1000, { now: () => clock, sleep: async (ms) => void sleeps.push(ms) }); + const slots = [await limiter.acquire(), await limiter.acquire(), await limiter.acquire()]; + ok(slots[0] === 10_000 && slots[1] === 11_000 && slots[2] === 12_000 && sleeps.join(",") === "1000,2000", `Slots im Sekundenabstand (${slots.join(",")}), Wartezeiten ${sleeps.join(",")}`); + clock = 20_000; + ok((await limiter.acquire()) === 20_000 && sleeps.length === 2, "nach Pause sofort wieder frei"); + + section("geocodeSite mit Fake-Provider"); + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const fake = fakeProvider((a) => (a.city === "Nirgendwo" ? { status: "not_found" } : { status: "ok", latitude: 53.55, longitude: a.houseNumber === "14" ? 10.01 : 10.0 })); + setGeocodingProvider(fake.provider); + const site = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo 1", ...addr } }); + + ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 1, "neue Adresse → ok, 1 Provider-Aufruf"); + const s1 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } }); + ok(s1.latitude === 53.55 && s1.longitude === 10.0 && s1.geocodeStatus === "ok" && s1.geocodeQuery === geocodeQueryFor(addr) && !!s1.geocodedAt, "Koordinaten, Status, Adresse und Zeitpunkt am Objekt gespeichert"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "site", entityId: site.id, action: "update" } })) === 1, "Koordinatenänderung auditiert"); + ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "unveränderte Adresse → Cache, kein weiterer Aufruf"); + await prisma.site.update({ where: { id: site.id }, data: { city: "hamburg " } }); + ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "nur Schreibweise geändert → Cache"); + await prisma.site.update({ where: { id: site.id }, data: { houseNumber: "14" } }); + ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 2 && (await prisma.site.findUniqueOrThrow({ where: { id: site.id } })).longitude === 10.01, "geänderte Adresse → neu verortet"); + await prisma.site.update({ where: { id: site.id }, data: { city: "Nirgendwo", postalCode: "99999" } }); + ok((await geocodeSite(A.tenantId, site.id)) === "not_found" && fake.calls.length === 3, "unbekannte Adresse → not_found"); + const s2 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } }); + ok(s2.geocodeStatus === "not_found" && s2.latitude === null && s2.longitude === null, "not_found entfernt veraltete automatische Koordinaten"); + ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 3, "not_found wird für dieselbe Adresse nicht wiederholt"); + + const failing = fakeProvider(() => "throw"); + const siteFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Fehler", ...addr } }); + ok((await geocodeSite(A.tenantId, siteFail.id, { provider: failing.provider })) === "failed" && (await prisma.site.findUniqueOrThrow({ where: { id: siteFail.id } })).geocodeStatus === "failed", "Providerfehler → Status failed"); + ok((await geocodeSite(A.tenantId, siteFail.id)) === "ok", "failed wird beim nächsten Lauf erneut versucht"); + + const manual = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo manuell", ...addr, latitude: 50.1, longitude: 8.6 } }); + const callsBefore = fake.calls.length; + ok((await geocodeSite(A.tenantId, manual.id)) === "manual" && fake.calls.length === callsBefore, "manuelle Koordinaten → kein Aufruf"); + const m = await prisma.site.findUniqueOrThrow({ where: { id: manual.id } }); + ok(m.latitude === 50.1 && m.longitude === 8.6 && m.geocodeStatus === "skipped", "manuelle Koordinaten bleiben unverändert (Status skipped)"); + + const none = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo none", ...addr } }); + ok((await geocodeSite(A.tenantId, none.id, { provider: null })) === "skipped" && (await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).latitude === null, "Provider none → skipped, ohne Ortsangabe, kein Fehler"); + const noAddr = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo ohne Adresse", street: "Irgendwo" } }); + ok((await geocodeSite(A.tenantId, noAddr.id)) === "skipped" && fake.calls.length === callsBefore, "unvollständige Adresse → skipped ohne Aufruf"); + + const bCalls = fake.calls.length; + ok((await geocodeSite(B.tenantId, none.id)) === "missing" && fake.calls.length === bCalls, "Mandant B: Objekt von A nicht gefunden, kein Aufruf"); + ok((await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).geocodeStatus === "skipped", "Objekt von A unverändert"); + + section("Processor & Hook"); + ok(typeof PROCESSORS["geocode-site"] === "function", "Processor geocode-site registriert"); + const viaJob = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job", ...addr } }); + await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJob.id }); + ok((await prisma.site.findUniqueOrThrow({ where: { id: viaJob.id } })).geocodeStatus === "ok", "Processor verortet das Objekt"); + setGeocodingProvider(failing.provider); + const viaJobFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job Fehler", ...addr } }); + let jobThrew = false; + try { + await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJobFail.id }); + } catch { + jobThrew = true; + } + ok(jobThrew, "Processor wirft bei failed → BullMQ wiederholt"); + setGeocodingProvider(fake.provider); + + const prev = process.env.GEOCODING_PROVIDER; + process.env.GEOCODING_PROVIDER = "none"; + ok((await requestSiteGeocoding(A.tenantId, viaJob.id)) === false, "GEOCODING_PROVIDER=none → kein Job"); + process.env.GEOCODING_PROVIDER = "nominatim"; + const hookCalls = fake.calls.length; + const created = await createSite(A.ctx.backoffice, { customerId: A.customerId, name: "Geo Hook", street: "Am Kaiserkai", houseNumber: "1", postalCode: "20457", city: "Hamburg" }); + await new Promise((resolve) => setTimeout(resolve, 100)); + ok(!!created.id && fake.calls.length === hookCalls, "Anlage eines Objekts: Verortung nur per Queue, nie inline im Request"); + if (prev === undefined) delete process.env.GEOCODING_PROVIDER; + else process.env.GEOCODING_PROVIDER = prev; + setGeocodingProvider(undefined); + + ok(nominatimHits === 0, "Nominatim wurde in keinem Test aufgerufen"); +}); diff --git a/scripts/test-planung-live.ts b/scripts/test-planung-live.ts new file mode 100644 index 0000000..abb7eb3 --- /dev/null +++ b/scripts/test-planung-live.ts @@ -0,0 +1,127 @@ +// L13 Planung — Live-Lage ohne GPS: Status aus der aktiven WorkSession (Unterwegs/In Arbeit/Pause/frei), +// „seit“ aus dem offenen Zeitabschnitt, Standort = Objekt des laufenden Auftrags, KEINE Geräte-Koordinaten +// im Ergebnis, Teamleiter nur eigenes Team, Monteur forbidden, Mandantentrennung, heutige Aufträge ohne Beginn. +// Lauf: npx tsx scripts/test-planung-live.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; +import { dayBounds, dayKeyOf } from "../src/lib/planning/days"; +import { getLiveSituation } from "../src/server/services/planning/live"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-live-a"; +const SLUG_B = "zz-planung-live-b"; +const DEVICE_LAT = 12.3456789; +const DEVICE_LNG = 98.7654321; +const MIN = 60_000; + +runSuite("L13 Planung – Live-Lage", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const now = new Date(); + const t = A.tenantId; + + await prisma.site.update({ where: { id: A.siteId }, data: { latitude: 53.5413, longitude: 9.9849, geocodeStatus: "ok", geocodedAt: now } }); + const noCoordSite = await prisma.site.create({ data: { tenantId: t, customerId: A.customerId, name: "Baustelle ohne Ortsangabe", city: "Hamburg" } }); + + const mk = async (title: string, data: Record, siteId = A.siteId) => { + const wo = await createWorkOrder(A.ctx.backoffice, { title, customerId: A.customerId, siteId }); + await prisma.workOrder.update({ where: { id: wo.id }, data }); + return wo; + }; + + // tech: running on the site with coordinates; the session carries (test) device coordinates that must never leak. + const oRun = await mk("Laufender Einsatz", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: new Date(now.getTime() - 120 * MIN) }); + const sRun = await prisma.workSession.create({ + data: { tenantId: t, workOrderId: oRun.id, userId: A.users.tech.id, teamId: A.teamId, status: "running", startedAt: new Date(now.getTime() - 120 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG, deviceInfo: "ZZ-Geraet-4711" }, + }); + await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "travel", startedAt: new Date(now.getTime() - 120 * MIN), endedAt: new Date(now.getTime() - 30 * MIN) } }); + const workSince = new Date(now.getTime() - 30 * MIN); + await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "work", startedAt: workSince } }); + // tech2: en route to a site without coordinates. + const oRoute = await mk("Anfahrt", { status: "en_route", assignedTeamId: A.teamId }, noCoordSite.id); + await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRoute.id, userId: A.users.tech2.id, teamId: A.teamId, status: "en_route", startedAt: new Date(now.getTime() - 10 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG } }); + // outsider (Team Süd): paused. + const oPause = await mk("Pausiert", { status: "paused", assignedTeamId: A.team2Id }); + const sPause = await prisma.workSession.create({ data: { tenantId: t, workOrderId: oPause.id, userId: A.users.outsider.id, teamId: A.team2Id, status: "paused", startedAt: new Date(now.getTime() - 60 * MIN) } }); + const pauseSince = new Date(now.getTime() - 5 * MIN); + await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sPause.id, userId: A.users.outsider.id, type: "break", startedAt: pauseSince } }); + // an ended session must not count + await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRun.id, userId: A.users.lead.id, teamId: A.teamId, status: "ended", startedAt: new Date(now.getTime() - 300 * MIN), endedAt: new Date(now.getTime() - 200 * MIN) } }); + + // today's orders without start + const today = dayBounds(dayKeyOf(now, TZ), TZ); + const early = new Date(Math.max(today.start.getTime() + MIN, now.getTime() - 60 * MIN)); + const later = new Date(now.getTime() + 120 * MIN); + const canEarly = early < now; + const canLater = later < today.end; + const oNotAccepted = canEarly ? await mk("Nicht angenommen", { status: "assigned", assignedTeamId: A.teamId, plannedStart: early }) : null; + const oOverdue = canEarly ? await mk("Überfällig", { status: "accepted", assignedTeamId: A.teamId, plannedStart: early }) : null; + const oNext = canLater ? await mk("Heute später", { status: "assigned", assignedTeamId: A.teamId, plannedStart: later }) : null; + + section("Status aus der WorkSession"); + const live = await getLiveSituation(A.ctx.backoffice); + const byUser = (id: string) => live.technicians.find((x) => x.userId === id); + const tech = byUser(A.users.tech.id); + ok(tech?.status === "working" && tech.since === workSince.toISOString(), "Monteur mit laufender Session → In Arbeit, seit Beginn des offenen Abschnitts"); + ok(tech?.current?.visible === true && tech.current.id === oRun.id && tech.current.latitude === 53.5413 && tech.current.longitude === 9.9849, "Standort = Koordinaten des Objekts des laufenden Auftrags"); + ok(tech?.current?.visible === true && tech.current.number === oRun.number && tech.current.customerName.includes("Hausverwaltung") && !!tech.current.address, "aktueller Auftrag mit Nummer, Kunde und Adresse"); + const tech2 = byUser(A.users.tech2.id); + ok(tech2?.status === "en_route" && tech2.current?.visible === true && tech2.current.latitude === null, "Unterwegs; Objekt ohne Koordinaten → ohne Ortsangabe"); + ok(live.withoutLocation === 1, "Zähler „ohne Ortsangabe“ = 1"); + const outsider = byUser(A.users.outsider.id); + ok(outsider?.status === "paused" && outsider.since === pauseSince.toISOString(), "Pause, seit Beginn der Pause"); + const lead = byUser(A.users.lead.id); + ok(lead?.status === "free" && lead.current === null && lead.since === null, "ohne aktive Session (beendete zählt nicht) → frei"); + ok(live.counts.working === 1 && live.counts.en_route === 1 && live.counts.paused === 1 && live.counts.free >= 2, `Zähler je Status ${JSON.stringify(live.counts)}`); + if (oNext) ok(!!tech?.next.some((n) => n.id === oNext.id) && !tech?.next.some((n) => n.id === oRun.id), "heute noch geplante Aufträge je Monteur"); + if (oNotAccepted && oOverdue) { + ok(live.attention.some((a) => a.id === oNotAccepted.id && a.reason === "not_accepted") && live.attention.some((a) => a.id === oOverdue.id && a.reason === "overdue"), "heute ohne Beginn: nicht angenommen / Beginn überschritten"); + ok(!live.attention.some((a) => a.id === oRun.id) && (!oNext || !live.attention.some((a) => a.id === oNext.id)), "laufende und spätere Aufträge nicht in „ohne Beginn“"); + } + + section("Kolonnen: ein Eintrag je Team"); + const nordCrew = live.crews.find((c) => c.teamId === A.teamId); + const suedCrew = live.crews.find((c) => c.teamId === A.team2Id); + ok( + live.crews.filter((c) => c.teamId === A.teamId).length === 1 && nordCrew?.status === "working" && nordCrew.current?.id === oRun.id && nordCrew.current.latitude === 53.5413, + "Kolonne Nord: ein Eintrag am Einsatzort des laufenden Auftrags, Status In Arbeit", + ); + const nordMembers = new Map(nordCrew?.members.map((m) => [m.userId, m]) ?? []); + ok( + nordMembers.get(A.users.tech.id)?.status === "working" && nordMembers.get(A.users.tech2.id)?.status === "en_route" && nordMembers.get(A.users.lead.id)?.status === "free" && nordMembers.get(A.users.tech2.id)?.orderNumber === oRoute.number, + "Mitglieder mit individuellem Status aus der eigenen Session", + ); + ok(suedCrew?.status === "paused" && suedCrew.members.some((m) => m.userId === A.users.outsider.id && m.status === "paused"), "Kolonne Süd: Pause"); + ok(live.technicians.find((x) => x.userId === A.users.tech.id)?.crewId === A.teamId, "Monteure mit Team gehören zu ihrer Kolonne (keine Einzelmarker)"); + ok(nordCrew?.delay === null, "kein Verzug bei 30 min von 2 h"); + + section("Datenschutz: keine Geräte-Koordinaten"); + for (const [label, result] of [ + ["Backoffice", live], + ["Filter Status", await getLiveSituation(A.ctx.backoffice, { status: "en_route" })], + ["Teamleiter", await getLiveSituation(A.ctx.lead)], + ] as const) { + const json = JSON.stringify(result); + ok(!json.includes(String(DEVICE_LAT)) && !json.includes(String(DEVICE_LNG)) && !/startLat|startLng|deviceInfo|ZZ-Geraet/.test(json), `${label}: keine Start-Koordinaten/Geräteinfo im Ergebnis`); + } + + section("Filter, Rollen, Mandantentrennung"); + const onlyWorking = await getLiveSituation(A.ctx.backoffice, { status: "working" }); + ok(onlyWorking.technicians.length === 1 && onlyWorking.technicians[0].userId === A.users.tech.id, "Filter Status „In Arbeit“"); + const onlySued = await getLiveSituation(A.ctx.backoffice, { teamId: A.team2Id }); + ok(onlySued.technicians.every((x) => x.teams.some((tm) => tm.id === A.team2Id)) && onlySued.technicians.some((x) => x.userId === A.users.outsider.id) && !onlySued.technicians.some((x) => x.userId === A.users.tech.id), "Filter Team Süd"); + const leadView = await getLiveSituation(A.ctx.lead); + const leadUsers = leadView.technicians.map((x) => x.userId); + ok(leadUsers.includes(A.users.tech.id) && leadUsers.includes(A.users.tech2.id) && !leadUsers.includes(A.users.outsider.id) && !leadUsers.includes(A.users.lead2.id), "Teamleiter sieht nur sein Team"); + ok(leadView.teams.length === 1 && leadView.teams[0].id === A.teamId && !leadView.attention.some((a) => a.teamName === "Team Süd"), "Teamleiter: nur eigenes Team in Auswahl und Hinweisen"); + ok(leadView.crews.length === 1 && leadView.crews[0].teamId === A.teamId, "Teamleiter: nur die eigene Kolonne"); + await expectCode(() => getLiveSituation(A.ctx.tech), "forbidden", "Monteur → forbidden"); + const liveB = await getLiveSituation(B.ctx.backoffice); + const aUsers = Object.values(A.users).map((u) => u.id); + const jsonB = JSON.stringify(liveB); + ok(liveB.technicians.every((x) => !aUsers.includes(x.userId)) && !jsonB.includes(oRun.number) && !jsonB.includes(A.teamId), "Mandant B sieht keine Monteure, Aufträge oder Teams von A"); +}); diff --git a/scripts/test-planung-recommend.ts b/scripts/test-planung-recommend.ts new file mode 100644 index 0000000..7f0a38c --- /dev/null +++ b/scripts/test-planung-recommend.ts @@ -0,0 +1,134 @@ +// L13 Planung — Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge: +// nächster Kandidat mit Kapazität gewinnt, Team ohne Kapazität ausgeschlossen, außerhalb des Radius +// ausgeschlossen, „knapp“ markiert, ohne Koordinaten → Hinweis, Rollen, Mandantentrennung. +// Lauf: npx tsx scripts/test-planung-recommend.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; +import { addDays, dayKeyOf, startOfWeek } from "../src/lib/planning/days"; +import { wallTimeToUtc } from "../src/lib/work-orders/time"; +import { findNearbyUnplanned, recommendAssignments } from "../src/server/services/planning/recommend"; +import { scheduleWorkOrder } from "../src/server/services/planning/schedule"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-rec-a"; +const SLUG_B = "zz-planung-rec-b"; +const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!; + +const TARGET = { latitude: 53.56, longitude: 10.0 }; +const NEAR3 = { latitude: 53.587, longitude: 10.0 }; // ≈ 3,0 km +const VERY_NEAR = { latitude: 53.565, longitude: 10.005 }; // ≈ 0,65 km +const FAR = { latitude: 53.87, longitude: 10.69 }; // Lübeck, ≈ 57 km + +async function site(f: TenantFixture, name: string, coords: { latitude: number; longitude: number } | null, geocodeStatus: string | null = coords ? "ok" : null) { + return prisma.site.create({ + data: { tenantId: f.tenantId, customerId: f.customerId, name, street: "Teststraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", ...(coords ?? {}), geocodeStatus, geocodedAt: coords ? new Date() : null }, + }); +} + +async function order(f: TenantFixture, siteId: string, title: string, extra: Record = {}) { + return createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, siteId, ...extra }); +} + +async function place(f: TenantFixture, id: string, teamId: string, start: Date, plannedDurationMinutes: number) { + const { version } = await prisma.workOrder.findUniqueOrThrow({ where: { id } }); + return scheduleWorkOrder(f.ctx.backoffice, { workOrderId: id, teamId, plannedStart: start, plannedDurationMinutes, baseVersion: version }); +} + +runSuite("L13 Planung – Empfehlungen", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; // never contact Nominatim from tests + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const D1 = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14)); + const D2 = addDays(D1, 1); + const D3 = addDays(D1, 2); + + // Third team "Ost" (1 member) far away. + const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.admin.id } }); + await prisma.teamMember.create({ data: { tenantId: A.tenantId, teamId: ost.id, userId: A.users.admin.id, validFrom: new Date(Date.now() - 30 * 86400_000) } }); + + const sTarget = await site(A, "Ziel", TARGET); + const sNear3 = await site(A, "Nachbar 3 km", NEAR3); + const sVeryNear = await site(A, "Nachbar 650 m", VERY_NEAR); + const sFar = await site(A, "Lübeck", FAR); + + section("Kandidaten"); + const nordD1 = await order(A, sNear3.id, "Nord D1 3 km"); + await place(A, nordD1.id, A.teamId, at(D1, "08:00"), 240); // Kolonne Nord: 480 − 240 = 240 frei + const suedD1 = await order(A, sVeryNear.id, "Süd D1 voll"); + await place(A, suedD1.id, A.team2Id, at(D1, "08:00"), 480); // Süd: 480 − 480 = 0 frei + const ostD1 = await order(A, sFar.id, "Ost D1 Lübeck"); + await place(A, ostD1.id, ost.id, at(D1, "08:00"), 120); + + const target = await order(A, sTarget.id, "Zielauftrag", { plannedDurationMinutes: undefined }); + const r1 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 }); + ok(r1.status === "ok" && r1.requiredMinutes === 120, "Status ok, benötigte Dauer 2 h (Fallback)"); + const top = r1.recommendations[0]; + ok(!!top && top.teamId === A.teamId && top.day === D1 && top.nearOrder.id === nordD1.id, "Team Nord am D1 (Auftrag in 3 km) wird vorgeschlagen"); + ok(!!top && top.distanceKm > 2.5 && top.distanceKm < 3.5 && top.freeMinutes === 240 && !top.tight, `Begründung strukturiert: ${top?.distanceKm} km, 4 h freie Kolonnenzeit`); + ok(!!top && top.text.includes("km von") && top.text.includes(nordD1.number) && top.text.includes("Team Nord") && top.text.includes("noch 4 h frei"), `Begründungstext: „${top?.text}“`); + ok(!r1.recommendations.some((r) => r.teamId === A.team2Id), "Kolonne Süd ohne freie Kapazität ausgeschlossen (obwohl näher)"); + ok(!r1.recommendations.some((r) => r.teamId === ost.id), "Team Ost außerhalb des Radius ausgeschlossen"); + ok(!!top && new Date(top.suggestedStart).getTime() === at(D1, "12:00").getTime(), "Vorgeschlagener Beginn direkt nach dem letzten Auftrag des Tages (12:00)"); + + const nordD2 = await order(A, sVeryNear.id, "Nord D2 650 m"); + await place(A, nordD2.id, A.teamId, at(D2, "08:00"), 120); + const nordD3 = await order(A, sNear3.id, "Nord D3 fast voll"); + await place(A, nordD3.id, A.teamId, at(D3, "07:00"), 420); // 480 − 420 = 60 min frei < 120 → knapp + const r2 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 }); + ok(r2.recommendations[0]?.teamId === A.teamId && r2.recommendations[0]?.day === D2 && r2.recommendations[0].nearOrder.id === nordD2.id, "nächster Kandidat mit Kapazität gewinnt (650 m am D2 vor 3 km am D1)"); + const tight = r2.recommendations.find((r) => r.day === D3); + ok(!!tight && tight.tight && tight.text.includes("knapp") && r2.recommendations.indexOf(tight) > 0, "knappe Kapazität markiert und nachrangig"); + ok(r2.recommendations.length <= 5 && r2.recommendations.every((r, i, arr) => i === 0 || arr[i - 1].score >= r.score), "höchstens 5, nach Score sortiert"); + const r2en = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3, locale: "en" }); + ok(r2en.recommendations[0]?.text.includes("from") && r2en.recommendations[0].text.includes("free"), `englischer Text: „${r2en.recommendations[0]?.text}“`); + const r3 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3, radiusKm: 0.5 }); + ok(r3.recommendations.length === 0 && !!r3.hint && r3.hint.includes("0.5 km"), `kleiner Radius → keine Vorschläge + Hinweis („${r3.hint}“)`); + + section("Ohne Koordinaten"); + const sNone = await site(A, "Ohne Koordinaten", null); + const noCoords = await order(A, sNone.id, "Auftrag ohne Verortung"); + const r4 = await recommendAssignments(bo, { workOrderId: noCoords.id, from: D1, days: 3 }); + ok(r4.status === "no_coordinates" && r4.recommendations.length === 0 && !!r4.hint?.includes("nicht verortet"), `ohne Koordinaten → Hinweis „${r4.hint}“`); + const sNotFound = await site(A, "Unbekannte Adresse", null, "not_found"); + const notFound = await order(A, sNotFound.id, "Adresse nicht gefunden"); + const r5 = await recommendAssignments(bo, { workOrderId: notFound.id, from: D1, days: 3 }); + ok(r5.status === "no_coordinates" && !!r5.hint?.includes("nicht gefunden"), "Adresse nicht gefunden → eigener Hinweis"); + const withoutSite = await createWorkOrder(bo, { title: "Ohne Objekt", customerId: A.customerId }); + ok((await recommendAssignments(bo, { workOrderId: withoutSite.id })).status === "no_coordinates", "Auftrag ohne Objekt → no_coordinates"); + ok((await findNearbyUnplanned(bo, { workOrderId: noCoords.id })).status === "no_coordinates", "Umkreis ohne Koordinaten → no_coordinates"); + + section("Nahe ungeplante Aufträge"); + const n1 = await order(A, sVeryNear.id, "Ungeplant nah"); + const n2 = await order(A, sFar.id, "Ungeplant fern"); + const nearby = await findNearbyUnplanned(bo, { workOrderId: target.id, radiusKm: 5 }); + ok(nearby.items.some((i) => i.id === n1.id) && !nearby.items.some((i) => i.id === n2.id) && !nearby.items.some((i) => i.id === target.id), "Umkreis 5 km: nahen Auftrag ja, fernen und sich selbst nein"); + ok(nearby.items.every((i, idx, arr) => idx === 0 || arr[idx - 1].distanceKm <= i.distanceKm) && (nearby.items.find((i) => i.id === n1.id)?.distanceKm ?? 99) < 1, "nach Entfernung sortiert (650 m)"); + + section("Rollen & Mandantentrennung"); + await expectCode(() => recommendAssignments(A.ctx.lead, { workOrderId: target.id }), "forbidden", "Teamleiter → forbidden"); + await expectCode(() => recommendAssignments(A.ctx.tech, { workOrderId: target.id }), "forbidden", "Monteur → forbidden"); + await expectCode(() => findNearbyUnplanned(A.ctx.tech, { workOrderId: target.id }), "forbidden", "Umkreis: Monteur → forbidden"); + await expectCode(() => recommendAssignments(B.ctx.backoffice, { workOrderId: target.id }), "not_found", "Mandant B: Auftrag von A → not_found"); + await expectCode(() => findNearbyUnplanned(B.ctx.backoffice, { workOrderId: target.id }), "not_found", "Mandant B: Umkreis für Auftrag von A → not_found"); + + // B has a team with free capacity and an order at identical coordinates + an unplanned order nearby. + const bVeryNear = await site(B, "B nah", VERY_NEAR); + const bTargetSite = await site(B, "B Ziel", TARGET); + const bPlanned = await order(B, bVeryNear.id, "B geplant"); + await place(B, bPlanned.id, B.teamId, at(D1, "08:00"), 60); + const bUnplanned = await order(B, bVeryNear.id, "B ungeplant"); + const aAgain = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 }); + const aNearby = await findNearbyUnplanned(bo, { workOrderId: target.id, radiusKm: 5 }); + ok(aAgain.recommendations.every((r) => r.teamId !== B.teamId && r.nearOrder.id !== bPlanned.id), "A: nie Teams/Aufträge von Mandant B in Vorschlägen"); + ok(!aNearby.items.some((i) => i.id === bUnplanned.id), "A: nie ungeplante Aufträge von Mandant B im Umkreis"); + const bTarget = await order(B, bTargetSite.id, "B Zielauftrag"); + const bRec = await recommendAssignments(B.ctx.backoffice, { workOrderId: bTarget.id, from: D1, days: 3 }); + const aTeamIds = [A.teamId, A.team2Id, ost.id]; + ok(bRec.recommendations.length > 0 && bRec.recommendations.every((r) => !aTeamIds.includes(r.teamId) && r.nearOrder.id === bPlanned.id), "B: Vorschläge nur aus eigenen Teams/Aufträgen"); + const bNear = await findNearbyUnplanned(B.ctx.backoffice, { workOrderId: bTarget.id, radiusKm: 5 }); + ok(bNear.items.length === 1 && bNear.items[0].id === bUnplanned.id, "B: Umkreis nur eigene ungeplante Aufträge"); +}); diff --git a/scripts/test-planung-watch.ts b/scripts/test-planung-watch.ts new file mode 100644 index 0000000..4222788 --- /dev/null +++ b/scripts/test-planung-watch.ts @@ -0,0 +1,176 @@ +// L13 Planung — Verzug und „früher fertig“: erfasste Kolonnenzeit (vereinigt), Warnung ab 80 %, Meldung ab +// 100 % mit Dedupe und erneuter Meldung je weitere +50 %, gefährdeter Folgeauftrag (Beginn inkl. Fahrzeit, +// Tageskapazität) mit Dedupe, freie Kolonnenzeit bei früherem Abschluss + Vorzieh-/Umkreis-Vorschläge, +// Event planning.capacity_freed, Empfänger (Backoffice + Teamleiter, nur In-App), Plantafel/Live-Lage, +// Rollen, Mandantentrennung, L12-Adapter. Alle Zeiten relativ zu „heute 12:00“ (unabhängig von der Uhrzeit). +// Lauf: npx tsx scripts/test-planung-watch.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { berlinAt, createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; +import { dayKeyOf } from "../src/lib/planning/days"; +import { getPlanningBoard } from "../src/server/services/planning/board"; +import { getLiveSituation } from "../src/server/services/planning/live"; +import { approvedEntryWhere, TIME_TRACKING_FIELDS, trackedEntryWhere, trackedSessionWhere } from "../src/server/services/planning/time-tracking"; +import { auditAlertLedger, evaluateDelays, getFreedCapacity, runPlanningWatch, watchContext } from "../src/server/services/planning/watch"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { JOB_QUEUES } from "../src/server/jobs/queues"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-watch-a"; +const SLUG_B = "zz-planung-watch-b"; +const T = (hh: number, mm = 0) => berlinAt(0, hh, mm); +const S1 = { latitude: 53.55, longitude: 10.0 }; +const S2 = { latitude: 53.73, longitude: 10.0 }; // ≈ 20 km nördlich +const S3 = { latitude: 53.559, longitude: 10.0 }; // ≈ 1 km +const FAR = { latitude: 53.87, longitude: 10.69 }; + +async function site(f: TenantFixture, name: string, c: { latitude: number; longitude: number } | null) { + return prisma.site.create({ data: { tenantId: f.tenantId, customerId: f.customerId, name, city: "Hamburg", postalCode: "20457", ...(c ?? {}), geocodeStatus: c ? "ok" : null, geocodedAt: c ? new Date() : null } }); +} + +async function order(f: TenantFixture, siteId: string | null, title: string, data: Record) { + const wo = await createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, ...(siteId ? { siteId } : {}) }); + await prisma.workOrder.update({ where: { id: wo.id }, data }); + return wo; +} + +async function session(f: TenantFixture, workOrderId: string, userId: string, teamId: string, status: "running" | "paused" | "en_route" | "ended", startedAt: Date, endedAt: Date | null = null) { + return prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId, userId, teamId, status, startedAt, endedAt } }); +} + +async function entry(f: TenantFixture, workSessionId: string, userId: string, startedAt: Date, endedAt: Date | null, type: "work" | "break" | "travel" = "work") { + return prisma.timeEntry.create({ data: { tenantId: f.tenantId, workSessionId, userId, type, startedAt, endedAt } }); +} + +const alerts = (f: TenantFixture) => prisma.auditLog.findMany({ where: { tenantId: f.tenantId, entity: "planning_alert" }, select: { entityId: true } }); + +runSuite("L13 Planung – Verzug & früher fertig", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const today = dayKeyOf(T(12), TZ); + + section("Job & Adapter"); + ok(JOB_QUEUES.planningWatch === "planning-watch" && typeof PROCESSORS["planning-watch"] === "function", "Queue planning-watch + Processor registriert"); + ok( + Object.keys(trackedEntryWhere).length === (TIME_TRACKING_FIELDS.entrySource ? 1 : 0) && + Object.keys(approvedEntryWhere).length === (TIME_TRACKING_FIELDS.entryApproval ? 1 : 0) && + Object.keys(trackedSessionWhere).length === (TIME_TRACKING_FIELDS.sessionManual ? 1 : 0), + `L12-Filter aktiv je nach Schema (source=${TIME_TRACKING_FIELDS.entrySource}, approvalStatus=${TIME_TRACKING_FIELDS.entryApproval}, manual=${TIME_TRACKING_FIELDS.sessionManual})`, + ); + + // Kolonne Nord (tech + tech2): laufender Auftrag, 2 h geplant. + const s1 = await site(A, "Baustelle 1", S1); + const s2 = await site(A, "Baustelle 20 km", S2); + const oRun = await order(A, s1.id, "Laufend Nord", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(8), plannedDurationMinutes: 120 }); + const sessTech = await session(A, oRun.id, A.users.tech.id, A.teamId, "running", T(8)); + await entry(A, sessTech.id, A.users.tech.id, T(8), T(9)); + await entry(A, sessTech.id, A.users.tech.id, T(9), T(9, 30), "break"); + await entry(A, sessTech.id, A.users.tech.id, T(9, 30), null); + const sessTech2 = await session(A, oRun.id, A.users.tech2.id, A.teamId, "running", T(8, 30)); + await entry(A, sessTech2.id, A.users.tech2.id, T(8, 30), null); + const oNext = await order(A, s2.id, "Folgeauftrag Nord", { status: "assigned", assignedTeamId: A.teamId, plannedStart: T(10, 45), plannedDurationMinutes: 60 }); + + section("Verzug: Kolonnenzeit und 80 %"); + const sys = watchContext(A.tenantId); + const e900 = await evaluateDelays(sys, { now: T(9, 0) }); + ok(e900.delays.find((d) => d.workOrderId === oRun.id)?.level === "ok" && e900.delays.find((d) => d.workOrderId === oRun.id)?.workedMinutes === 60, "09:00: 60 min (50 %) → kein Verzug"); + const e940 = await evaluateDelays(sys, { now: T(9, 40) }); + const d940 = e940.delays.find((d) => d.workOrderId === oRun.id); + ok(d940?.workedMinutes === 100, `erfasste Kolonnenzeit vereinigt 08:00–09:40 = 100 min (summiert wären 140) — ${d940?.workedMinutes}`); + ok(d940?.level === "risk" && d940.percent === 83 && d940.remainingMinutes === 20, "83 % → Verzug droht, Restzeit 20 min"); + ok(!e940.followups.some((f) => f.workOrderId === oNext.id), "09:40 + 20 min Rest + 31 min Fahrt (20 km) = 10:31 < Beginn 10:45 → Folgeauftrag nicht gefährdet"); + const e1020 = await evaluateDelays(sys, { now: T(10, 20) }); + const f1020 = e1020.followups.find((f) => f.workOrderId === oNext.id); + ok(!!f1020 && f1020.reason === "start" && f1020.travelMinutes === 31 && f1020.blockedByNumber === oRun.number, "10:20: Rest 0 + 31 min Fahrt = 10:51 > 10:45 → Folgeauftrag gefährdet (Beginn)"); + + const board = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(9, 40) }); + ok(board.orders[oRun.id]?.delay?.level === "risk" && board.orders[oRun.id].delay?.percent === 83 && !board.orders[oNext.id]?.atRisk, "Plantafel 09:40: Karte „Verzug droht · 83 %“, Folgeauftrag noch nicht gefährdet"); + const board1020 = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(10, 20) }); + ok(board1020.orders[oRun.id]?.delay?.level === "overrun" && board1020.orders[oNext.id]?.atRisk?.byNumber === oRun.number, "Plantafel 10:20: „Dauer überschritten“ + Folgeauftrag „gefährdet durch Verzug …“"); + const live940 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(9, 40) }); + ok(live940.crews.find((c) => c.teamId === A.teamId)?.delay?.level === "risk", "Live-Lage: Kolonne Nord mit Verzugswarnung"); + + section("Meldungen: 100 %, Dedupe, +50 %"); + const r940 = await runPlanningWatch(A.tenantId, { now: T(9, 40) }); + ok(r940.overrun === 0 && r940.followupAtRisk === 0, `09:40: keine Meldung bei 83 % (${JSON.stringify(r940)})`); + const r1010 = await runPlanningWatch(A.tenantId, { now: T(10, 10) }); + ok(r1010.overrun === 1 && r1010.followupAtRisk === 0, "10:10: 130 min = 108 % → planning.overrun"); + const r1020 = await runPlanningWatch(A.tenantId, { now: T(10, 20) }); + ok(r1020.overrun === 0 && r1020.followupAtRisk === 1, "10:20: keine erneute Überschreitungsmeldung, 1 × Folgeauftrag gefährdet"); + ok((await runPlanningWatch(A.tenantId, { now: T(10, 25) })).followupAtRisk === 0, "Folgeauftrag gefährdet wird je Auftrag/Tag nur einmal gemeldet"); + ok((await runPlanningWatch(A.tenantId, { now: T(10, 40) })).overrun === 0, "10:40 (133 %): keine erneute Meldung"); + ok((await runPlanningWatch(A.tenantId, { now: T(11, 0) })).overrun === 1, "11:00: 180 min = 150 % → erneute Meldung (+50 %)"); + ok((await runPlanningWatch(A.tenantId, { now: T(11, 10) })).overrun === 0, "11:10: keine weitere Meldung vor 200 %"); + const keys = (await alerts(A)).map((a) => a.entityId); + ok(keys.includes(`planning.overrun:${oRun.id}:0`) && keys.includes(`planning.overrun:${oRun.id}:1`) && keys.includes(`planning.followup_at_risk:${oNext.id}:${today}`), "Dedupe-Einträge im Audit-Protokoll (planning_alert)"); + + const overrunNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.overrun", entityId: oRun.id }, select: { userId: true, emailedAt: true, title: true, message: true } }); + const noteUsers = new Set(overrunNotes.map((n) => n.userId)); + ok(noteUsers.has(A.users.backoffice.id) && noteUsers.has(A.users.admin.id) && noteUsers.has(A.users.lead.id), "Empfänger: Backoffice + Teamleiter der Kolonne"); + ok(!noteUsers.has(A.users.tech.id) && !noteUsers.has(A.users.lead2.id), "nicht: Monteure, Teamleiter anderer Kolonnen"); + ok(overrunNotes.every((n) => n.emailedAt === null) && overrunNotes.some((n) => n.message.includes("150 %") && n.title.includes(oRun.number)), "nur In-App (keine E-Mail), Text mit Prozent und Nummer"); + const followNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.followup_at_risk", entityId: oNext.id }, select: { message: true } }); + ok(followNotes.length > 0 && followNotes.every((n) => n.message.includes(oRun.number)), "Folgeauftrag-Meldung nennt den verursachenden Auftrag"); + + section("Folgeauftrag gefährdet durch Tageskapazität"); + await prisma.team.update({ where: { id: A.team2Id }, data: { dailyCapacityMinutes: 180 } }); + const oSud = await order(A, null, "Laufend Süd", { status: "in_progress", assignedTeamId: A.team2Id, plannedStart: T(7), plannedDurationMinutes: 120 }); + const sessSud = await session(A, oSud.id, A.users.outsider.id, A.team2Id, "running", T(7)); + await entry(A, sessSud.id, A.users.outsider.id, T(7), null); + const oSudNext = await order(A, null, "Folgeauftrag Süd ohne Uhrzeit", { status: "assigned", assignedTeamId: A.team2Id, plannedStart: berlinAt(0, 0), plannedDurationMinutes: 60 }); + const eSud = await evaluateDelays(sys, { now: T(9, 40) }); + const fSud = eSud.followups.find((f) => f.workOrderId === oSudNext.id); + ok(!!fSud && fSud.reason === "capacity" && fSud.travelMinutes === 10, "160 min statt 120 + Folgeauftrag 60 + 10 min Fahrt > 180 min Kolonnentag → gefährdet (Kapazität)"); + + section("Früher fertig: freie Kolonnenzeit und Vorschläge"); + const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.lead.id } }); + await prisma.teamMember.createMany({ data: [A.users.admin.id, A.users.backoffice.id].map((userId) => ({ tenantId: A.tenantId, teamId: ost.id, userId, validFrom: new Date(Date.now() - 30 * 86400_000) })) }); + const s3 = await site(A, "Nachbar 1 km", S3); + const sFar = await site(A, "Lübeck", FAR); + const oDone = await order(A, s1.id, "Früher fertig Ost", { status: "technically_completed", assignedTeamId: ost.id, plannedStart: T(8), plannedDurationMinutes: 240 }); + await session(A, oDone.id, A.users.admin.id, ost.id, "ended", T(8), T(10)); + const oLater = await order(A, s2.id, "Ost später heute", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(14), plannedDurationMinutes: 60 }); + const oTooLong = await order(A, s2.id, "Ost zu lang", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(15), plannedDurationMinutes: 180 }); + const oNear = await order(A, s3.id, "Ungeplant nah", { status: "draft", plannedDurationMinutes: 60 }); + const oFar = await order(A, sFar.id, "Ungeplant fern", { status: "draft", plannedDurationMinutes: 60 }); + + const freed = await getFreedCapacity(A.ctx.backoffice, { now: T(10, 30) }); + const fo = freed.teams.find((x) => x.teamId === ost.id); + ok(!!fo && fo.earlyMinutes === 120 && fo.availableMinutes === 90 && fo.availableFrom === T(10, 30).toISOString(), "Ost: 2 h früher fertig (Ende 10:00 statt 12:00), 90 min ab jetzt frei"); + ok(!freed.teams.some((x) => x.teamId === A.teamId), "laufende Kolonne Nord nicht als früher fertig"); + const kinds = new Map(fo?.suggestions.map((s) => [s.order.id, s]) ?? []); + ok(kinds.get(oLater.id)?.kind === "pull_forward" && !kinds.has(oTooLong.id), "Vorziehen: späterer Auftrag der Kolonne, der in die freie Zeit passt (nicht der zu lange)"); + ok(kinds.get(oNear.id)?.kind === "nearby_unplanned" && (kinds.get(oNear.id)?.distanceKm ?? 99) < 2 && !kinds.has(oFar.id), "ungeplanter Auftrag im Umkreis (1 km) ja, ferner nein"); + ok(kinds.get(oLater.id)?.suggestedStart === T(10, 30).toISOString() && kinds.get(oLater.id)?.day === today, "Vorschlag vorbelegt ab 10:30 heute — keine automatische Änderung"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: oLater.id } })).plannedStart?.getTime() === T(14).getTime(), "geplanter Auftrag bleibt unverändert"); + const live1030 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(10, 30) }); + ok(live1030.freed.some((x) => x.teamId === ost.id && x.suggestions.length >= 2), "Live-Lage zeigt „früher fertig“ mit Vorschlägen"); + + const rFreed = await runPlanningWatch(A.tenantId, { now: T(10, 30) }); + ok(rFreed.capacityFreed === 1, "planning.capacity_freed einmal gemeldet"); + ok((await runPlanningWatch(A.tenantId, { now: T(10, 50) })).capacityFreed === 0, "Dedupe je Kolonne/Tag/Auftrag"); + const freedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.capacity_freed", entityId: oDone.id }, select: { userId: true, message: true } }); + ok(freedNotes.some((n) => n.userId === A.users.lead.id) && freedNotes.some((n) => n.message.includes("120 min")), "Meldung an Teamleiter der Kolonne mit 120 min"); + + section("Rollen & Mandantentrennung"); + await expectCode(() => getFreedCapacity(A.ctx.tech, { now: T(10, 30) }), "forbidden", "Monteur: früher fertig → forbidden"); + const leadFreed = await getFreedCapacity(A.ctx.lead2, { now: T(10, 30) }); + ok(leadFreed.teams.length === 0, "Teamleiter Süd sieht die Kolonne Ost nicht"); + const bSys = watchContext(B.tenantId); + const bEval = await evaluateDelays(bSys, { now: T(11, 0) }); + ok(bEval.delays.length === 0 && bEval.followups.length === 0, "Mandant B: keine Aufträge/Sessions von A in der Auswertung"); + ok((await getFreedCapacity(B.ctx.backoffice, { now: T(10, 30) })).teams.length === 0, "Mandant B: kein „früher fertig“ von A"); + const rB = await runPlanningWatch(B.tenantId, { now: T(11, 0) }); + ok(rB.overrun + rB.followupAtRisk + rB.capacityFreed === 0 && (await alerts(B)).length === 0, "Mandant B: keine Meldungen, keine Dedupe-Einträge"); + ok((await prisma.notification.count({ where: { tenantId: B.tenantId, type: { startsWith: "planning." } } })) === 0, "Mandant B: keine Planungs-Benachrichtigungen"); + + // Ein Dedupe-Schlüssel in B blockiert A nicht (Ledger je Mandant). + const oRun2 = await order(A, s1.id, "Laufend Nord 2", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(6), plannedDurationMinutes: 60 }); + const sess2 = await session(A, oRun2.id, A.users.lead.id, A.teamId, "running", T(6)); + await entry(A, sess2.id, A.users.lead.id, T(6), null); + ok(await auditAlertLedger.claim(bSys, `planning.overrun:${oRun2.id}:0`, {}), "B beansprucht (künstlich) den Schlüssel eines A-Auftrags"); + ok((await runPlanningWatch(A.tenantId, { now: T(11, 20) })).overrun >= 1, "A meldet trotzdem (Dedupe mandantengetrennt)"); +}); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index d828066..5bc084c 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -17,6 +17,7 @@ import { type LucideIcon, } from "lucide-react"; import { PageHead } from "@/components/mockup-ui"; +import { PlanningTiles } from "@/components/planning/conflicts-tile"; import { buttonCls } from "@/components/work-orders/button-cls"; import { pageContext } from "@/components/work-orders/page-context"; import { Field, inputCls } from "@/components/work-orders/ui"; @@ -164,6 +165,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr ); })} + )} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 8f77b5e..13ff9d7 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -67,7 +67,8 @@ export default async function AppLayout({