Files
craftvia/scripts/test-e2e-regular-order.ts
T

241 lines
20 KiB
TypeScript

// 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<WorkOrderExtraction> = {
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");
});