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