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