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
+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)");
});