From 9e35bb4e475432e2e834165aead7f287da57e1f1 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:22:40 +0200 Subject: [PATCH] L5 Berichte & Unterschrift: Tests und Lane-Bericht Flow-Tests (Content, Status, Unterschrift, Versionierung, Mandantentrennung, Scope) und PDF-Render-Smoke; Lane-Bericht docs/craftvia/lanes/berichte.md. Co-Authored-By: Claude Opus 5 --- docs/craftvia/lanes/berichte.md | 80 ++++++++ scripts/test-berichte-flow.ts | 345 ++++++++++++++++++++++++++++++++ scripts/test-berichte-pdf.ts | 143 +++++++++++++ 3 files changed, 568 insertions(+) create mode 100644 docs/craftvia/lanes/berichte.md create mode 100644 scripts/test-berichte-flow.ts create mode 100644 scripts/test-berichte-pdf.ts diff --git a/docs/craftvia/lanes/berichte.md b/docs/craftvia/lanes/berichte.md new file mode 100644 index 0000000..d5ef468 --- /dev/null +++ b/docs/craftvia/lanes/berichte.md @@ -0,0 +1,80 @@ +# Lane L5 – Berichte & Unterschrift + +Branch `lane/berichte` (Basis `bf44567`, `feature/craftvia-mvp`). Spec §16, §17, §18, §32, US-007/008/009, ARCHITEKTUR §4.7. + +## Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §16 Tagesbericht | `createDailyReport`: Entwurf je Auftrag + Kalendertag (Mandanten-Zeitzone), Inhalte nur des Tages (Zeiten, Notizen, Fotos, Material). Auftrag → `daily_report_created` über `transitionWorkOrder` (aus `paused`/`waiting_material` über `in_progress`), Auftrag bleibt offen. Idempotent je Tag. Event `work_order.daily_report_created`. | +| §17.1/17.2 Abschlussbericht | `createCompletionReport` prüft `getCompletionBlockers` (Checkliste, Pflichtfotos, laufende Zeiten) → `ServiceError("blocked", details: CompletionBlocker[])`. Snapshot mit allen Inhalten aus §17.2 (`ReportContent`). | +| Pflichtangaben | Absenden verlangt „Ausgeführte Leistungen“ (`REPORT_REQUIRED_TEXTS`) + erneut Blocker-Prüfung → strukturierte Liste. | +| §17.3 PDF | Nach Freigabe (`report:approve`) Job `report-pdf` → `generateReportPdf`: HTML-Template (React SSR) → Chromium/Playwright → Dokument (Kategorie `daily_report`/`completion_report`, Sichtbarkeit `customer_report`, an Auftrag/Kunde/Objekt → erscheint in Objekt-Historie), `pdfDocumentId` + SHA-256 `pdfChecksum`. Bestehendes PDF wird nie ersetzt. | +| §17.4 Versionierung | `createNewVersion` (nur `report:approve`): neue Version gleiche `lineageId`, `version+1`, Entwurf aus freigegebenem Snapshot. Die freigegebene Version bleibt unverändert und wird erst bei Freigabe der Nachfolgerin `superseded` (so existiert immer ein gültiges freigegebenes PDF). | +| §18.1 Unterschrift | `captureSignature`: Name, Funktion, Datum/Uhrzeit (`signedAt`), Bestätigungstext (messages, mit Berichts-/Auftragsnummer + Datum), Bezug Bericht, erfassender Nutzer. Canvas-Pad mit Pointer Events (Maus/Touch/Stift), Löschen, PNG-Export → Server Action → Dokument `signature`. | +| §18.2 Ausnahmen | `customer_absent`/`refused`/`later` → Begründung Pflicht; `not_required` nur bei `signatureRequired=false` oder `report:approve`. Erfasste Unterschrift (`signed`) wird nie überschrieben; `later` kann nachgereicht werden (Auftrag `signature_pending` → `in_review`). | +| §32 PDF-Merkmale | Bericht-ID, Versionsnummer, Erstellungs-/Freigabedatum, Freigabestatus, Prüfsumme (Fuß: SHA-256 des Inhalts-Snapshots; SHA-256 der PDF-Datei am Bericht/Dokument), A4, Seitenumbrüche, Fotoraster 2-spaltig, Kopf-/Fußzeile mit Seitenzahl, Mandantenlogo sonst Firmenname, Inter eingebettet. | +| Freigabe | Teamleiter (`report:approve_team`) → `team_approved`; Backoffice (`report:approve`) → `approved` (Inhalt final eingefroren, Event `report.approved`, PDF-Job). Zurückweisen mit Pflichtgrund → `rejected`, Abschluss-Auftrag `in_review` → `in_progress`, Event `report.rejected`. | +| Auftragsstatus bei Absenden | Abschlussbericht v1: … → `in_progress` → `technically_completed` → `signature_pending` (keine Unterschrift/`later`) bzw. `in_review` (`signed`, `not_required`, `refused`, `customer_absent`; bei den letzten beiden zusätzlich Event `work_order.signature_missing`). Folgeversionen ändern den Auftragsstatus nicht. | +| US-009 Berichtsseite | `/reports` (zur Prüfung zuerst, Filter Typ/Status/Team/Zeitraum), `/reports/[id]` (strukturierte Ansicht, PDF-Link, Versionen, Aktionen, Badge „Lotse-Entwurf“ bei `aiDrafted`). | + +## Dateien + +- Vertrag/Client-safe: `src/lib/reports/content.ts` (Zod `ReportContent`, Status-/Outcome-Konstanten), `src/lib/reports/dates.ts` (Tagesfenster Zeitzone), `src/lib/reports/action-state.ts` +- Services `src/server/services/reports/`: `build-content.ts`, `common.ts` (Scope `reportScope`/`requireVisibleReport`, Audit, Refresh), `create.ts`, `edit.ts`, `submit.ts`, `approve.ts`, `reject.ts`, `new-version.ts`, `signature.ts`, `pdf.ts`, `files.ts` (Datei-Auslieferung nur für im Snapshot referenzierte Dokumente), `queries.ts` (Liste/Detail/Mobil), `read-ctx.ts`, `http.ts` (API-Adapter), `_stubs/{work-orders,documents}.ts` +- PDF: `src/server/pdf/render.ts`, `src/server/pdf/templates/report.tsx`, Processor `src/server/jobs/processors/report-pdf.ts` +- Actions `src/server/actions/reports/`: `workflow.ts` (create/save/submit/approve/reject/newVersion/regeneratePdf), `signature.ts`, `_state.ts` +- API: `POST /api/v1/work-orders/[id]/daily-report`, `POST /api/v1/work-orders/[id]/completion-report`, `POST /api/v1/reports/[id]/approve`, `GET /api/v1/reports/[id]/pdf`, zusätzlich `GET /api/v1/reports/[id]/files/[documentId]` (Fotos/Unterschrift in Ansicht) +- UI Backoffice: `src/app/(app)/reports/page.tsx`, `src/app/(app)/reports/[id]/page.tsx`; Komponenten `src/components/reports/{report-view,review-actions,reject-form,status-badge,blocker-list,action-message,step-indicator,signature-pad}.tsx` +- UI Mobil: `src/components/reports/mobile/{report-editor,report-review,sign-flow,report-screen,sign-screen,create-report-form}.tsx`; dünne Seiten `src/app/(app)/m/(field)/orders/[id]/{report,sign}/page.tsx` +- Texte: `messages/de/reports.json`, `messages/en/reports.json` +- Tests: `scripts/test-berichte-flow.ts`, `scripts/test-berichte-pdf.ts` + +Fremd-Einzeiler/erlaubte Eingriffe: `src/server/jobs/processors/index.ts` (Registrierung `report-pdf`, mit `turbopackIgnore`), `Dockerfile` (neue Stage `worker` am Ende). `src/lib/nav.ts` war bereits eingetragen. `package.json`/`package-lock.json`: neue Abhängigkeit `playwright-core`. + +## Abhängigkeit `playwright-core` + +`playwright-core@^1.63` (Apache-2.0, ~8 MB entpackt, kein postinstall-Download, keine transitiven Laufzeit-Abhängigkeiten). Vom Architektur-Vertrag vorgegeben (§1 PDF). Wird nur im Worker geladen (`turbopackIgnore` im Processor-Registry-Eintrag), nicht im App-Bundle. Browser-Auflösung: `PDF_CHROMIUM_PATH` → Playwright-Chromium → lokal installiertes Google Chrome (`channel: "chrome"`, Entwicklerrechner). + +**Worker-Image:** braucht Chromium + Schriften. Vorschlag als Stage `worker` im `Dockerfile` (Debian-Paket `chromium`, `fonts-dejavu-core`, `fonts-liberation`, `PDF_CHROMIUM_PATH=/usr/bin/chromium`, Start `npx tsx scripts/craftvia-worker.ts`). Compose-Service (`target: worker`, `REDIS_URL`, `DATABASE_URL`, `S3_*`) ist noch einzutragen (nicht Lane-Ownership). + +## Tests + +| Skript | Prüfungen | Ergebnis | +|---|---|---| +| `scripts/test-berichte-flow.ts` | 70: Content-Builder (Tagesfilter Zeiten/Notizen/Fotos/Material, Materialabweichungen Menge/nicht verwendet/zusätzlich/undokumentiert, Kopfdaten), Tagesbericht + Auftragsstatus + Idempotenz + Nummernkreis, Abschluss-Blocker, Pflichtangaben, Unterschrift-Validierung je outcome, Statusfolgen submit/team_approve/reject/resubmit/approve inkl. Auftragsstatus, Einfrieren nach Freigabe, Versionierung (v1 nie überschrieben, superseded erst bei Freigabe v2), Mandantentrennung (lesen/ändern/freigeben/Liste/direkter DB-Update), Rollen/Scope (Monteur ohne Zuweisung, Teamleiter anderes Team, Monteur ohne Freigaberecht), Audit before/after | grün | +| `scripts/test-berichte-pdf.ts` | 11: PDF gerendert, Dokument > 0 Bytes, beginnt mit `%PDF`, Kategorie/Sichtbarkeit/Auftragsbezug, SHA-256 gespeichert und = Bytes, zweiter Lauf überschreibt nicht, Download eigener Mandant, Mandant B → not_found, nicht referenziertes Dokument → not_found. Ohne startbaren Browser: Skip mit Meldung (Exit 0) | grün (lokal über Google Chrome) | + +Gate: siehe Rückmeldung an den Architekten (`npm run gate` grün). + +## Stubs / Abhängigkeiten zu anderen Lanes + +| Stub | Vertrag | Ersetzen durch | +|---|---|---| +| `services/reports/_stubs/work-orders.ts#transitionWorkOrder` | ARCHITEKTUR §3 (canTransition + requiredPermission, Scope, `WorkOrderStatusChange`, Version+1, Audit, Event) | L2 `services/work-orders/transition.ts` | +| `services/reports/_stubs/work-orders.ts#getCompletionBlockers` | §3 Guards vor Abschluss → `CompletionBlocker[]` | L2 (Datei gemäß L2, z. B. `services/work-orders/guards.ts`) | +| `services/reports/_stubs/documents.ts#storeFile/readFileBytes` | §4.3 `services/documents/store.ts` (Allowlist, Magic Bytes, Größenlimit, SHA-256, Lineage) | Architekt/Dokumente (`services/documents/store.ts`) | +| Unterschrift-Upload über Server Action (Data-URL) | §4.6 `POST /api/v1/uploads` | L4 – für Offline-Sync (`signature.capture` referenziert `documentId`) | + +Imports sind mit `TODO(merge …)` markiert. `/api/v1/reports/...` nutzt einen eigenen Adapter (`http.ts`, `moduleGuard("reports")`), da noch kein gemeinsames `requireApiContext` existiert. + +L4 bindet die mobilen Seiten ein: Link „Bericht“ im Auftragsdetail → `/m/orders/[id]/report` (Tabs Abschluss/Tag), Abschluss → `/m/orders/[id]/sign`. Sync-Ops `report.save_draft`/`report.submit`/`signature.capture` können direkt `updateReportTexts`/`submitReport` (`expectedWorkOrderVersion`)/`captureSignature` (`clientId`) aufrufen. + +## Bekannte Lücken / offene Punkte + +- **Schema unverändert** (keine Migration). Mandantenlogo: `TenantSettings.logoKey` ist kein `Document` → `logoDocumentId` bleibt `null`, PDF zeigt Firmennamen, bis ein Logo-Upload als Dokument existiert. +- Berichtsnummer (`B-…`) liegt im Snapshot (`content.reportNumber`), nicht als Spalte – Suche nach Nummer braucht ggf. eine Spalte (Architekt). +- PDF wird ohne laufenden Worker nicht erzeugt, solange `REDIS_URL` gesetzt ist (Job bleibt in der Queue). Ohne Redis läuft `dispatchJob` inline, der Processor ist im App-Bundle aber absichtlich nicht enthalten → Fehler wird geloggt, Freigabe bleibt gültig, „PDF erzeugen“ auf der Detailseite stößt den Job erneut an. +- `generateReportPdf` rendert Fotos in Originalgröße (Data-URI); bei vielen großen Fotos ggf. auf Derivate (L4 `image-derivatives`) umstellen. +- E-Mail-Versand des PDF (§17.3 „optional“) und Empfänger der Events liegen bei L6. +- Mobile-Seiten sind ohne L4-Shell nur über direkte URL erreichbar; Offline-Fähigkeit (L7) nicht Teil dieser Lane. +- `src/server/dsgvo/pii-fields.ts`: `Report.createdById/approvedById/teamApprovedById`, `Signature.capturedById`, `signerName` sollten vom Architekten eingetragen werden (Fundament-Datei, nicht geändert). + +## Screens / Routen + +| Route | Rolle | Inhalt | +|---|---|---| +| `/reports` | Backoffice, Teamleiter (Scope) | Liste, zur Prüfung zuerst, Filter Typ/Status/Team/Von–Bis | +| `/reports/[id]` | Backoffice, Teamleiter (Scope) | Strukturierte Ansicht, Metadaten, PDF-Link, Versionen, Freigeben/Als Teamleiter prüfen/Zurückweisen (Popup `?reject=1`)/Neue Version/PDF erzeugen | +| `/m/orders/[id]/report?type=completion\|daily` | Monteur, Teamleiter | Blocker → Bericht erstellen → prüfen/ergänzen → (Tag) absenden / (Abschluss) weiter zur Unterschrift | +| `/m/orders/[id]/sign` | Monteur, Teamleiter | Unterschrift (Pad) oder Grund, dann Bericht absenden | diff --git a/scripts/test-berichte-flow.ts b/scripts/test-berichte-flow.ts new file mode 100644 index 0000000..082d2ef --- /dev/null +++ b/scripts/test-berichte-flow.ts @@ -0,0 +1,345 @@ +// 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/reports/_stubs/documents"; + +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); + }); diff --git a/scripts/test-berichte-pdf.ts b/scripts/test-berichte-pdf.ts new file mode 100644 index 0000000..4dfc166 --- /dev/null +++ b/scripts/test-berichte-pdf.ts @@ -0,0 +1,143 @@ +// L5 Berichte — PDF-Render-Smoke: Freigabe → generateReportPdf → Datei beginnt mit %PDF, Checksumme gespeichert, +// PDF unveränderlich (zweiter Lauf überspringt), Download nur im eigenen Mandanten. +// Ohne startbaren Chromium/Chrome wird der Render-Teil mit klarer Meldung übersprungen (Exit 0). +// +// Lauf: npx tsx scripts/test-berichte-pdf.ts + +import "dotenv/config"; +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 { pdfRendererAvailable } from "../src/server/pdf/render"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { generateReportPdf } from "../src/server/services/reports/pdf"; +import { openReportFile } from "../src/server/services/reports/files"; +import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const SLUG_A = "zz-berichte-pdf-a"; +const SLUG_B = "zz-berichte-pdf-b"; +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.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.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-pdf.test" } } }); +} + +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(); + const available = await pdfRendererAvailable(); + if (!available.ok) { + console.log(`⚠ ÜBERSPRUNGEN: kein Chromium startbar (${available.reason}). Abhilfe: PDF_CHROMIUM_PATH setzen oder "npx playwright-core install chromium".`); + return; + } + + const tA = await prisma.tenant.create({ data: { name: "PDF A", slug: SLUG_A } }); + const tB = await prisma.tenant.create({ data: { name: "PDF B", slug: SLUG_B } }); + await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb PDF GmbH", address: "Hafenstraße 12, 20457 Hamburg", phone: "+49 40 1", email: "info@pdf.example" } }); + const mk = async (tenantId: string, key: string) => { + const identity = await prisma.identity.create({ data: { email: `${key}@zz-berichte-pdf.test`, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name: `Nutzer ${key}` } }); + }; + const tech = await mk(tA.id, "tech"); + const office = await mk(tA.id, "office"); + const officeB = await mk(tB.id, "officeb"); + const techCtx = ctxOf(tA.id, tech.id, "technician"); + const officeCtx = ctxOf(tA.id, office.id, "backoffice"); + const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice"); + + const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kundin & Söhne " } }); + const wo = await prisma.workOrder.create({ + data: { tenantId: tA.id, number: "A-PDF1", customerId: customer.id, title: "Heizung warten", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } }, + }); + for (let i = 0; i < 3; i++) { + const doc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: `foto-${i}.png`, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } }); + await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: doc.id, phase: "after", comment: `Foto ${i}`, takenAt: new Date() } }); + } + + const { report } = await createDailyReport(techCtx, { workOrderId: wo.id }); + await updateReportTexts(techCtx, { reportId: report.id, texts: { workPerformed: "Brenner gereinigt.\nDruck geprüft.".repeat(40), hints: "Filter in 6 Monaten tauschen" } }); + await submitReport(techCtx, { reportId: report.id }); + const approved = await approveReport(officeCtx, { reportId: report.id }, { dispatchPdf: async () => {} }); + ok(approved.status === "approved" && !approved.pdfDocumentId, "Bericht freigegeben, PDF noch nicht erzeugt"); + + const t0 = Date.now(); + const res = await generateReportPdf(officeCtx, report.id); + ok(!res.skipped, `PDF gerendert (${Date.now() - t0} ms)`); + const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const doc = await prisma.document.findUniqueOrThrow({ where: { id: stored.pdfDocumentId! } }); + ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`); + ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag"); + ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert"); + const bytes = await readFileBytes(doc.storageKey); + if (bytes) { + ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF"); + ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes"); + } else { + console.log("⚠ Storage-Stub ohne Bytes (S3_* nicht gesetzt) — Byte-Prüfung übersprungen"); + } + + const again = await generateReportPdf(officeCtx, report.id); + ok(again.skipped && again.documentId === doc.id, "Zweiter Lauf überschreibt das freigegebene PDF nicht"); + + if (bytes) { + const file = await openReportFile(officeCtx, report.id, "pdf"); + ok(file.mimeType === "application/pdf", "Download im eigenen Mandanten möglich"); + } + try { + await openReportFile(officeBCtx, report.id, "pdf"); + ok(false, "Mandant B: PDF von A → not_found erwartet"); + } catch (err) { + ok(err instanceof ServiceError && err.code === "not_found", "Mandant B: PDF von A → not_found"); + } + try { + await openReportFile(officeCtx, report.id, doc.id === "x" ? "y" : "unrelated-document-id"); + ok(false, "Nicht referenziertes Dokument → not_found erwartet"); + } catch (err) { + ok(err instanceof ServiceError && err.code === "not_found", "Nicht referenziertes Dokument über Berichtsroute → not_found"); + } +} + +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✓ PDF-Smoke abgeschlossen"); + process.exit(failures ? 1 : 0); + });