Files
craftvia/scripts/test-lotse-draft.ts

295 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// L9 Lotse – Berichtsentwurf, Datenminimierung, Vollständigkeitsprüfung, Freigabeprinzip, Einstellungen, Protokoll.
//
// Deckt ab: Provider-Fake (Mapping Output → Vorschläge im Report-content, missingInformation → Liste),
// Datenminimierung (KI-Input ohne Telefon/E-Mail/Adresse/Namen – Snapshot-Assertion), Rechte/Scope/Status
// (approved → blocked, Monteur ohne Zuweisung → not_found, ohne lotse:use → forbidden, Lotse aus → forbidden),
// Mandantentrennung (B liest/ändert nichts von A), Submit ohne Prüfbestätigung → invalid, Audit.
//
// Lauf: npx tsx scripts/test-lotse-draft.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { Prisma } from "@prisma/client";
import { prisma, dbForTenant } from "../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
import { buildReportContent, tenantTimeZone } from "../src/server/services/reports/build-content";
import { dateKeyToDbDate, localDateKey } from "../src/lib/reports/dates";
import { parseReportContent } from "../src/lib/reports/content";
import { submitReport } from "../src/server/services/reports/submit";
import { updateReportTexts } from "../src/server/services/reports/edit";
import { FakeLotseProvider } from "../src/server/ai/lotse/fake";
import { reportDraftSystemPrompt } from "../src/server/ai/lotse/prompt";
import { draftReportWithLotse, prepareDraftInput } from "../src/server/services/lotse/draft-report";
import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions";
import { checkCompleteness } from "../src/server/services/lotse/completeness";
import { applyLotseReview } from "../src/server/services/lotse/review";
import { initials, scrubText, type MinimizationContext } from "../src/server/services/lotse/minimize";
import { isLotseEnabled, lotseVoice, updateLotseSettings } from "../src/server/services/lotse/settings";
import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function expectErr(fn: () => Promise<unknown>, code: ServiceError["code"], msg: string, detail?: { reason?: string; field?: string }) {
try {
await fn();
ok(false, `${msg} — kein Fehler`);
} catch (err) {
if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`);
const d = (err.details ?? {}) as { reason?: string; field?: string };
const match = err.code === code && (!detail?.reason || d.reason === detail.reason) && (!detail?.field || d.field === detail.field);
ok(match, `${msg} (${err.code}${d.reason ? `/${d.reason}` : ""}${d.field ? `/${d.field}` : ""})`);
}
}
/** Key-order independent JSON (Postgres jsonb reorders object keys). */
const canon = (v: unknown): string =>
JSON.stringify(v, (_k, val) => (val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b))) : val));
const SLUGS = ["zz-lotse-a", "zz-lotse-b"];
const DOMAIN = "@zz-lotse.test";
async function cleanup() {
const tenants = await prisma.tenant.findMany({ where: { slug: { in: SLUGS } }, select: { id: true } });
const ids = tenants.map((t) => t.id);
if (ids.length) {
const w = { where: { tenantId: { in: ids } } };
await prisma.notification.deleteMany(w);
await prisma.aiGeneration.deleteMany(w);
await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w);
await prisma.activityNote.deleteMany(w);
await prisma.voiceNote.deleteMany(w);
await prisma.materialUsage.deleteMany(w);
await prisma.materialPlan.deleteMany(w);
await prisma.timeEntry.deleteMany(w);
await prisma.workSession.deleteMany(w);
await prisma.photo.deleteMany(w);
await prisma.checklistItem.deleteMany(w);
await prisma.photoRequirement.deleteMany(w);
await prisma.workOrderStatusChange.deleteMany(w);
await prisma.workOrderAssignee.deleteMany(w);
await prisma.document.deleteMany(w);
await prisma.workOrder.deleteMany(w);
await prisma.site.deleteMany(w);
await prisma.contact.deleteMany(w);
await prisma.customer.deleteMany(w);
await prisma.numberSequence.deleteMany(w);
await prisma.auditLog.deleteMany(w);
await prisma.tenantModule.deleteMany(w);
await prisma.tenantSettings.deleteMany(w);
await prisma.user.deleteMany(w);
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN } } });
}
async function mkUser(tenantId: string, key: string, name: string) {
const identity = await prisma.identity.create({ data: { email: `${key}${DOMAIN}`, passwordHash: "x" } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
}
const ctxOf = (tenantId: string, userId: string, role: RoleKey, drop: string[] = []): ServiceCtx => ({
db: dbForTenant(tenantId),
tenantId,
userId,
permissions: new Set(ROLE_DEFS[role].permissions.filter((p) => !drop.includes(p))),
});
async function main() {
await cleanup();
// ---------- fixtures ----------
const tA = await prisma.tenant.create({ data: { name: "Lotse-Test A", slug: SLUGS[0] } });
const tB = await prisma.tenant.create({ data: { name: "Lotse-Test B", slug: SLUGS[1] } });
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Lotse Test A GmbH", phone: "040 1234567", email: "buero@lotse-a.test", address: "Werftstraße 5, 20457 Hamburg" } });
await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Lotse Test B" } });
const tech = await mkUser(tA.id, "tech", "Max Monteur");
const outsider = await mkUser(tA.id, "outsider", "Otto Fremd");
const office = await mkUser(tA.id, "office", "Bernd Büro");
const admin = await mkUser(tA.id, "admin", "Anna Admin");
const techB = await mkUser(tB.id, "techb", "Tom Bader");
const adminB = await mkUser(tB.id, "adminb", "Bea Chefin");
const techCtx = ctxOf(tA.id, tech.id, "technician");
const techNoLotse = ctxOf(tA.id, tech.id, "technician", ["lotse:use"]);
const outsiderCtx = ctxOf(tA.id, outsider.id, "technician");
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
const adminCtx = ctxOf(tA.id, admin.id, "tenant-admin");
const ctxB = ctxOf(tB.id, techB.id, "technician");
const adminBCtx = ctxOf(tB.id, adminB.id, "tenant-admin");
const customer = await prisma.customer.create({
data: { tenantId: tA.id, firstName: "Erika", lastName: "Mustermann", phone: "0171 9876543", email: "erika@kunde.test", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" },
});
const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Klaus Kontakt", phone: "040 555666", email: "klaus@kunde.test" } });
const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Haus Mustermann", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" } });
const wo = await prisma.workOrder.create({
data: {
tenantId: tA.id,
number: "A-LOTSE-1",
customerId: customer.id,
siteId: site.id,
contactId: contact.id,
title: "Heizkörper tauschen",
description: "Kundin erreichbar unter 0171 9876543 oder erika@kunde.test, Lindenallee 7, 22111 Hamburg.",
scope: "Heizkörper im Bad ersetzen",
status: "in_progress",
signatureRequired: true,
assignees: { create: [{ tenantId: tA.id, userId: tech.id }] },
checklistItems: { create: [{ tenantId: tA.id, key: "vent", label: "Anlage entlüftet", required: true }] },
photoRequirements: { create: [{ tenantId: tA.id, key: "typ", label: "Typenschild" }] },
materialPlans: {
create: [
{ tenantId: tA.id, name: "Heizkörper 600x1000", plannedQuantity: new Prisma.Decimal(2), unit: "Stk", sortOrder: 1 },
{ tenantId: tA.id, name: "Thermostatventil", plannedQuantity: new Prisma.Decimal(1), unit: "Stk", sortOrder: 2 },
],
},
},
include: { materialPlans: { orderBy: { sortOrder: "asc" } } },
});
await prisma.materialUsage.create({
data: { tenantId: tA.id, workOrderId: wo.id, materialPlanId: wo.materialPlans[0].id, name: wo.materialPlans[0].name, actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "partially_used", recordedById: tech.id },
});
const noteText = "Max Monteur hat den alten Heizkörper demontiert. Rückruf an Klaus Kontakt unter 040 555666, Mail klaus@kunde.test.";
const note = await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: noteText } });
const tz = await tenantTimeZone(techCtx);
const today = localDateKey(new Date(), tz);
const content = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: today, reportNumber: "B-LOTSE-1", version: 1, technicianUserId: tech.id });
const report = await prisma.report.create({
data: { tenantId: tA.id, workOrderId: wo.id, type: "daily", reportDate: dateKeyToDbDate(today), lineageId: randomUUID(), status: "draft", content: content as unknown as Prisma.InputJsonValue, createdById: tech.id },
});
const approved = await prisma.report.create({
data: { tenantId: tA.id, workOrderId: wo.id, type: "completion", reportDate: dateKeyToDbDate(today), lineageId: randomUUID(), status: "approved", content: content as unknown as Prisma.InputJsonValue, createdById: tech.id },
});
// ---------- 1. Datenminimierung ----------
const m0: MinimizationContext = { employees: [], contacts: [], customerPersons: [], phones: [], emails: [], addressParts: [] };
ok(scrubText("Bitte 0151-12345678 anrufen", m0) === "Bitte [Telefon] anrufen", "Muster: Mobilnummer → [Telefon]");
ok(scrubText("Rückruf +49 40 1234567", m0) === "Rückruf [Telefon]", "Muster: internationale Nummer → [Telefon]");
ok(scrubText("Mail an x.y@firma-nord.de", m0) === "Mail an [E-Mail]", "Muster: E-Mail → [E-Mail]");
ok(scrubText("Baustelle Am Kaiserkai 30, 20457 Hamburg", m0) === "Baustelle Am [Adresse]", "Muster: Straße + PLZ/Ort → [Adresse]");
ok(scrubText("Termin 12.10.2026, 24 m Kupferrohr, Auftrag A-2026-0001", m0) === "Termin 12.10.2026, 24 m Kupferrohr, Auftrag A-2026-0001", "Datum, Mengen, Auftragsnummern bleiben erhalten");
ok(initials("Anna-Lena Bauer") === "A. B.", "Initialen aus Namen");
const input = await prepareDraftInput(techCtx, report, parseReportContent(report.content));
const json = JSON.stringify(input);
const secrets = ["0171 9876543", "erika@kunde.test", "Lindenallee", "22111", "040 555666", "klaus@kunde.test", "Max Monteur", "Monteur hat", "Mustermann", "Klaus Kontakt", "040 1234567", "buero@lotse-a.test", "Werftstraße", "Hamburg"];
const leaked = secrets.filter((s) => json.includes(s));
ok(leaked.length === 0, `Snapshot KI-Input enthält keine Telefon/E-Mail/Adresse/Namen${leaked.length ? ` — gefunden: ${leaked.join(", ")}` : ""}`);
ok(input.workOrder.description === "Kundin erreichbar unter [Telefon] oder [E-Mail], [Adresse].", `Snapshot Auftragsbeschreibung minimiert: „${input.workOrder.description}“`);
ok(input.notes.length === 1 && input.notes[0].text === "M. M. hat den alten Heizkörper demontiert. Rückruf an Ansprechpartner unter [Telefon], Mail [E-Mail].", `Snapshot Notiz: Mitarbeiter → Initialen, Ansprechpartner → Rolle: „${input.notes[0]?.text}“`);
ok(!("customer" in input) && input.materials.length === 1 && input.materials[0].status === "partially_used" && input.materials[0].planned === "2", "Kein Kundenblock im Input; Material des Tages mit Plan-/Istmenge");
ok(input.addressForm === "neutral" && input.locale === "de", "Ohne Einstellung: neutrale Anrede");
ok(reportDraftSystemPrompt({ addressForm: "neutral", locale: "de" }).includes("ohne Anrede-Pronomen"), "System-Prompt neutral ohne Pronomen");
ok(reportDraftSystemPrompt({ addressForm: "sie", locale: "de" }).includes("Sie-Form") && reportDraftSystemPrompt({ addressForm: "du", locale: "de" }).includes("du-Form"), "System-Prompt Sie/du je Einstellung");
// ---------- 2. Einstellungen ----------
await expectErr(() => updateLotseSettings(techCtx, { enabled: false, addressForm: "du" }), "forbidden", "Monteur darf Lotse-Einstellungen nicht ändern");
await updateLotseSettings(adminCtx, { enabled: false, addressForm: "sie" });
ok(!(await isLotseEnabled(adminCtx)) && (await lotseVoice(adminCtx)).addressForm === "sie", "Admin: Lotse aus + Anrede Sie gespeichert");
ok(await isLotseEnabled(adminBCtx), "Mandant B bleibt unberührt (Lotse an)");
const fake = new FakeLotseProvider({
output: { workPerformed: "- Alten Heizkörper demontiert", hints: "Thermostatventil noch montieren", missingInformation: ["Grund für die Mindermenge Heizkörper fehlt"] },
});
await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: fake }), "forbidden", "Lotse aus → Entwurf gesperrt", { reason: "disabled" });
await expectErr(() => checkCompleteness(techCtx, wo.id), "forbidden", "Lotse aus → Vollständigkeitsprüfung gesperrt", { reason: "disabled" });
await updateLotseSettings(adminCtx, { enabled: true, addressForm: "sie" });
ok((await prepareDraftInput(techCtx, report, parseReportContent(report.content))).addressForm === "sie", "Anrede aus Mandanteneinstellung im KI-Input");
const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } });
ok(Boolean(audit?.before) && Boolean(audit?.after), "Einstellungsänderung im Audit (before/after)");
// ---------- 3. Rechte / Scope / Status / Mandant ----------
await expectErr(() => draftReportWithLotse(techNoLotse, report.id, { provider: fake }), "forbidden", "Ohne lotse:use → forbidden");
await expectErr(() => draftReportWithLotse(outsiderCtx, report.id, { provider: fake }), "not_found", "Monteur ohne Zuweisung → not_found");
await expectErr(() => draftReportWithLotse(ctxB, report.id, { provider: fake }), "not_found", "Mandant B → not_found");
await expectErr(() => draftReportWithLotse(techCtx, approved.id, { provider: fake }), "blocked", "Freigegebener Bericht → blocked", { reason: "not_editable" });
await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: null }), "invalid", "Ohne KI-Konfiguration → Hinweis nicht eingerichtet", { reason: "not_configured" });
await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: new FakeLotseProvider({ fail: new Error("boom") }) }), "conflict", "Provider-Fehler → provider_failed", { reason: "provider_failed" });
const untouched = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
ok(!untouched.aiDrafted && !untouched.aiGenerationId && (await prisma.aiGeneration.count({ where: { tenantId: tA.id } })) === 0, "Fehlschläge ändern nichts (kein aiDrafted, keine AiGeneration)");
ok(fake.draftCalls.length === 0, "Bei Rechte-/Scope-/Statusfehlern wird der Provider nie aufgerufen");
// ---------- 4. Entwurf ----------
const res = await draftReportWithLotse(techCtx, report.id, { provider: fake });
const drafted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
const dc = parseReportContent(drafted.content);
ok(res.suggestions === 2 && drafted.aiDrafted && drafted.aiGenerationId === res.generationId, "Entwurf: aiDrafted + aiGenerationId gesetzt");
ok(dc.lotse?.suggestions.map((s) => `${s.field}:${s.state}`).join(",") === "workPerformed:pending,hints:pending", "Mapping Output → Vorschläge (nur nicht-leere Felder, Status pending)");
ok(dc.lotse?.suggestions[0].text === "- Alten Heizkörper demontiert", "Vorschlagstext übernommen");
ok(JSON.stringify(dc.texts) === JSON.stringify(parseReportContent(report.content).texts), "Berichtstexte bleiben unverändert (nur Vorschlag)");
ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: note.id } })).text === noteText, "Originalnotiz bleibt unverändert");
const gen = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: res.generationId } });
ok(gen.kind === "report_draft" && gen.entityType === "report" && gen.entityId === report.id && gen.model === "fake-lotse-1" && gen.inputTokens === 1200 && gen.createdById === tech.id, "AiGeneration protokolliert (Art, Modell, Tokens, Nutzer)");
ok(canon(gen.input) === canon(fake.draftCalls[0]), "AiGeneration.input = exakt der an den Provider gesendete (minimierte) Input");
const draftAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "report", entityId: report.id }, orderBy: { createdAt: "desc" } });
ok((draftAudit?.after as { aiGenerationId?: string } | null)?.aiGenerationId === res.generationId, "Audit-Eintrag zum Entwurf");
// ---------- 5. Vollständigkeitsprüfung ----------
const items = await checkCompleteness(techCtx, wo.id);
const codes = items.map((i) => i.code);
for (const c of ["photo_requirement", "checklist_item", "material_reason", "material_unconfirmed", "no_work_time", "signature_missing"] as const) ok(codes.includes(c), `Regel ${c} erkannt`);
ok(!codes.includes("no_description"), "Tätigkeitsbeschreibung vorhanden → keine Meldung");
const hint = items.find((i) => i.code === "lotse_hint");
ok(hint?.text === "Grund für die Mindermenge Heizkörper fehlt" && hint.source === "lotse" && hint.href === `/m/orders/${wo.id}/report?type=daily`, "missingInformation → Liste mit Sprungziel");
ok(items.every((i) => i.href.startsWith(`/m/orders/${wo.id}/`)), "Jeder Punkt hat einen Deep-Link in die mobile Unterseite");
ok(items.find((i) => i.code === "material_reason")?.label === "Heizkörper 600x1000" && items.find((i) => i.code === "photo_requirement")?.href.endsWith("/photos") === true, "Klartext-Label + passende Unterseite");
const rulesOnly = codes.indexOf("lotse_hint") === codes.length - 1;
ok(rulesOnly, "Deterministische Regeln vor KI-Hinweisen");
await expectErr(() => checkCompleteness(outsiderCtx, wo.id), "not_found", "Vollständigkeit: Monteur ohne Zuweisung → not_found");
await expectErr(() => checkCompleteness(ctxB, wo.id), "not_found", "Vollständigkeit: Mandant B → not_found");
await expectErr(() => checkCompleteness(techNoLotse, wo.id), "forbidden", "Vollständigkeit ohne lotse:use → forbidden");
// ---------- 6. Vorschläge übernehmen / verwerfen ----------
await expectErr(() => decideLotseSuggestion(ctxB, { reportId: report.id, field: "workPerformed", decision: "accept" }), "not_found", "Mandant B kann Vorschlag nicht übernehmen");
await expectErr(() => decideLotseSuggestion(outsiderCtx, { reportId: report.id, field: "workPerformed", decision: "accept" }), "not_found", "Monteur ohne Zuweisung kann Vorschlag nicht übernehmen");
await decideLotseSuggestion(techCtx, { reportId: report.id, field: "workPerformed", decision: "accept", text: "- Alten Heizkörper im Bad demontiert" });
await decideLotseSuggestion(techCtx, { reportId: report.id, field: "hints", decision: "discard" });
const decided = parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content);
ok(decided.texts.workPerformed === "- Alten Heizkörper im Bad demontiert", "Übernehmen (bearbeitet) → Berichtstext gesetzt");
ok(decided.texts.hints === content.texts.hints, "Verwerfen → Text unverändert");
ok(decided.lotse?.suggestions.map((s) => s.state).join(",") === "accepted,discarded", "Vorschlagsstatus accepted/discarded");
await expectErr(() => decideLotseSuggestion(techCtx, { reportId: report.id, field: "hints", decision: "accept" }), "conflict", "Bereits entschiedener Vorschlag → conflict", { reason: "no_suggestion" });
// ---------- 7. Freigabeprinzip ----------
ok(applyLotseReview(techCtx, { aiDrafted: false }, decided, undefined) === decided, "Bericht ohne Lotse: keine Prüfbestätigung nötig");
await expectErr(() => submitReport(techCtx, { reportId: report.id }), "invalid", "Submit ohne Prüfbestätigung → invalid", { field: "aiReviewed" });
await expectErr(() => submitReport(techCtx, { reportId: report.id, aiReviewed: false }), "invalid", "Submit mit aiReviewed=false → invalid", { field: "aiReviewed" });
ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf");
await updateReportTexts(techCtx, { reportId: report.id, texts: { nextSteps: "Ventil montieren" } });
ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).lotse?.generationId === res.generationId, "Texte speichern behält den Lotse-Block");
const submitted = await submitReport(techCtx, { reportId: report.id, aiReviewed: true });
const sc = parseReportContent(submitted.content);
ok(submitted.status === "submitted" && Boolean(sc.lotse?.reviewedAt) && sc.lotse?.reviewedById === tech.id, "Submit mit Bestätigung → submitted + Prüfnachweis (reviewedAt/reviewedById) im Snapshot");
await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: fake }), "blocked", "Eingereichter Bericht → kein neuer Entwurf", { reason: "not_editable" });
await expectErr(() => decideLotseSuggestion(techCtx, { reportId: report.id, field: "workPerformed", decision: "discard" }), "blocked", "Eingereichter Bericht → keine Vorschlagsentscheidung");
// ---------- 8. Protokoll ----------
await expectErr(() => listAiGenerations(techCtx), "forbidden", "Monteur sieht das KI-Protokoll nicht");
const officeList = await listAiGenerations(officeCtx);
ok(officeList.total === 1 && !officeList.canSeeContent && !("input" in officeList.items[0]) && officeList.items[0].userName === "Max Monteur", "Backoffice: Liste ohne Inhalte (Zeit, Art, Modell, Tokens, Nutzer)");
await expectErr(() => getAiGenerationContent(officeCtx, res.generationId), "forbidden", "Inhalte nur für Mandantenadministratoren");
ok(canon((await getAiGenerationContent(adminCtx, res.generationId)).input) === canon(fake.draftCalls[0]), "Admin sieht gesendete Daten");
const listB = await listAiGenerations(adminBCtx);
ok(listB.total === 0, "Mandant B sieht keine KI-Nutzung von A");
await expectErr(() => getAiGenerationContent(adminBCtx, res.generationId), "not_found", "Mandant B kann Inhalte von A nicht lesen");
}
main()
.catch((err) => {
console.error(err);
failures++;
})
.finally(async () => {
await cleanup().catch((e) => console.error("cleanup failed", e));
await prisma.$disconnect();
console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Lotse-Entwurfsprüfungen grün");
process.exit(failures ? 1 : 0);
});