// L5 Berichte & Unterschrift — Service-Tests gegen die lokale DB. // // Deckt ab: Content-Builder (Tagesfilter, Materialabweichungen), Statusfolgen create/submit/approve/reject, // Auftragsstatus je Unterschriftsstand, Unterschrift-Validierung je outcome, Versionierung (freigegebene // Version wird nie überschrieben), Mandantentrennung (B sieht/ändert nichts von A) und Rollen/Scope // (Monteur ohne Zuweisung → not_found, fehlende Rechte → forbidden), Audit-Einträge. // // Lauf: npx tsx scripts/test-berichte-flow.ts import "dotenv/config"; 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 } from "../src/server/services/reports/build-content"; import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create"; import { updateReportTexts } from "../src/server/services/reports/edit"; import { captureSignature } from "../src/server/services/reports/signature"; import { submitReport } from "../src/server/services/reports/submit"; import { approveReport, type ApproveDeps } from "../src/server/services/reports/approve"; import { rejectReport } from "../src/server/services/reports/reject"; import { createNewVersion } from "../src/server/services/reports/new-version"; import { requireVisibleReport } from "../src/server/services/reports/common"; import { listReports } from "../src/server/services/reports/queries"; import { storeFile } from "../src/server/services/documents/store"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; async function expectCode(fn: () => Promise, code: ServiceError["code"], msg: string) { try { await fn(); ok(false, `${msg} — kein Fehler`); } catch (err) { const got = err instanceof ServiceError ? err.code : (err as Error).message; ok(got === code, `${msg} (${got})`); } } const SLUG_A = "zz-berichte-a"; const SLUG_B = "zz-berichte-b"; const EMAIL = (s: string) => `${s}@zz-berichte.test`; const DAY1 = "2026-09-10"; const DAY2 = "2026-09-11"; // Europe/Berlin = UTC+2 in September const at = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+02:00`); const PNG_1PX = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64"); async function cleanup() { const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); const ids = tenants.map((t) => t.id); if (ids.length) { const w = { where: { tenantId: { in: ids } } }; await prisma.signature.deleteMany(w); await prisma.report.deleteMany(w); await prisma.photo.deleteMany(w); await prisma.materialUsage.deleteMany(w); await prisma.materialPlan.deleteMany(w); await prisma.timeEntry.deleteMany(w); await prisma.workSession.deleteMany(w); await prisma.activityNote.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.teamMember.deleteMany(w); await prisma.team.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.tenantSettings.deleteMany(w); await prisma.user.deleteMany(w); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-berichte.test" } } }); } async function mkUser(tenantId: string, key: string, name: string) { const identity = await prisma.identity.create({ data: { email: EMAIL(`${key}-${tenantId.slice(-6)}`), passwordHash: "x" } }); return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } }); } const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions), }); async function main() { await cleanup(); // ---------- fixtures ---------- const tA = await prisma.tenant.create({ data: { name: "Berichte-Test A", slug: SLUG_A } }); const tB = await prisma.tenant.create({ data: { name: "Berichte-Test B", slug: SLUG_B } }); await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb A GmbH", address: "Weg 1, 20095 Hamburg" } }); await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Betrieb B" } }); const tech = await mkUser(tA.id, "tech", "Max Monteur"); const tech2 = await mkUser(tA.id, "tech2", "Fremd Monteur"); const lead = await mkUser(tA.id, "lead", "Tina Teamleiter"); const lead2 = await mkUser(tA.id, "lead2", "Andere Teamleiterin"); const office = await mkUser(tA.id, "office", "Bernd Büro"); const officeB = await mkUser(tB.id, "officeb", "Zoe Büro B"); const techCtx = ctxOf(tA.id, tech.id, "technician"); const tech2Ctx = ctxOf(tA.id, tech2.id, "technician"); const leadCtx = ctxOf(tA.id, lead.id, "team-lead"); const lead2Ctx = ctxOf(tA.id, lead2.id, "team-lead"); const officeCtx = ctxOf(tA.id, office.id, "backoffice"); const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice"); const team = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Nord", leaderUserId: lead.id } }); await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: team.id, userId: tech.id, validFrom: new Date("2026-01-01") } }); const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde GmbH", customerNumber: "K-00001", street: "Hauptstr.", houseNumber: "5", postalCode: "20095", city: "Hamburg" } }); const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Frau Kontakt", role: "Hausverwaltung" } }); const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Wohnanlage Süd", city: "Hamburg" } }); const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number: "A-09001", customerId: customer.id, siteId: site.id, contactId: contact.id, title: "Elektroinstallation Keller", description: "Leitungen und Dosen setzen", status: "in_progress", assignedTeamId: team.id, signatureRequired: true, assignees: { create: [{ tenantId: tA.id, userId: tech.id }] }, }, }); const item = await prisma.checklistItem.create({ data: { tenantId: tA.id, workOrderId: wo.id, key: "fi", label: "FI-Schutzschalter geprüft", required: true } }); const req = await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo.id, key: "typenschild", label: "Typenschild" } }); const pKabel = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "NYM-J 3x1,5", plannedQuantity: new Prisma.Decimal(10), unit: "m" } }); const pDose = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "Abzweigdose", plannedQuantity: new Prisma.Decimal(2), unit: "Stk", sortOrder: 1 } }); const pUnused = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "Kabelbinder", plannedQuantity: new Prisma.Decimal(5), unit: "Stk", sortOrder: 2 } }); const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId: tech.id, teamId: team.id, status: "ended", startedAt: at(DAY1, "08:00"), endedAt: at(DAY2, "09:00") } }); await prisma.timeEntry.createMany({ data: [ { tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "work", startedAt: at(DAY1, "08:00"), endedAt: at(DAY1, "12:00") }, { tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "break", startedAt: at(DAY1, "12:00"), endedAt: at(DAY1, "12:30") }, { tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "work", startedAt: at(DAY2, "07:00"), endedAt: at(DAY2, "09:00") }, ], }); await prisma.activityNote.createMany({ data: [ { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: "Tag1 Leitungen verlegt", createdAt: at(DAY1, "11:00") }, { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: "Tag2 Dosen gesetzt", createdAt: at(DAY2, "08:30") }, { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "deviation", text: "Tag2 Dose falsche Größe", createdAt: at(DAY2, "08:40") }, ], }); const photoDoc = async (name: string) => storeFile(techCtx, { bytes: PNG_1PX, fileName: name, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } }); const d1 = await photoDoc("tag1.png"); const d2 = await photoDoc("tag2.png"); const d3 = await photoDoc("tag2-intern.png"); await prisma.photo.createMany({ data: [ { tenantId: tA.id, workOrderId: wo.id, documentId: d1.id, phase: "before", takenAt: at(DAY1, "08:05"), includeInReport: true }, { tenantId: tA.id, workOrderId: wo.id, documentId: d2.id, phase: "after", comment: "Fertig", takenAt: at(DAY2, "08:55"), includeInReport: true }, { tenantId: tA.id, workOrderId: wo.id, documentId: d3.id, phase: "during", takenAt: at(DAY2, "08:10"), includeInReport: false }, ], }); await prisma.materialUsage.createMany({ data: [ { tenantId: tA.id, workOrderId: wo.id, materialPlanId: pKabel.id, name: pKabel.name, actualQuantity: new Prisma.Decimal(12), unit: "m", usageStatus: "fully_used", deviationReason: "Umweg über Schacht", createdAt: at(DAY1, "11:30") }, { tenantId: tA.id, workOrderId: wo.id, materialPlanId: pDose.id, name: pDose.name, actualQuantity: new Prisma.Decimal(0), unit: "Stk", usageStatus: "not_used", deviationReason: "Falsche Größe geliefert", createdAt: at(DAY2, "08:45") }, { tenantId: tA.id, workOrderId: wo.id, name: "Muffe", actualQuantity: new Prisma.Decimal(3), unit: "Stk", usageStatus: "additional", deviationReason: "Zusätzlich benötigt", createdAt: at(DAY2, "08:50") }, ], }); // ---------- content builder ---------- console.log("\n— Content-Builder —"); const c1 = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: DAY1, reportNumber: "B-T", version: 1, technicianUserId: tech.id }); ok(c1.time.totalsByType.work === 240 && c1.time.totalsByType.break === 30 && c1.time.totalMinutes === 240, "Tagesbericht Tag 1: nur Zeiten des Tages, Pause nicht in Summe"); ok(c1.texts.workPerformed.includes("Tag1") && !c1.texts.workPerformed.includes("Tag2"), "Tagesbericht Tag 1: nur Notizen des Tages"); ok(c1.photos.length === 1 && c1.photos[0].documentId === d1.id, "Tagesbericht Tag 1: nur Fotos des Tages"); ok(c1.materials.used.length === 1 && c1.materials.used[0].deviation && c1.materials.used[0].deviationReason === "Umweg über Schacht", "Materialabweichung (Menge) mit Grund enthalten"); ok(c1.materials.notUsed.length === 0 && c1.materials.additional.length === 0, "Tagesbericht Tag 1: keine Material-Einträge anderer Tage"); ok(c1.customer.name === "Kunde GmbH" && c1.site?.name === "Wohnanlage Süd" && c1.contact?.name === "Frau Kontakt" && c1.tenant.name === "Musterbetrieb A GmbH", "Kopfdaten Mandant/Kunde/Objekt/Ansprechpartner"); const c2 = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: DAY2, reportNumber: "B-T", version: 1, technicianUserId: tech.id }); ok(c2.photos.length === 1 && c2.photos[0].documentId === d2.id, "Tag 2: Foto ohne includeInReport ausgeschlossen"); ok(c2.materials.notUsed[0]?.deviationReason === "Falsche Größe geliefert" && c2.materials.additional[0]?.name === "Muffe", "Tag 2: nicht verwendet + zusätzlich mit Gründen"); ok(c2.texts.deviations.includes("falsche Größe"), "Tag 2: Abweichungsnotiz vorbelegt"); const cc = await buildReportContent(techCtx, { workOrderId: wo.id, type: "completion", reportDate: DAY2, reportNumber: "B-T", version: 1, technicianUserId: tech.id }); ok(cc.time.totalMinutes === 360 && cc.workDates.join(",") === `${DAY1},${DAY2}`, "Abschluss: alle Zeiten + Einsatzdaten"); ok(cc.materials.notUsed.some((m) => m.planId === pUnused.id && !m.documented), "Abschluss: geplantes, nicht dokumentiertes Material als Abweichung"); ok(cc.photos.length === 2, "Abschluss: alle Berichtsfotos"); // ---------- scope & tenant isolation ---------- console.log("\n— Rollen/Scope & Mandantentrennung —"); await expectCode(() => createDailyReport(tech2Ctx, { workOrderId: wo.id, reportDate: DAY2 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht anlegen → not_found"); await expectCode(() => buildReportContent(tech2Ctx, { workOrderId: wo.id, type: "daily", reportDate: DAY2, reportNumber: "x", version: 1, technicianUserId: null }), "not_found", "Monteur ohne Zuweisung: Content lesen → not_found"); await expectCode(() => createDailyReport(officeBCtx, { workOrderId: wo.id, reportDate: DAY2 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found"); // ---------- daily report ---------- console.log("\n— Tagesbericht —"); const daily = await createDailyReport(techCtx, { workOrderId: wo.id, reportDate: DAY2 }); ok(daily.created && daily.report.status === "draft", "Tagesbericht-Entwurf angelegt"); const woAfterDaily = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); ok(woAfterDaily.status === "daily_report_created", `Auftrag → daily_report_created (${woAfterDaily.status})`); const dailyAgain = await createDailyReport(techCtx, { workOrderId: wo.id, reportDate: DAY2 }); ok(!dailyAgain.created && dailyAgain.report.id === daily.report.id, "Tagesbericht idempotent je Tag"); ok(/^B-\d+$/.test((daily.report.content as { reportNumber: string }).reportNumber), "Berichtsnummer aus Nummernkreis (B-)"); await expectCode(() => requireVisibleReport(tech2Ctx, daily.report.id), "not_found", "Monteur ohne Zuweisung: Bericht lesen → not_found"); await expectCode(() => requireVisibleReport(officeBCtx, daily.report.id), "not_found", "Mandant B: Bericht von A lesen → not_found"); await expectCode(() => updateReportTexts(officeBCtx, { reportId: daily.report.id, texts: { hints: "B war hier" } }), "not_found", "Mandant B: Bericht von A ändern → not_found"); await expectCode(() => approveReport(officeBCtx, { reportId: daily.report.id }, { dispatchPdf: async () => {} }), "not_found", "Mandant B: Bericht von A freigeben → not_found"); let isoThrow = false; try { await dbForTenant(tB.id).report.update({ where: { id: daily.report.id }, data: { status: "approved" } }); } catch { isoThrow = true; } ok(isoThrow, "Mandant B: direkter DB-Update auf Bericht von A wird vom Tenant-Guard abgewiesen"); const listB = await listReports(officeBCtx, {}); ok(listB.items.length === 0, "Mandant B: Liste enthält keine Berichte von A"); const listTech2 = await listReports(tech2Ctx, {}); ok(listTech2.items.length === 0, "Monteur ohne Zuweisung: Liste leer"); // ---------- completion report ---------- console.log("\n— Abschlussbericht, Blocker, Unterschrift —"); try { await createCompletionReport(techCtx, { workOrderId: wo.id }); ok(false, "Abschlussbericht mit offenen Pflichtpunkten → blocked erwartet"); } catch (err) { const blockers = (err as ServiceError).details as Array<{ kind: string }>; ok(err instanceof ServiceError && err.code === "blocked" && blockers.some((b) => b.kind === "checklist_item") && blockers.some((b) => b.kind === "photo_requirement"), "Abschluss blockiert: Checkliste + Pflichtfoto als Liste"); } await prisma.checklistItem.update({ where: { id: item.id }, data: { checked: true, checkedById: tech.id, checkedAt: new Date() } }); const d4 = await photoDoc("typenschild.png"); await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: d4.id, photoRequirementId: req.id, phase: "after", takenAt: at(DAY2, "08:58") } }); const completion = (await createCompletionReport(techCtx, { workOrderId: wo.id })).report; ok(completion.status === "draft" && completion.type === "completion", "Abschlussbericht-Entwurf angelegt"); await updateReportTexts(techCtx, { reportId: completion.id, texts: { workPerformed: "" } }); await expectCode(() => submitReport(techCtx, { reportId: completion.id }), "blocked", "Absenden ohne Pflichtangabe (Ausgeführte Leistungen) → blocked"); await updateReportTexts(techCtx, { reportId: completion.id, texts: { workPerformed: "Leitungen verlegt, Dosen gesetzt.", nextSteps: "Abnahme" } }); const confirmationText = "Bestätigt."; await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", imageDocumentId: d4.id, confirmationText }), "invalid", "signed ohne Name → invalid"); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", confirmationText }), "invalid", "signed ohne Bild → invalid"); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", imageDocumentId: d4.id, confirmationText }), "invalid", "signed mit Nicht-Unterschrift-Dokument → invalid"); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "refused", confirmationText }), "invalid", "refused ohne Begründung → invalid"); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "customer_absent", reason: " ", confirmationText }), "invalid", "customer_absent mit leerer Begründung → invalid"); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "later", confirmationText }), "invalid", "later ohne Begründung → invalid"); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "not_required", confirmationText }), "forbidden", "not_required bei Pflicht-Unterschrift durch Monteur → forbidden"); await expectCode(() => captureSignature(tech2Ctx, { reportId: completion.id, outcome: "later", reason: "x", confirmationText }), "not_found", "Monteur ohne Zuweisung: Unterschrift → not_found"); await captureSignature(techCtx, { reportId: completion.id, outcome: "later", reason: "Kunde erst morgen vor Ort", confirmationText }); ok((await prisma.signature.count({ where: { reportId: completion.id, outcome: "later" } })) === 1, "later mit Begründung gespeichert"); const submitted = await submitReport(techCtx, { reportId: completion.id }); const woPending = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); ok(submitted.status === "submitted", "Bericht → submitted"); ok(woPending.status === "signature_pending", `Auftrag ohne Unterschrift → signature_pending (${woPending.status})`); const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: wo.id }, orderBy: { createdAt: "asc" }, select: { toStatus: true } }); ok(history.map((h) => h.toStatus).join(">").endsWith("in_progress>technically_completed>signature_pending"), "Statusfolge über transitionWorkOrder protokolliert"); await expectCode(() => updateReportTexts(techCtx, { reportId: completion.id, texts: { hints: "nachträglich" } }), "conflict", "Bearbeiten nach Absenden → conflict"); const sigDoc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: "sig.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } }); await captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", signerRole: "Eigentümer", imageDocumentId: sigDoc.id, confirmationText }); const woSigned = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); ok(woSigned.status === "in_review", `Unterschrift nachgereicht → Auftrag in_review (${woSigned.status})`); await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "refused", reason: "x", confirmationText }), "conflict", "Erfasste Unterschrift wird nicht überschrieben"); // ---------- review ---------- console.log("\n— Prüfung: Teamleiter, Zurückweisen, Freigabe —"); const dispatched: string[] = []; const deps: ApproveDeps = { dispatchPdf: async (_c, id) => void dispatched.push(id) }; await expectCode(() => approveReport(techCtx, { reportId: completion.id }, deps), "forbidden", "Monteur darf nicht freigeben"); await expectCode(() => approveReport(lead2Ctx, { reportId: completion.id }, deps), "not_found", "Teamleiter eines anderen Teams → not_found"); const teamApproved = await approveReport(leadCtx, { reportId: completion.id }, deps); ok(teamApproved.status === "team_approved" && teamApproved.teamApprovedById === lead.id, "Teamleiter → team_approved"); ok(dispatched.length === 0, "Teamleiter-Prüfung erzeugt kein PDF"); await expectCode(() => rejectReport(officeCtx, { reportId: completion.id, reason: "" }), "invalid", "Zurückweisen ohne Grund → invalid"); const rejected = await rejectReport(officeCtx, { reportId: completion.id, reason: "Materialliste unvollständig" }); const woRejected = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); ok(rejected.status === "rejected" && rejected.rejectionReason === "Materialliste unvollständig", "Backoffice → rejected mit Grund"); ok(woRejected.status === "in_progress", `Auftrag → in_progress (Korrektur) (${woRejected.status})`); await updateReportTexts(techCtx, { reportId: completion.id, texts: { hints: "Materialliste ergänzt" } }); await submitReport(techCtx, { reportId: completion.id }); const woResubmitted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); ok(woResubmitted.status === "in_review", `Erneut abgesendet mit Unterschrift → in_review (${woResubmitted.status})`); const approved = await approveReport(officeCtx, { reportId: completion.id }, deps); ok(approved.status === "approved" && approved.approvedById === office.id, "Backoffice → approved"); ok(dispatched.includes(completion.id), "Freigabe stößt PDF-Job an"); const frozen = JSON.stringify(approved.content); const approvedAt = approved.approvedAt?.getTime(); ok((approved.content as { signature: { outcome: string } }).signature.outcome === "signed", "Freigegebener Inhalt enthält Unterschrift"); await expectCode(() => updateReportTexts(officeCtx, { reportId: completion.id, texts: { hints: "überschreiben" } }), "conflict", "Freigegebener Bericht nicht editierbar"); await expectCode(() => submitReport(techCtx, { reportId: completion.id }), "conflict", "Freigegebener Bericht nicht erneut absendbar"); await expectCode(() => captureSignature(officeCtx, { reportId: completion.id, outcome: "not_required", confirmationText }), "conflict", "Freigegebener Bericht: keine Unterschriftsänderung"); // ---------- versioning ---------- console.log("\n— Versionierung —"); await expectCode(() => createNewVersion(techCtx, { reportId: completion.id }), "forbidden", "Monteur darf keine neue Version anlegen"); const v2 = await createNewVersion(officeCtx, { reportId: completion.id }); ok(v2.version === 2 && v2.lineageId === completion.lineageId && v2.status === "draft", "Neue Version: gleiche Lineage, Version 2, Entwurf"); await expectCode(() => createNewVersion(officeCtx, { reportId: completion.id }), "conflict", "Zweite neue Version aus alter Version → conflict"); const v1AfterNew = await prisma.report.findUniqueOrThrow({ where: { id: completion.id } }); ok(v1AfterNew.status === "approved" && JSON.stringify(v1AfterNew.content) === frozen, "Version 1 bleibt unverändert freigegeben"); await updateReportTexts(officeCtx, { reportId: v2.id, texts: { hints: "Korrektur nach Freigabe" } }); await submitReport(officeCtx, { reportId: v2.id }); const v2Approved = await approveReport(officeCtx, { reportId: v2.id }, deps); const v1Final = await prisma.report.findUniqueOrThrow({ where: { id: completion.id } }); ok(v2Approved.status === "approved", "Version 2 freigegeben"); ok(v1Final.status === "superseded", "Version 1 → superseded"); ok(JSON.stringify(v1Final.content) === frozen && v1Final.approvedAt?.getTime() === approvedAt, "Version 1 Inhalt/Freigabezeit nie überschrieben"); ok((v2Approved.content as { texts: { hints: string } }).texts.hints === "Korrektur nach Freigabe", "Version 2 enthält die Korrektur"); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Neue Version ändert den Auftragsstatus nicht"); // ---------- audit ---------- const audits = await prisma.auditLog.findMany({ where: { tenantId: tA.id, entity: { in: ["report", "signature"] } } }); ok(audits.length >= 10 && audits.some((a) => a.before !== null && a.after !== null), `Audit-Einträge mit before/after (${audits.length})`); const officeList = await listReports(officeCtx, {}); ok(officeList.items.every((i) => i.status !== "superseded") && officeList.items.some((i) => i.id === v2.id), "Liste: ersetzte Versionen standardmäßig ausgeblendet"); } 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} Fehler` : "\n✓ Alle Berichte-Flow-Tests grün"); process.exit(failures ? 1 : 0); });