// 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 = {}) { 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"); });