Files
craftvia/scripts/test-planung-live.ts
T
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

128 lines
10 KiB
TypeScript
Raw 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 — Live-Lage ohne GPS: Status aus der aktiven WorkSession (Unterwegs/In Arbeit/Pause/frei),
// „seit“ aus dem offenen Zeitabschnitt, Standort = Objekt des laufenden Auftrags, KEINE Geräte-Koordinaten
// im Ergebnis, Teamleiter nur eigenes Team, Monteur forbidden, Mandantentrennung, heutige Aufträge ohne Beginn.
// Lauf: npx tsx scripts/test-planung-live.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
import { dayBounds, dayKeyOf } from "../src/lib/planning/days";
import { getLiveSituation } from "../src/server/services/planning/live";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-live-a";
const SLUG_B = "zz-planung-live-b";
const DEVICE_LAT = 12.3456789;
const DEVICE_LNG = 98.7654321;
const MIN = 60_000;
runSuite("L13 Planung – Live-Lage", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none";
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const now = new Date();
const t = A.tenantId;
await prisma.site.update({ where: { id: A.siteId }, data: { latitude: 53.5413, longitude: 9.9849, geocodeStatus: "ok", geocodedAt: now } });
const noCoordSite = await prisma.site.create({ data: { tenantId: t, customerId: A.customerId, name: "Baustelle ohne Ortsangabe", city: "Hamburg" } });
const mk = async (title: string, data: Record<string, unknown>, siteId = A.siteId) => {
const wo = await createWorkOrder(A.ctx.backoffice, { title, customerId: A.customerId, siteId });
await prisma.workOrder.update({ where: { id: wo.id }, data });
return wo;
};
// tech: running on the site with coordinates; the session carries (test) device coordinates that must never leak.
const oRun = await mk("Laufender Einsatz", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: new Date(now.getTime() - 120 * MIN) });
const sRun = await prisma.workSession.create({
data: { tenantId: t, workOrderId: oRun.id, userId: A.users.tech.id, teamId: A.teamId, status: "running", startedAt: new Date(now.getTime() - 120 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG, deviceInfo: "ZZ-Geraet-4711" },
});
await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "travel", startedAt: new Date(now.getTime() - 120 * MIN), endedAt: new Date(now.getTime() - 30 * MIN) } });
const workSince = new Date(now.getTime() - 30 * MIN);
await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "work", startedAt: workSince } });
// tech2: en route to a site without coordinates.
const oRoute = await mk("Anfahrt", { status: "en_route", assignedTeamId: A.teamId }, noCoordSite.id);
await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRoute.id, userId: A.users.tech2.id, teamId: A.teamId, status: "en_route", startedAt: new Date(now.getTime() - 10 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG } });
// outsider (Team Süd): paused.
const oPause = await mk("Pausiert", { status: "paused", assignedTeamId: A.team2Id });
const sPause = await prisma.workSession.create({ data: { tenantId: t, workOrderId: oPause.id, userId: A.users.outsider.id, teamId: A.team2Id, status: "paused", startedAt: new Date(now.getTime() - 60 * MIN) } });
const pauseSince = new Date(now.getTime() - 5 * MIN);
await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sPause.id, userId: A.users.outsider.id, type: "break", startedAt: pauseSince } });
// an ended session must not count
await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRun.id, userId: A.users.lead.id, teamId: A.teamId, status: "ended", startedAt: new Date(now.getTime() - 300 * MIN), endedAt: new Date(now.getTime() - 200 * MIN) } });
// today's orders without start
const today = dayBounds(dayKeyOf(now, TZ), TZ);
const early = new Date(Math.max(today.start.getTime() + MIN, now.getTime() - 60 * MIN));
const later = new Date(now.getTime() + 120 * MIN);
const canEarly = early < now;
const canLater = later < today.end;
const oNotAccepted = canEarly ? await mk("Nicht angenommen", { status: "assigned", assignedTeamId: A.teamId, plannedStart: early }) : null;
const oOverdue = canEarly ? await mk("Überfällig", { status: "accepted", assignedTeamId: A.teamId, plannedStart: early }) : null;
const oNext = canLater ? await mk("Heute später", { status: "assigned", assignedTeamId: A.teamId, plannedStart: later }) : null;
section("Status aus der WorkSession");
const live = await getLiveSituation(A.ctx.backoffice);
const byUser = (id: string) => live.technicians.find((x) => x.userId === id);
const tech = byUser(A.users.tech.id);
ok(tech?.status === "working" && tech.since === workSince.toISOString(), "Monteur mit laufender Session → In Arbeit, seit Beginn des offenen Abschnitts");
ok(tech?.current?.visible === true && tech.current.id === oRun.id && tech.current.latitude === 53.5413 && tech.current.longitude === 9.9849, "Standort = Koordinaten des Objekts des laufenden Auftrags");
ok(tech?.current?.visible === true && tech.current.number === oRun.number && tech.current.customerName.includes("Hausverwaltung") && !!tech.current.address, "aktueller Auftrag mit Nummer, Kunde und Adresse");
const tech2 = byUser(A.users.tech2.id);
ok(tech2?.status === "en_route" && tech2.current?.visible === true && tech2.current.latitude === null, "Unterwegs; Objekt ohne Koordinaten → ohne Ortsangabe");
ok(live.withoutLocation === 1, "Zähler „ohne Ortsangabe“ = 1");
const outsider = byUser(A.users.outsider.id);
ok(outsider?.status === "paused" && outsider.since === pauseSince.toISOString(), "Pause, seit Beginn der Pause");
const lead = byUser(A.users.lead.id);
ok(lead?.status === "free" && lead.current === null && lead.since === null, "ohne aktive Session (beendete zählt nicht) → frei");
ok(live.counts.working === 1 && live.counts.en_route === 1 && live.counts.paused === 1 && live.counts.free >= 2, `Zähler je Status ${JSON.stringify(live.counts)}`);
if (oNext) ok(!!tech?.next.some((n) => n.id === oNext.id) && !tech?.next.some((n) => n.id === oRun.id), "heute noch geplante Aufträge je Monteur");
if (oNotAccepted && oOverdue) {
ok(live.attention.some((a) => a.id === oNotAccepted.id && a.reason === "not_accepted") && live.attention.some((a) => a.id === oOverdue.id && a.reason === "overdue"), "heute ohne Beginn: nicht angenommen / Beginn überschritten");
ok(!live.attention.some((a) => a.id === oRun.id) && (!oNext || !live.attention.some((a) => a.id === oNext.id)), "laufende und spätere Aufträge nicht in „ohne Beginn“");
}
section("Kolonnen: ein Eintrag je Team");
const nordCrew = live.crews.find((c) => c.teamId === A.teamId);
const suedCrew = live.crews.find((c) => c.teamId === A.team2Id);
ok(
live.crews.filter((c) => c.teamId === A.teamId).length === 1 && nordCrew?.status === "working" && nordCrew.current?.id === oRun.id && nordCrew.current.latitude === 53.5413,
"Kolonne Nord: ein Eintrag am Einsatzort des laufenden Auftrags, Status In Arbeit",
);
const nordMembers = new Map(nordCrew?.members.map((m) => [m.userId, m]) ?? []);
ok(
nordMembers.get(A.users.tech.id)?.status === "working" && nordMembers.get(A.users.tech2.id)?.status === "en_route" && nordMembers.get(A.users.lead.id)?.status === "free" && nordMembers.get(A.users.tech2.id)?.orderNumber === oRoute.number,
"Mitglieder mit individuellem Status aus der eigenen Session",
);
ok(suedCrew?.status === "paused" && suedCrew.members.some((m) => m.userId === A.users.outsider.id && m.status === "paused"), "Kolonne Süd: Pause");
ok(live.technicians.find((x) => x.userId === A.users.tech.id)?.crewId === A.teamId, "Monteure mit Team gehören zu ihrer Kolonne (keine Einzelmarker)");
ok(nordCrew?.delay === null, "kein Verzug bei 30 min von 2 h");
section("Datenschutz: keine Geräte-Koordinaten");
for (const [label, result] of [
["Backoffice", live],
["Filter Status", await getLiveSituation(A.ctx.backoffice, { status: "en_route" })],
["Teamleiter", await getLiveSituation(A.ctx.lead)],
] as const) {
const json = JSON.stringify(result);
ok(!json.includes(String(DEVICE_LAT)) && !json.includes(String(DEVICE_LNG)) && !/startLat|startLng|deviceInfo|ZZ-Geraet/.test(json), `${label}: keine Start-Koordinaten/Geräteinfo im Ergebnis`);
}
section("Filter, Rollen, Mandantentrennung");
const onlyWorking = await getLiveSituation(A.ctx.backoffice, { status: "working" });
ok(onlyWorking.technicians.length === 1 && onlyWorking.technicians[0].userId === A.users.tech.id, "Filter Status „In Arbeit“");
const onlySued = await getLiveSituation(A.ctx.backoffice, { teamId: A.team2Id });
ok(onlySued.technicians.every((x) => x.teams.some((tm) => tm.id === A.team2Id)) && onlySued.technicians.some((x) => x.userId === A.users.outsider.id) && !onlySued.technicians.some((x) => x.userId === A.users.tech.id), "Filter Team Süd");
const leadView = await getLiveSituation(A.ctx.lead);
const leadUsers = leadView.technicians.map((x) => x.userId);
ok(leadUsers.includes(A.users.tech.id) && leadUsers.includes(A.users.tech2.id) && !leadUsers.includes(A.users.outsider.id) && !leadUsers.includes(A.users.lead2.id), "Teamleiter sieht nur sein Team");
ok(leadView.teams.length === 1 && leadView.teams[0].id === A.teamId && !leadView.attention.some((a) => a.teamName === "Team Süd"), "Teamleiter: nur eigenes Team in Auswahl und Hinweisen");
ok(leadView.crews.length === 1 && leadView.crews[0].teamId === A.teamId, "Teamleiter: nur die eigene Kolonne");
await expectCode(() => getLiveSituation(A.ctx.tech), "forbidden", "Monteur → forbidden");
const liveB = await getLiveSituation(B.ctx.backoffice);
const aUsers = Object.values(A.users).map((u) => u.id);
const jsonB = JSON.stringify(liveB);
ok(liveB.technicians.every((x) => !aUsers.includes(x.userId)) && !jsonB.includes(oRun.number) && !jsonB.includes(A.teamId), "Mandant B sieht keine Monteure, Aufträge oder Teams von A");
});