Files
craftvia/scripts/test-e2e-emergency.ts
T

123 lines
10 KiB
TypeScript

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