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/messages/de/notifications.json b/messages/de/notifications.json index 3689c31..b11cf65 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -45,7 +45,10 @@ "emergency_completed": "Notdiensteinsatz abgeschlossen", "import_ready_for_review": "Import zur Prüfung", "import_failed": "Fehler bei Dokumentverarbeitung", - "sync_failed": "Synchronisationsfehler" + "sync_failed": "Synchronisationsfehler", + "planning_capacity_freed": "Früher fertig", + "planning_overrun": "Geplante Dauer überschritten", + "planning_followup_at_risk": "Folgeauftrag gefährdet" }, "events": { "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, @@ -64,7 +67,10 @@ "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." }, - "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." } + "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({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": { "unknown": "unbekannt", @@ -178,7 +184,8 @@ "voice_note": "Sprachnotiz", "notification": "Benachrichtigung", "notification_settings": "Benachrichtigungseinstellungen", - "sync_operation": "Synchronisation" + "sync_operation": "Synchronisation", + "planning_alert": "Planungshinweis" } } } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 3206fdf..2bb02d6 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -45,7 +45,10 @@ "emergency_completed": "Emergency call-out completed", "import_ready_for_review": "Import ready for review", "import_failed": "Document processing failed", - "sync_failed": "Sync failed" + "sync_failed": "Sync failed", + "planning_capacity_freed": "Finished early", + "planning_overrun": "Planned duration exceeded", + "planning_followup_at_risk": "Follow-up order at risk" }, "events": { "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, @@ -64,7 +67,10 @@ "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." }, - "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." } + "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({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": { "unknown": "unknown", @@ -178,7 +184,8 @@ "voice_note": "Voice note", "notification": "Notification", "notification_settings": "Notification settings", - "sync_operation": "Sync" + "sync_operation": "Sync", + "planning_alert": "Planning alert" } } } 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-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/api/v1/planning/board/route.ts b/src/app/api/v1/planning/board/route.ts new file mode 100644 index 0000000..3c00ba5 --- /dev/null +++ b/src/app/api/v1/planning/board/route.ts @@ -0,0 +1,24 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; +import { getPlanningBoard, weekRange } from "@/server/services/planning/board"; + +/** + * GET /api/v1/planning/board?from=YYYY-MM-DD&to=YYYY-MM-DD&teamId=…&orderTypeId=…&priority=… + * Default range: current week (Mon–Sun, tenant timezone), max. 42 days. Backoffice: all teams; + * team leads: own teams (read-only). Access is checked in the service. + */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + const sp = new URL(req.url).searchParams; + const week = await weekRange(ctx, sp.get("from")); + const teamIds = sp.getAll("teamId").filter(Boolean); + return json( + await getPlanningBoard(ctx, { + from: sp.get("from") ?? week.from, + to: sp.get("to") ?? week.to, + teamIds: teamIds.length ? teamIds : undefined, + orderTypeId: sp.get("orderTypeId") || undefined, + priority: (sp.get("priority") || undefined) as "low" | "normal" | "high" | "urgent" | undefined, + }), + ); +}); diff --git a/src/app/api/v1/planning/live/route.ts b/src/app/api/v1/planning/live/route.ts new file mode 100644 index 0000000..58256a8 --- /dev/null +++ b/src/app/api/v1/planning/live/route.ts @@ -0,0 +1,19 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; +import { getLiveSituation, LIVE_STATUSES, type LiveStatus } from "@/server/services/planning/live"; + +/** + * GET /api/v1/planning/live?teamId=…&status=en_route|working|paused|free — polled every 30 s by + * /planning/live. Location = site of the running order; never device coordinates. + */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + const sp = new URL(req.url).searchParams; + const status = sp.get("status"); + return json( + await getLiveSituation(ctx, { + teamId: sp.get("teamId") || undefined, + status: status && (LIVE_STATUSES as readonly string[]).includes(status) ? (status as LiveStatus) : undefined, + }), + ); +}); diff --git a/src/app/api/v1/planning/recommendations/route.ts b/src/app/api/v1/planning/recommendations/route.ts new file mode 100644 index 0000000..7a25c94 --- /dev/null +++ b/src/app/api/v1/planning/recommendations/route.ts @@ -0,0 +1,25 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, withApi } from "@/server/api/respond"; +import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend"; + +/** + * GET /api/v1/planning/recommendations?workOrderId=…&days=10&radiusKm=25&nearbyRadiusKm=5&locale=de + * Suggestions only (straight-line distance + free capacity); nothing is saved. + */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders", "work_order:assign"); + const sp = new URL(req.url).searchParams; + const workOrderId = sp.get("workOrderId"); + if (!workOrderId) throw new ApiError("invalid", "workOrderId required"); + const locale = sp.get("locale") === "en" ? "en" : "de"; + const [recommendations, nearby] = await Promise.all([ + recommendAssignments(ctx, { + workOrderId, + days: sp.get("days") ?? undefined, + radiusKm: sp.get("radiusKm") ?? undefined, + locale, + }), + findNearbyUnplanned(ctx, { workOrderId, radiusKm: sp.get("nearbyRadiusKm") ?? undefined }), + ]); + return json({ ...recommendations, nearby }); +}); diff --git a/src/app/api/v1/planning/schedule/route.ts b/src/app/api/v1/planning/schedule/route.ts new file mode 100644 index 0000000..714483b --- /dev/null +++ b/src/app/api/v1/planning/schedule/route.ts @@ -0,0 +1,15 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; +import { scheduleWorkOrder, type ScheduleInput } from "@/server/services/planning/schedule"; + +/** + * POST /api/v1/planning/schedule — body { workOrderId, teamId, plannedStart, plannedEnd?, plannedDurationMinutes?, baseVersion }. + * Assign + dates in one transaction (L2 services, event work_order.assigned, audit). 409 conflict when + * the order changed meanwhile ("Auftrag wurde zwischenzeitlich geändert"). Response contains the + * conflicts of the target team on the target day(s). + */ +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders", "work_order:assign"); + const body = await readJsonObject(req); + return json(await scheduleWorkOrder(ctx, body as ScheduleInput)); +}); diff --git a/src/lib/api/openapi.ts b/src/lib/api/openapi.ts index eebd29d..0dfbdc8 100644 --- a/src/lib/api/openapi.ts +++ b/src/lib/api/openapi.ts @@ -1106,6 +1106,139 @@ const paths: Record> = { responses: { "200": binaryResponse("Datei"), ...errors("not_found") }, }), }, + // ---------- L13 Planung ---------- + "/planning/board": { + get: op({ + tag: "Planung", + operationId: "getPlanningBoard", + summary: "Plantafel: Aufträge je Team und Tag mit Kapazität, Auslastung und Konflikten", + description: + "Standard-Zeitraum: aktuelle Woche (Mo–So, Mandanten-Zeitzone), maximal 42 Tage. Teams sind Kolonnen: Kapazität = `dailyCapacityMinutes` je Arbeitstag (nicht je Person). `work_order:read_all` sieht alle Teams (Einplanen mit `work_order:assign` + `work_order:write`); Teamleiter (`work_order:read_team` + Leitung eines Teams) nur eigene Teams, lesend; sonst 403. Konflikte (`severity: conflict`): `overbooked`, `overlap`, `assignee_double_booked`, `outside_working_days`; Hinweis (`severity: hint`, nicht in `conflictCount`): `crew_incomplete` (< 2 aktive Personen). Liegt heute im Zeitraum, tragen Karten `delay` (≥ 80 % erfasste Kolonnenzeit) bzw. `atRisk` (Folgeauftrag gefährdet). Filter `orderTypeId`/`priority` wirken auf die angezeigten Karten, nicht auf die Auslastung.", + module: "work_orders", + permissions: [], + parameters: [ + query("from", str({ format: "date" }), "Erster Tag (YYYY-MM-DD)"), + query("to", str({ format: "date" }), "Letzter Tag (YYYY-MM-DD)"), + { name: "teamId", in: "query", required: false, schema: arr(str()), style: "form", explode: true, description: "mehrfach möglich" }, + query("orderTypeId", str()), + query("priority", str({ enum: ["low", "normal", "high", "urgent"] })), + ], + responses: { + "200": jsonResponse( + "Plantafel", + open( + { + from: str({ format: "date" }), + to: str({ format: "date" }), + timeZone: str(), + days: arr(obj({ key: str({ format: "date" }), weekday: int({ minimum: 0, maximum: 6 }) })), + teams: arr(open({ id: str(), name: str(), dailyCapacityMinutes: int({ description: "Kolonnen-Arbeitstag in Minuten" }), workingDays: int({ description: "Bitmaske Mo=1 … So=64" }), days: arr(open({ day: str(), memberCount: int(), capacityMinutes: int(), plannedMinutes: int(), utilization: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) })) })), + orders: { type: "object", additionalProperties: { type: "object" } }, + unplanned: arr({ type: "object" }), + conflictCount: int(), + }, + ["from", "to", "teams", "orders", "unplanned"], + ), + ), + ...errors("unprocessable"), + }, + }), + }, + "/planning/schedule": { + post: op({ + tag: "Planung", + operationId: "scheduleWorkOrder", + summary: "Auftrag einplanen (Team + Termin) – atomar über Zuweisung und Auftragsänderung", + description: + "Nutzt `assignWorkOrder` (Event `work_order.assigned`) und `updateWorkOrder` in einer Transaktion; Audit. Nur Status draft/review_required/planned/assigned/accepted. Veraltete `baseVersion` → 409 („Auftrag wurde zwischenzeitlich geändert“). Antwort enthält die Konflikte des Zielteams am Zieltag.", + module: "work_orders", + permissions: ["work_order:assign", "work_order:write"], + requestBody: jsonBody( + obj( + { + workOrderId: str(), + teamId: str(), + plannedStart: dateTime(), + plannedEnd: nDateTime({ description: "weggelassen: bisherige Länge bleibt erhalten" }), + plannedDurationMinutes: { type: ["integer", "null"], minimum: 15, maximum: 20160 }, + baseVersion: int({ minimum: 1 }), + }, + ["workOrderId", "teamId", "plannedStart", "baseVersion"], + ), + ), + responses: { + "200": jsonResponse( + "Eingeplant", + open({ id: str(), number: str(), version: int(), status: str({ enum: WORK_ORDER_STATUSES }), teamId: str(), plannedStart: dateTime(), plannedEnd: nDateTime(), plannedDurationMinutes: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) }, ["id", "version", "conflicts"]), + ), + ...errors("not_found", "conflict", "unprocessable"), + }, + }), + }, + "/planning/recommendations": { + get: op({ + tag: "Planung", + operationId: "getPlanningRecommendations", + summary: "Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge", + description: + "Nur Vorschläge, nichts wird gespeichert. Ohne Koordinaten am Objekt: `status: no_coordinates` + Hinweis; Verortung wird im Hintergrund angestoßen. Top 5 nach Score (Distanz dominant, freie Kapazität, frühes Datum gewichtet nach Priorität).", + module: "work_orders", + permissions: ["work_order:assign"], + parameters: [ + { name: "workOrderId", in: "query", required: true, schema: str() }, + query("days", int({ minimum: 1, maximum: 31, default: 10 })), + query("radiusKm", num({ minimum: 0.5, maximum: 200, default: 25 })), + query("nearbyRadiusKm", num({ minimum: 0.5, maximum: 100, default: 5 })), + query("locale", str({ enum: ["de", "en"], default: "de" })), + ], + responses: { + "200": jsonResponse( + "Empfehlungen", + open( + { + status: str({ enum: ["ok", "no_coordinates", "not_schedulable"] }), + hint: nstr(), + requiredMinutes: int(), + recommendations: arr(open({ teamId: str(), teamName: str(), day: str({ format: "date" }), distanceKm: num(), nearOrder: obj({ id: str(), number: str() }), freeMinutes: int(), tight: bool(), score: num(), suggestedStart: dateTime(), text: str() })), + nearby: open({ status: str(), radiusKm: num(), items: arr({ type: "object" }) }), + }, + ["status", "recommendations", "nearby"], + ), + ), + ...errors("not_found", "unprocessable"), + }, + }), + }, + "/planning/live": { + get: op({ + tag: "Planung", + operationId: "getLiveSituation", + summary: "Live-Lage der Monteure (ohne GPS)", + description: + "Standort = Einsatzort (Objekt) des Auftrags der aktiven WorkSession; Status aus der Session (`en_route`, `working`, `paused`, sonst `free`). Es werden keine Geräte-Koordinaten gelesen oder ausgeliefert. `crews`: ein Eintrag je Team/Kolonne mit individuellem Status der Mitglieder und Verzugswarnung (`delay`); `freed`: Kolonnen, die heute früher fertig wurden, mit Vorzieh-/Umkreis-Vorschlägen (nur Vorschlag). Teamleiter: nur eigene Teams.", + module: "work_orders", + permissions: [], + parameters: [query("teamId", str()), query("status", str({ enum: ["en_route", "working", "paused", "free"] }))], + responses: { + "200": jsonResponse( + "Live-Lage", + open( + { + generatedAt: dateTime(), + crews: arr(open({ teamId: str(), teamName: str(), status: str(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, members: arr({ type: "object" }), freedMinutes: { type: ["integer", "null"] } })), + technicians: arr(open({ userId: str(), name: str(), crewId: nstr(), status: str(), since: nDateTime(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, next: arr({ type: "object" }) })), + freed: arr({ type: "object" }), + counts: { type: "object" }, + attention: arr({ type: "object" }), + withoutLocation: int(), + }, + ["generatedAt", "technicians"], + ), + ), + ...errors("unprocessable"), + }, + }), + }, "/openapi.json": { get: op({ tag: "Meta", @@ -1137,6 +1270,7 @@ export const openApiDocument = { { name: "Import", description: "Dokumentenimport mit KI-Extraktion" }, { name: "Berichte", description: "Tages-/Abschlussberichte" }, { name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" }, + { name: "Planung", description: "Plantafel, Live-Lage, Einsatz-Empfehlungen" }, { name: "Meta" }, ], paths, diff --git a/src/lib/events.ts b/src/lib/events.ts index 0a05092..1b5cea3 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -19,6 +19,10 @@ export const EVENT_TYPES = [ "import.ready_for_review", "import.failed", "sync.failed", + // L13 Planung (emitted only by the planning-watch job; in-app to back office + team leads) + "planning.capacity_freed", + "planning.overrun", + "planning.followup_at_risk", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; diff --git a/src/server/actions/work_orders/planning.ts b/src/server/actions/work_orders/planning.ts new file mode 100644 index 0000000..755ad7b --- /dev/null +++ b/src/server/actions/work_orders/planning.ts @@ -0,0 +1,27 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { moduleGuard } from "@/server/action-guard"; +import { toActionError, type ActionState } from "@/server/api/action-state"; +import { ctxFromGuard } from "@/server/services/context"; +import { updateTeamPlanningSettings } from "@/server/services/planning/team-settings"; + +const guard = moduleGuard("work_orders"); + +/** L13 Planung: crew capacity (working day in minutes) + working days (popup on the planning board). */ +export async function saveTeamCapacityAction(teamId: string, returnTo: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("team:manage")); + const workingDays = fd + .getAll("day") + .map(Number) + .filter((n) => [1, 2, 4, 8, 16, 32, 64].includes(n)) + .reduce((mask, bit) => mask | bit, 0); + await updateTeamPlanningSettings(ctx, teamId, { dailyCapacityMinutes: String(fd.get("minutes") ?? ""), workingDays }); + } catch (err) { + return toActionError(err); + } + revalidatePath("/planning"); + redirect(returnTo.startsWith("/planning") && !returnTo.startsWith("//") ? returnTo : "/planning"); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index bc7653b..204dd5c 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -13,6 +13,8 @@ export const PROCESSORS: Partial Promise import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle "image-derivatives": () => import("./image-derivatives").then((m) => m.process), "ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker) + "geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim) + "planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min) }; /** Inline fallback when no Redis is available (dev/demo). */ diff --git a/src/server/jobs/processors/planning-watch.ts b/src/server/jobs/processors/planning-watch.ts new file mode 100644 index 0000000..1b43a3e --- /dev/null +++ b/src/server/jobs/processors/planning-watch.ts @@ -0,0 +1,17 @@ +import { runPlanningWatch, runPlanningWatchAllTenants } from "@/server/services/planning/watch"; +import type { JobPayload } from "../queues"; + +/** + * Queue "planning-watch" (L13 Planung): every 5 min (job scheduler registered by the craftvia worker) + * evaluates running orders (delay ≥ 100 % → planning.overrun, follow-up order at risk → + * planning.followup_at_risk) and early completions (planning.capacity_freed). Deduplicated via the + * audit-log ledger; payload tenantId "*" = all tenants. + */ +export async function process(payload: JobPayload): Promise { + if (!payload?.tenantId || payload.tenantId === "*") { + const r = await runPlanningWatchAllTenants(); + if (r.overrun || r.followupAtRisk || r.capacityFreed || r.failed) console.info(`[planning-watch] ${JSON.stringify(r)}`); + return; + } + await runPlanningWatch(payload.tenantId); +} diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts index f310555..d62cfdb 100644 --- a/src/server/jobs/queues.ts +++ b/src/server/jobs/queues.ts @@ -13,6 +13,8 @@ export const JOB_QUEUES = { reportPdf: "report-pdf", imageDerivatives: "image-derivatives", aiRetention: "ai-retention", + geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s) + planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min } as const; export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES]; @@ -97,6 +99,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise { } finally { await q.close(); } + // L13 Planung: planning watch every 5 minutes (all tenants) + const watch = new Queue(JOB_QUEUES.planningWatch, { connection }); + try { + await watch.upsertJobScheduler( + "planning-watch-5min", + { every: 5 * 60 * 1000 }, + { name: JOB_QUEUES.planningWatch, data: { tenantId: "*", entityId: "watch" }, opts: { removeOnComplete: { count: 50 }, removeOnFail: { count: 50 } } }, + ); + } finally { + await watch.close(); + } } export async function closeJobQueues(): Promise { diff --git a/src/server/services/notifications/handle-event.ts b/src/server/services/notifications/handle-event.ts index aa09d5e..77c7a8f 100644 --- a/src/server/services/notifications/handle-event.ts +++ b/src/server/services/notifications/handle-event.ts @@ -136,6 +136,11 @@ function textVars(locale: Locale, f: EventFacts): Record { + if (can(ctx, "work_order:read_all")) { + return { + all: true, + teamIds: null, + canSchedule: can(ctx, "work_order:assign") && can(ctx, "work_order:write"), + canManageTeams: can(ctx, "team:manage"), + }; + } + if (can(ctx, "work_order:read_team")) { + const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } }); + if (led.length > 0) return { all: false, teamIds: led.map((t) => t.id), canSchedule: false, canManageTeams: false }; + } + throw new ServiceError("forbidden", "planning not allowed"); +} + +/** Recommendations and scheduling are backoffice functions (all teams + assign right). */ +export async function assertCanPlanAll(ctx: ServiceCtx): Promise { + const access = await planningAccess(ctx); + if (!access.all || !can(ctx, "work_order:assign")) throw new ServiceError("forbidden", "missing permission work_order:assign"); + return access; +} diff --git a/src/server/services/planning/board.ts b/src/server/services/planning/board.ts new file mode 100644 index 0000000..830832e --- /dev/null +++ b/src/server/services/planning/board.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; +import { + assigneeDoubleBookings, + computeTeamDay, + isConflict, + type ConflictKind, + type PlanningConflict, + type TeamDay, +} from "@/lib/planning/capacity"; +import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, startOfWeek, weekdayIndex } from "@/lib/planning/days"; +import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { planningAccess, type PlanningAccess } from "@/server/services/planning/access"; +import { + loadPlannedOrders, + loadTeams, + loadUnplannedOrders, + toBoardOrder, + toPlanningOrder, + toPlanningTeam, + type BoardOrder, +} from "@/server/services/planning/data"; +import { evaluateDelays } from "@/server/services/planning/watch"; +import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared"; + +/** + * Planning board (L13, Plantafel): per crew (team) and day planned orders, crew capacity, utilization, + * conflicts (+ crew_incomplete hints) and — when today is in range — delay/at-risk markers. + */ + +export const MAX_BOARD_DAYS = 42; + +const dayKey = z.string().refine(isDayKey, "invalid_day"); +const boardSchema = z.object({ + from: dayKey, + to: dayKey, + teamIds: z.array(z.string().min(1).max(64)).max(100).optional(), + orderTypeId: z.string().min(1).max(64).nullish(), + priority: z.enum(WORK_ORDER_PRIORITIES).nullish(), +}); +export type BoardInput = z.input; + +export type BoardTeamDay = TeamDay; + +export type BoardTeam = { + id: string; + name: string; + leaderName: string | null; + dailyCapacityMinutes: number; + workingDays: number; + members: { userId: string; name: string }[]; + days: BoardTeamDay[]; +}; + +export type PlanningBoard = { + from: string; + to: string; + today: string; + timeZone: string; + days: { key: string; weekday: number }[]; + teams: BoardTeam[]; + orders: Record; + unplanned: BoardOrder[]; + /** Conflicts and hints (severity); `conflictCount` counts conflicts only. */ + conflicts: PlanningConflict[]; + conflictCount: number; + access: PlanningAccess; +}; + +export async function getPlanningBoard(ctx: ServiceCtx, raw: BoardInput, opts: { now?: Date } = {}): Promise { + const input = parseInput(boardSchema, raw); + if (input.to < input.from || dayRange(input.from, input.to).length > MAX_BOARD_DAYS) throw new ServiceError("invalid", "invalid_range"); + const access = await planningAccess(ctx); + const timeZone = await tenantTimezone(ctx); + const now = opts.now ?? new Date(); + const today = dayKeyOf(now, timeZone); + + let teamIds = access.teamIds; + if (input.teamIds?.length) teamIds = teamIds ? teamIds.filter((id) => input.teamIds!.includes(id)) : input.teamIds; + const days = dayRange(input.from, input.to); + const start = dayBounds(input.from, timeZone).start; + const end = dayBounds(input.to, timeZone).end; + + const teams = await loadTeams(ctx, teamIds); + const [rows, unplannedRows] = await Promise.all([ + loadPlannedOrders(ctx, { teamIds: teams.map((t) => t.id), start, end }), + loadUnplannedOrders(ctx, access.teamIds), + ]); + const planningOrders = rows.map(toPlanningOrder); + const visible = (r: { orderTypeId: string | null; priority: string }) => + (!input.orderTypeId || r.orderTypeId === input.orderTypeId) && (!input.priority || r.priority === input.priority); + const visibleIds = new Set(rows.filter(visible).map((r) => r.id)); + + const doubleBookings = days.flatMap((day) => assigneeDoubleBookings(planningOrders, day, timeZone)); + const conflicts: PlanningConflict[] = [...doubleBookings]; + + const boardTeams: BoardTeam[] = teams.map((team) => { + const pt = toPlanningTeam(team); + const teamOrders = planningOrders.filter((o) => o.teamId === team.id); + const members = new Map(); + for (const m of team.members) { + if (m.user.status === "ACTIVE" && m.validFrom < end && (!m.validTo || m.validTo > start)) members.set(m.userId, m.user.name); + } + return { + id: team.id, + name: team.name, + leaderName: team.leader?.name ?? null, + dailyCapacityMinutes: team.dailyCapacityMinutes, + workingDays: team.workingDays, + members: [...members].map(([userId, name]) => ({ userId, name })), + days: days.map((day) => { + const td = computeTeamDay(pt, day, teamOrders, timeZone); + conflicts.push(...td.conflicts); + return { + ...td, + orderIds: td.orderIds.filter((id) => visibleIds.has(id)), + conflicts: [...td.conflicts, ...doubleBookings.filter((c) => c.day === day && c.teamIds.includes(team.id))], + }; + }), + }; + }); + + const kindsByOrder = new Map>(); + for (const c of conflicts.filter(isConflict)) { + for (const id of c.orderIds) { + const set = kindsByOrder.get(id) ?? new Set(); + set.add(c.kind); + kindsByOrder.set(id, set); + } + } + const orders: Record = Object.fromEntries(rows.filter(visible).map((r) => [r.id, toBoardOrder(r, timeZone, [...(kindsByOrder.get(r.id) ?? [])])])); + + // Delay (≥ 80 %) and endangered follow-up orders — only meaningful when today is shown. + if (today >= input.from && today <= input.to && teams.length > 0) { + const { delays, followups } = await evaluateDelays(ctx, { now, teamIds: teams.map((t) => t.id) }); + for (const d of delays) { + if (d.level !== "ok" && orders[d.workOrderId]) orders[d.workOrderId].delay = { level: d.level, percent: d.percent, workedMinutes: d.workedMinutes }; + } + for (const f of followups) { + if (orders[f.workOrderId]) orders[f.workOrderId].atRisk = { byOrderId: f.blockedById, byNumber: f.blockedByNumber, reason: f.reason }; + } + } + + return { + from: input.from, + to: input.to, + today, + timeZone, + days: days.map((key) => ({ key, weekday: weekdayIndex(key) })), + teams: boardTeams, + orders, + unplanned: unplannedRows.filter(visible).map((r) => toBoardOrder(r, timeZone)), + conflicts, + conflictCount: conflicts.filter(isConflict).length, + access, + }; +} + +/** Mon–Sun of the week containing `day` (default: today in the tenant timezone). */ +export async function weekRange(ctx: ServiceCtx, day?: string | null): Promise<{ from: string; to: string; today: string }> { + const today = dayKeyOf(new Date(), await tenantTimezone(ctx)); + const from = startOfWeek(day && isDayKey(day) ? day : today); + return { from, to: addDays(from, 6), today }; +} + +/** Dashboard tile "Konflikte diese Woche" (Mon–Sun of the current week in the tenant timezone). */ +export async function countWeekConflicts(ctx: ServiceCtx): Promise<{ count: number; from: string } | null> { + try { + const today = dayKeyOf(new Date(), await tenantTimezone(ctx)); + const from = startOfWeek(today); + const board = await getPlanningBoard(ctx, { from, to: addDays(from, 6) }); + return { count: board.conflictCount, from }; + } catch (err) { + if (err instanceof ServiceError && err.code === "forbidden") return null; + throw err; + } +} diff --git a/src/server/services/planning/data.ts b/src/server/services/planning/data.ts new file mode 100644 index 0000000..e4b239e --- /dev/null +++ b/src/server/services/planning/data.ts @@ -0,0 +1,184 @@ +import type { Prisma } from "@prisma/client"; +import { isValidLatLng } from "@/lib/geo/distance"; +import { effectiveDurationMinutes, hasTimeOfDay, orderDays, type PlanningOrder, type PlanningTeam } from "@/lib/planning/capacity"; +import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ServiceCtx } from "@/server/services/context"; +import { customerDisplayName } from "@/server/services/work-orders/options"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Shared loaders + DTOs of the planning services (tenant via ctx.db, visibility via workOrderScope). */ + +/** Unplanned = open planning statuses without team or without planned start (+ assigned/accepted without date). */ +export const UNPLANNED_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"]; +const NEEDS_TEAM_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned"]; + +export const ORDER_SELECT = { + id: true, + number: true, + title: true, + status: true, + priority: true, + version: true, + plannedStart: true, + plannedEnd: true, + plannedDurationMinutes: true, + assignedTeamId: true, + orderTypeId: true, + isEmergency: true, + orderType: { select: { name: true, defaultDurationMinutes: true } }, + customer: { select: { companyName: true, firstName: true, lastName: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true, geocodeStatus: true } }, + assignees: { select: { userId: true } }, +} as const satisfies Prisma.WorkOrderSelect; + +export type OrderRow = Prisma.WorkOrderGetPayload<{ select: typeof ORDER_SELECT }>; + +export type BoardOrder = { + id: string; + number: string; + title: string; + status: WorkOrderStatus; + statusGroup: StatusGroup; + priority: "low" | "normal" | "high" | "urgent"; + version: number; + isEmergency: boolean; + customerName: string; + siteId: string | null; + siteName: string | null; + siteAddress: string | null; + hasCoordinates: boolean; + orderTypeId: string | null; + orderTypeName: string | null; + teamId: string | null; + plannedStart: string | null; + plannedEnd: string | null; + plannedDurationMinutes: number | null; + durationMinutes: number; + allDay: boolean; + multiDay: boolean; + assigneeIds: string[]; + conflictKinds: string[]; + /** Running order: recorded crew work time vs. planned duration (≥ 80 % → risk, ≥ 100 % → overrun). */ + delay: { level: "risk" | "overrun"; percent: number; workedMinutes: number } | null; + /** Planned follow-up order endangered by the delay of another order of the same crew. */ + atRisk: { byOrderId: string; byNumber: string; reason: "start" | "capacity" } | null; +}; + +export function toPlanningOrder(r: OrderRow): PlanningOrder { + return { + id: r.id, + teamId: r.assignedTeamId, + plannedStart: r.plannedStart, + plannedEnd: r.plannedEnd, + plannedDurationMinutes: r.plannedDurationMinutes, + orderTypeDefaultMinutes: r.orderType?.defaultDurationMinutes ?? null, + assigneeIds: r.assignees.map((a) => a.userId), + }; +} + +export function siteAddress(s: { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null } | null): string | null { + if (!s) return null; + const line = [[s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(", "); + return line || null; +} + +export function toBoardOrder(r: OrderRow, timeZone: string, conflictKinds: string[] = []): BoardOrder { + const po = toPlanningOrder(r); + return { + id: r.id, + number: r.number, + title: r.title, + status: r.status as WorkOrderStatus, + statusGroup: STATUS_GROUP[r.status as WorkOrderStatus], + priority: r.priority, + version: r.version, + isEmergency: r.isEmergency, + customerName: customerDisplayName(r.customer), + siteId: r.site?.id ?? null, + siteName: r.site?.name ?? null, + siteAddress: siteAddress(r.site), + hasCoordinates: isValidLatLng(r.site), + orderTypeId: r.orderTypeId, + orderTypeName: r.orderType?.name ?? null, + teamId: r.assignedTeamId, + plannedStart: r.plannedStart?.toISOString() ?? null, + plannedEnd: r.plannedEnd?.toISOString() ?? null, + plannedDurationMinutes: r.plannedDurationMinutes, + durationMinutes: effectiveDurationMinutes(po, timeZone), + allDay: !r.plannedStart || !hasTimeOfDay(r.plannedStart, timeZone), + multiDay: orderDays(po, timeZone).length > 1, + assigneeIds: po.assigneeIds, + conflictKinds, + delay: null, + atRisk: null, + }; +} + +export async function loadTeams(ctx: ServiceCtx, teamIds: string[] | null) { + return ctx.db.team.findMany({ + where: { deletedAt: null, status: "active", ...(teamIds ? { id: { in: teamIds } } : {}) }, + select: { + id: true, + name: true, + leaderUserId: true, + dailyCapacityMinutes: true, + workingDays: true, + leader: { select: { id: true, name: true, status: true } }, + members: { select: { userId: true, validFrom: true, validTo: true, user: { select: { name: true, status: true } } }, orderBy: { validFrom: "asc" } }, + }, + orderBy: { name: "asc" }, + }); +} + +export type TeamRow = Awaited>[number]; + +export function toPlanningTeam(t: TeamRow): PlanningTeam { + return { + id: t.id, + dailyCapacityMinutes: t.dailyCapacityMinutes, + workingDays: t.workingDays, + members: t.members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo, active: m.user.status === "ACTIVE" })), + }; +} + +/** Orders of the teams planned in [start, end) incl. multi-day orders overlapping the range. */ +export async function loadPlannedOrders(ctx: ServiceCtx, opts: { teamIds: string[]; start: Date; end: Date; excludeId?: string }): Promise { + if (opts.teamIds.length === 0) return []; + return ctx.db.workOrder.findMany({ + where: { + AND: [ + await workOrderScope(ctx), + { + assignedTeamId: { in: opts.teamIds }, + status: { not: "cancelled" }, + plannedStart: { lt: opts.end, not: null }, + OR: [{ plannedEnd: null, plannedStart: { gte: opts.start } }, { plannedEnd: { gt: opts.start } }], + }, + opts.excludeId ? { id: { not: opts.excludeId } } : {}, + ], + }, + select: ORDER_SELECT, + orderBy: [{ plannedStart: "asc" }, { number: "asc" }], + take: 3000, + }); +} + +export async function loadUnplannedOrders(ctx: ServiceCtx, teamIds: string[] | null, take = 200): Promise { + return ctx.db.workOrder.findMany({ + where: { + AND: [ + await workOrderScope(ctx), + { status: { in: UNPLANNED_STATUSES } }, + { OR: [{ plannedStart: null }, { assignedTeamId: null, status: { in: NEEDS_TEAM_STATUSES } }] }, + teamIds ? { assignedTeamId: { in: teamIds } } : {}, + ], + }, + select: ORDER_SELECT, + orderBy: [{ priority: "desc" }, { createdAt: "asc" }], + take, + }); +} + +export async function loadOrder(ctx: ServiceCtx, workOrderId: string): Promise { + return ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, await workOrderScope(ctx)] }, select: ORDER_SELECT }); +} diff --git a/src/server/services/planning/live.ts b/src/server/services/planning/live.ts new file mode 100644 index 0000000..6936d24 --- /dev/null +++ b/src/server/services/planning/live.ts @@ -0,0 +1,271 @@ +import { z } from "zod"; +import { isValidLatLng } from "@/lib/geo/distance"; +import { dayBounds, dayKeyOf } from "@/lib/planning/days"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ServiceCtx } from "@/server/services/context"; +import { planningAccess } from "@/server/services/planning/access"; +import { loadTeams, siteAddress } from "@/server/services/planning/data"; +import { trackedSessionWhere } from "@/server/services/planning/time-tracking"; +import { evaluateDelays, getFreedCapacity, type FreedTeam } from "@/server/services/planning/watch"; +import { customerDisplayName } from "@/server/services/work-orders/options"; +import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * Live situation (L13, Live-Lage) WITHOUT GPS: a technician is shown at the site of the order of + * his active WorkSession; the status comes from that session (en_route → Unterwegs, running → In + * Arbeit, paused → Pause, none → frei). Teams are crews ("Kolonnen"): ONE entry/marker per crew at + * the site of its running order with the individual status of each member; technicians without a + * team are shown individually. Device positions are never read: WorkSession.startLat/startLng, + * deviceInfo and photo coordinates are not selected and never part of the result (privacy decision). + */ + +export const LIVE_STATUSES = ["en_route", "working", "paused", "free"] as const; +export type LiveStatus = (typeof LIVE_STATUSES)[number]; + +const SESSION_STATUS: Record = { en_route: "en_route", running: "working", paused: "paused" }; +const RANK: Record = { working: 3, en_route: 2, paused: 1, free: 0 }; + +const liveSchema = z.object({ + teamId: z.string().min(1).max(64).nullish(), + status: z.enum(LIVE_STATUSES).nullish(), +}); + +export type LiveOrderRef = { + id: string; + number: string; + title: string; + customerName: string; + siteName: string | null; + address: string | null; + latitude: number | null; + longitude: number | null; +}; + +export type LiveDelay = { level: "risk" | "overrun"; percent: number; workedMinutes: number; plannedMinutes: number }; + +export type LiveTechnician = { + userId: string; + name: string; + teams: { id: string; name: string }[]; + /** Crew the person currently belongs to (team of the running session, else first team). */ + crewId: string | null; + status: LiveStatus; + since: string | null; + /** null = no active session; `visible=false` = session on an order outside the caller's scope */ + current: (LiveOrderRef & { visible: true }) | { visible: false } | null; + delay: LiveDelay | null; + next: { id: string; number: string; title: string; plannedStart: string; customerName: string; city: string | null }[]; +}; + +export type LiveCrew = { + teamId: string; + teamName: string; + /** Highest member status: In Arbeit > Unterwegs > Pause > frei */ + status: LiveStatus; + current: LiveOrderRef | null; + delay: LiveDelay | null; + members: { userId: string; name: string; status: LiveStatus; since: string | null; orderNumber: string | null }[]; + freedMinutes: number | null; +}; + +export type LiveSituation = { + generatedAt: string; + timeZone: string; + teams: { id: string; name: string }[]; + crews: LiveCrew[]; + technicians: LiveTechnician[]; + counts: Record; + attention: { id: string; number: string; title: string; plannedStart: string; teamName: string | null; customerName: string; reason: "not_accepted" | "overdue" }[]; + freed: FreedTeam[]; + withoutLocation: number; +}; + +export async function getLiveSituation(ctx: ServiceCtx, raw: z.input = {}, opts: { now?: Date } = {}): Promise { + const access = await planningAccess(ctx); + const input = parseInput(liveSchema, raw); + const timeZone = await tenantTimezone(ctx); + const now = opts.now ?? new Date(); + const today = dayBounds(dayKeyOf(now, timeZone), timeZone); + + const allTeams = await loadTeams(ctx, access.teamIds); + const teams = input.teamId ? allTeams.filter((tm) => tm.id === input.teamId) : allTeams; + + // People: active members today + team leaders of the visible teams. + const people = new Map(); + const addPerson = (userId: string, name: string, team: { id: string; name: string }) => { + const p = people.get(userId) ?? { name, teams: [] }; + if (!p.teams.some((x) => x.id === team.id)) p.teams.push(team); + people.set(userId, p); + }; + for (const team of teams) { + const ref = { id: team.id, name: team.name }; + for (const m of team.members) { + if (m.user.status === "ACTIVE" && m.validFrom <= now && (!m.validTo || m.validTo > now)) addPerson(m.userId, m.user.name, ref); + } + if (team.leader && team.leader.status === "ACTIVE") addPerson(team.leader.id, team.leader.name, ref); + } + + // Active clock sessions — explicit select: NEVER startLat/startLng/deviceInfo. + const sessionWhere = { AND: [trackedSessionWhere, { status: { in: ["en_route", "running", "paused"] as ("en_route" | "running" | "paused")[] } }] }; + const sessions = await ctx.db.workSession.findMany({ + where: access.all && !input.teamId ? sessionWhere : { AND: [sessionWhere, { userId: { in: [...people.keys()] } }] }, + select: { id: true, userId: true, teamId: true, status: true, startedAt: true, workOrderId: true }, + orderBy: { startedAt: "desc" }, + take: 1000, + }); + // Backoffice without team filter: also technicians with a session outside any team. + const unknown = [...new Set(sessions.map((s) => s.userId).filter((id) => !people.has(id)))]; + if (unknown.length) { + const users = await ctx.db.user.findMany({ where: { id: { in: unknown }, status: "ACTIVE" }, select: { id: true, name: true } }); + for (const u of users) people.set(u.id, { name: u.name, teams: [] }); + } + const sessionByUser = new Map(); + for (const s of sessions) if (!sessionByUser.has(s.userId) && people.has(s.userId)) sessionByUser.set(s.userId, s); + + const activeSessions = [...sessionByUser.values()]; + const openSegments = activeSessions.length + ? await ctx.db.timeEntry.findMany({ + where: { workSessionId: { in: activeSessions.map((s) => s.id) }, endedAt: null }, + select: { workSessionId: true, startedAt: true }, + orderBy: { startedAt: "desc" }, + }) + : []; + const segmentStart = new Map(); + for (const e of openSegments) if (!segmentStart.has(e.workSessionId)) segmentStart.set(e.workSessionId, e.startedAt); + + const scope = await workOrderScope(ctx); + const teamIds = teams.map((tm) => tm.id); + const userIds = [...people.keys()]; + const [currentOrders, todays, delayEval, freed] = await Promise.all([ + activeSessions.length + ? ctx.db.workOrder.findMany({ + where: { AND: [scope, { id: { in: [...new Set(activeSessions.map((s) => s.workOrderId))] } }] }, + select: { + id: true, + number: true, + title: true, + customer: { select: { companyName: true, firstName: true, lastName: true } }, + site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true } }, + }, + }) + : Promise.resolve([]), + ctx.db.workOrder.findMany({ + where: { + AND: [ + scope, + { + plannedStart: { gte: today.start, lt: today.end }, + status: { in: ["planned", "assigned", "accepted"] }, + OR: [...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []), ...(userIds.length ? [{ assignees: { some: { userId: { in: userIds } } } }] : [])], + }, + access.all && !input.teamId ? {} : { assignedTeamId: { in: teamIds } }, + ], + }, + select: { + id: true, + number: true, + title: true, + status: true, + plannedStart: true, + assignedTeamId: true, + team: { select: { name: true } }, + customer: { select: { companyName: true, firstName: true, lastName: true } }, + site: { select: { city: true } }, + assignees: { select: { userId: true } }, + }, + orderBy: { plannedStart: "asc" }, + take: 500, + }), + evaluateDelays(ctx, { now, teamIds: access.all && !input.teamId ? null : teamIds }), + getFreedCapacity(ctx, { now }), + ]); + const orderById = new Map(currentOrders.map((o) => [o.id, o])); + const delayById = new Map( + delayEval.delays + .filter((d) => d.level !== "ok") + .map((d) => [d.workOrderId, { level: d.level as "risk" | "overrun", percent: d.percent, workedMinutes: d.workedMinutes, plannedMinutes: d.plannedMinutes }]), + ); + + const technicians: LiveTechnician[] = [...people].map(([userId, p]) => { + const session = sessionByUser.get(userId); + const status: LiveStatus = session ? SESSION_STATUS[session.status] ?? "free" : "free"; + const order = session ? orderById.get(session.workOrderId) : undefined; + let current: LiveTechnician["current"] = null; + if (session && order) { + const coords = isValidLatLng(order.site) ? { latitude: order.site!.latitude as number, longitude: order.site!.longitude as number } : { latitude: null, longitude: null }; + current = { + visible: true, + id: order.id, + number: order.number, + title: order.title, + customerName: customerDisplayName(order.customer), + siteName: order.site?.name ?? null, + address: siteAddress(order.site), + ...coords, + }; + } else if (session) { + current = { visible: false }; + } + const myTeams = new Set(p.teams.map((x) => x.id)); + const crewId = session?.teamId && myTeams.has(session.teamId) ? session.teamId : p.teams[0]?.id ?? null; + const next = todays + .filter((o) => (o.assignees.length ? o.assignees.some((a) => a.userId === userId) : !!o.assignedTeamId && myTeams.has(o.assignedTeamId))) + .slice(0, 3) + .map((o) => ({ id: o.id, number: o.number, title: o.title, plannedStart: o.plannedStart!.toISOString(), customerName: customerDisplayName(o.customer), city: o.site?.city ?? null })); + const since = session ? (segmentStart.get(session.id) ?? session.startedAt).toISOString() : null; + const delay = current?.visible ? delayById.get(current.id) ?? null : null; + return { userId, name: p.name, teams: p.teams, crewId, status, since, current, delay, next }; + }); + + const counts = Object.fromEntries(LIVE_STATUSES.map((s) => [s, technicians.filter((x) => x.status === s).length])) as Record; + const matches = (x: { status: LiveStatus }) => !input.status || x.status === input.status; + const freedByTeam = new Map(freed.teams.map((f) => [f.teamId, f.earlyMinutes])); + + // One entry per crew (team): members with their own status, crew order = order of the most active member. + const crews: LiveCrew[] = teams + .map((team) => { + const members = technicians.filter((x) => x.crewId === team.id).sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de")); + const lead = members.find((m) => m.current?.visible); + const current = lead?.current?.visible ? (({ visible: _v, ...rest }) => (void _v, rest))(lead.current) : null; + return { + teamId: team.id, + teamName: team.name, + status: members[0]?.status ?? ("free" as LiveStatus), + current, + delay: current ? delayById.get(current.id) ?? null : null, + members: members.map((m) => ({ userId: m.userId, name: m.name, status: m.status, since: m.since, orderNumber: m.current?.visible ? m.current.number : null })), + freedMinutes: freedByTeam.get(team.id) ?? null, + }; + }) + .filter((c) => c.members.length > 0 && matches(c)); + + const filtered = technicians + .filter(matches) + .sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de")); + + const attention = todays + .filter((o) => o.plannedStart! < now) + .map((o) => ({ + id: o.id, + number: o.number, + title: o.title, + plannedStart: o.plannedStart!.toISOString(), + teamName: o.team?.name ?? null, + customerName: customerDisplayName(o.customer), + reason: ((o.status as WorkOrderStatus) === "accepted" ? "overdue" : "not_accepted") as "overdue" | "not_accepted", + })); + + return { + generatedAt: now.toISOString(), + timeZone, + teams: allTeams.map((tm) => ({ id: tm.id, name: tm.name })), + crews, + technicians: filtered, + counts, + attention, + freed: input.teamId ? freed.teams.filter((f) => f.teamId === input.teamId) : freed.teams, + // running orders (distinct) whose site has no coordinates — shown in the list only + withoutLocation: new Set(filtered.flatMap((x) => (x.current?.visible && x.current.latitude === null ? [x.current.id] : []))).size, + }; +} diff --git a/src/server/services/planning/recommend.ts b/src/server/services/planning/recommend.ts new file mode 100644 index 0000000..96ac15d --- /dev/null +++ b/src/server/services/planning/recommend.ts @@ -0,0 +1,222 @@ +import { z } from "zod"; +import deMessages from "../../../../messages/de/planning.json"; +import enMessages from "../../../../messages/en/planning.json"; +import { formatDistance, haversineKm, isValidLatLng } from "@/lib/geo/distance"; +import { computeTeamDay, effectiveDurationMinutes, minutesOnDay, timeWindow } from "@/lib/planning/capacity"; +import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, minutesOfDay } from "@/lib/planning/days"; +import { fillTemplate, formatDayShort, formatMinutes } from "@/lib/planning/text"; +import { wallTimeToUtc } from "@/lib/work-orders/time"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { requestSiteGeocoding } from "@/server/services/geo/dispatch"; +import { assertCanPlanAll } from "@/server/services/planning/access"; +import { + loadOrder, + loadPlannedOrders, + loadTeams, + loadUnplannedOrders, + toBoardOrder, + toPlanningOrder, + toPlanningTeam, + type OrderRow, +} from "@/server/services/planning/data"; +import { SCHEDULABLE_STATUSES } from "@/server/services/planning/schedule"; +import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared"; + +/** + * Assignment suggestions (L13): straight-line distance to orders a team already has on a day + + * free capacity that day. Suggestions only — the human always schedules (no auto-save). + * Score: distance dominates (0.6), then free capacity (0.2), early date (weight by priority), tight −0.25. + */ + +const MESSAGES = { de: deMessages, en: enMessages } as const; +type Locale = keyof typeof MESSAGES; + +const PRIORITY_DATE_WEIGHT = { low: 0.05, normal: 0.15, high: 0.25, urgent: 0.35 } as const; +const DAY_START_MINUTES = 8 * 60; +const LATEST_START_MINUTES = 18 * 60; + +const recommendSchema = z.object({ + workOrderId: z.string().min(1).max(64), + days: z.coerce.number().int().min(1).max(31).default(10), + radiusKm: z.coerce.number().min(0.5).max(200).default(25), + from: z.string().refine(isDayKey).optional(), + locale: z.enum(["de", "en"]).default("de"), +}); +export type RecommendInput = z.input; + +export type Recommendation = { + teamId: string; + teamName: string; + day: string; + distanceKm: number; + nearOrder: { id: string; number: string }; + freeMinutes: number; + requiredMinutes: number; + tight: boolean; + score: number; + suggestedStart: string; + text: string; +}; + +export type RecommendationResult = { + workOrderId: string; + number: string; + status: "ok" | "no_coordinates" | "not_schedulable"; + geocodeStatus: string | null; + requiredMinutes: number; + radiusKm: number; + recommendations: Recommendation[]; + hint: string | null; +}; + +async function loadTarget(ctx: ServiceCtx, workOrderId: string): Promise { + const row = await loadOrder(ctx, workOrderId); + if (!row) throw new ServiceError("not_found", "work_order_not_found"); + return row; +} + +/** Ask for geocoding when the target site has no coordinates (not for confirmed "not found"). */ +function nudgeGeocoding(ctx: ServiceCtx, row: OrderRow) { + if (row.site && row.site.geocodeStatus !== "not_found") void requestSiteGeocoding(ctx.tenantId, row.site.id); +} + +export async function recommendAssignments(ctx: ServiceCtx, raw: RecommendInput): Promise { + await assertCanPlanAll(ctx); + const input = parseInput(recommendSchema, raw); + const timeZone = await tenantTimezone(ctx); + const t = MESSAGES[input.locale as Locale].recommend; + const row = await loadTarget(ctx, input.workOrderId); + const target = toPlanningOrder(row); + const requiredMinutes = effectiveDurationMinutes({ ...target, plannedEnd: null }, timeZone); + const base = { + workOrderId: row.id, + number: row.number, + geocodeStatus: row.site?.geocodeStatus ?? null, + requiredMinutes, + radiusKm: input.radiusKm, + recommendations: [] as Recommendation[], + }; + + if (!SCHEDULABLE_STATUSES.includes(row.status)) return { ...base, status: "not_schedulable", hint: t.notSchedulable }; + if (!row.site || !isValidLatLng(row.site)) { + nudgeGeocoding(ctx, row); + return { ...base, status: "no_coordinates", hint: row.site?.geocodeStatus === "not_found" ? t.addressNotFound : t.noCoordinates }; + } + const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number }; + + const today = dayKeyOf(new Date(), timeZone); + const from = input.from && input.from > today ? input.from : today; + const days = dayRange(from, addDays(from, input.days - 1)); + const teams = await loadTeams(ctx, null); + const rows = await loadPlannedOrders(ctx, { + teamIds: teams.map((tm) => tm.id), + start: dayBounds(days[0], timeZone).start, + end: dayBounds(days[days.length - 1], timeZone).end, + excludeId: row.id, + }); + const byId = new Map(rows.map((r) => [r.id, r])); + const orders = rows.map(toPlanningOrder); + + const candidates: Omit[] = []; + for (const team of teams) { + const pt = toPlanningTeam(team); + const teamOrders = orders.filter((o) => o.teamId === team.id); + days.forEach((day, idx) => { + const td = computeTeamDay(pt, day, teamOrders, timeZone); + if (!td.workingDay || td.capacityMinutes <= 0) return; + const freeMinutes = td.capacityMinutes - td.plannedMinutes; + if (freeMinutes <= 0) return; + let near: { id: string; number: string; km: number } | null = null; + for (const o of teamOrders) { + if (minutesOnDay(o, day, timeZone) <= 0) continue; + const site = byId.get(o.id)?.site; + if (!site || !isValidLatLng(site)) continue; + const km = haversineKm(origin, { latitude: site.latitude as number, longitude: site.longitude as number }); + if (!near || km < near.km) near = { id: o.id, number: byId.get(o.id)!.number, km }; + } + if (!near || near.km > input.radiusKm) return; + const tight = freeMinutes < requiredMinutes; + const score = + 0.6 * (1 - near.km / input.radiusKm) + + 0.2 * (Math.min(freeMinutes / requiredMinutes, 2) / 2) + + PRIORITY_DATE_WEIGHT[row.priority] * (1 - idx / days.length) - + (tight ? 0.25 : 0); + + // Suggested start: 08:00 or right after the team's last timed order that day (15-min grid). + const lastEnd = teamOrders + .filter((o) => minutesOnDay(o, day, timeZone) > 0) + .map((o) => timeWindow(o, timeZone)?.end) + .filter((v): v is number => typeof v === "number") + .sort((a, b) => b - a)[0]; + let startMinutes = DAY_START_MINUTES; + if (lastEnd !== undefined) { + const m = Math.ceil(minutesOfDay(new Date(lastEnd), timeZone) / 15) * 15; + if (m > startMinutes && m <= LATEST_START_MINUTES) startMinutes = m; + } + const hh = String(Math.floor(startMinutes / 60)).padStart(2, "0"); + const mm = String(startMinutes % 60).padStart(2, "0"); + const suggestedStart = wallTimeToUtc(`${day}T${hh}:${mm}`, timeZone) ?? dayBounds(day, timeZone).start; + + candidates.push({ + teamId: team.id, + teamName: team.name, + day, + distanceKm: Math.round(near.km * 10) / 10, + nearOrder: { id: near.id, number: near.number }, + freeMinutes, + requiredMinutes, + tight, + score: Math.round(score * 1000) / 1000, + suggestedStart: suggestedStart.toISOString(), + }); + }); + } + + const locale = input.locale; + const recommendations = candidates + .sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm || a.day.localeCompare(b.day)) + .slice(0, 5) + .map((c) => ({ + ...c, + text: fillTemplate(c.tight ? t.reasonTight : t.reason, { + distance: formatDistance(c.distanceKm, locale), + number: c.nearOrder.number, + team: c.teamName, + day: formatDayShort(c.day, locale), + free: formatMinutes(c.freeMinutes, locale), + required: formatMinutes(c.requiredMinutes, locale), + }), + })); + + return { ...base, status: "ok", recommendations, hint: recommendations.length ? null : fillTemplate(t.none, { radius: input.radiusKm, days: input.days }) }; +} + +const nearbySchema = z.object({ + workOrderId: z.string().min(1).max(64), + radiusKm: z.coerce.number().min(0.5).max(100).default(5), +}); + +export type NearbyOrder = ReturnType & { distanceKm: number }; + +/** Other unplanned orders around the target ("zusammen einplanen?"). */ +export async function findNearbyUnplanned( + ctx: ServiceCtx, + raw: z.input, +): Promise<{ status: "ok" | "no_coordinates"; radiusKm: number; items: NearbyOrder[] }> { + await assertCanPlanAll(ctx); + const input = parseInput(nearbySchema, raw); + const timeZone = await tenantTimezone(ctx); + const row = await loadTarget(ctx, input.workOrderId); + if (!row.site || !isValidLatLng(row.site)) { + nudgeGeocoding(ctx, row); + return { status: "no_coordinates", radiusKm: input.radiusKm, items: [] }; + } + const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number }; + const items = (await loadUnplannedOrders(ctx, null, 500)) + .filter((r) => r.id !== row.id && r.site && isValidLatLng(r.site)) + .map((r) => ({ ...toBoardOrder(r, timeZone), distanceKm: Math.round(haversineKm(origin, { latitude: r.site!.latitude as number, longitude: r.site!.longitude as number }) * 10) / 10 })) + .filter((r) => r.distanceKm <= input.radiusKm) + .sort((a, b) => a.distanceKm - b.distanceKm) + .slice(0, 10); + return { status: "ok", radiusKm: input.radiusKm, items }; +} diff --git a/src/server/services/planning/schedule.ts b/src/server/services/planning/schedule.ts new file mode 100644 index 0000000..998d21d --- /dev/null +++ b/src/server/services/planning/schedule.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board"; +import { assignWorkOrder } from "@/server/services/work-orders/assign"; +import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * Schedule a work order from the planning board: team + planned start/end (+ duration) in ONE + * transaction on top of the L2 services (assignWorkOrder → work_order.assigned, updateWorkOrder → + * work_order.changed). The human always confirms — nothing is scheduled automatically. + */ + +export { SCHEDULABLE_STATUSES }; +export const VERSION_CONFLICT_MESSAGE = "Auftrag wurde zwischenzeitlich geändert"; + +const scheduleSchema = z.object({ + workOrderId: z.string().min(1).max(64), + teamId: z.string().min(1).max(64), + plannedStart: z.coerce.date(), + plannedEnd: z.coerce.date().nullish(), + plannedDurationMinutes: z.coerce.number().int().min(15).max(14 * 24 * 60).nullish(), + baseVersion: z.coerce.number().int().positive(), +}); +export type ScheduleInput = z.input; + +export type ScheduleResult = { + id: string; + number: string; + version: number; + status: WorkOrderStatus; + teamId: string; + plannedStart: string; + plannedEnd: string | null; + plannedDurationMinutes: number | null; + /** Conflicts of the target team on the target day(s), computed after the commit. */ + conflicts: PlanningConflict[]; +}; + +export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise { + assertCan(ctx, "work_order:assign"); + assertCan(ctx, "work_order:write"); + const input = parseInput(scheduleSchema, raw); + if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]); + + const saved = await inTransaction(ctx, async (tx) => { + const wo = await tx.db.workOrder.findFirst({ + where: { AND: [{ id: input.workOrderId }, await workOrderScope(tx)] }, + select: { + id: true, + number: true, + status: true, + version: true, + assignedTeamId: true, + teamLeadUserId: true, + plannedStart: true, + plannedEnd: true, + plannedDurationMinutes: true, + assignees: { select: { userId: true } }, + }, + }); + if (!wo) throw new ServiceError("not_found", "work_order_not_found"); + if (wo.version !== input.baseVersion) { + throw new ServiceError("conflict", VERSION_CONFLICT_MESSAGE, { reason: "version_conflict", currentVersion: wo.version, baseVersion: input.baseVersion }); + } + if (!SCHEDULABLE_STATUSES.includes(wo.status as WorkOrderStatus)) throw new ServiceError("invalid", "not_schedulable", { status: wo.status }); + + // Keep the order's length when only the start moves and no new end was given. + const plannedEnd = + input.plannedEnd !== undefined + ? input.plannedEnd ?? null + : wo.plannedStart && wo.plannedEnd + ? new Date(input.plannedStart.getTime() + (wo.plannedEnd.getTime() - wo.plannedStart.getTime())) + : null; + const duration = input.plannedDurationMinutes !== undefined ? input.plannedDurationMinutes ?? null : wo.plannedDurationMinutes; + + let version = wo.version; + let status = wo.status as WorkOrderStatus; + const teamChanged = wo.assignedTeamId !== input.teamId; + if (teamChanged || ["draft", "review_required", "planned"].includes(wo.status)) { + let userIds = wo.assignees.map((a) => a.userId); + if (teamChanged && userIds.length) { + const now = new Date(); + const stay = await tx.db.teamMember.findMany({ + where: { teamId: input.teamId, userId: { in: userIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] }, + select: { userId: true }, + }); + userIds = [...new Set(stay.map((s) => s.userId))]; + } + const res = await assignWorkOrder(tx, { + workOrderId: wo.id, + teamId: input.teamId, + userIds, + teamLeadUserId: teamChanged ? null : wo.teamLeadUserId, + baseVersion: version, + }); + version = res.version; + status = res.status; + } + + const datesChanged = wo.plannedStart?.getTime() !== input.plannedStart.getTime() || (wo.plannedEnd?.getTime() ?? null) !== (plannedEnd?.getTime() ?? null); + if (datesChanged) { + const res = await updateWorkOrder(tx, wo.id, { plannedStart: input.plannedStart, plannedEnd }, version); + version = res.version; + } + if (duration !== wo.plannedDurationMinutes) { + version = await writeWithVersion(tx, { id: wo.id, version }, { plannedDurationMinutes: duration }); + } + + if (version !== wo.version) { + await writeAuditLog({ + tenantId: tx.tenantId, + actorId: tx.userId, + action: "update", + entity: "work_order", + entityId: wo.id, + before: snapshot({ assignedTeamId: wo.assignedTeamId, plannedStart: wo.plannedStart, plannedEnd: wo.plannedEnd, plannedDurationMinutes: wo.plannedDurationMinutes, status: wo.status }), + after: snapshot({ source: "planning", assignedTeamId: input.teamId, plannedStart: input.plannedStart, plannedEnd, plannedDurationMinutes: duration, status, version }), + }); + } + return { id: wo.id, number: wo.number, version, status, plannedEnd, duration }; + }); + + let conflicts: PlanningConflict[] = []; + try { + const days = orderDays({ plannedStart: input.plannedStart, plannedEnd: saved.plannedEnd }, await tenantTimezone(ctx)).slice(0, MAX_BOARD_DAYS); + const board = await getPlanningBoard(ctx, { from: days[0], to: days[days.length - 1], teamIds: [input.teamId] }); + conflicts = board.conflicts.filter((c) => c.teamIds.includes(input.teamId)); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + } + + return { + id: saved.id, + number: saved.number, + version: saved.version, + status: saved.status, + teamId: input.teamId, + plannedStart: input.plannedStart.toISOString(), + plannedEnd: saved.plannedEnd?.toISOString() ?? null, + plannedDurationMinutes: saved.duration, + conflicts, + }; +} diff --git a/src/server/services/planning/summary.ts b/src/server/services/planning/summary.ts new file mode 100644 index 0000000..117a073 --- /dev/null +++ b/src/server/services/planning/summary.ts @@ -0,0 +1,23 @@ +import { dayKeyOf } from "@/lib/planning/days"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { getPlanningBoard } from "@/server/services/planning/board"; +import { getLiveSituation } from "@/server/services/planning/live"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; + +/** Dashboard tile "Planung heute" (L13): crews on the job, conflicts today, unplanned orders. Null without planning access. */ +export async function getPlanningToday(ctx: ServiceCtx): Promise<{ day: string; teamsTotal: number; teamsWorking: number; conflicts: number; unplanned: number } | null> { + try { + const day = dayKeyOf(new Date(), await tenantTimezone(ctx)); + const [board, live] = await Promise.all([getPlanningBoard(ctx, { from: day, to: day }), getLiveSituation(ctx)]); + return { + day, + teamsTotal: board.teams.length, + teamsWorking: live.crews.filter((c) => c.status !== "free").length, + conflicts: board.conflictCount, + unplanned: board.unplanned.length, + }; + } catch (err) { + if (err instanceof ServiceError && err.code === "forbidden") return null; + throw err; + } +} diff --git a/src/server/services/planning/team-settings.ts b/src/server/services/planning/team-settings.ts new file mode 100644 index 0000000..4d9a35f --- /dev/null +++ b/src/server/services/planning/team-settings.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { parseInput } from "@/server/services/work-orders/_shared"; + +/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */ + +const settingsSchema = z.object({ + dailyCapacityMinutes: z.coerce.number().int().min(0).max(24 * 60), + workingDays: z.coerce.number().int().min(0).max(127), +}); +export type TeamPlanningSettingsInput = z.input; + +export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) { + assertCan(ctx, "team:manage"); + const input = parseInput(settingsSchema, raw); + const before = await ctx.db.team.findFirst({ + where: { id: teamId, deletedAt: null }, + select: { id: true, dailyCapacityMinutes: true, workingDays: true }, + }); + if (!before) throw new ServiceError("not_found", "team_not_found"); + const after = await ctx.db.team.update({ + where: { id: teamId }, + data: input, + select: { id: true, dailyCapacityMinutes: true, workingDays: true }, + }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: teamId, before, after }); + return after; +} diff --git a/src/server/services/planning/time-tracking.ts b/src/server/services/planning/time-tracking.ts new file mode 100644 index 0000000..47d5af1 --- /dev/null +++ b/src/server/services/planning/time-tracking.ts @@ -0,0 +1,30 @@ +import { Prisma } from "@prisma/client"; + +/** + * Adapter to the time tracking model of lane L12 (read-only use in planning). + * + * L12 adds `TimeEntry.source` (tracked | manual), `TimeEntry.approvalStatus` (approved | pending | + * rejected) and `WorkSession.manual`. Planning rules: + * - live situation / delay evaluation: only `source = tracked` entries of non-manual sessions, + * - actual duration of completed orders: only `approvalStatus = approved` entries. + * The filters are enabled automatically as soon as the generated Prisma client knows the fields + * (after the L12 merge); before that they are no-ops, so this lane compiles against both schemas. + */ + +const entryFields = new Set(Object.values(Prisma.TimeEntryScalarFieldEnum)); +const sessionFields = new Set(Object.values(Prisma.WorkSessionScalarFieldEnum)); + +export const TIME_TRACKING_FIELDS = { + entrySource: entryFields.has("source"), + entryApproval: entryFields.has("approvalStatus"), + sessionManual: sessionFields.has("manual"), +} as const; + +/** Time entries recorded by the running clock (no manual additions). */ +export const trackedEntryWhere = (TIME_TRACKING_FIELDS.entrySource ? { source: "tracked" } : {}) as unknown as Prisma.TimeEntryWhereInput; + +/** Approved time entries (actual duration of completed orders). */ +export const approvedEntryWhere = (TIME_TRACKING_FIELDS.entryApproval ? { approvalStatus: "approved" } : {}) as unknown as Prisma.TimeEntryWhereInput; + +/** Sessions started by the clock (manual after-the-fact sessions excluded). */ +export const trackedSessionWhere = (TIME_TRACKING_FIELDS.sessionManual ? { manual: false } : {}) as unknown as Prisma.WorkSessionWhereInput; diff --git a/src/server/services/planning/watch.ts b/src/server/services/planning/watch.ts new file mode 100644 index 0000000..9221c38 --- /dev/null +++ b/src/server/services/planning/watch.ts @@ -0,0 +1,391 @@ +import { haversineKm, isValidLatLng } from "@/lib/geo/distance"; +import { + computeTeamDay, + effectiveDurationMinutes, + estimateTravelMinutes, + hasTimeOfDay, + overrunBucket, + unionMinutes, +} from "@/lib/planning/capacity"; +import { addDays, dayBounds, dayKeyOf, isDayKey } from "@/lib/planning/days"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import { writeAuditLog } from "@/server/audit"; +import { dbForTenant, prisma } from "@/server/db"; +import { emitEvent } from "@/server/events"; +import { PERMISSIONS } from "@/server/rbac"; +import type { ServiceCtx } from "@/server/services/context"; +import { planningAccess } from "@/server/services/planning/access"; +import { + loadPlannedOrders, + loadTeams, + loadUnplannedOrders, + ORDER_SELECT, + toBoardOrder, + toPlanningOrder, + toPlanningTeam, + type BoardOrder, + type OrderRow, +} from "@/server/services/planning/data"; +import { approvedEntryWhere, trackedEntryWhere, trackedSessionWhere } from "@/server/services/planning/time-tracking"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * L13 Planung — watch functions: + * - evaluateDelays: running orders, recorded crew work time (union of tracked `work` segments of all + * sessions of the order — parallel crew members count once) vs. planned duration → ≥ 80 % "risk", + * ≥ 100 % "overrun"; next planned order of the same crew today → endangered when remaining time + + * estimated travel passes its start, or the overrun pushes the crew day over capacity. + * - getFreedCapacity: orders of the day completed before their planned end → free crew minutes + + * suggestions (pull forward later/next orders of the crew, nearby unplanned orders). Never changes data. + * - runPlanningWatch: the only place that emits planning.* events (job `planning-watch`, every 5 min); + * duplicates are prevented by an append-only ledger in the audit log (entity `planning_alert`). + */ + +export const DELAY_RISK_RATIO = 0.8; +export const RUNNING_STATUSES: WorkOrderStatus[] = ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"]; +export const COMPLETED_STATUSES: WorkOrderStatus[] = ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"]; +const UPCOMING_STATUSES: WorkOrderStatus[] = ["planned", "assigned", "accepted"]; +const ACTIVE_SESSION_STATUSES: ("en_route" | "running" | "paused")[] = ["en_route", "running", "paused"]; +export const MIN_FREED_MINUTES = 15; +const PULL_FORWARD_DAYS = 5; +const MAX_SUGGESTIONS = 3; + +const coords = (row: Pick | undefined) => + row?.site && isValidLatLng(row.site) ? { latitude: row.site.latitude as number, longitude: row.site.longitude as number } : null; + +const roundUpQuarter = (ms: number) => Math.ceil(ms / (15 * 60_000)) * 15 * 60_000; + +// --------------------------------------------------------------------------------------------- +// Delays +// --------------------------------------------------------------------------------------------- + +export type OrderDelay = { + workOrderId: string; + number: string; + teamId: string | null; + plannedMinutes: number; + workedMinutes: number; + remainingMinutes: number; + ratio: number; + percent: number; + level: "ok" | "risk" | "overrun"; +}; + +export type FollowupRisk = { + workOrderId: string; + number: string; + teamId: string; + day: string; + blockedById: string; + blockedByNumber: string; + reason: "start" | "capacity"; + etaAt: string; + travelMinutes: number; +}; + +export async function evaluateDelays(ctx: ServiceCtx, opts: { now?: Date; teamIds?: string[] | null } = {}): Promise<{ delays: OrderDelay[]; followups: FollowupRisk[] }> { + const now = opts.now ?? new Date(); + const timeZone = await tenantTimezone(ctx); + const scope = await workOrderScope(ctx); + const running = await ctx.db.workOrder.findMany({ + where: { + AND: [ + scope, + { status: { in: RUNNING_STATUSES } }, + { workSessions: { some: { AND: [trackedSessionWhere, { status: { in: ACTIVE_SESSION_STATUSES } }] } } }, + opts.teamIds ? { assignedTeamId: { in: opts.teamIds } } : {}, + ], + }, + select: ORDER_SELECT, + take: 500, + }); + if (running.length === 0) return { delays: [], followups: [] }; + + const entries = await ctx.db.timeEntry.findMany({ + where: { AND: [trackedEntryWhere, { type: "work", startedAt: { lt: now }, workSession: { AND: [trackedSessionWhere, { workOrderId: { in: running.map((r) => r.id) } }] } }] }, + select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } }, + }); + const segments = new Map(); + for (const e of entries) { + const list = segments.get(e.workSession.workOrderId) ?? []; + list.push({ start: e.startedAt.getTime(), end: Math.min((e.endedAt ?? now).getTime(), now.getTime()) }); + segments.set(e.workSession.workOrderId, list); + } + + const delays: OrderDelay[] = running.map((r) => { + const plannedMinutes = effectiveDurationMinutes(toPlanningOrder(r), timeZone); + const workedMinutes = unionMinutes(segments.get(r.id) ?? []); + const ratio = plannedMinutes > 0 ? workedMinutes / plannedMinutes : 0; + return { + workOrderId: r.id, + number: r.number, + teamId: r.assignedTeamId, + plannedMinutes, + workedMinutes, + remainingMinutes: Math.max(0, plannedMinutes - workedMinutes), + ratio, + percent: Math.round(ratio * 100), + level: ratio >= 1 ? "overrun" : ratio >= DELAY_RISK_RATIO ? "risk" : "ok", + }; + }); + + const today = dayKeyOf(now, timeZone); + const { start, end } = dayBounds(today, timeZone); + const teamIds = [...new Set(delays.map((d) => d.teamId).filter((t): t is string => !!t))]; + const [teams, dayOrders] = await Promise.all([loadTeams(ctx, teamIds), loadPlannedOrders(ctx, { teamIds, start, end })]); + const runningById = new Map(running.map((r) => [r.id, r])); + + const followups: FollowupRisk[] = []; + for (const d of delays) { + const team = d.teamId ? teams.find((t) => t.id === d.teamId) : undefined; + if (!team) continue; + const next = dayOrders + .filter((o) => o.assignedTeamId === team.id && o.id !== d.workOrderId && UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() >= start.getTime()) + .sort((a, b) => a.plannedStart!.getTime() - b.plannedStart!.getTime())[0]; + if (!next) continue; + const from = coords(runningById.get(d.workOrderId)); + const to = coords(next); + const travelMinutes = estimateTravelMinutes(from && to ? haversineKm(from, to) : null); + const eta = now.getTime() + (d.remainingMinutes + travelMinutes) * 60_000; + const overrunExtra = Math.max(0, d.workedMinutes - d.plannedMinutes); + let reason: FollowupRisk["reason"] | null = null; + if (hasTimeOfDay(next.plannedStart!, timeZone) && eta > next.plannedStart!.getTime()) { + reason = "start"; + } else if (overrunExtra > 0) { + const td = computeTeamDay(toPlanningTeam(team), today, dayOrders.filter((o) => o.assignedTeamId === team.id).map(toPlanningOrder), timeZone); + if (td.capacityMinutes > 0 && td.plannedMinutes + overrunExtra + travelMinutes > td.capacityMinutes) reason = "capacity"; + } + if (reason) { + followups.push({ + workOrderId: next.id, + number: next.number, + teamId: team.id, + day: today, + blockedById: d.workOrderId, + blockedByNumber: d.number, + reason, + etaAt: new Date(eta).toISOString(), + travelMinutes, + }); + } + } + return { delays, followups }; +} + +// --------------------------------------------------------------------------------------------- +// Freed capacity ("früher fertig") +// --------------------------------------------------------------------------------------------- + +export type FreedSuggestion = { + kind: "pull_forward" | "nearby_unplanned"; + order: BoardOrder; + distanceKm: number | null; + day: string; + suggestedStart: string; +}; + +export type FreedTeam = { + teamId: string; + teamName: string; + day: string; + earlyMinutes: number; + availableMinutes: number; + availableFrom: string; + orders: { id: string; number: string; earlyMinutes: number; finishedAt: string; actualMinutes: number | null }[]; + suggestions: FreedSuggestion[]; +}; + +export async function getFreedCapacity( + ctx: ServiceCtx, + raw: { date?: string | null; now?: Date; radiusKm?: number; withSuggestions?: boolean } = {}, +): Promise<{ date: string; teams: FreedTeam[] }> { + const access = await planningAccess(ctx); + const now = raw.now ?? new Date(); + const timeZone = await tenantTimezone(ctx); + const today = dayKeyOf(now, timeZone); + const date = raw.date && isDayKey(raw.date) ? raw.date : today; + const radiusKm = raw.radiusKm ?? 15; + const { start, end } = dayBounds(date, timeZone); + + const rows = await ctx.db.workOrder.findMany({ + where: { + AND: [ + await workOrderScope(ctx), + { status: { in: COMPLETED_STATUSES }, plannedStart: { gte: start, lt: end } }, + access.teamIds ? { assignedTeamId: { in: access.teamIds } } : { assignedTeamId: { not: null } }, + ], + }, + select: ORDER_SELECT, + take: 500, + }); + if (rows.length === 0) return { date, teams: [] }; + const ids = rows.map((r) => r.id); + + // Session end (clock sessions only) and approved work time (actual duration after L12 approval). + const [sessions, approved] = await Promise.all([ + ctx.db.workSession.findMany({ + where: { AND: [trackedSessionWhere, { workOrderId: { in: ids }, status: "ended", endedAt: { not: null } }] }, + select: { workOrderId: true, endedAt: true }, + }), + ctx.db.timeEntry.findMany({ + where: { AND: [approvedEntryWhere, { type: "work", endedAt: { not: null }, workSession: { workOrderId: { in: ids } } }] }, + select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } }, + }), + ]); + const lastEnd = new Map(); + for (const s of sessions) lastEnd.set(s.workOrderId, Math.max(lastEnd.get(s.workOrderId) ?? 0, s.endedAt!.getTime())); + const approvedSegments = new Map(); + for (const e of approved) { + const list = approvedSegments.get(e.workSession.workOrderId) ?? []; + list.push({ start: e.startedAt.getTime(), end: e.endedAt!.getTime() }); + approvedSegments.set(e.workSession.workOrderId, list); + } + + type Early = { row: OrderRow; finishedAt: number; plannedEndAt: number; earlyMinutes: number }; + const byTeam = new Map(); + for (const row of rows) { + const finishedAt = lastEnd.get(row.id); + if (!finishedAt || !row.plannedStart || !row.assignedTeamId || !hasTimeOfDay(row.plannedStart, timeZone)) continue; + const plannedEndAt = row.plannedStart.getTime() + effectiveDurationMinutes(toPlanningOrder(row), timeZone) * 60_000; + const earlyMinutes = Math.floor((plannedEndAt - finishedAt) / 60_000); + if (earlyMinutes < MIN_FREED_MINUTES) continue; + const list = byTeam.get(row.assignedTeamId) ?? []; + list.push({ row, finishedAt, plannedEndAt, earlyMinutes }); + byTeam.set(row.assignedTeamId, list); + } + if (byTeam.size === 0) return { date, teams: [] }; + + const teams = await loadTeams(ctx, [...byTeam.keys()]); + const unplanned = raw.withSuggestions === false || date !== today ? [] : await loadUnplannedOrders(ctx, access.teamIds, 300); + const result: FreedTeam[] = []; + for (const team of teams) { + const early = byTeam.get(team.id)!; + const availableFromMs = Math.max(now.getTime(), ...early.map((e) => e.finishedAt)); + const availableMinutes = early.reduce((sum, e) => sum + Math.max(0, Math.floor((e.plannedEndAt - Math.max(e.finishedAt, now.getTime())) / 60_000)), 0); + const suggestions: FreedSuggestion[] = []; + + if (raw.withSuggestions !== false && date === today && availableMinutes >= MIN_FREED_MINUTES) { + const suggestedStart = new Date(roundUpQuarter(availableFromMs)).toISOString(); + const later = await loadPlannedOrders(ctx, { teamIds: [team.id], start: new Date(availableFromMs), end: dayBounds(addDays(date, PULL_FORWARD_DAYS), timeZone).end }); + for (const o of later + .filter((o) => UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() > availableFromMs) + .filter((o) => effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes) + .slice(0, MAX_SUGGESTIONS)) { + suggestions.push({ kind: "pull_forward", order: toBoardOrder(o, timeZone), distanceKm: null, day: date, suggestedStart }); + } + const latest = [...early].sort((a, b) => b.finishedAt - a.finishedAt)[0]; + const origin = coords(latest.row); + if (origin) { + unplanned + .map((o) => ({ o, c: coords(o) })) + .filter((x): x is { o: OrderRow; c: { latitude: number; longitude: number } } => !!x.c) + .map(({ o, c }) => ({ o, km: haversineKm(origin, c) })) + .filter(({ o, km }) => km <= radiusKm && effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes) + .sort((a, b) => a.km - b.km) + .slice(0, MAX_SUGGESTIONS) + .forEach(({ o, km }) => suggestions.push({ kind: "nearby_unplanned", order: toBoardOrder(o, timeZone), distanceKm: Math.round(km * 10) / 10, day: date, suggestedStart })); + } + } + + result.push({ + teamId: team.id, + teamName: team.name, + day: date, + earlyMinutes: early.reduce((sum, e) => sum + e.earlyMinutes, 0), + availableMinutes, + availableFrom: new Date(availableFromMs).toISOString(), + orders: early.map((e) => ({ + id: e.row.id, + number: e.row.number, + earlyMinutes: e.earlyMinutes, + finishedAt: new Date(e.finishedAt).toISOString(), + actualMinutes: approvedSegments.has(e.row.id) ? unionMinutes(approvedSegments.get(e.row.id)!) : null, + })), + suggestions, + }); + } + return { date, teams: result }; +} + +// --------------------------------------------------------------------------------------------- +// Watch job (events) +// --------------------------------------------------------------------------------------------- + +export const WATCH_ACTOR_ID = "system:planning-watch"; + +/** System context of the watch job: all permissions within ONE tenant (dbForTenant + RLS). */ +export function watchContext(tenantId: string): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId: WATCH_ACTOR_ID, permissions: new Set(PERMISSIONS) }; +} + +export type AlertLedger = { claim(ctx: ServiceCtx, key: string, facts: Record): Promise }; + +/** Dedupe ledger in the append-only audit log (entity `planning_alert`, entityId = dedupe key). */ +export const auditAlertLedger: AlertLedger = { + async claim(ctx, key, facts) { + const seen = await ctx.db.auditLog.findFirst({ where: { entity: "planning_alert", entityId: key }, select: { id: true } }); + if (seen) return false; + await writeAuditLog({ tenantId: ctx.tenantId, action: "create", entity: "planning_alert", entityId: key, after: facts }); + return true; + }, +}; + +export type WatchResult = { overrun: number; followupAtRisk: number; capacityFreed: number }; + +export async function runPlanningWatch( + tenantId: string, + opts: { now?: Date; emit?: typeof emitEvent; ledger?: AlertLedger } = {}, +): Promise { + const ctx = watchContext(tenantId); + const now = opts.now ?? new Date(); + const emit = opts.emit ?? emitEvent; + const ledger = opts.ledger ?? auditAlertLedger; + const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 }; + + const { delays, followups } = await evaluateDelays(ctx, { now }); + for (const d of delays) { + const bucket = overrunBucket(d.ratio); + if (bucket < 0) continue; + if (!(await ledger.claim(ctx, `planning.overrun:${d.workOrderId}:${bucket}`, { percent: d.percent, teamId: d.teamId }))) continue; + await emit(ctx, { type: "planning.overrun", entityType: "work_order", entityId: d.workOrderId, data: { percent: d.percent, teamId: d.teamId, occurrenceId: bucket } }); + result.overrun++; + } + for (const f of followups) { + if (!(await ledger.claim(ctx, `planning.followup_at_risk:${f.workOrderId}:${f.day}`, { blockedBy: f.blockedByNumber, reason: f.reason }))) continue; + await emit(ctx, { + type: "planning.followup_at_risk", + entityType: "work_order", + entityId: f.workOrderId, + data: { blockerNumber: f.blockedByNumber, teamId: f.teamId, reason: f.reason, occurrenceId: f.day }, + }); + result.followupAtRisk++; + } + const freed = await getFreedCapacity(ctx, { now, withSuggestions: false }); + for (const team of freed.teams) { + for (const o of team.orders) { + if (!(await ledger.claim(ctx, `planning.capacity_freed:${team.teamId}:${freed.date}:${o.id}`, { minutes: o.earlyMinutes }))) continue; + await emit(ctx, { type: "planning.capacity_freed", entityType: "work_order", entityId: o.id, data: { teamId: team.teamId, minutes: o.earlyMinutes, occurrenceId: freed.date } }); + result.capacityFreed++; + } + } + return result; +} + +/** All tenants (job payload tenantId "*"); a failing tenant never stops the others. */ +export async function runPlanningWatchAllTenants(opts: { now?: Date } = {}): Promise<{ tenants: number; failed: number } & WatchResult> { + const tenants = await prisma.tenant.findMany({ select: { id: true } }); + const total = { tenants: tenants.length, failed: 0, overrun: 0, followupAtRisk: 0, capacityFreed: 0 }; + for (const t of tenants) { + try { + const r = await runPlanningWatch(t.id, opts); + total.overrun += r.overrun; + total.followupAtRisk += r.followupAtRisk; + total.capacityFreed += r.capacityFreed; + } catch (err) { + total.failed++; + console.error(`[planning-watch] tenant ${t.id} failed:`, (err as Error).message); + } + } + return total; +}