// L10a E2E §43.3 / §38 „Regulärer Auftrag vom Import bis zur Freigabe" — Service-Ebene gegen die DB. // // Import (Fake-Provider) → Dublettenkandidat + Objektkandidat → Prüfung/Bestätigung → Zuweisung → // Vorab-Download (Bundle) → Annehmen/Losfahren/Arbeit → Material/Fotos/Zeiten/Notizen/Checkliste → // Abschlussbericht → Unterschrift → Teamleiter- und Backoffice-Freigabe → PDF (skip-fähig ohne // Chromium) → Abrechnung → abgerechnet → Objekt-Historie. Dazu Mandantentrennung (Mandant B) und // Rollen/Scope (Monteur ohne Zuweisung) an jedem Schritt. // // Lauf: npx tsx scripts/test-e2e-regular-order.ts (auch mit RLS_ENFORCED=true lauffähig) import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import type { WorkOrderExtraction } from "../src/server/ai/providers"; import { FakeExtractionProvider } from "../src/server/ai/extraction/fake"; import { createImport } from "../src/server/services/imports/upload"; import { processImport } from "../src/server/services/imports/process"; import { confirmImport } from "../src/server/services/imports/confirm"; import { getImportDetail } from "../src/server/services/imports/queries"; import { extractionToForm } from "../src/lib/imports/review"; import { assignWorkOrder } from "../src/server/services/work-orders/assign"; import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist"; import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing"; import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries"; import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions"; import { createNote } from "../src/server/services/field/notes"; import { upsertMaterialUsage } from "../src/server/services/field/materials"; 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 { storeFile } from "../src/server/services/documents/store"; import { authorizeDocumentAccess } from "../src/server/services/documents/access"; 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 { approveReport } from "../src/server/services/reports/approve"; import { generateReportPdf } from "../src/server/services/reports/pdf"; import { openReportFile } from "../src/server/services/reports/files"; import { contentOf, requireVisibleReport } from "../src/server/services/reports/common"; import { pdfRendererAvailable } from "../src/server/pdf/render"; import { readStoredBytes } from "../src/server/services/documents/read"; import { getSiteHistory } from "../src/server/services/sites/history"; import { checklistTogglePayload, materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload, } from "../src/lib/sync/ops"; import { buildPdf } from "./make-sample-pdfs"; import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-e2e-reg-a"; const SLUG_B = "zz-q-e2e-reg-b"; runSuite("E2E regulärer Auftrag", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const bo = A.ctx.backoffice; const tech = A.ctx.tech; // ---------- 1. Import ---------- section("1. Import einer Auftragsbestätigung (Fake-Provider)"); const pdf = buildPdf([[{ text: "Auftragsbestätigung AB-2026-4711" }, { text: `Hausverwaltung ${SLUG_A} GmbH, Hafenstraße 12, 20457 Hamburg` }]]); const extraction: Partial = { orderNumber: { value: "AB-2026-4711", confidence: 0.97 }, customerNumber: { value: `K-${SLUG_A}`, confidence: 0.95 }, companyName: { value: `Hausverwaltung ${SLUG_A} GmbH`, confidence: 0.95 }, customerAddress: { value: { street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.9 }, siteName: { value: `Wohnanlage ${SLUG_A}`, confidence: 0.75, source: "Bauvorhaben" }, siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, title: { value: "Austausch Heizungspumpe", confidence: 0.88 }, plannedStart: { value: "01.10.2026", confidence: 0.9 }, plannedEnd: { value: "02.10.2026", confidence: 0.9 }, positions: { value: [ { name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", quantity: 1, unit: "Stk", isMaterial: true }, { name: "Montagearbeiten", quantity: 3, unit: "Std", isMaterial: false }, ], confidence: 0.86, }, }; const dispatched: unknown[] = []; const job = await createImport(bo, { bytes: pdf, fileName: "AB-2026-4711.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) }); ok(job.status === "uploaded" && dispatched.length === 1, "Upload angenommen, Extraktion eingereiht"); const processed = await processImport(bo, job.id, { provider: new FakeExtractionProvider({ extraction, text: "AB-2026-4711" }), loadBytes: async () => pdf }); ok(processed.status === "review_required", "Extraktion → Prüfung erforderlich"); ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "kein Auftrag ohne Bestätigung"); await expectCode(() => getImportDetail(B.ctx.backoffice, job.id), "not_found", "Mandant B sieht den Import von A nicht"); await expectCode(() => getImportDetail(tech, job.id), "forbidden", "Monteur hat keinen Zugriff auf die Prüfmaske"); // ---------- 2. Prüfung / Bestätigung ---------- section("2. Prüfung: bestehender Kunde + bestehendes Objekt zugeordnet"); const detail = await getImportDetail(bo, job.id); ok(detail.customerCandidates[0]?.customerId === A.customerId, "Dublettenprüfung schlägt den bestehenden Kunden vor"); const siteCandidates = (detail.extraction as unknown as { siteCandidates?: { siteId: string }[] }).siteCandidates ?? []; ok(siteCandidates.some((s) => s.siteId === A.siteId), "bestehendes Objekt an gleicher Adresse als Kandidat"); const form = extractionToForm(detail.extraction.fields, { customerId: A.customerId, siteId: A.siteId }); form.order!.title = "Heizungspumpe Wohnanlage tauschen"; const confirmed = await confirmImport(bo, job.id, form); const wo0 = await prisma.workOrder.findUniqueOrThrow({ where: { id: confirmed.workOrderId }, include: { materialPlans: true } }); ok(wo0.status === "planned" && wo0.customerId === A.customerId && wo0.siteId === A.siteId && wo0.sourceImportId === job.id, "Auftrag geplant, Kunde/Objekt zugeordnet, Import verknüpft"); ok(wo0.materialPlans.length === 1 && wo0.materialPlans[0].articleNumber === "HP-2560", "Materialvorgabe aus Positionen übernommen"); const woId = wo0.id; // Backoffice ergänzt Pflichtpunkt + Pflichtfoto. const item = await addChecklistItem(bo, woId, { label: "Anlage entlüftet", required: true }); await addPhotoRequirement(bo, woId, { key: "typenschild", label: "Typenschild neue Pumpe" }); // ---------- 3. Zuweisung ---------- section("3. Zuweisung an Team Nord"); await expectCode(() => assignWorkOrder(tech, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }), "forbidden", "Monteur darf nicht zuweisen"); await expectCode(() => assignWorkOrder(B.ctx.backoffice, { workOrderId: woId, teamId: B.teamId }), "not_found", "Mandant B kann den Auftrag von A nicht zuweisen"); const assigned = await assignWorkOrder(bo, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }); ok(assigned.status === "assigned", "Auftrag zugewiesen"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "work_order.assigned", entityId: woId } })) === 1, "Monteur erhält Benachrichtigung „Auftrag zugewiesen“"); // ---------- 4. Vorab-Download ---------- section("4. Team lädt Auftrag und Dokumente (Bundle)"); const bundle = await getFieldBundle(tech); const bundled = bundle.orders.find((o) => o.id === woId); ok(!!bundled && bundled.site?.accessNotes === "Schlüssel beim Hausmeister" && bundled.materialPlans.length === 1 && bundled.photoRequirements.length === 1, "Bundle enthält Auftrag mit Zugangshinweis, Material, Pflichtfoto"); ok(!(await getFieldBundle(A.ctx.outsider)).orders.some((o) => o.id === woId), "Monteur ohne Zuweisung (anderes Team) erhält den Auftrag nicht"); ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === woId), "Mandant B erhält den Auftrag nicht"); ok(!!bundled && !bundled.documents.some((d) => d.category === "order_confirmation"), "interne Auftragsbestätigung (backoffice_only) nicht im Monteur-Bundle"); await expectCode(() => getFieldOrderDetail(A.ctx.outsider, woId), "not_found", "Auftragsdetail für fremden Monteur → not_found"); // ---------- 5. Einsatz ---------- section("5. Einsatz: Annehmen, Losfahren, Arbeiten, Material, Fotos, Zeiten, Notizen"); const started = new Date(Date.now() - 3 * 3600_000); await transitionWorkOrder(tech, { workOrderId: woId, to: "accepted" }); await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "travel", at: started.toISOString() })); const workStart = await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work", at: new Date(started.getTime() + 30 * 60_000).toISOString() })); ok(workStart.workOrderStatus === "in_progress", "Arbeit gestartet → in Arbeit"); await expectCode(() => startSession(A.ctx.outsider, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "fremder Monteur kann keinen Einsatz starten"); await expectCode(() => startSession(B.ctx.tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "Mandant B kann keinen Einsatz starten"); const jpeg = await jpegBytes("Typenschild"); const before = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "vorher.jpg", type: "image/jpeg" }); await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: before.documentId, phase: "before", comment: "Alte Pumpe" })); await createNote(tech, noteCreatePayload.parse({ workOrderId: woId, kind: "work_done", text: "Alte Pumpe demontiert, neue Pumpe eingesetzt." })); await pauseSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 90 * 60_000).toISOString() })); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } })).status === "paused", "Pause → Auftrag pausiert"); await resumeSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 105 * 60_000).toISOString() })); await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, materialPlanId: wo0.materialPlans[0].id, quantity: 1, unit: "Stk", usageStatus: "fully_used" })); await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Flachdichtung 1 Zoll", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Alte Dichtungen porös" })); await expectCode( () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Ohne Grund", quantity: 1, unit: "Stk", usageStatus: "additional" })), "invalid", "Zusatzmaterial ohne Begründung → invalid", ); const req = await prisma.photoRequirement.findFirstOrThrow({ where: { workOrderId: woId } }); const plate = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" }); await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: plate.documentId, phase: "after", photoRequirementId: req.id })); await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woId, itemId: item.id, checked: true })); await expectCode(() => createCompletionReport(tech, { workOrderId: woId }), "blocked", "Abschlussbericht bei laufender Arbeitszeit → blocked"); const ended = await endSession(tech, sessionControlPayload.parse({ workOrderId: woId })); ok(ended.workSeconds > 0 && ended.breakSeconds >= 14 * 60 && ended.travelSeconds >= 29 * 60, `Zeiten berechnet (Arbeit ${Math.round(ended.workSeconds / 60)} min, Pause ${Math.round(ended.breakSeconds / 60)} min, Anfahrt ${Math.round(ended.travelSeconds / 60)} min)`); // ---------- 6. Abschlussbericht + Unterschrift ---------- section("6. Abschlussbericht und Kundenunterschrift"); const { report } = await createCompletionReport(tech, { workOrderId: woId }); const content = contentOf(report); ok(content.photos.length === 2 && content.materials.used.length === 1 && content.materials.additional.length === 1, "Berichts-Snapshot enthält Fotos und Material (Soll/Ist + Zusatz)"); ok(content.time.totalMinutes > 0 && content.site?.name === `Wohnanlage ${SLUG_A}`, "Berichts-Snapshot enthält Arbeitszeit und Objekt"); ok(content.texts.workPerformed.includes("Alte Pumpe demontiert"), "„Ausgeführte Leistungen“ aus den Tätigkeitsnotizen vorbelegt"); await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "" } }); await expectCode(() => submitReport(tech, { reportId: report.id }), "blocked", "Absenden ohne „Ausgeführte Leistungen“ → blocked"); await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Heizungspumpe getauscht, Anlage entlüftet und geprüft." } }); const sigImg = await storeFile(tech, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: woId } }); await captureSignature(tech, { reportId: report.id, outcome: "signed", signerName: "Frau Prüf", signerRole: "Objektbetreuung", imageDocumentId: sigImg.id, confirmationText: "Arbeiten ordnungsgemäß ausgeführt." }); await expectCode(() => captureSignature(B.ctx.tech, { reportId: report.id, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen"); await submitReport(tech, { reportId: report.id }); const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } }); ok(afterSubmit.status === "in_review", "Unterschrift erfasst → Auftrag „Zur Prüfung“"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "report.submitted", entityId: report.id } })) === 1, "Teamleiter wird zur Prüfung benachrichtigt"); // ---------- 7. Freigabe ---------- section("7. Teamleiter- und Backoffice-Freigabe"); await expectCode(() => approveReport(tech, { reportId: report.id }, { dispatchPdf: async () => undefined }), "forbidden", "Monteur darf nicht freigeben"); await expectCode(() => approveReport(B.ctx.backoffice, { reportId: report.id }, { dispatchPdf: async () => undefined }), "not_found", "Mandant B kann nicht freigeben"); await expectCode(() => releaseForBilling(bo, { workOrderId: woId }), "blocked", "Abrechnung vor Berichtsfreigabe → blocked"); const teamApproved = await approveReport(A.ctx.lead, { reportId: report.id }, { dispatchPdf: async () => undefined }); ok(teamApproved.status === "team_approved", "Teamleiter prüft → team_approved"); const pdfJobs: string[] = []; const approved = await approveReport(bo, { reportId: report.id }, { dispatchPdf: async (_c, id) => void pdfJobs.push(id) }); ok(approved.status === "approved" && pdfJobs[0] === report.id, "Backoffice gibt frei → approved, PDF-Job angestoßen"); await expectCode(() => updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "nachträglich geändert" } }), ["conflict", "invalid", "blocked"], "freigegebener Bericht ist unveränderlich"); // ---------- 8. PDF ---------- section("8. PDF-Arbeitsnachweis"); const renderer = await pdfRendererAvailable(); if (!renderer.ok) { console.log(`⚠ PDF-Erzeugung übersprungen: kein Chromium startbar (${renderer.reason})`); } else { const pdfRes = await generateReportPdf(bo, report.id); const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdfRes.documentId } }); ok(!pdfRes.skipped && stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF erzeugt, Prüfsumme am Bericht"); ok(doc.category === "completion_report" && doc.siteId === A.siteId && doc.workOrderId === woId, "PDF als Abschlussbericht an Auftrag und Objekt abgelegt"); const bytes = await readStoredBytes(doc.storageKey); if (bytes) ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF"); await expectCode(() => openReportFile(B.ctx.backoffice, report.id, "pdf"), "not_found", "Mandant B kann das PDF nicht laden"); await expectCode(() => authorizeDocumentAccess(A.ctx.outsider, doc.id), "not_found", "fremder Monteur kann das PDF nicht laden"); } // ---------- 9. Abrechnung ---------- section("9. Abrechnungsfreigabe und abgerechnet"); await expectCode(() => releaseForBilling(tech, { workOrderId: woId }), "forbidden", "Monteur darf nicht zur Abrechnung freigeben"); await expectCode(() => releaseForBilling(A.ctx.lead, { workOrderId: woId }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben"); const released = await releaseForBilling(bo, { workOrderId: woId }); ok(released.status === "released_for_billing", "Auftrag zur Abrechnung freigegeben"); const billed = await markBilled(bo, { workOrderId: woId }); ok(billed.status === "billed", "Auftrag abgerechnet"); ok((await codeOf(transitionWorkOrder(bo, { workOrderId: woId, to: "cancelled", reason: "zu spät" }))) === "invalid", "abgerechneter Auftrag kann nicht storniert werden"); const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: woId }, orderBy: { createdAt: "asc" }, select: { toStatus: true } }); const path = history.map((h) => h.toStatus).join(">"); ok(path === "planned>assigned>accepted>en_route>in_progress>paused>in_progress>technically_completed>in_review>released_for_billing>billed", `Statushistorie vollständig (${path})`); ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: woId } })) >= 10, "jede Statusänderung auditiert"); const woDetail = await getWorkOrderDetail(bo, woId); ok(woDetail.id === woId, "Backoffice-Detail lesbar"); // ---------- 10. Objekt-Historie ---------- section("10. Einsatz erscheint in der Objekt-Historie"); const hist = await getSiteHistory(bo, A.siteId); const entry = hist.items.find((i) => i.workOrderId === woId); ok(!!entry && entry.approvedReports.length === 1 && entry.signed && entry.photoCount === 2, "Historie: freigegebener Bericht, Unterschrift, Fotos"); ok(!!entry && entry.materials.some((m) => m.name === "Hocheffizienzpumpe 25-60") && entry.workDone.length === 1, "Historie: Material und durchgeführte Arbeiten"); const techHistory = await getSiteHistory(A.ctx.tech2, A.siteId); ok(techHistory.onlyApproved && techHistory.items.some((i) => i.workOrderId === woId), "Teamkollege sieht den freigegebenen Einsatz in der Objekt-Historie"); await expectCode(() => getSiteHistory(A.ctx.outsider, A.siteId), "not_found", "Monteur ohne Auftrag am Objekt → not_found"); await expectCode(() => getSiteHistory(B.ctx.backoffice, A.siteId), "not_found", "Mandant B → not_found"); await expectCode(() => requireVisibleReport(B.ctx.backoffice, report.id), "not_found", "Mandant B kann den Bericht nicht lesen"); });