L13 Planung: Plantafel-, Einplan-, Empfehlungs-, Live- und Verzugsdienste mit API und Meldungen

Services board/schedule (L2 assign+update in inTransaction, Versionskonflikt, Audit), recommend (Luftlinie + freie Kolonnenzeit), live (Kolonnen, ohne Geräte-Koordinaten), watch (Verzug ab 80 %, Überschreitung, gefährdete Folgeaufträge, früher fertig mit Vorschlägen), Kolonnenkapazität. Job planning-watch alle 5 min, Events planning.capacity_freed/overrun/followup_at_risk (In-App an Backoffice + Teamleiter, Dedupe im Audit-Log), L12-Adapter für source/approvalStatus/manual. API /api/v1/planning/{board,schedule,recommendations,live} + OpenAPI. Tests core, recommend, live, watch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:13:39 +02:00
co-authored by Claude Opus 5
parent 213bcca3a1
commit 5e10523df6
29 changed files with 2579 additions and 6 deletions
+4
View File
@@ -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=<ISO>`, 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 |
+10 -3
View File
@@ -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"
}
}
}
+10 -3
View File
@@ -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"
}
}
}
+302
View File
@@ -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<PlanningOrder> & { 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<string, unknown> = {}) => 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)");
});
+127
View File
@@ -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<string, unknown>, 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");
});
+134
View File
@@ -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<string, unknown> = {}) {
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");
});
+176
View File
@@ -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<string, unknown>) {
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)");
});
+24
View File
@@ -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,
}),
);
});
+19
View File
@@ -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,
}),
);
});
@@ -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 });
});
+15
View File
@@ -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));
});
+134
View File
@@ -1106,6 +1106,139 @@ const paths: Record<string, Record<string, Schema>> = {
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,
+4
View File
@@ -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];
@@ -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<ActionState> {
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");
}
+2
View File
@@ -13,6 +13,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"report-pdf": () => 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). */
@@ -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<void> {
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);
}
+13
View File
@@ -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<void> {
} finally {
await q.close();
}
// L13 Planung: planning watch every 5 minutes (all tenants)
const watch = new Queue<JobPayload>(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<void> {
@@ -136,6 +136,11 @@ function textVars(locale: Locale, f: EventFacts): Record<string, string | undefi
actor: f.actorName ?? fallbackText(locale, "system"),
fileName: f.fileName ?? fallbackText(locale, "document"),
reason: f.syncErrorCode,
// L13 Planung
team: f.team,
minutes: f.planningMinutes,
percent: f.planningPercent,
blocker: f.planningBlocker,
};
}
@@ -47,6 +47,10 @@ export type EventFacts = {
actorName?: string;
emergencyStart?: Date | null;
emergencyEnd?: Date | null;
// L13 Planung
planningMinutes?: string;
planningPercent?: string;
planningBlocker?: string;
};
export type RecipientPlan = {
@@ -292,6 +296,20 @@ export async function resolveRecipients(
rule = { users: [backofficeWhere()], userIds: [op.userId] };
break;
}
case "planning.capacity_freed":
case "planning.overrun":
case "planning.followup_at_risk": {
// L13 Planung: in-app only — back office + team leads of the order's crew.
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
const d = event.data ?? {};
if (typeof d.minutes === "number" || typeof d.minutes === "string") facts.planningMinutes = String(d.minutes);
if (typeof d.percent === "number" || typeof d.percent === "string") facts.planningPercent = String(d.percent);
if (typeof d.blockerNumber === "string") facts.planningBlocker = d.blockerNumber;
rule = { users: [backofficeWhere()], userIds: teamLeadIds(wo), mailToUsers: false };
break;
}
default:
return null;
}
+38
View File
@@ -0,0 +1,38 @@
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Who may use the planning views (L13):
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
* - everyone else (Monteur): forbidden.
*/
export type PlanningAccess = {
all: boolean;
/** null = all teams of the tenant */
teamIds: string[] | null;
canSchedule: boolean;
canManageTeams: boolean;
};
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
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<PlanningAccess> {
const access = await planningAccess(ctx);
if (!access.all || !can(ctx, "work_order:assign")) throw new ServiceError("forbidden", "missing permission work_order:assign");
return access;
}
+177
View File
@@ -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<typeof boardSchema>;
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<string, BoardOrder>;
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<PlanningBoard> {
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<string, string>();
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<string, Set<ConflictKind>>();
for (const c of conflicts.filter(isConflict)) {
for (const id of c.orderIds) {
const set = kindsByOrder.get(id) ?? new Set<ConflictKind>();
set.add(c.kind);
kindsByOrder.set(id, set);
}
}
const orders: Record<string, BoardOrder> = 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;
}
}
+184
View File
@@ -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<ReturnType<typeof loadTeams>>[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<OrderRow[]> {
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<OrderRow[]> {
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<OrderRow | null> {
return ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, await workOrderScope(ctx)] }, select: ORDER_SELECT });
}
+271
View File
@@ -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<string, LiveStatus> = { en_route: "en_route", running: "working", paused: "paused" };
const RANK: Record<LiveStatus, number> = { 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<LiveStatus, number>;
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<typeof liveSchema> = {}, opts: { now?: Date } = {}): Promise<LiveSituation> {
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<string, { name: string; teams: { id: string; name: string }[] }>();
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<string, (typeof sessions)[number]>();
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<string, Date>();
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<LiveStatus, number>;
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,
};
}
+222
View File
@@ -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<typeof recommendSchema>;
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<OrderRow> {
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<RecommendationResult> {
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<Recommendation, "text">[] = [];
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<typeof toBoardOrder> & { distanceKm: number };
/** Other unplanned orders around the target ("zusammen einplanen?"). */
export async function findNearbyUnplanned(
ctx: ServiceCtx,
raw: z.input<typeof nearbySchema>,
): 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 };
}
+148
View File
@@ -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<typeof scheduleSchema>;
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<ScheduleResult> {
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,
};
}
+23
View File
@@ -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;
}
}
@@ -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<typeof settingsSchema>;
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;
}
@@ -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<string>(Object.values(Prisma.TimeEntryScalarFieldEnum));
const sessionFields = new Set<string>(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;
+391
View File
@@ -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<OrderRow, "site"> | 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<string, { start: number; end: number }[]>();
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<string, number>();
for (const s of sessions) lastEnd.set(s.workOrderId, Math.max(lastEnd.get(s.workOrderId) ?? 0, s.endedAt!.getTime()));
const approvedSegments = new Map<string, { start: number; end: number }[]>();
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<string, Early[]>();
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<string>(PERMISSIONS) };
}
export type AlertLedger = { claim(ctx: ServiceCtx, key: string, facts: Record<string, unknown>): Promise<boolean> };
/** 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<WatchResult> {
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;
}