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