// L13 Planung — Verzug und „früher fertig“: erfasste Kolonnenzeit (vereinigt), Warnung ab 80 %, Meldung ab // 100 % mit Dedupe und erneuter Meldung je weitere +50 %, gefährdeter Folgeauftrag (Beginn inkl. Fahrzeit, // Tageskapazität) mit Dedupe, freie Kolonnenzeit bei früherem Abschluss + Vorzieh-/Umkreis-Vorschläge, // Event planning.capacity_freed, Empfänger (Backoffice + Teamleiter, nur In-App), Plantafel/Live-Lage, // Rollen, Mandantentrennung, L12-Adapter. Alle Zeiten relativ zu „heute 12:00“ (unabhängig von der Uhrzeit). // Lauf: npx tsx scripts/test-planung-watch.ts import "dotenv/config"; import { prisma } from "../src/server/db"; import { berlinAt, createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; import { dayKeyOf } from "../src/lib/planning/days"; import { getPlanningBoard } from "../src/server/services/planning/board"; import { getLiveSituation } from "../src/server/services/planning/live"; import { approvedEntryWhere, TIME_TRACKING_FIELDS, trackedEntryWhere, trackedSessionWhere } from "../src/server/services/planning/time-tracking"; import { auditAlertLedger, evaluateDelays, getFreedCapacity, runPlanningWatch, watchContext } from "../src/server/services/planning/watch"; import { PROCESSORS } from "../src/server/jobs/processors"; import { JOB_QUEUES } from "../src/server/jobs/queues"; import { createWorkOrder } from "../src/server/services/work-orders/create"; const TZ = "Europe/Berlin"; const SLUG_A = "zz-planung-watch-a"; const SLUG_B = "zz-planung-watch-b"; const T = (hh: number, mm = 0) => berlinAt(0, hh, mm); const S1 = { latitude: 53.55, longitude: 10.0 }; const S2 = { latitude: 53.73, longitude: 10.0 }; // ≈ 20 km nördlich const S3 = { latitude: 53.559, longitude: 10.0 }; // ≈ 1 km const FAR = { latitude: 53.87, longitude: 10.69 }; async function site(f: TenantFixture, name: string, c: { latitude: number; longitude: number } | null) { return prisma.site.create({ data: { tenantId: f.tenantId, customerId: f.customerId, name, city: "Hamburg", postalCode: "20457", ...(c ?? {}), geocodeStatus: c ? "ok" : null, geocodedAt: c ? new Date() : null } }); } async function order(f: TenantFixture, siteId: string | null, title: string, data: Record) { const wo = await createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, ...(siteId ? { siteId } : {}) }); await prisma.workOrder.update({ where: { id: wo.id }, data }); return wo; } async function session(f: TenantFixture, workOrderId: string, userId: string, teamId: string, status: "running" | "paused" | "en_route" | "ended", startedAt: Date, endedAt: Date | null = null) { return prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId, userId, teamId, status, startedAt, endedAt } }); } async function entry(f: TenantFixture, workSessionId: string, userId: string, startedAt: Date, endedAt: Date | null, type: "work" | "break" | "travel" = "work") { return prisma.timeEntry.create({ data: { tenantId: f.tenantId, workSessionId, userId, type, startedAt, endedAt } }); } const alerts = (f: TenantFixture) => prisma.auditLog.findMany({ where: { tenantId: f.tenantId, entity: "planning_alert" }, select: { entityId: true } }); runSuite("L13 Planung – Verzug & früher fertig", [SLUG_A, SLUG_B], async () => { process.env.GEOCODING_PROVIDER = "none"; const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const today = dayKeyOf(T(12), TZ); section("Job & Adapter"); ok(JOB_QUEUES.planningWatch === "planning-watch" && typeof PROCESSORS["planning-watch"] === "function", "Queue planning-watch + Processor registriert"); ok( Object.keys(trackedEntryWhere).length === (TIME_TRACKING_FIELDS.entrySource ? 1 : 0) && Object.keys(approvedEntryWhere).length === (TIME_TRACKING_FIELDS.entryApproval ? 1 : 0) && Object.keys(trackedSessionWhere).length === (TIME_TRACKING_FIELDS.sessionManual ? 1 : 0), `L12-Filter aktiv je nach Schema (source=${TIME_TRACKING_FIELDS.entrySource}, approvalStatus=${TIME_TRACKING_FIELDS.entryApproval}, manual=${TIME_TRACKING_FIELDS.sessionManual})`, ); // Kolonne Nord (tech + tech2): laufender Auftrag, 2 h geplant. const s1 = await site(A, "Baustelle 1", S1); const s2 = await site(A, "Baustelle 20 km", S2); const oRun = await order(A, s1.id, "Laufend Nord", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(8), plannedDurationMinutes: 120 }); const sessTech = await session(A, oRun.id, A.users.tech.id, A.teamId, "running", T(8)); await entry(A, sessTech.id, A.users.tech.id, T(8), T(9)); await entry(A, sessTech.id, A.users.tech.id, T(9), T(9, 30), "break"); await entry(A, sessTech.id, A.users.tech.id, T(9, 30), null); const sessTech2 = await session(A, oRun.id, A.users.tech2.id, A.teamId, "running", T(8, 30)); await entry(A, sessTech2.id, A.users.tech2.id, T(8, 30), null); const oNext = await order(A, s2.id, "Folgeauftrag Nord", { status: "assigned", assignedTeamId: A.teamId, plannedStart: T(10, 45), plannedDurationMinutes: 60 }); section("Verzug: Kolonnenzeit und 80 %"); const sys = watchContext(A.tenantId); const e900 = await evaluateDelays(sys, { now: T(9, 0) }); ok(e900.delays.find((d) => d.workOrderId === oRun.id)?.level === "ok" && e900.delays.find((d) => d.workOrderId === oRun.id)?.workedMinutes === 60, "09:00: 60 min (50 %) → kein Verzug"); const e940 = await evaluateDelays(sys, { now: T(9, 40) }); const d940 = e940.delays.find((d) => d.workOrderId === oRun.id); ok(d940?.workedMinutes === 100, `erfasste Kolonnenzeit vereinigt 08:00–09:40 = 100 min (summiert wären 140) — ${d940?.workedMinutes}`); ok(d940?.level === "risk" && d940.percent === 83 && d940.remainingMinutes === 20, "83 % → Verzug droht, Restzeit 20 min"); ok(!e940.followups.some((f) => f.workOrderId === oNext.id), "09:40 + 20 min Rest + 31 min Fahrt (20 km) = 10:31 < Beginn 10:45 → Folgeauftrag nicht gefährdet"); const e1020 = await evaluateDelays(sys, { now: T(10, 20) }); const f1020 = e1020.followups.find((f) => f.workOrderId === oNext.id); ok(!!f1020 && f1020.reason === "start" && f1020.travelMinutes === 31 && f1020.blockedByNumber === oRun.number, "10:20: Rest 0 + 31 min Fahrt = 10:51 > 10:45 → Folgeauftrag gefährdet (Beginn)"); const board = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(9, 40) }); ok(board.orders[oRun.id]?.delay?.level === "risk" && board.orders[oRun.id].delay?.percent === 83 && !board.orders[oNext.id]?.atRisk, "Plantafel 09:40: Karte „Verzug droht · 83 %“, Folgeauftrag noch nicht gefährdet"); const board1020 = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(10, 20) }); ok(board1020.orders[oRun.id]?.delay?.level === "overrun" && board1020.orders[oNext.id]?.atRisk?.byNumber === oRun.number, "Plantafel 10:20: „Dauer überschritten“ + Folgeauftrag „gefährdet durch Verzug …“"); const live940 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(9, 40) }); ok(live940.crews.find((c) => c.teamId === A.teamId)?.delay?.level === "risk", "Live-Lage: Kolonne Nord mit Verzugswarnung"); section("Meldungen: 100 %, Dedupe, +50 %"); const r940 = await runPlanningWatch(A.tenantId, { now: T(9, 40) }); ok(r940.overrun === 0 && r940.followupAtRisk === 0, `09:40: keine Meldung bei 83 % (${JSON.stringify(r940)})`); const r1010 = await runPlanningWatch(A.tenantId, { now: T(10, 10) }); ok(r1010.overrun === 1 && r1010.followupAtRisk === 0, "10:10: 130 min = 108 % → planning.overrun"); const r1020 = await runPlanningWatch(A.tenantId, { now: T(10, 20) }); ok(r1020.overrun === 0 && r1020.followupAtRisk === 1, "10:20: keine erneute Überschreitungsmeldung, 1 × Folgeauftrag gefährdet"); ok((await runPlanningWatch(A.tenantId, { now: T(10, 25) })).followupAtRisk === 0, "Folgeauftrag gefährdet wird je Auftrag/Tag nur einmal gemeldet"); ok((await runPlanningWatch(A.tenantId, { now: T(10, 40) })).overrun === 0, "10:40 (133 %): keine erneute Meldung"); ok((await runPlanningWatch(A.tenantId, { now: T(11, 0) })).overrun === 1, "11:00: 180 min = 150 % → erneute Meldung (+50 %)"); ok((await runPlanningWatch(A.tenantId, { now: T(11, 10) })).overrun === 0, "11:10: keine weitere Meldung vor 200 %"); const keys = (await alerts(A)).map((a) => a.entityId); ok(keys.includes(`planning.overrun:${oRun.id}:0`) && keys.includes(`planning.overrun:${oRun.id}:1`) && keys.includes(`planning.followup_at_risk:${oNext.id}:${today}`), "Dedupe-Einträge im Audit-Protokoll (planning_alert)"); const overrunNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.overrun", entityId: oRun.id }, select: { userId: true, emailedAt: true, title: true, message: true } }); const noteUsers = new Set(overrunNotes.map((n) => n.userId)); ok(noteUsers.has(A.users.backoffice.id) && noteUsers.has(A.users.admin.id) && noteUsers.has(A.users.lead.id), "Empfänger: Backoffice + Teamleiter der Kolonne"); ok(!noteUsers.has(A.users.tech.id) && !noteUsers.has(A.users.lead2.id), "nicht: Monteure, Teamleiter anderer Kolonnen"); ok(overrunNotes.every((n) => n.emailedAt === null) && overrunNotes.some((n) => n.message.includes("150 %") && n.title.includes(oRun.number)), "nur In-App (keine E-Mail), Text mit Prozent und Nummer"); const followNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.followup_at_risk", entityId: oNext.id }, select: { message: true } }); ok(followNotes.length > 0 && followNotes.every((n) => n.message.includes(oRun.number)), "Folgeauftrag-Meldung nennt den verursachenden Auftrag"); section("Folgeauftrag gefährdet durch Tageskapazität"); await prisma.team.update({ where: { id: A.team2Id }, data: { dailyCapacityMinutes: 180 } }); const oSud = await order(A, null, "Laufend Süd", { status: "in_progress", assignedTeamId: A.team2Id, plannedStart: T(7), plannedDurationMinutes: 120 }); const sessSud = await session(A, oSud.id, A.users.outsider.id, A.team2Id, "running", T(7)); await entry(A, sessSud.id, A.users.outsider.id, T(7), null); const oSudNext = await order(A, null, "Folgeauftrag Süd ohne Uhrzeit", { status: "assigned", assignedTeamId: A.team2Id, plannedStart: berlinAt(0, 0), plannedDurationMinutes: 60 }); const eSud = await evaluateDelays(sys, { now: T(9, 40) }); const fSud = eSud.followups.find((f) => f.workOrderId === oSudNext.id); ok(!!fSud && fSud.reason === "capacity" && fSud.travelMinutes === 10, "160 min statt 120 + Folgeauftrag 60 + 10 min Fahrt > 180 min Kolonnentag → gefährdet (Kapazität)"); section("Früher fertig: freie Kolonnenzeit und Vorschläge"); const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.lead.id } }); await prisma.teamMember.createMany({ data: [A.users.admin.id, A.users.backoffice.id].map((userId) => ({ tenantId: A.tenantId, teamId: ost.id, userId, validFrom: new Date(Date.now() - 30 * 86400_000) })) }); const s3 = await site(A, "Nachbar 1 km", S3); const sFar = await site(A, "Lübeck", FAR); const oDone = await order(A, s1.id, "Früher fertig Ost", { status: "technically_completed", assignedTeamId: ost.id, plannedStart: T(8), plannedDurationMinutes: 240 }); await session(A, oDone.id, A.users.admin.id, ost.id, "ended", T(8), T(10)); const oLater = await order(A, s2.id, "Ost später heute", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(14), plannedDurationMinutes: 60 }); const oTooLong = await order(A, s2.id, "Ost zu lang", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(15), plannedDurationMinutes: 180 }); const oNear = await order(A, s3.id, "Ungeplant nah", { status: "draft", plannedDurationMinutes: 60 }); const oFar = await order(A, sFar.id, "Ungeplant fern", { status: "draft", plannedDurationMinutes: 60 }); const freed = await getFreedCapacity(A.ctx.backoffice, { now: T(10, 30) }); const fo = freed.teams.find((x) => x.teamId === ost.id); ok(!!fo && fo.earlyMinutes === 120 && fo.availableMinutes === 90 && fo.availableFrom === T(10, 30).toISOString(), "Ost: 2 h früher fertig (Ende 10:00 statt 12:00), 90 min ab jetzt frei"); ok(!freed.teams.some((x) => x.teamId === A.teamId), "laufende Kolonne Nord nicht als früher fertig"); const kinds = new Map(fo?.suggestions.map((s) => [s.order.id, s]) ?? []); ok(kinds.get(oLater.id)?.kind === "pull_forward" && !kinds.has(oTooLong.id), "Vorziehen: späterer Auftrag der Kolonne, der in die freie Zeit passt (nicht der zu lange)"); ok(kinds.get(oNear.id)?.kind === "nearby_unplanned" && (kinds.get(oNear.id)?.distanceKm ?? 99) < 2 && !kinds.has(oFar.id), "ungeplanter Auftrag im Umkreis (1 km) ja, ferner nein"); ok(kinds.get(oLater.id)?.suggestedStart === T(10, 30).toISOString() && kinds.get(oLater.id)?.day === today, "Vorschlag vorbelegt ab 10:30 heute — keine automatische Änderung"); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: oLater.id } })).plannedStart?.getTime() === T(14).getTime(), "geplanter Auftrag bleibt unverändert"); const live1030 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(10, 30) }); ok(live1030.freed.some((x) => x.teamId === ost.id && x.suggestions.length >= 2), "Live-Lage zeigt „früher fertig“ mit Vorschlägen"); const rFreed = await runPlanningWatch(A.tenantId, { now: T(10, 30) }); ok(rFreed.capacityFreed === 1, "planning.capacity_freed einmal gemeldet"); ok((await runPlanningWatch(A.tenantId, { now: T(10, 50) })).capacityFreed === 0, "Dedupe je Kolonne/Tag/Auftrag"); const freedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.capacity_freed", entityId: oDone.id }, select: { userId: true, message: true } }); ok(freedNotes.some((n) => n.userId === A.users.lead.id) && freedNotes.some((n) => n.message.includes("120 min")), "Meldung an Teamleiter der Kolonne mit 120 min"); section("Rollen & Mandantentrennung"); await expectCode(() => getFreedCapacity(A.ctx.tech, { now: T(10, 30) }), "forbidden", "Monteur: früher fertig → forbidden"); const leadFreed = await getFreedCapacity(A.ctx.lead2, { now: T(10, 30) }); ok(leadFreed.teams.length === 0, "Teamleiter Süd sieht die Kolonne Ost nicht"); const bSys = watchContext(B.tenantId); const bEval = await evaluateDelays(bSys, { now: T(11, 0) }); ok(bEval.delays.length === 0 && bEval.followups.length === 0, "Mandant B: keine Aufträge/Sessions von A in der Auswertung"); ok((await getFreedCapacity(B.ctx.backoffice, { now: T(10, 30) })).teams.length === 0, "Mandant B: kein „früher fertig“ von A"); const rB = await runPlanningWatch(B.tenantId, { now: T(11, 0) }); ok(rB.overrun + rB.followupAtRisk + rB.capacityFreed === 0 && (await alerts(B)).length === 0, "Mandant B: keine Meldungen, keine Dedupe-Einträge"); ok((await prisma.notification.count({ where: { tenantId: B.tenantId, type: { startsWith: "planning." } } })) === 0, "Mandant B: keine Planungs-Benachrichtigungen"); // Ein Dedupe-Schlüssel in B blockiert A nicht (Ledger je Mandant). const oRun2 = await order(A, s1.id, "Laufend Nord 2", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(6), plannedDurationMinutes: 60 }); const sess2 = await session(A, oRun2.id, A.users.lead.id, A.teamId, "running", T(6)); await entry(A, sess2.id, A.users.lead.id, T(6), null); ok(await auditAlertLedger.claim(bSys, `planning.overrun:${oRun2.id}:0`, {}), "B beansprucht (künstlich) den Schlüssel eines A-Auftrags"); ok((await runPlanningWatch(A.tenantId, { now: T(11, 20) })).overrun >= 1, "A meldet trotzdem (Dedupe mandantengetrennt)"); });