// L10a E2E §43.3 „Fehlende Pflichtfotos" und „Fehlende Unterschrift". // // Pflichtfotos: Pflichtfoto + Checklistenpunkt „mit Foto" blockieren technisch abschließen, // Abschlussbericht und Sync-Op mit strukturierter Blocker-Liste; Blocker verschwinden erst mit den // passenden Fotos. Unterschrift: ohne Unterschrift → „Unterschrift ausstehend“ (kein Weg zur Prüfung // oder Abrechnung), Ablehnung/Abwesenheit nur mit Grund (→ Prüfung + Hinweis ans Backoffice), // „später“ + Nachreichen, „nicht erforderlich“ nur ohne Pflicht, erfasste Unterschrift unveränderlich. // // Lauf: npx tsx scripts/test-e2e-guards.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import type { CompletionBlocker } from "../src/lib/work-orders/status"; import { ServiceError } from "../src/server/services/context"; import { createWorkOrder } from "../src/server/services/work-orders/create"; import { assignWorkOrder } from "../src/server/services/work-orders/assign"; import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; import { endSession, startSession } from "../src/server/services/field/sessions"; import { toggleChecklistItem } from "../src/server/services/field/checklist"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { attachPhoto } from "../src/server/services/field/photos"; import { applyOperations } from "../src/server/services/sync/apply"; import { storeFile } from "../src/server/services/documents/store"; import { createCompletionReport } 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 { contentOf } from "../src/server/services/reports/common"; import { checklistTogglePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops"; import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-e2e-guard-a"; const SLUG_B = "zz-q-e2e-guard-b"; async function blockersOf(fn: () => Promise): Promise { try { await fn(); return "ok"; } catch (err) { return err instanceof ServiceError && err.code === "blocked" ? (err.details as CompletionBlocker[]) : String((err as { code?: string }).code ?? (err as Error).message); } } /** Order assigned to tech, worked and ended; ready for the completion step. */ async function workedOrder(A: TenantFixture, input: { signatureRequired: boolean; title: string; photoRequirements?: { key: string; label: string }[]; checklistItems?: { label: string; required: boolean; requiresPhoto?: boolean }[] }) { const wo = await createWorkOrder(A.ctx.backoffice, { customerId: A.customerId, siteId: A.siteId, applyTemplate: false, ...input }); await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] }); await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() })); await endSession(A.ctx.tech, sessionControlPayload.parse({ workOrderId: wo.id })); return wo.id; } async function readyReport(A: TenantFixture, workOrderId: string) { const { report } = await createCompletionReport(A.ctx.tech, { workOrderId }); await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Arbeiten ausgeführt." } }); return report.id; } const status = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).status; runSuite("E2E Pflichtfotos und Unterschrift", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const tech = A.ctx.tech; const jpeg = await jpegBytes("Nachweis"); // ================= Pflichtfotos ================= section("Fehlende Pflichtfotos blockieren den Abschluss"); const w1 = await workedOrder(A, { title: "Wärmepumpe montieren", signatureRequired: true, photoRequirements: [{ key: "typenschild", label: "Typenschild" }], checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }], }); const b1 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w1, to: "technically_completed" })); ok(Array.isArray(b1) && b1.some((b) => b.kind === "photo_requirement" && b.label === "Typenschild") && b1.some((b) => b.kind === "checklist_item"), "technisch abschließen → blocked mit Pflichtfoto + Checklistenpunkt"); const b2 = await blockersOf(() => createCompletionReport(tech, { workOrderId: w1 })); ok(Array.isArray(b2) && b2.length === 2, "Abschlussbericht → blocked mit derselben Liste"); const listed = await getCompletionBlockers(tech, w1); ok(listed.length === 2, "Blocker-Liste für die UI abrufbar"); await expectCode(() => getCompletionBlockers(B.ctx.tech, w1), "not_found", "Mandant B: Blocker-Liste → not_found"); await expectCode(() => getCompletionBlockers(A.ctx.outsider, w1), "not_found", "Monteur ohne Zuweisung: Blocker-Liste → not_found"); const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w1 }, include: { checklistItems: true, photoRequirements: true } }); const viaSync = await applyOperations(tech, { deviceId: "g", operations: [{ clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: wo1.version, payload: { workOrderId: w1, to: "technically_completed" }, clientCreatedAt: new Date().toISOString() }] }); ok(viaSync.results[0].status === "rejected" && viaSync.results[0].errorCode === "blocked", "auch offline (Sync) → rejected blocked"); await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: w1, itemId: wo1.checklistItems[0].id, checked: true })); const b3 = await getCompletionBlockers(tech, w1); ok(b3.some((b) => b.kind === "checklist_item"), "abgehakt ohne Foto → Checklistenpunkt „mit Foto“ bleibt offen"); const notAnImage = Buffer.from("%PDF-1.4 kein Foto"); await expectCode(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: notAnImage, name: "fake.jpg", type: "image/jpeg" }), "invalid", "Nicht-Bild als Foto → invalid"); const p1 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "funktion.jpg", type: "image/jpeg" }); await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p1.documentId, checklistItemId: wo1.checklistItems[0].id, phase: "after" })); const b4 = await getCompletionBlockers(tech, w1); ok(b4.length === 1 && b4[0].kind === "photo_requirement", "Foto am Checklistenpunkt → nur noch Pflichtfoto offen"); const p2 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" }); await expectCode(() => attachPhoto(A.ctx.tech2, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id })), ["not_found", "forbidden"], "Foto eines anderen Nutzers kann nicht angehängt werden"); await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id, phase: "after" })); ok((await getCompletionBlockers(tech, w1)).length === 0, "alle Pflichtfotos vorhanden → keine Blocker"); ok((await codeOf(createCompletionReport(tech, { workOrderId: w1 }))) === "ok", "Abschlussbericht jetzt möglich"); // ================= Unterschrift ================= section("Fehlende Unterschrift: ohne Erfassung bleibt der Auftrag „Unterschrift ausstehend“"); const w2 = await workedOrder(A, { title: "Ohne Unterschrift", signatureRequired: true }); const r2 = await readyReport(A, w2); await submitReport(tech, { reportId: r2 }); ok((await status(w2)) === "signature_pending", "Abschluss ohne Unterschrift → signature_pending"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w2, userId: A.users.backoffice.id } })) >= 1, "Backoffice erhält „Unterschrift fehlt“"); const b5 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w2, to: "in_review" })); ok(Array.isArray(b5) && b5.some((b) => b.kind === "missing_field" && b.field === "signature"), "zur Prüfung ohne Unterschrift → blocked (signature)"); await expectCode(() => releaseForBilling(A.ctx.backoffice, { workOrderId: w2 }), "invalid", "Abrechnung aus „Unterschrift ausstehend“ nicht möglich"); const img2 = await storeFile(tech, { bytes: await pngBytes(), fileName: "u.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: w2 } }); await captureSignature(tech, { reportId: r2, outcome: "signed", signerName: "Herr Nachtrag", imageDocumentId: img2.id, confirmationText: "Bestätigt" }); ok((await status(w2)) === "in_review", "nachgereichte Unterschrift → automatisch zur Prüfung"); await expectCode(() => captureSignature(tech, { reportId: r2, outcome: "refused", reason: "doch nicht", confirmationText: "x" }), "conflict", "erfasste Unterschrift kann nicht überschrieben werden"); section("Unterschrift verweigert / Kunde abwesend: nur mit Grund"); const w3 = await workedOrder(A, { title: "Kunde verweigert", signatureRequired: true }); const r3 = await readyReport(A, w3); await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "refused", confirmationText: "x" }), "invalid", "Verweigerung ohne Grund → invalid"); await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "customer_absent", reason: " ", confirmationText: "x" }), "invalid", "Abwesenheit mit leerem Grund → invalid"); await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Ohne Bild", confirmationText: "x" }), "invalid", "Unterschrift ohne Bild → invalid"); await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Fremdes Bild", imageDocumentId: img2.id, confirmationText: "x" }), "invalid", "Unterschriftsbild eines anderen Auftrags → invalid"); await captureSignature(tech, { reportId: r3, outcome: "refused", reason: "Kunde mit Ausführung nicht einverstanden", confirmationText: "Bestätigt" }); await submitReport(tech, { reportId: r3 }); const rep3 = await prisma.report.findUniqueOrThrow({ where: { id: r3 } }); ok((await status(w3)) === "in_review", "Verweigerung mit Grund → Zur Prüfung"); ok(contentOf(rep3).signature?.outcome === "refused" && contentOf(rep3).signature?.reason === "Kunde mit Ausführung nicht einverstanden", "Grund im Berichts-Snapshot"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w3 } })) >= 1, "Backoffice wird über die fehlende Unterschrift informiert"); await expectCode(() => captureSignature(B.ctx.tech, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen"); await expectCode(() => captureSignature(A.ctx.outsider, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Monteur ohne Zuweisung kann keine Unterschrift erfassen"); section("„Später“ und „nicht erforderlich“"); const w4 = await workedOrder(A, { title: "Später unterschreiben", signatureRequired: true }); const r4 = await readyReport(A, w4); await expectCode(() => captureSignature(tech, { reportId: r4, outcome: "not_required", confirmationText: "x" }), "forbidden", "Monteur: „nicht erforderlich“ bei Pflicht-Unterschrift → forbidden"); await captureSignature(tech, { reportId: r4, outcome: "later", reason: "Ansprechpartner erst morgen da", confirmationText: "Bestätigt" }); await submitReport(tech, { reportId: r4 }); ok((await status(w4)) === "signature_pending", "„später“ → Unterschrift ausstehend"); await captureSignature(tech, { reportId: r4, outcome: "customer_absent", reason: "Auch am Folgetag nicht erreichbar", confirmationText: "Bestätigt" }); ok((await status(w4)) === "in_review", "Nachtrag „Kunde abwesend“ mit Grund → Zur Prüfung"); const w5 = await workedOrder(A, { title: "Besichtigung ohne Unterschrift", signatureRequired: false }); const r5 = await readyReport(A, w5); await captureSignature(tech, { reportId: r5, outcome: "not_required", confirmationText: "Bestätigt" }); await submitReport(tech, { reportId: r5 }); ok((await status(w5)) === "in_review", "Auftrag ohne Unterschriftspflicht: „nicht erforderlich“ → Zur Prüfung"); });