Merge lane/planung in feature/craftvia-mvp

Konflikte gelöst (additiv): Benachrichtigungstexte (Zeiterfassung + Planung, JSON zusammengeführt),
Event-Typen, Navigation, Empfänger-Felder, handle-event (reason inkl. rejectionReason + Planungsfelder),
Smoke-Prüfungen (Teamleiter: Zeiten + Planung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:15:47 +02:00
co-authored by Claude Opus 5
77 changed files with 6326 additions and 21 deletions
+3 -2
View File
@@ -18,8 +18,9 @@ async function main() {
continue;
}
const processor = await load();
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency });
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
workers.push(w);
console.info(`[worker] listening on ${name}`);
+81
View File
@@ -0,0 +1,81 @@
/**
* L13 Planung — geocode sites that have no (or outdated) coordinates, respecting the OSM Nominatim
* usage policy: strictly sequential, max. 1 request per second (process-wide limiter in the
* provider), identifying User-Agent, results cached on the site (unchanged addresses are skipped).
* Manually entered coordinates are never overwritten.
*
* Usage:
* npx tsx scripts/geocode-backfill.ts # all tenants
* npx tsx scripts/geocode-backfill.ts --tenant=demo --limit=50
* npx tsx scripts/geocode-backfill.ts --dry-run # only list what would be geocoded
* npx tsx scripts/geocode-backfill.ts --retry-not-found # also retry addresses marked not_found
*
* For larger data sets use a self-hosted or contracted geocoding service (GEOCODING_URL).
*/
import "dotenv/config";
import { isValidLatLng } from "../src/lib/geo/distance";
import { dbForTenant, prisma } from "../src/server/db";
import { geocodingConfig } from "../src/server/services/geo/config";
import { geocodeSite, type GeocodeOutcome } from "../src/server/services/geo/geocode-site";
import { geocodeQueryFor } from "../src/server/services/geo/normalize";
const arg = (name: string) => process.argv.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const flag = (name: string) => process.argv.includes(`--${name}`);
async function main() {
const cfg = geocodingConfig();
const dryRun = flag("dry-run");
const retryNotFound = flag("retry-not-found");
const limit = Number(arg("limit") ?? Infinity);
const slug = arg("tenant");
if (cfg.provider === "none" && !dryRun) {
console.log("GEOCODING_PROVIDER=none — nichts zu tun.");
return;
}
console.log(`Provider ${cfg.provider} (${cfg.url}), User-Agent "${cfg.userAgent}", ≤ 1 Anfrage/s${dryRun ? " — Probelauf" : ""}`);
const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true }, orderBy: { slug: "asc" } });
const totals: Record<string, number> = {};
let done = 0;
for (const tenant of tenants) {
const db = dbForTenant(tenant.id);
const sites = await db.site.findMany({
where: { deletedAt: null },
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, country: true, latitude: true, longitude: true, geocodedAt: true, geocodeStatus: true, geocodeQuery: true },
orderBy: { createdAt: "asc" },
});
const pending = sites.filter((s) => {
const query = geocodeQueryFor(s);
if (!query) return false;
if (isValidLatLng(s) && !s.geocodedAt) return false; // manual coordinates
if (s.geocodeQuery !== query) return true;
if (s.geocodeStatus === "ok") return false;
if (s.geocodeStatus === "not_found") return retryNotFound;
return true;
});
if (!pending.length) continue;
console.log(`\n${tenant.slug}: ${pending.length} Objekt(e)`);
for (const site of pending) {
if (done >= limit) break;
done++;
if (dryRun) {
console.log(` · ${site.name} (${[site.postalCode, site.city].filter(Boolean).join(" ")})`);
continue;
}
if (retryNotFound && site.geocodeStatus === "not_found") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: null } });
const outcome: GeocodeOutcome = await geocodeSite(tenant.id, site.id);
totals[outcome] = (totals[outcome] ?? 0) + 1;
console.log(` ${outcome.padEnd(9)} ${site.name}`);
}
}
console.log(`\nFertig: ${done} Objekt(e) ${JSON.stringify(totals)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+19
View File
@@ -102,6 +102,17 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true },
{ path: "/teams" },
{ path: "/settings/users", expect: [200, 307, 403] },
// L13 Planung
{ path: "/planning", mustContain: ["Plantafel", 'data-planning-view="days"', 'data-day-count="5"', "Heute", "Team Nord", "Team Süd", "Ungeplante Aufträge"], perf: true },
{ path: "/planning?view=today", mustContain: ['data-planning-view="today"', "Ganztägig", "Team Nord", "Team Süd"] },
{ path: "/planning?view=week", mustContain: ['data-planning-view="week"', 'data-day-count="7"'] },
{ path: "/dashboard", mustContain: ["Planung heute", "Teams im Einsatz"] },
{ path: `/planning?schedule=${x.d07.id}`, mustContain: "Plantafel" },
{ path: "/planning/live", mustContain: ["Live-Lage", "keine GPS-Ortung", "OpenStreetMap"], mustNotContain: ["startLat", "startLng"], perf: true },
{ path: "/api/v1/planning/board", mustContain: ['"teams"', '"conflictCount"'] },
{ path: "/api/v1/planning/live", mustContain: '"technicians"', mustNotContain: ["startLat", "startLng", "deviceInfo"] },
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, mustContain: '"recommendations"' },
{ path: "/dashboard", mustContain: "Konflikte diese Woche" },
],
},
{
@@ -120,6 +131,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] },
{ path: "/m/approvals", mustContain: "Zeiten freigeben" },
{ path: "/m/profile", mustContain: "Zeiten freigeben" },
// L13 Planung: team lead read-only, own team only
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
],
},
{
@@ -155,6 +169,8 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: m(x.d08), expect: [404] },
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
{ path: "/dashboard", expect: [307, 308], redirectTo: "/m" },
{ path: "/planning", expect: [404] },
{ path: "/planning/live", expect: [404] },
],
},
{
@@ -181,6 +197,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] },
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] },
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] },
],
},
];
+302
View File
@@ -0,0 +1,302 @@
// L13 Planung — Kern: Haversine, Arbeitstage/Kapazität (Mitgliedschaft gültig ab/bis), Auslastung,
// Konflikte (overbooked, overlap, assignee_double_booked, outside_working_days), Plantafel mit Rollen
// und Mandantentrennung, scheduleWorkOrder (Version, Scope, Recht, Event, Audit, atomar).
// Lauf: npx tsx scripts/test-planung-core.ts (eigene zz-Mandanten, räumt auf)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
import { formatDistance, haversineKm, isValidLatLng } from "../src/lib/geo/distance";
import { addDays, dayBounds, dayKeyOf, isDayKey, isWorkingDay, startOfWeek, weekdayIndex } from "../src/lib/planning/days";
import {
activeMemberCount,
assigneeDoubleBookings,
computeTeamDay,
effectiveDurationMinutes,
estimateTravelMinutes,
isConflict,
minutesOnDay,
overrunBucket,
teamCapacityMinutes,
unionMinutes,
utilizationPercent,
type PlanningOrder,
} from "../src/lib/planning/capacity";
import { addWorkingDays, nextWorkingDays } from "../src/lib/planning/days";
import { formatDayShort, formatMinutes } from "../src/lib/planning/text";
import { wallTimeToUtc } from "../src/lib/work-orders/time";
import { ServiceError } from "../src/server/services/context";
import { countWeekConflicts, getPlanningBoard } from "../src/server/services/planning/board";
import { scheduleWorkOrder, VERSION_CONFLICT_MESSAGE } from "../src/server/services/planning/schedule";
import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
import { createWorkOrder } from "../src/server/services/work-orders/create";
import { ensureDefaultOrderTypes } from "../src/server/services/work-orders/settings";
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-core-a";
const SLUG_B = "zz-planung-core-b";
const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!;
const po = (p: Partial<PlanningOrder> & { id: string }): PlanningOrder => ({
teamId: null,
plannedStart: null,
plannedEnd: null,
plannedDurationMinutes: null,
orderTypeDefaultMinutes: null,
assigneeIds: [],
...p,
});
runSuite("L13 Planung – Kern", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none";
section("Haversine & Formatierung");
const hh = { latitude: 53.5503, longitude: 9.992 };
const ber = { latitude: 52.5163, longitude: 13.3777 };
const d = haversineKm(hh, ber);
ok(d > 250 && d < 262, `Hamburg–Berlin ≈ 255 km (${d.toFixed(1)} km)`);
ok(haversineKm(hh, hh) === 0, "gleicher Punkt → 0 km");
ok(Math.abs(haversineKm(hh, ber) - haversineKm(ber, hh)) < 1e-9, "symmetrisch");
const eq = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 });
ok(Math.abs(eq - 111.195) < 0.1, `1° Länge am Äquator ≈ 111,2 km (${eq.toFixed(3)})`);
ok(formatDistance(3.24, "de") === "3,2 km" && formatDistance(0.85, "de") === "850 m" && formatDistance(3.24, "en") === "3.2 km", "„3,2 km“ / „850 m“ / en „3.2 km“");
ok(!isValidLatLng({ latitude: null, longitude: 9 }) && !isValidLatLng({ latitude: 91, longitude: 0 }) && isValidLatLng(hh), "Koordinaten-Validierung");
ok(formatMinutes(240, "de") === "4 h" && formatMinutes(270, "de") === "4,5 h" && formatMinutes(45, "de") === "45 min", "Dauer „4 h“ / „4,5 h“ / „45 min“");
ok(formatDayShort("2026-09-15", "de") === "Di 15.09.", `Tag kurz „Di 15.09.“ (${formatDayShort("2026-09-15", "de")})`);
section("Tage, Dauer, Kapazität, Konflikte (reine Regeln)");
const mon = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14));
const tue = addDays(mon, 1);
const wed = addDays(mon, 2);
const sat = addDays(mon, 5);
ok(weekdayIndex(mon) === 0 && weekdayIndex(addDays(mon, 6)) === 6, "Wochentag Mo = 0 … So = 6");
ok(isWorkingDay(31, mon) && !isWorkingDay(31, sat) && isWorkingDay(31 | 32, sat), "Arbeitstage-Bitmaske (Mo–Fr, mit Sa)");
ok(isDayKey("2026-02-28") && !isDayKey("2026-02-30") && !isDayKey("28.02.2026"), "Tagesschlüssel validiert");
const dst = dayBounds("2026-10-25", TZ);
ok(dst.end.getTime() - dst.start.getTime() === 25 * 3600_000, "Zeitumstellung: 25-Stunden-Tag");
ok(effectiveDurationMinutes(po({ id: "a", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "09:00"), plannedDurationMinutes: 90, orderTypeDefaultMinutes: 480 }), TZ) === 90, "Dauer: explizit vor Beginn/Ende und Auftragsart");
ok(effectiveDurationMinutes(po({ id: "b", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "11:30"), orderTypeDefaultMinutes: 480 }), TZ) === 210, "Dauer: aus Beginn/Ende (3,5 h)");
ok(effectiveDurationMinutes(po({ id: "c", plannedStart: at(mon, "08:00"), orderTypeDefaultMinutes: 180 }), TZ) === 180, "Dauer: Standard der Auftragsart");
ok(effectiveDurationMinutes(po({ id: "d", plannedStart: at(mon, "08:00") }), TZ) === 120, "Dauer: Fallback 120 min");
const multi = po({ id: "e", plannedStart: at(mon, "08:00"), plannedEnd: at(tue, "16:00") });
ok(minutesOnDay(multi, mon, TZ) === 480 && minutesOnDay(multi, tue, TZ) === 480 && minutesOnDay(multi, wed, TZ) === 0, "mehrtägig ohne Dauer: je Tag 480 min");
ok(minutesOnDay({ ...multi, plannedDurationMinutes: 600 }, tue, TZ) === 300, "mehrtägig mit Dauer: gleichmäßig verteilt");
const dateOnly = po({ id: "f", plannedStart: at(mon, "00:00"), plannedEnd: at(tue, "00:00") });
ok(minutesOnDay(dateOnly, mon, TZ) === 120 && minutesOnDay(dateOnly, tue, TZ) === 0, "Ende um Mitternacht gehört zum Vortag");
const longAgo = new Date(Date.now() - 400 * 86400_000);
const team = {
id: "t",
dailyCapacityMinutes: 480,
workingDays: 31,
members: [
{ userId: "u1", validFrom: longAgo, validTo: null, active: true },
{ userId: "u2", validFrom: longAgo, validTo: dayBounds(mon, TZ).start, active: true }, // endet vor Montag
{ userId: "u3", validFrom: dayBounds(tue, TZ).start, validTo: null, active: true }, // ab Dienstag
{ userId: "u4", validFrom: longAgo, validTo: null, active: false }, // deaktiviert
{ userId: "u1", validFrom: longAgo, validTo: null, active: true }, // doppelte Zeile zählt einmal
],
};
ok(activeMemberCount(team, mon, TZ) === 1 && activeMemberCount(team, tue, TZ) === 2, "aktive Mitglieder: gültig ab/bis, inaktiv, doppelt (Mo 1, Di 2)");
ok(teamCapacityMinutes(team, mon, TZ) === 480 && teamCapacityMinutes(team, tue, TZ) === 480, "Kolonnenkapazität = Arbeitstag der Kolonne (480), nicht × Personen");
ok(teamCapacityMinutes(team, sat, TZ) === 0, "Kapazität Sa: kein Arbeitstag → 0");
ok(teamCapacityMinutes({ ...team, members: [] }, tue, TZ) === 0, "Kolonne ohne aktive Mitglieder → 0");
ok(utilizationPercent(360, 960) === 38 && utilizationPercent(60, 0) === null, "Auslastung 6 h / 16 h = 38 %; ohne Kapazität null");
const hintMon = computeTeamDay(team, mon, [], TZ).conflicts.find((c) => c.kind === "crew_incomplete");
ok(!!hintMon && hintMon.severity === "hint" && hintMon.memberCount === 1, "crew_incomplete: Hinweis bei weniger als 2 aktiven Personen");
ok(!computeTeamDay(team, tue, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete") && !computeTeamDay(team, sat, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete"), "kein Hinweis bei 2 Personen bzw. an Nicht-Arbeitstagen");
ok(nextWorkingDays("2026-09-18", 5).join(",") === "2026-09-18,2026-09-21,2026-09-22,2026-09-23,2026-09-24" && nextWorkingDays("2026-09-19", 2).join(",") === "2026-09-19,2026-09-21", "Standardansicht: heute + nächste Werktage (Wochenende übersprungen)");
ok(addWorkingDays("2026-09-21", -5) === "2026-09-14" && addWorkingDays("2026-09-18", 1) === "2026-09-21", "Werktage vor/zurück");
const crew = { ...team, dailyCapacityMinutes: 960 };
const orders = [
po({ id: "o1", teamId: "t", plannedStart: at(tue, "08:00"), plannedEnd: at(tue, "12:00") }),
po({ id: "o2", teamId: "t", plannedStart: at(tue, "11:00"), plannedEnd: at(tue, "13:00") }),
po({ id: "o3", teamId: "t", plannedStart: at(tue, "13:00"), plannedDurationMinutes: 600 }),
];
const td = computeTeamDay(crew, tue, orders, TZ);
ok(td.plannedMinutes === 960 && td.capacityMinutes === 960 && td.utilization === 100, "Tageslast 16 h bei 16 h Kolonnenkapazität = 100 %");
ok(!td.conflicts.some((c) => c.kind === "overbooked"), "genau ausgelastet ist nicht überbucht");
const overlaps = td.conflicts.filter((c) => c.kind === "overlap");
ok(overlaps.length === 1 && overlaps[0].orderIds.includes("o1") && overlaps[0].orderIds.includes("o2"), "Überschneidung o1/o2; o3 beginnt genau am Ende von o2 → keine");
const td2 = computeTeamDay(crew, tue, [...orders, po({ id: "o4", teamId: "t", plannedStart: at(tue, "00:00"), plannedDurationMinutes: 30 })], TZ);
ok(td2.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 30), "überbucht um 30 min");
ok(td2.conflicts.filter((c) => c.kind === "overlap").length === 1, "Auftrag ohne Uhrzeit erzeugt keine Überschneidung");
const tdSat = computeTeamDay(crew, sat, [po({ id: "o5", teamId: "t", plannedStart: at(sat, "09:00") })], TZ);
ok(tdSat.conflicts.length === 1 && tdSat.conflicts[0].kind === "outside_working_days", "Auftrag am Samstag → outside_working_days (nicht zusätzlich überbucht)");
const dbl = assigneeDoubleBookings(
[
po({ id: "x1", teamId: "t", plannedStart: at(tue, "09:00"), plannedEnd: at(tue, "10:00"), assigneeIds: ["u1"] }),
po({ id: "x2", teamId: "s", plannedStart: at(tue, "09:30"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u1"] }),
po({ id: "x3", teamId: "s", plannedStart: at(tue, "10:00"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u3"] }),
po({ id: "x4", teamId: "s", plannedStart: at(wed, "09:00"), plannedEnd: at(wed, "11:00"), assigneeIds: ["u1"] }),
],
tue,
TZ,
);
ok(dbl.length === 1 && dbl[0].userId === "u1" && dbl[0].teamIds.length === 2, "Person in zwei Kolonnen gleichzeitig → assignee_double_booked (nur überlappende Zeitfenster)");
section("Kolonnenzeit, Fahrzeit, Überschreitungsstufen (rein)");
const hm = (v: string) => at(tue, v).getTime();
ok(unionMinutes([{ start: hm("08:00"), end: hm("10:00") }, { start: hm("09:00"), end: hm("11:00") }, { start: hm("12:00"), end: hm("13:00") }]) === 240, "Arbeitssegmente zeitlich vereinigt (3 h + 1 h), nicht über Personen summiert");
ok(unionMinutes([]) === 0 && unionMinutes([{ start: hm("10:00"), end: hm("09:00") }]) === 0, "leere/ungültige Segmente → 0");
ok(estimateTravelMinutes(null) === 10 && estimateTravelMinutes(1) === 10 && estimateTravelMinutes(50) === 78 && estimateTravelMinutes(20) === 31, "Fahrzeit = Luftlinie × 1,3 / 50 km/h, mind. 10 min (50 km → 78 min)");
ok(overrunBucket(0.99) === -1 && overrunBucket(1) === 0 && overrunBucket(1.49) === 0 && overrunBucket(1.5) === 1 && overrunBucket(2) === 2, "Meldestufen: 100 %, danach je weitere +50 %");
section("Plantafel (Datenbank)");
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const bo = A.ctx.backoffice;
await ensureDefaultOrderTypes(bo.db, A.tenantId);
const montage = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "montage" } });
const besichtigung = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "besichtigung" } });
ok(montage.defaultDurationMinutes === 480 && besichtigung.defaultDurationMinutes === 60, "Standard-Auftragsarten mit Dauer (Montage 480, Besichtigung 60)");
const mk = (title: string, extra: Record<string, unknown> = {}) => createWorkOrder(bo, { title, customerId: A.customerId, siteId: A.siteId, ...extra });
const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version;
const place = async (id: string, teamId: string, start: Date, extra: { plannedEnd?: Date | null; plannedDurationMinutes?: number | null } = {}) =>
scheduleWorkOrder(bo, { workOrderId: id, teamId, plannedStart: start, baseVersion: await version(id), ...extra });
const w1 = await mk("Planung 1");
await assignWorkOrder(bo, { workOrderId: w1.id, teamId: A.teamId, userIds: [A.users.tech.id] });
await place(w1.id, A.teamId, at(mon, "08:00"), { plannedEnd: at(mon, "12:00") });
const w2 = await mk("Planung 2");
await place(w2.id, A.teamId, at(mon, "10:00"), { plannedEnd: at(mon, "14:00") });
const w3 = await mk("Planung 3");
await place(w3.id, A.teamId, at(mon, "14:00"), { plannedDurationMinutes: 600 });
const w4 = await mk("Planung Samstag");
await place(w4.id, A.team2Id, at(sat, "09:00"), { plannedEnd: at(sat, "11:00") });
const w5 = await mk("Planung Süd mit Max", { priority: "high" });
await assignWorkOrder(bo, { workOrderId: w5.id, teamId: A.team2Id, userIds: [A.users.tech.id] });
await place(w5.id, A.team2Id, at(mon, "09:00"), { plannedEnd: at(mon, "11:00") });
const draft = await mk("Noch ungeplant");
const board = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6) });
const nord = board.teams.find((t) => t.id === A.teamId)!;
const sued = board.teams.find((t) => t.id === A.team2Id)!;
const nMon = nord.days.find((x) => x.day === mon)!;
ok(board.days.length === 7 && board.timeZone === TZ, "Woche Mo–So in Mandanten-Zeitzone");
ok(nMon.capacityMinutes === 480 && nMon.plannedMinutes === 1080 && nMon.utilization === 225 && nMon.memberCount === 2, `Kolonne Nord Mo: 18 h / 8 h · 225 % (${nMon.plannedMinutes}/${nMon.capacityMinutes}/${nMon.utilization})`);
ok(nMon.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 600), "Kolonne Nord Mo überbucht um 10 h");
ok(
!nMon.conflicts.some((c) => c.kind === "crew_incomplete") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "crew_incomplete" && c.severity === "hint" && c.memberCount === 1),
"Hinweis „Kolonne unvollständig“ nur bei Team Süd (1 Person)",
);
ok(nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w1.id) && c.orderIds.includes(w2.id)), "Überschneidung zweier Aufträge desselben Teams");
ok(!nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w3.id)), "anschließender Auftrag (14:00) überschneidet nicht");
ok(sued.days.find((x) => x.day === sat)!.conflicts.some((c) => c.kind === "outside_working_days" && c.orderIds.includes(w4.id)), "Samstag bei Team Süd → außerhalb der Arbeitstage");
const dblC = board.conflicts.find((c) => c.kind === "assignee_double_booked");
ok(!!dblC && dblC.userId === A.users.tech.id && dblC.orderIds.includes(w1.id) && dblC.orderIds.includes(w5.id) && dblC.teamIds.length === 2, "Monteur Nord + Einzelzuweisung Süd gleichzeitig → doppelt eingeplant");
ok(
nMon.conflicts.some((c) => c.kind === "assignee_double_booked") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "assignee_double_booked"),
"Doppelbuchung in beiden Teamzeilen sichtbar",
);
ok(board.orders[w1.id]?.conflictKinds.includes("overlap") && board.orders[w1.id].conflictKinds.includes("assignee_double_booked"), "Auftragskarte trägt ihre Konfliktarten");
ok(nMon.orderIds.length === 3 && board.orders[w3.id].durationMinutes === 600 && board.orders[w1.id].assigneeIds.includes(A.users.tech.id), "Zelle zeigt Aufträge mit Dauer und Einzelzuweisung");
ok(nord.days.find((x) => x.day === tue)!.utilization === 0 && nord.members.length === 2, "Di ohne Aufträge: 0 %; Mitglieder aufklappbar (2)");
ok(board.unplanned.some((o) => o.id === draft.id) && !board.unplanned.some((o) => o.id === w1.id), "Seitenleiste: ungeplanter Entwurf ja, eingeplanter Auftrag nein");
ok(board.conflictCount >= 4 && board.conflictCount === board.conflicts.filter(isConflict).length && board.conflicts.some((c) => c.severity === "hint"), `Konfliktzähler ohne Hinweise (${board.conflictCount})`);
ok(!board.orders[w4.id].conflictKinds.includes("crew_incomplete"), "Hinweise färben keine Auftragskarte rot");
ok(board.access.all && board.access.canSchedule, "Backoffice: alle Teams, darf einplanen");
await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: dayBounds(tue, TZ).start } });
const b2 = await getPlanningBoard(bo, { from: mon, to: tue, teamIds: [A.teamId] });
ok(
b2.teams.length === 1 && b2.teams[0].days[0].memberCount === 2 && b2.teams[0].days[1].memberCount === 1 && b2.teams[0].days[1].capacityMinutes === 480 && b2.teams[0].days[1].conflicts.some((c) => c.kind === "crew_incomplete"),
"Mitgliedschaft „gültig bis Di“ → Di 1 Person: Kapazität bleibt, Hinweis Kolonne unvollständig",
);
await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: null } });
const settings = await updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 360, workingDays: 63 });
const b3 = await getPlanningBoard(bo, { from: sat, to: sat, teamIds: [A.team2Id] });
ok(settings.workingDays === 63 && b3.teams[0].days[0].capacityMinutes === 360 && !b3.teams[0].days[0].conflicts.some((c) => c.kind === "outside_working_days"), "Kolonnenkapazität 6 h + Samstag als Arbeitstag wirkt sofort");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "team", entityId: A.team2Id, action: "update" } })) === 1, "Kolonnenkapazität auditiert");
await expectCode(() => updateTeamPlanningSettings(A.ctx.lead, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "forbidden", "Kolonnenkapazität: Teamleiter → forbidden");
await expectCode(() => updateTeamPlanningSettings(B.ctx.admin, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "not_found", "Kolonnenkapazität: Mandant B → not_found");
await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 480, workingDays: 200 }), "invalid", "Kolonnenkapazität: ungültige Bitmaske → invalid");
const bf = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6), priority: "high" });
ok(!!bf.orders[w5.id] && !bf.orders[w1.id] && bf.teams.find((t) => t.id === A.teamId)!.days[0].plannedMinutes === 1080, "Filter Priorität blendet Karten aus, Auslastung bleibt vollständig");
ok(!bf.unplanned.some((o) => o.id === draft.id), "Filter wirkt auch auf die Seitenleiste");
section("Plantafel: Rollen & Mandantentrennung");
const leadBoard = await getPlanningBoard(A.ctx.lead, { from: mon, to: addDays(mon, 6) });
ok(leadBoard.teams.length === 1 && leadBoard.teams[0].id === A.teamId && !leadBoard.access.canSchedule, "Teamleiter: nur eigenes Team, nur lesend");
ok(!!leadBoard.orders[w1.id] && !leadBoard.orders[w5.id] && !leadBoard.unplanned.some((o) => o.id === draft.id), "Teamleiter: keine Aufträge anderer Teams, keine teamlosen Aufträge");
await expectCode(() => getPlanningBoard(A.ctx.tech, { from: mon, to: mon }), "forbidden", "Monteur → forbidden");
const bB = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: addDays(mon, 6) });
ok(
bB.teams.every((t) => t.id !== A.teamId && t.id !== A.team2Id) && Object.keys(bB.orders).length === 0 && !bB.unplanned.some((o) => [w1.id, draft.id].includes(o.id)),
"Mandant B sieht keine Teams und Aufträge von A",
);
const bBforeign = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: mon, teamIds: [A.teamId] });
ok(bBforeign.teams.length === 0, "Mandant B: fremde Team-ID liefert nichts");
await expectCode(() => getPlanningBoard(bo, { from: mon, to: addDays(mon, 60) }), "invalid", "Zeitraum > 42 Tage → invalid");
await expectCode(() => getPlanningBoard(bo, { from: tue, to: mon }), "invalid", "Ende vor Beginn → invalid");
ok((await countWeekConflicts(B.ctx.backoffice))?.count === 0, "Dashboard-Zähler Mandant B: 0 Konflikte diese Woche");
ok((await countWeekConflicts(A.ctx.tech)) === null, "Dashboard-Zähler für Monteur: keine Kachel");
section("scheduleWorkOrder");
const s1 = await mk("Einplanen Entwurf");
const s1before = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } });
const r1 = await scheduleWorkOrder(bo, { workOrderId: s1.id, teamId: A.teamId, plannedStart: at(mon, "15:00"), plannedDurationMinutes: 60, baseVersion: s1before.version });
const s1after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } });
ok(
s1after.status === "assigned" && s1after.assignedTeamId === A.teamId && s1after.plannedStart?.getTime() === at(mon, "15:00").getTime() && s1after.plannedDurationMinutes === 60,
"Entwurf → zugewiesen mit Team, Beginn und Dauer",
);
ok(r1.version === s1after.version && s1after.version > s1before.version, "neue Version zurückgegeben");
ok(r1.conflicts.some((c) => c.kind === "overbooked") && r1.conflicts.every((c) => c.teamIds.includes(A.teamId)), "Antwort enthält Konflikte des Zielteams am Zieltag");
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, entityId: s1.id, type: "work_order.assigned" } })) > 0, "Event work_order.assigned → Benachrichtigung ans Team");
const audits = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s1.id } });
const planningAudit = audits.find((a) => (a.after as { source?: string } | null)?.source === "planning");
ok(!!planningAudit && planningAudit.actorId === A.users.backoffice.id && (planningAudit.before as { assignedTeamId?: string | null }).assignedTeamId === null, "Audit „planning“ mit before/after und Akteur");
const s2 = await mk("Verschieben");
await place(s2.id, A.teamId, at(tue, "08:00"), { plannedEnd: at(tue, "10:30") });
const v2 = await version(s2.id);
await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.team2Id, plannedStart: at(wed, "13:00"), baseVersion: v2 });
const s2after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } });
ok(s2after.assignedTeamId === A.team2Id && s2after.plannedEnd?.getTime() === at(wed, "15:30").getTime(), "Verschieben in anderes Team/Tag behält die Länge (2,5 h)");
let conflictErr: unknown = null;
try {
await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: v2 });
} catch (err) {
conflictErr = err;
}
ok(conflictErr instanceof ServiceError && conflictErr.code === "conflict" && conflictErr.message === VERSION_CONFLICT_MESSAGE, "veraltete Version → conflict „Auftrag wurde zwischenzeitlich geändert“");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } })).assignedTeamId === A.team2Id, "bei Konflikt nichts überschrieben");
const s3 = await mk("Atomar");
await expectCode(
() => scheduleWorkOrder(bo, { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), plannedEnd: at(tue, "09:00"), baseVersion: s3.version }),
"invalid",
"Ende vor Beginn → invalid (Prüfung im Auftragsservice nach der Zuweisung)",
);
const s3after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } });
ok(s3after.status === "draft" && s3after.assignedTeamId === null && s3after.version === s3.version, "atomar: Zuweisung zurückgerollt (Entwurf, ohne Team, gleiche Version)");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s3.id, action: "update" } })) === 0, "atomar: kein Audit aus dem zurückgerollten Versuch");
ok((await prisma.workOrderStatusChange.count({ where: { tenantId: A.tenantId, workOrderId: s3.id, toStatus: "assigned" } })) === 0, "atomar: keine Statushistorie „zugewiesen“");
const s3input = { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), baseVersion: s3.version };
await expectCode(() => scheduleWorkOrder(A.ctx.lead, s3input), "forbidden", "Teamleiter darf nicht einplanen");
await expectCode(() => scheduleWorkOrder(A.ctx.tech, s3input), "forbidden", "Monteur darf nicht einplanen");
await expectCode(() => scheduleWorkOrder(B.ctx.backoffice, s3input), ["not_found", "invalid"], "Mandant B: Auftrag von A → abgewiesen");
const bOrder = await createWorkOrder(B.ctx.backoffice, { title: "B-Auftrag", customerId: B.customerId, siteId: B.siteId });
await expectCode(
() => scheduleWorkOrder(B.ctx.backoffice, { workOrderId: bOrder.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: bOrder.version }),
"invalid",
"Mandant B: Team von A → invalid (team_not_found)",
);
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } })).status === "draft", "Auftrag von A unverändert");
await expectCode(() => scheduleWorkOrder(bo, { ...s3input, plannedStart: "kein Datum" }), "invalid", "ungültiges Datum → invalid");
const s4 = await mk("Läuft schon");
await place(s4.id, A.teamId, at(tue, "08:00"));
await transitionWorkOrder(A.ctx.tech, { workOrderId: s4.id, to: "in_progress" });
const v4 = await version(s4.id);
await expectCode(() => scheduleWorkOrder(bo, { workOrderId: s4.id, teamId: A.team2Id, plannedStart: at(wed, "08:00"), baseVersion: v4 }), "invalid", "laufender Auftrag → invalid (not_schedulable)");
});
+178
View File
@@ -0,0 +1,178 @@
// L13 Planung — Geocoding: Normalisierung/Änderungserkennung, Nominatim-Anfrage (URL, User-Agent,
// Accept-Language, Auswertung, Fehler) mit injiziertem fetch, Drosselung 1/s mit Fake-Uhr, Processor mit
// Fake-Provider (Cache bei unveränderter Adresse, not_found, failed → Retry, manuelle Koordinaten,
// Provider none, Mandantentrennung), Hook beim Anlegen ohne Inline-Aufruf. Nominatim wird NIE aufgerufen.
// Lauf: npx tsx scripts/test-planung-geocode.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
import { geocodingConfig, mapConfig } from "../src/server/services/geo/config";
import { requestSiteGeocoding } from "../src/server/services/geo/dispatch";
import { geocodeSite } from "../src/server/services/geo/geocode-site";
import { buildNominatimUrl, createNominatimProvider } from "../src/server/services/geo/nominatim";
import { addressChanged, geocodeQueryFor } from "../src/server/services/geo/normalize";
import { GeocodingError, setGeocodingProvider, type GeocodeAddress, type GeocodingProvider } from "../src/server/services/geo/provider";
import { createIntervalLimiter } from "../src/server/services/geo/rate-limit";
import { process as geocodeProcessor } from "../src/server/jobs/processors/geocode-site";
import { PROCESSORS } from "../src/server/jobs/processors";
import { createSite } from "../src/server/services/sites/sites";
const SLUG_A = "zz-planung-geo-a";
const SLUG_B = "zz-planung-geo-b";
// Guard: any request to a Nominatim host fails the suite.
let nominatimHits = 0;
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (/nominatim|openstreetmap/i.test(url)) {
nominatimHits++;
throw new Error("Nominatim must never be called from tests");
}
return realFetch(input, init);
}) as typeof fetch;
function fakeProvider(answer: (a: GeocodeAddress) => { status: "ok"; latitude: number; longitude: number } | { status: "not_found" } | "throw") {
const calls: GeocodeAddress[] = [];
const provider: GeocodingProvider = {
name: "fake",
async geocode(a) {
calls.push(a);
const r = answer(a);
if (r === "throw") throw new GeocodingError("http 503");
return r;
},
};
return { provider, calls };
}
runSuite("L13 Planung – Geocoding", [SLUG_A, SLUG_B], async () => {
section("Normalisierung & Änderungserkennung");
const addr = { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" };
ok(geocodeQueryFor(addr) === "hafenstraße 12|20457|hamburg|de", `normierte Adresse (${geocodeQueryFor(addr)})`);
ok(geocodeQueryFor({ ...addr, street: " HAFENSTRASSE ".replace("SS", "ß"), city: "hamburg " }) === geocodeQueryFor(addr), "Groß-/Kleinschreibung und Leerzeichen egal");
ok(geocodeQueryFor({ street: "Hafenstraße", houseNumber: "12" }) === null, "ohne PLZ und Ort → nicht verortbar");
ok(!addressChanged(addr, { ...addr, city: "HAMBURG" }) && addressChanged(addr, { ...addr, houseNumber: "14" }), "nur relevante Adressänderungen zählen");
section("Nominatim-Anfrage (ohne Netz)");
const url = buildNominatimUrl("https://nominatim.example/", addr);
ok(
url.pathname === "/search" && url.searchParams.get("format") === "jsonv2" && url.searchParams.get("limit") === "1" && url.searchParams.get("street") === "12 Hafenstraße" && url.searchParams.get("postalcode") === "20457" && url.searchParams.get("city") === "Hamburg" && url.searchParams.get("countrycodes") === "de",
`strukturierte Suche (${url.search})`,
);
const cfg = geocodingConfig({ APP_BASE_URL: "https://app.craftvia.example" });
ok(/^Craftvia\/\d+\.\d+\.\d+ \(\+https:\/\/app\.craftvia\.example\)$/.test(cfg.userAgent) && cfg.url === "https://nominatim.openstreetmap.org" && cfg.provider === "nominatim", `Standard-User-Agent „${cfg.userAgent}“`);
ok(geocodingConfig({ GEOCODING_USER_AGENT: "Betrieb/1 (+mailto:it@example.org)", GEOCODING_PROVIDER: "none", GEOCODING_URL: "https://geo.example/" }).userAgent === "Betrieb/1 (+mailto:it@example.org)", "User-Agent per GEOCODING_USER_AGENT überschreibbar");
ok(geocodingConfig({ GEOCODING_PROVIDER: "none" }).provider === "none" && geocodingConfig({ GEOCODING_URL: "https://geo.example/" }).url === "https://geo.example", "Provider none / eigene GEOCODING_URL");
ok(mapConfig({}).tileUrl === "https://tile.openstreetmap.org/{z}/{x}/{y}.png" && mapConfig({}).attribution.includes("OpenStreetMap"), "Kartenkacheln: OSM-Standard mit Namensnennung");
const seen: { url: string; headers: Headers }[] = [];
let reply: () => Response = () => Response.json([{ lat: "53.5413", lon: "9.9849" }]);
const acquired: number[] = [];
const nominatim = createNominatimProvider(
{ url: "https://nominatim.example", userAgent: "Craftvia/test (+https://app.example)", timeoutMs: 1000 },
{
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
seen.push({ url: String(input), headers: new Headers(init?.headers) });
return reply();
}) as typeof fetch,
limiter: { acquire: async () => (acquired.push(Date.now()), Date.now()) },
},
);
const hit = await nominatim.geocode(addr);
ok(hit.status === "ok" && hit.latitude === 53.5413 && hit.longitude === 9.9849, "Treffer ausgewertet");
ok(seen[0]?.headers.get("user-agent") === "Craftvia/test (+https://app.example)" && seen[0].headers.get("accept-language") === "de", "User-Agent und Accept-Language de gesetzt");
ok(acquired.length === 1, "jede Anfrage läuft über den Drosselungs-Limiter");
reply = () => Response.json([]);
ok((await nominatim.geocode(addr)).status === "not_found", "leere Antwort → not_found");
reply = () => new Response("busy", { status: 503 });
let threw = false;
try {
await nominatim.geocode(addr);
} catch (err) {
threw = err instanceof GeocodingError;
}
ok(threw, "HTTP-Fehler → GeocodingError (wiederholbar)");
section("Drosselung 1 Anfrage/s (Fake-Uhr)");
let clock = 10_000;
const sleeps: number[] = [];
const limiter = createIntervalLimiter(1000, { now: () => clock, sleep: async (ms) => void sleeps.push(ms) });
const slots = [await limiter.acquire(), await limiter.acquire(), await limiter.acquire()];
ok(slots[0] === 10_000 && slots[1] === 11_000 && slots[2] === 12_000 && sleeps.join(",") === "1000,2000", `Slots im Sekundenabstand (${slots.join(",")}), Wartezeiten ${sleeps.join(",")}`);
clock = 20_000;
ok((await limiter.acquire()) === 20_000 && sleeps.length === 2, "nach Pause sofort wieder frei");
section("geocodeSite mit Fake-Provider");
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const fake = fakeProvider((a) => (a.city === "Nirgendwo" ? { status: "not_found" } : { status: "ok", latitude: 53.55, longitude: a.houseNumber === "14" ? 10.01 : 10.0 }));
setGeocodingProvider(fake.provider);
const site = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo 1", ...addr } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 1, "neue Adresse → ok, 1 Provider-Aufruf");
const s1 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s1.latitude === 53.55 && s1.longitude === 10.0 && s1.geocodeStatus === "ok" && s1.geocodeQuery === geocodeQueryFor(addr) && !!s1.geocodedAt, "Koordinaten, Status, Adresse und Zeitpunkt am Objekt gespeichert");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "site", entityId: site.id, action: "update" } })) === 1, "Koordinatenänderung auditiert");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "unveränderte Adresse → Cache, kein weiterer Aufruf");
await prisma.site.update({ where: { id: site.id }, data: { city: "hamburg " } });
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "nur Schreibweise geändert → Cache");
await prisma.site.update({ where: { id: site.id }, data: { houseNumber: "14" } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 2 && (await prisma.site.findUniqueOrThrow({ where: { id: site.id } })).longitude === 10.01, "geänderte Adresse → neu verortet");
await prisma.site.update({ where: { id: site.id }, data: { city: "Nirgendwo", postalCode: "99999" } });
ok((await geocodeSite(A.tenantId, site.id)) === "not_found" && fake.calls.length === 3, "unbekannte Adresse → not_found");
const s2 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s2.geocodeStatus === "not_found" && s2.latitude === null && s2.longitude === null, "not_found entfernt veraltete automatische Koordinaten");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 3, "not_found wird für dieselbe Adresse nicht wiederholt");
const failing = fakeProvider(() => "throw");
const siteFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Fehler", ...addr } });
ok((await geocodeSite(A.tenantId, siteFail.id, { provider: failing.provider })) === "failed" && (await prisma.site.findUniqueOrThrow({ where: { id: siteFail.id } })).geocodeStatus === "failed", "Providerfehler → Status failed");
ok((await geocodeSite(A.tenantId, siteFail.id)) === "ok", "failed wird beim nächsten Lauf erneut versucht");
const manual = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo manuell", ...addr, latitude: 50.1, longitude: 8.6 } });
const callsBefore = fake.calls.length;
ok((await geocodeSite(A.tenantId, manual.id)) === "manual" && fake.calls.length === callsBefore, "manuelle Koordinaten → kein Aufruf");
const m = await prisma.site.findUniqueOrThrow({ where: { id: manual.id } });
ok(m.latitude === 50.1 && m.longitude === 8.6 && m.geocodeStatus === "skipped", "manuelle Koordinaten bleiben unverändert (Status skipped)");
const none = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo none", ...addr } });
ok((await geocodeSite(A.tenantId, none.id, { provider: null })) === "skipped" && (await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).latitude === null, "Provider none → skipped, ohne Ortsangabe, kein Fehler");
const noAddr = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo ohne Adresse", street: "Irgendwo" } });
ok((await geocodeSite(A.tenantId, noAddr.id)) === "skipped" && fake.calls.length === callsBefore, "unvollständige Adresse → skipped ohne Aufruf");
const bCalls = fake.calls.length;
ok((await geocodeSite(B.tenantId, none.id)) === "missing" && fake.calls.length === bCalls, "Mandant B: Objekt von A nicht gefunden, kein Aufruf");
ok((await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).geocodeStatus === "skipped", "Objekt von A unverändert");
section("Processor & Hook");
ok(typeof PROCESSORS["geocode-site"] === "function", "Processor geocode-site registriert");
const viaJob = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job", ...addr } });
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJob.id });
ok((await prisma.site.findUniqueOrThrow({ where: { id: viaJob.id } })).geocodeStatus === "ok", "Processor verortet das Objekt");
setGeocodingProvider(failing.provider);
const viaJobFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job Fehler", ...addr } });
let jobThrew = false;
try {
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJobFail.id });
} catch {
jobThrew = true;
}
ok(jobThrew, "Processor wirft bei failed → BullMQ wiederholt");
setGeocodingProvider(fake.provider);
const prev = process.env.GEOCODING_PROVIDER;
process.env.GEOCODING_PROVIDER = "none";
ok((await requestSiteGeocoding(A.tenantId, viaJob.id)) === false, "GEOCODING_PROVIDER=none → kein Job");
process.env.GEOCODING_PROVIDER = "nominatim";
const hookCalls = fake.calls.length;
const created = await createSite(A.ctx.backoffice, { customerId: A.customerId, name: "Geo Hook", street: "Am Kaiserkai", houseNumber: "1", postalCode: "20457", city: "Hamburg" });
await new Promise((resolve) => setTimeout(resolve, 100));
ok(!!created.id && fake.calls.length === hookCalls, "Anlage eines Objekts: Verortung nur per Queue, nie inline im Request");
if (prev === undefined) delete process.env.GEOCODING_PROVIDER;
else process.env.GEOCODING_PROVIDER = prev;
setGeocodingProvider(undefined);
ok(nominatimHits === 0, "Nominatim wurde in keinem Test aufgerufen");
});
+127
View File
@@ -0,0 +1,127 @@
// 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");
});
+134
View File
@@ -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");
});
+176
View File
@@ -0,0 +1,176 @@
// L13 Planung — Verzug und „früher fertig“: erfasste Kolonnenzeit (vereinigt), Warnung ab 80 %, Meldung ab
// 100 % mit Dedupe und erneuter Meldung je weitere +50 %, gefährdeter Folgeauftrag (Beginn inkl. Fahrzeit,
// Tageskapazität) mit Dedupe, freie Kolonnenzeit bei früherem Abschluss + Vorzieh-/Umkreis-Vorschläge,
// Event planning.capacity_freed, Empfänger (Backoffice + Teamleiter, nur In-App), Plantafel/Live-Lage,
// Rollen, Mandantentrennung, L12-Adapter. Alle Zeiten relativ zu „heute 12:00“ (unabhängig von der Uhrzeit).
// Lauf: npx tsx scripts/test-planung-watch.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { berlinAt, createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
import { dayKeyOf } from "../src/lib/planning/days";
import { getPlanningBoard } from "../src/server/services/planning/board";
import { getLiveSituation } from "../src/server/services/planning/live";
import { approvedEntryWhere, TIME_TRACKING_FIELDS, trackedEntryWhere, trackedSessionWhere } from "../src/server/services/planning/time-tracking";
import { auditAlertLedger, evaluateDelays, getFreedCapacity, runPlanningWatch, watchContext } from "../src/server/services/planning/watch";
import { PROCESSORS } from "../src/server/jobs/processors";
import { JOB_QUEUES } from "../src/server/jobs/queues";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-watch-a";
const SLUG_B = "zz-planung-watch-b";
const T = (hh: number, mm = 0) => berlinAt(0, hh, mm);
const S1 = { latitude: 53.55, longitude: 10.0 };
const S2 = { latitude: 53.73, longitude: 10.0 }; // ≈ 20 km nördlich
const S3 = { latitude: 53.559, longitude: 10.0 }; // ≈ 1 km
const FAR = { latitude: 53.87, longitude: 10.69 };
async function site(f: TenantFixture, name: string, c: { latitude: number; longitude: number } | null) {
return prisma.site.create({ data: { tenantId: f.tenantId, customerId: f.customerId, name, city: "Hamburg", postalCode: "20457", ...(c ?? {}), geocodeStatus: c ? "ok" : null, geocodedAt: c ? new Date() : null } });
}
async function order(f: TenantFixture, siteId: string | null, title: string, data: Record<string, unknown>) {
const wo = await createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, ...(siteId ? { siteId } : {}) });
await prisma.workOrder.update({ where: { id: wo.id }, data });
return wo;
}
async function session(f: TenantFixture, workOrderId: string, userId: string, teamId: string, status: "running" | "paused" | "en_route" | "ended", startedAt: Date, endedAt: Date | null = null) {
return prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId, userId, teamId, status, startedAt, endedAt } });
}
async function entry(f: TenantFixture, workSessionId: string, userId: string, startedAt: Date, endedAt: Date | null, type: "work" | "break" | "travel" = "work") {
return prisma.timeEntry.create({ data: { tenantId: f.tenantId, workSessionId, userId, type, startedAt, endedAt } });
}
const alerts = (f: TenantFixture) => prisma.auditLog.findMany({ where: { tenantId: f.tenantId, entity: "planning_alert" }, select: { entityId: true } });
runSuite("L13 Planung – Verzug & früher fertig", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none";
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const today = dayKeyOf(T(12), TZ);
section("Job & Adapter");
ok(JOB_QUEUES.planningWatch === "planning-watch" && typeof PROCESSORS["planning-watch"] === "function", "Queue planning-watch + Processor registriert");
ok(
Object.keys(trackedEntryWhere).length === (TIME_TRACKING_FIELDS.entrySource ? 1 : 0) &&
Object.keys(approvedEntryWhere).length === (TIME_TRACKING_FIELDS.entryApproval ? 1 : 0) &&
Object.keys(trackedSessionWhere).length === (TIME_TRACKING_FIELDS.sessionManual ? 1 : 0),
`L12-Filter aktiv je nach Schema (source=${TIME_TRACKING_FIELDS.entrySource}, approvalStatus=${TIME_TRACKING_FIELDS.entryApproval}, manual=${TIME_TRACKING_FIELDS.sessionManual})`,
);
// Kolonne Nord (tech + tech2): laufender Auftrag, 2 h geplant.
const s1 = await site(A, "Baustelle 1", S1);
const s2 = await site(A, "Baustelle 20 km", S2);
const oRun = await order(A, s1.id, "Laufend Nord", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(8), plannedDurationMinutes: 120 });
const sessTech = await session(A, oRun.id, A.users.tech.id, A.teamId, "running", T(8));
await entry(A, sessTech.id, A.users.tech.id, T(8), T(9));
await entry(A, sessTech.id, A.users.tech.id, T(9), T(9, 30), "break");
await entry(A, sessTech.id, A.users.tech.id, T(9, 30), null);
const sessTech2 = await session(A, oRun.id, A.users.tech2.id, A.teamId, "running", T(8, 30));
await entry(A, sessTech2.id, A.users.tech2.id, T(8, 30), null);
const oNext = await order(A, s2.id, "Folgeauftrag Nord", { status: "assigned", assignedTeamId: A.teamId, plannedStart: T(10, 45), plannedDurationMinutes: 60 });
section("Verzug: Kolonnenzeit und 80 %");
const sys = watchContext(A.tenantId);
const e900 = await evaluateDelays(sys, { now: T(9, 0) });
ok(e900.delays.find((d) => d.workOrderId === oRun.id)?.level === "ok" && e900.delays.find((d) => d.workOrderId === oRun.id)?.workedMinutes === 60, "09:00: 60 min (50 %) → kein Verzug");
const e940 = await evaluateDelays(sys, { now: T(9, 40) });
const d940 = e940.delays.find((d) => d.workOrderId === oRun.id);
ok(d940?.workedMinutes === 100, `erfasste Kolonnenzeit vereinigt 08:00–09:40 = 100 min (summiert wären 140) — ${d940?.workedMinutes}`);
ok(d940?.level === "risk" && d940.percent === 83 && d940.remainingMinutes === 20, "83 % → Verzug droht, Restzeit 20 min");
ok(!e940.followups.some((f) => f.workOrderId === oNext.id), "09:40 + 20 min Rest + 31 min Fahrt (20 km) = 10:31 < Beginn 10:45 → Folgeauftrag nicht gefährdet");
const e1020 = await evaluateDelays(sys, { now: T(10, 20) });
const f1020 = e1020.followups.find((f) => f.workOrderId === oNext.id);
ok(!!f1020 && f1020.reason === "start" && f1020.travelMinutes === 31 && f1020.blockedByNumber === oRun.number, "10:20: Rest 0 + 31 min Fahrt = 10:51 > 10:45 → Folgeauftrag gefährdet (Beginn)");
const board = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(9, 40) });
ok(board.orders[oRun.id]?.delay?.level === "risk" && board.orders[oRun.id].delay?.percent === 83 && !board.orders[oNext.id]?.atRisk, "Plantafel 09:40: Karte „Verzug droht · 83 %“, Folgeauftrag noch nicht gefährdet");
const board1020 = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(10, 20) });
ok(board1020.orders[oRun.id]?.delay?.level === "overrun" && board1020.orders[oNext.id]?.atRisk?.byNumber === oRun.number, "Plantafel 10:20: „Dauer überschritten“ + Folgeauftrag „gefährdet durch Verzug …“");
const live940 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(9, 40) });
ok(live940.crews.find((c) => c.teamId === A.teamId)?.delay?.level === "risk", "Live-Lage: Kolonne Nord mit Verzugswarnung");
section("Meldungen: 100 %, Dedupe, +50 %");
const r940 = await runPlanningWatch(A.tenantId, { now: T(9, 40) });
ok(r940.overrun === 0 && r940.followupAtRisk === 0, `09:40: keine Meldung bei 83 % (${JSON.stringify(r940)})`);
const r1010 = await runPlanningWatch(A.tenantId, { now: T(10, 10) });
ok(r1010.overrun === 1 && r1010.followupAtRisk === 0, "10:10: 130 min = 108 % → planning.overrun");
const r1020 = await runPlanningWatch(A.tenantId, { now: T(10, 20) });
ok(r1020.overrun === 0 && r1020.followupAtRisk === 1, "10:20: keine erneute Überschreitungsmeldung, 1 × Folgeauftrag gefährdet");
ok((await runPlanningWatch(A.tenantId, { now: T(10, 25) })).followupAtRisk === 0, "Folgeauftrag gefährdet wird je Auftrag/Tag nur einmal gemeldet");
ok((await runPlanningWatch(A.tenantId, { now: T(10, 40) })).overrun === 0, "10:40 (133 %): keine erneute Meldung");
ok((await runPlanningWatch(A.tenantId, { now: T(11, 0) })).overrun === 1, "11:00: 180 min = 150 % → erneute Meldung (+50 %)");
ok((await runPlanningWatch(A.tenantId, { now: T(11, 10) })).overrun === 0, "11:10: keine weitere Meldung vor 200 %");
const keys = (await alerts(A)).map((a) => a.entityId);
ok(keys.includes(`planning.overrun:${oRun.id}:0`) && keys.includes(`planning.overrun:${oRun.id}:1`) && keys.includes(`planning.followup_at_risk:${oNext.id}:${today}`), "Dedupe-Einträge im Audit-Protokoll (planning_alert)");
const overrunNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.overrun", entityId: oRun.id }, select: { userId: true, emailedAt: true, title: true, message: true } });
const noteUsers = new Set(overrunNotes.map((n) => n.userId));
ok(noteUsers.has(A.users.backoffice.id) && noteUsers.has(A.users.admin.id) && noteUsers.has(A.users.lead.id), "Empfänger: Backoffice + Teamleiter der Kolonne");
ok(!noteUsers.has(A.users.tech.id) && !noteUsers.has(A.users.lead2.id), "nicht: Monteure, Teamleiter anderer Kolonnen");
ok(overrunNotes.every((n) => n.emailedAt === null) && overrunNotes.some((n) => n.message.includes("150 %") && n.title.includes(oRun.number)), "nur In-App (keine E-Mail), Text mit Prozent und Nummer");
const followNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.followup_at_risk", entityId: oNext.id }, select: { message: true } });
ok(followNotes.length > 0 && followNotes.every((n) => n.message.includes(oRun.number)), "Folgeauftrag-Meldung nennt den verursachenden Auftrag");
section("Folgeauftrag gefährdet durch Tageskapazität");
await prisma.team.update({ where: { id: A.team2Id }, data: { dailyCapacityMinutes: 180 } });
const oSud = await order(A, null, "Laufend Süd", { status: "in_progress", assignedTeamId: A.team2Id, plannedStart: T(7), plannedDurationMinutes: 120 });
const sessSud = await session(A, oSud.id, A.users.outsider.id, A.team2Id, "running", T(7));
await entry(A, sessSud.id, A.users.outsider.id, T(7), null);
const oSudNext = await order(A, null, "Folgeauftrag Süd ohne Uhrzeit", { status: "assigned", assignedTeamId: A.team2Id, plannedStart: berlinAt(0, 0), plannedDurationMinutes: 60 });
const eSud = await evaluateDelays(sys, { now: T(9, 40) });
const fSud = eSud.followups.find((f) => f.workOrderId === oSudNext.id);
ok(!!fSud && fSud.reason === "capacity" && fSud.travelMinutes === 10, "160 min statt 120 + Folgeauftrag 60 + 10 min Fahrt > 180 min Kolonnentag → gefährdet (Kapazität)");
section("Früher fertig: freie Kolonnenzeit und Vorschläge");
const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.lead.id } });
await prisma.teamMember.createMany({ data: [A.users.admin.id, A.users.backoffice.id].map((userId) => ({ tenantId: A.tenantId, teamId: ost.id, userId, validFrom: new Date(Date.now() - 30 * 86400_000) })) });
const s3 = await site(A, "Nachbar 1 km", S3);
const sFar = await site(A, "Lübeck", FAR);
const oDone = await order(A, s1.id, "Früher fertig Ost", { status: "technically_completed", assignedTeamId: ost.id, plannedStart: T(8), plannedDurationMinutes: 240 });
await session(A, oDone.id, A.users.admin.id, ost.id, "ended", T(8), T(10));
const oLater = await order(A, s2.id, "Ost später heute", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(14), plannedDurationMinutes: 60 });
const oTooLong = await order(A, s2.id, "Ost zu lang", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(15), plannedDurationMinutes: 180 });
const oNear = await order(A, s3.id, "Ungeplant nah", { status: "draft", plannedDurationMinutes: 60 });
const oFar = await order(A, sFar.id, "Ungeplant fern", { status: "draft", plannedDurationMinutes: 60 });
const freed = await getFreedCapacity(A.ctx.backoffice, { now: T(10, 30) });
const fo = freed.teams.find((x) => x.teamId === ost.id);
ok(!!fo && fo.earlyMinutes === 120 && fo.availableMinutes === 90 && fo.availableFrom === T(10, 30).toISOString(), "Ost: 2 h früher fertig (Ende 10:00 statt 12:00), 90 min ab jetzt frei");
ok(!freed.teams.some((x) => x.teamId === A.teamId), "laufende Kolonne Nord nicht als früher fertig");
const kinds = new Map(fo?.suggestions.map((s) => [s.order.id, s]) ?? []);
ok(kinds.get(oLater.id)?.kind === "pull_forward" && !kinds.has(oTooLong.id), "Vorziehen: späterer Auftrag der Kolonne, der in die freie Zeit passt (nicht der zu lange)");
ok(kinds.get(oNear.id)?.kind === "nearby_unplanned" && (kinds.get(oNear.id)?.distanceKm ?? 99) < 2 && !kinds.has(oFar.id), "ungeplanter Auftrag im Umkreis (1 km) ja, ferner nein");
ok(kinds.get(oLater.id)?.suggestedStart === T(10, 30).toISOString() && kinds.get(oLater.id)?.day === today, "Vorschlag vorbelegt ab 10:30 heute — keine automatische Änderung");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: oLater.id } })).plannedStart?.getTime() === T(14).getTime(), "geplanter Auftrag bleibt unverändert");
const live1030 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(10, 30) });
ok(live1030.freed.some((x) => x.teamId === ost.id && x.suggestions.length >= 2), "Live-Lage zeigt „früher fertig“ mit Vorschlägen");
const rFreed = await runPlanningWatch(A.tenantId, { now: T(10, 30) });
ok(rFreed.capacityFreed === 1, "planning.capacity_freed einmal gemeldet");
ok((await runPlanningWatch(A.tenantId, { now: T(10, 50) })).capacityFreed === 0, "Dedupe je Kolonne/Tag/Auftrag");
const freedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.capacity_freed", entityId: oDone.id }, select: { userId: true, message: true } });
ok(freedNotes.some((n) => n.userId === A.users.lead.id) && freedNotes.some((n) => n.message.includes("120 min")), "Meldung an Teamleiter der Kolonne mit 120 min");
section("Rollen & Mandantentrennung");
await expectCode(() => getFreedCapacity(A.ctx.tech, { now: T(10, 30) }), "forbidden", "Monteur: früher fertig → forbidden");
const leadFreed = await getFreedCapacity(A.ctx.lead2, { now: T(10, 30) });
ok(leadFreed.teams.length === 0, "Teamleiter Süd sieht die Kolonne Ost nicht");
const bSys = watchContext(B.tenantId);
const bEval = await evaluateDelays(bSys, { now: T(11, 0) });
ok(bEval.delays.length === 0 && bEval.followups.length === 0, "Mandant B: keine Aufträge/Sessions von A in der Auswertung");
ok((await getFreedCapacity(B.ctx.backoffice, { now: T(10, 30) })).teams.length === 0, "Mandant B: kein „früher fertig“ von A");
const rB = await runPlanningWatch(B.tenantId, { now: T(11, 0) });
ok(rB.overrun + rB.followupAtRisk + rB.capacityFreed === 0 && (await alerts(B)).length === 0, "Mandant B: keine Meldungen, keine Dedupe-Einträge");
ok((await prisma.notification.count({ where: { tenantId: B.tenantId, type: { startsWith: "planning." } } })) === 0, "Mandant B: keine Planungs-Benachrichtigungen");
// Ein Dedupe-Schlüssel in B blockiert A nicht (Ledger je Mandant).
const oRun2 = await order(A, s1.id, "Laufend Nord 2", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(6), plannedDurationMinutes: 60 });
const sess2 = await session(A, oRun2.id, A.users.lead.id, A.teamId, "running", T(6));
await entry(A, sess2.id, A.users.lead.id, T(6), null);
ok(await auditAlertLedger.claim(bSys, `planning.overrun:${oRun2.id}:0`, {}), "B beansprucht (künstlich) den Schlüssel eines A-Auftrags");
ok((await runPlanningWatch(A.tenantId, { now: T(11, 20) })).overrun >= 1, "A meldet trotzdem (Dedupe mandantengetrennt)");
});