// L10a E2E §43.3 / §39 „Notdiensteinsatz" — Monteur legt Notdienst mit vorläufigem Kunden an → Einsatz → // Abschluss ohne Unterschrift (Kunde abwesend) → Backoffice wird informiert → Backoffice ordnet den // bestehenden Kunden zu, bestätigt das Objekt, ergänzt den Auftrag → Berichtsfreigabe → Abrechnung. // Dazu Rollen (Monteur/Teamleiter dürfen nicht prüfen) und Mandantentrennung. // // Lauf: npx tsx scripts/test-e2e-emergency.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import { createEmergencyOrder } from "../src/server/services/emergency/create"; import { assignEmergencyToCustomer, completeEmergencyOrderData, correctEmergencySite, getEmergencyReview, listEmergencyReviews, releaseEmergencyForBilling, } from "../src/server/services/emergency/review"; import { markBilled } from "../src/server/services/work-orders/release-billing"; import { endSession } from "../src/server/services/field/sessions"; import { createNote } from "../src/server/services/field/notes"; import { upsertMaterialUsage } from "../src/server/services/field/materials"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { attachPhoto } from "../src/server/services/field/photos"; 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 { getCustomer } from "../src/server/services/customers/customers"; import { getSiteHistory } from "../src/server/services/sites/history"; import { materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload } from "../src/lib/sync/ops"; import { codeOf, createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-e2e-nd-a"; const SLUG_B = "zz-q-e2e-nd-b"; const noPdf = { dispatchPdf: async () => undefined }; runSuite("E2E Notdienst", [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; section("1. Monteur legt Notdienst mit vorläufigem Kunden an"); const startedAt = new Date(Date.now() - 2 * 3600_000); const r = await createEmergencyOrder(tech, { clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() }, // same phone + postal code as the existing customer → duplicate candidate for the backoffice customer: { mode: "new", lastName: "Hausverwaltung Hafen", phone: "040 5551000", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, site: { mode: "new", name: "Keller", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, onSiteContact: { name: "Hausmeister Hafen", phone: "0171 5550000" }, reason: "Heizung ausgefallen, Wohnungen ohne Warmwasser", startedAt: startedAt.toISOString(), }); const wo = await prisma.workOrder.findUniqueOrThrow({ where: { id: r.workOrderId }, include: { customer: true, site: true } }); ok(/^N-\d+$/.test(wo.number) && wo.isEmergency && wo.status === "in_progress", `Notdienstauftrag ${wo.number} in Arbeit`); ok(wo.customer.status === "provisional" && wo.customer.customerNumber === null && wo.site?.status === "provisional", "Kunde und Objekt vorläufig (ohne Kundennummer)"); ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "running" } })) === 1, "Einsatz läuft (Arbeitszeit gestartet)"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.created", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst angelegt“"); await expectCode(() => getCustomer(A.ctx.outsider, wo.customerId), "not_found", "anderer Monteur sieht den vorläufigen Kunden nicht"); await expectCode(() => getCustomer(B.ctx.admin, wo.customerId), "not_found", "Mandant B sieht den vorläufigen Kunden nicht"); section("2. Dokumentation und Abschluss ohne Unterschrift"); await createNote(tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "Umwälzpumpe getauscht, Anlage wieder in Betrieb." })); await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Umwälzpumpe 25-40", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Pumpe defekt" })); const up = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: await jpegBytes("Pumpe"), name: "pumpe.jpg", type: "image/jpeg" }); await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "after" })); await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id })); const report = (await createCompletionReport(tech, { workOrderId: wo.id })).report; await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Umwälzpumpe getauscht, Warmwasser wieder verfügbar." } }); await expectCode(() => captureSignature(tech, { reportId: report.id, outcome: "customer_absent", confirmationText: "x" }), "invalid", "Kunde abwesend ohne Begründung → invalid"); await captureSignature(tech, { reportId: report.id, outcome: "customer_absent", reason: "Nachts niemand erreichbar", confirmationText: "Bestätigt" }); await submitReport(tech, { reportId: report.id }); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Abschlussbericht eingereicht → Zur Prüfung"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.completed", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst abgeschlossen“ (In-App)"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "work_order.signature_missing", entityId: wo.id } })) === 1, "Backoffice erhält Hinweis „Unterschrift fehlt“"); const mails = await prisma.mailLog.count({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }); ok(mails >= 1, `Pflicht-E-Mail an das Backoffice eingereiht/protokolliert (${mails})`); section("3. Rollen und Mandantentrennung der Nachbearbeitung"); await expectCode(() => listEmergencyReviews(tech), "forbidden", "Monteur: Prüfliste → forbidden"); await expectCode(() => getEmergencyReview(A.ctx.lead, wo.id), "forbidden", "Teamleiter: Prüfung → forbidden"); await expectCode(() => getEmergencyReview(B.ctx.backoffice, wo.id), "not_found", "Mandant B: Prüfung → not_found"); await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: wo.id, targetCustomerId: A.customerId }), "not_found", "Mandant B: Kunde zuordnen → not_found"); ok(!(await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).some((x) => x.id === wo.id), "Mandant B: Notdienst nicht in der Liste"); section("4. Backoffice prüft am nächsten Arbeitstag die Stammdaten"); const listed = (await listEmergencyReviews(bo)).find((x) => x.id === wo.id); ok(!!listed && listed.progress.count === 0 && listed.progress.total === 5, "in „Zu prüfen“ mit 0 von 5 Prüfschritten"); const review = await getEmergencyReview(bo, wo.id); ok(review.duplicates.some((d) => d.customerId === A.customerId), "bestehender Kunde als Dublettenkandidat vorgeschlagen"); await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung mit vorläufigen Stammdaten → blocked"); const assigned = await assignEmergencyToCustomer(bo, { workOrderId: wo.id, targetCustomerId: A.customerId }); const provisional = await prisma.customer.findUniqueOrThrow({ where: { id: assigned.sourceId } }); const afterAssign = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id }, include: { site: true } }); ok(afterAssign.customerId === A.customerId && provisional.status === "merged" && provisional.mergedIntoId === A.customerId, "Auftrag dem bestehenden Kunden zugeordnet, vorläufiger Kunde zusammengeführt"); ok(afterAssign.site?.customerId === A.customerId && afterAssign.version > wo.version, "Einsatzort mit umgehängt, Auftragsversion erhöht (Offline-Schutz)"); await correctEmergencySite(bo, { workOrderId: wo.id, patch: { name: "Heizungskeller Hafenstraße 12", accessNotes: "Hausmeister öffnet" }, confirm: true }); ok((await prisma.site.findUniqueOrThrow({ where: { id: afterAssign.siteId! } })).status === "active", "Objekt korrigiert und bestätigt"); const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } }); await completeEmergencyOrderData(bo, { workOrderId: wo.id, title: "Notdienst: Ausfall Heizung", orderTypeId: notdienst.id, billingType: "time_material" }); await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung ohne freigegebenen Bericht → blocked"); await approveReport(A.ctx.lead, { reportId: report.id }, noPdf); await approveReport(bo, { reportId: report.id }, noPdf); ok((await getEmergencyReview(bo, wo.id)).progress.count === 4, "4 von 5 Prüfschritten erledigt"); section("5. Auftrag wird zur Abrechnung freigegeben"); const released = await releaseEmergencyForBilling(bo, { workOrderId: wo.id }); ok(released.status === "released_for_billing", "Notdienst zur Abrechnung freigegeben"); ok((await getEmergencyReview(bo, wo.id)).progress.count === 5, "5 von 5 Prüfschritten erledigt"); ok(!(await listEmergencyReviews(bo)).some((x) => x.id === wo.id) && (await listEmergencyReviews(bo, { filter: "all" })).some((x) => x.id === wo.id), "aus „Zu prüfen“ entfernt, unter „Alle“ sichtbar"); ok((await codeOf(markBilled(tech, { workOrderId: wo.id }))) === "forbidden", "Monteur darf nicht abrechnen"); ok((await markBilled(bo, { workOrderId: wo.id })).status === "billed", "abgerechnet"); const audits = await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: wo.id } }); ok(audits >= 4, `Prüfschritte auditiert (${audits})`); const hist = await getSiteHistory(bo, afterAssign.siteId!); const entry = hist.items.find((i) => i.workOrderId === wo.id); ok(!!entry && entry.isEmergency && entry.approvedReports.length === 1 && !entry.signed, "Objekt-Historie: Notdienst mit freigegebenem Bericht, ohne Unterschrift"); });