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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// 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");
|
||||
});
|
||||
Reference in New Issue
Block a user