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