Files
craftvia/scripts/test-planung-recommend.ts
msolarczekandClaude Opus 5 5e10523df6 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>
2026-09-15 10:13:39 +02:00

135 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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");
});