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>
303 lines
25 KiB
TypeScript
303 lines
25 KiB
TypeScript
// 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)");
|
||
});
|