diff --git a/scripts/test-notdienst-flow.ts b/scripts/test-notdienst-flow.ts new file mode 100644 index 0000000..c71f4ca --- /dev/null +++ b/scripts/test-notdienst-flow.ts @@ -0,0 +1,216 @@ +// Lane L8 „Notdienst" — Erfassung, Transaktion, Sichtbarkeit, Such-Service, Sync-Op, Abschluss-Event. +// +// Nachweise: vorläufiger Kunde/Objekt + Auftrag N-… in_progress + WorkSession in EINER Transaktion +// (Fehler im Auftrag → kein verwaister Kunde), vorläufiger Kunde für Monteure nur über den eigenen +// Notdienstauftrag sichtbar, Such-Service mit minimalem Feldumfang + Audit + Mandantentrennung, +// Sync-Op emergency.create idempotent, Events emergency.created / emergency.completed. +// +// Lauf: npx tsx scripts/test-notdienst-flow.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { SyncOperationInput } from "../src/lib/sync/envelope"; +import type { EmergencyCreateInput } from "../src/lib/emergency/schemas"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { createEmergencyOrder } from "../src/server/services/emergency/create"; +import { listSitesForEmergency, searchCustomersForEmergency, emergencyTeamOptions } from "../src/server/services/emergency/lookup"; +import { buildEmergencyCompletedData } from "../src/server/services/emergency/completion"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { requireVisibleWorkOrder } from "../src/server/services/work-orders/visibility"; +import { getCustomer } from "../src/server/services/customers/customers"; +import { endSession } from "../src/server/services/field/sessions"; +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 { cleanupTenants, createFixture, expectCode, failures, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; + +const SLUG_A = "zz-l8-flow-a"; +const SLUG_B = "zz-l8-flow-b"; + +export function emergencyPayload(over: Partial = {}): EmergencyCreateInput { + return { + clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() }, + customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, + site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, + onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" }, + reason: "Wasserrohrbruch im Keller", + ...over, + }; +} + +function syncOp(payload: unknown, clientOpId = randomUUID()): SyncOperationInput { + return { clientOpId, opType: "emergency.create", payload: payload as Record, clientCreatedAt: new Date().toISOString() }; +} + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B]); + const A = await createFixture(SLUG_A); + const B = await createFixture(SLUG_B); + try { + console.log("\n— Erfassung mit vorläufigem Kunden —"); + const p1 = emergencyPayload(); + const r1 = await createEmergencyOrder(A.ctx.tech, p1); + const wo = await prisma.workOrder.findUniqueOrThrow({ + where: { id: r1.workOrderId }, + include: { customer: true, site: true, contact: true, orderType: true, assignees: true, workSessions: { include: { entries: true } }, statusHistory: true }, + }); + ok(wo.isEmergency && /^N-\d+$/.test(wo.number), `Auftrag ist Notdienst mit Nummer aus Sequenz emergency (${wo.number})`); + ok(wo.status === "in_progress" && wo.statusHistory.some((h) => h.toStatus === "in_progress"), "Status in_progress inkl. Statushistorie"); + ok(wo.orderType?.key === "notdienst", "Auftragsart notdienst"); + ok(wo.emergencyReason === "Wasserrohrbruch im Keller" && wo.title === "Wasserrohrbruch im Keller", "Grund als emergencyReason + Titel"); + ok(wo.createdById === A.users.tech.id, "createdById = Monteur"); + ok(wo.customer.status === "provisional" && wo.customer.isProvisional && wo.customer.customerNumber === null, "Kunde vorläufig, ohne Kundennummer"); + ok(wo.site?.status === "provisional" && wo.site.customerId === wo.customerId && wo.site.city === "Hamburg", "Einsatzadresse als vorläufiges Objekt"); + ok(wo.contact?.name === "Lutz Meier" && wo.contact.phone === "0171 1234567" && wo.site?.contactId === wo.contactId, "Ansprechpartner vor Ort + Telefon"); + ok(wo.assignedTeamId === A.teamId && wo.teamLeadUserId === A.users.lead.id, "Default: eigenes Team + Teamleiter"); + ok(wo.assignees.length === 1 && wo.assignees[0].userId === A.users.tech.id, "Default: eigener User zugewiesen"); + const session = wo.workSessions[0]; + ok(wo.workSessions.length === 1 && session.status === "running" && session.clientId === p1.clientIds.session, "WorkSession gestartet (clientId)"); + ok(session.entries.length === 1 && session.entries[0].type === "work" && !session.entries[0].endedAt, "Arbeitszeit-Segment offen"); + ok(r1.version === wo.version && r1.sessionId === session.id && !r1.replayed, "Ergebnis mit Version und Session-ID"); + const auditRows = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entityId: { in: [r1.workOrderId, r1.customerId, r1.siteId] } }, select: { entity: true, action: true } }); + ok(["emergency", "customer", "site", "work_order"].every((e) => auditRows.some((a) => a.entity === e && a.action === "create")), "Audit: emergency, customer, site, work_order angelegt"); + const created = await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.backoffice.id, entityId: r1.workOrderId } }); + ok(created === 1, "Event emergency.created → Benachrichtigung ans Backoffice"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.tech.id } })) === 0, "Akteur selbst nicht benachrichtigt"); + + console.log("\n— Transaktion —"); + const failing = emergencyPayload({ + customer: { mode: "new", lastName: "Rollback", phone: "040 999" }, + site: { mode: "new", street: "Rollbackweg", city: "Hamburg" }, + onSiteContact: { name: "Rolf Rollback", phone: "040 999" }, + reason: "Rollback-Test", + }); + await expectCode( + () => + createEmergencyOrder(A.ctx.tech, failing, { + createWorkOrder: async (c, i) => { + await createWorkOrder(c, i); + throw Object.assign(new Error("simulated failure after work order insert"), { code: "invalid" }); + }, + }), + "invalid", + "Fehler beim Auftrag → Fehler an Aufrufer", + ); + ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Rollback" } })) === 0, "kein verwaister vorläufiger Kunde"); + ok((await prisma.site.count({ where: { tenantId: A.tenantId, street: "Rollbackweg" } })) === 0, "kein verwaistes Objekt"); + ok((await prisma.contact.count({ where: { tenantId: A.tenantId, name: "Rolf Rollback" } })) === 0, "kein verwaister Ansprechpartner"); + ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, emergencyReason: "Rollback-Test" } })) === 0, "kein Auftrag"); + ok((await prisma.workSession.count({ where: { clientId: failing.clientIds.session } })) === 0, "keine WorkSession"); + const r2 = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 1111" } })); + ok(Number(r2.number.slice(2)) === Number(r1.number.slice(2)) + 1, `Nummernkreis ohne Lücke nach Rollback (${r1.number} → ${r2.number})`); + + console.log("\n— Sichtbarkeit vorläufiger Kunde —"); + ok((await getCustomer(A.ctx.tech, r1.customerId)).id === r1.customerId, "Monteur sieht vorläufigen Kunden über eigenen Notdienstauftrag"); + await expectCode(() => getCustomer(A.ctx.outsider, r1.customerId), "not_found", "anderer Monteur (fremdes Team) → not_found (Kunde)"); + await expectCode(() => requireVisibleWorkOrder(A.ctx.outsider, r1.workOrderId), "not_found", "anderer Monteur → not_found (Auftrag)"); + ok((await requireVisibleWorkOrder(A.ctx.lead, r1.workOrderId)).id === r1.workOrderId, "Teamleiter des gewählten Teams sieht den Auftrag"); + await expectCode(() => getCustomer(B.ctx.admin, r1.customerId), "not_found", "Mandant B → not_found (Kunde)"); + await expectCode(() => requireVisibleWorkOrder(B.ctx.admin, r1.workOrderId), "not_found", "Mandant B → not_found (Auftrag)"); + + console.log("\n— Such-Service —"); + const hits = await searchCustomersForEmergency(A.ctx.outsider, SLUG_A); + ok(hits.some((h) => h.id === A.customerId), "Monteur findet fremden Kunden für den Notdienst"); + ok(hits.every((h) => JSON.stringify(Object.keys(h).sort()) === JSON.stringify(["city", "customerNumber", "id", "name", "provisional"])), "nur minimale Felder (id, Nummer, Name, Ort, vorläufig)"); + ok((await searchCustomersForEmergency(A.ctx.outsider, "Meier")).some((h) => h.id === r1.customerId && h.provisional), "vorläufige Kunden sind auffindbar (Dublettenvermeidung)"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_customer_search", actorId: A.users.outsider.id } })) >= 2, "Suchzugriffe auditiert"); + ok((await searchCustomersForEmergency(B.ctx.tech, SLUG_A)).length === 0, "Mandant B findet keine Kunden von A"); + ok((await searchCustomersForEmergency(A.ctx.tech, "a")).length === 0, "Suchbegriff < 2 Zeichen → keine Treffer"); + await expectCode(() => searchCustomersForEmergency(A.ctx.backoffice, "Kunde"), "forbidden", "ohne emergency:create → forbidden"); + const sites = await listSitesForEmergency(A.ctx.outsider, A.customerId); + ok(sites.some((s) => s.id === A.siteId) && sites.every((s) => JSON.stringify(Object.keys(s).sort()) === JSON.stringify(["address", "id", "name"])), "Objekte des Kunden mit minimalen Feldern"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_site_lookup", entityId: A.customerId } })) === 1, "Objektabfrage auditiert"); + await expectCode(() => listSitesForEmergency(B.ctx.tech, A.customerId), "not_found", "Mandant B: Objekte von A → not_found"); + const teamsOfTech = await emergencyTeamOptions(A.ctx.tech); + ok(teamsOfTech.length === 1 && teamsOfTech[0].id === A.teamId && teamsOfTech[0].members.some((m) => m.id === A.users.lead.id), "Teamauswahl: nur eigene Teams inkl. Mitglieder"); + + console.log("\n— Bestehender Kunde + Objekt, Validierung, Rechte —"); + const r3 = await createEmergencyOrder( + A.ctx.outsider, + emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "Frau Kontakt", phone: "040 5555" } }), + ); + const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: r3.workOrderId }, include: { customer: true, contact: true } }); + ok(wo3.customerId === A.customerId && wo3.siteId === A.siteId && wo3.customer.status === "active", "bestehender Kunde/Objekt bleiben unverändert aktiv"); + ok(wo3.contact?.customerId === A.customerId && wo3.assignedTeamId === A.otherTeamId, "Kontakt am bestehenden Kunden, eigenes Team des Monteurs"); + ok((await getCustomer(A.ctx.outsider, A.customerId)).id === A.customerId, "Monteur sieht bestehenden Kunden danach über eigenen Notdienst"); + const r3b = await createEmergencyOrder( + A.ctx.outsider, + emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "frau kontakt", phone: "040 5555" } }), + ); + ok(r3b.contactId === r3.contactId, "gleicher Ansprechpartner (Name + Telefon) wird wiederverwendet"); + await expectCode( + () => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: r1.siteId } })), + "invalid", + "Objekt eines anderen Kunden → invalid", + ); + await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ teamId: A.teamId })), "invalid", "fremdes Team → invalid"); + await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ assigneeIds: [A.users.tech.id] })), "invalid", "Kollege aus fremdem Team → invalid"); + const withColleague = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ assigneeIds: [A.users.lead.id] })); + ok((await prisma.workOrderAssignee.count({ where: { workOrderId: withColleague.workOrderId } })) === 2, "Kollege aus eigenem Team zugewiesen"); + await expectCode(() => createEmergencyOrder(B.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId } })), "invalid", "Mandant B mit Kunden-ID von A → invalid (nicht gefunden)"); + ok((await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } })) === 0, "in Mandant B nichts angelegt"); + await expectCode(() => createEmergencyOrder(A.ctx.backoffice, emergencyPayload()), "forbidden", "Backoffice ohne emergency:create → forbidden"); + await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ reason: " " })), "invalid", "Grund fehlt → invalid"); + await expectCode( + () => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", lastName: "Ohne Telefon", phone: "" } })), + "invalid", + "vorläufiger Kunde ohne Telefon → invalid", + ); + await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ onSiteContact: { name: "X", phone: "" } })), "invalid", "Ansprechpartner ohne Telefon → invalid"); + + console.log("\n— Sync-Op emergency.create —"); + const syncPayload = emergencyPayload({ customer: { mode: "new", lastName: "Offline", phone: "040 2222" }, offline: true }); + const op = syncOp(syncPayload); + const s1 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0]; + const syncWoId = s1.idMap?.[syncPayload.clientIds.workOrder]; + ok(s1.status === "applied" && !!syncWoId && !!s1.idMap?.[syncPayload.clientIds.session] && !!s1.idMap?.[syncPayload.clientIds.customer!], "applied mit idMap (Auftrag, Session, Kunde)"); + const s2 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0]; + const sorted = (m?: Record) => JSON.stringify(Object.entries(m ?? {}).sort()); + ok(s2.status === "duplicate" && sorted(s2.idMap) === sorted(s1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap"); + const s3 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0]; + ok(s3.status === "applied" && s3.idMap?.[syncPayload.clientIds.workOrder] === syncWoId, "neue clientOpId, gleiche Client-IDs → derselbe Auftrag (Replay)"); + ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Offline" } })) === 1, "keine Doppelanlage des Kunden"); + ok((await prisma.workSession.findFirst({ where: { clientId: syncPayload.clientIds.session } }))?.startedOffline === true, "Offline-Flag an der Session"); + const s4 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp({ ...syncPayload, reason: "" })] })).results[0]; + ok(s4.status === "rejected" && s4.errorCode === "invalid", "ungültige Payload → rejected invalid"); + const s5 = (await applyOperations(A.ctx.outsider, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0]; + ok(s5.status === "rejected" && s5.errorCode === "invalid", "Replay durch anderen Nutzer → rejected (keine Übernahme)"); + await prisma.tenantModule.create({ data: { tenantId: A.tenantId, moduleKey: "emergency", enabled: false } }); + const s6 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(emergencyPayload())] })).results[0]; + ok(s6.status === "rejected" && s6.errorCode === "forbidden", "Modul emergency deaktiviert → rejected forbidden"); + await prisma.tenantModule.deleteMany({ where: { tenantId: A.tenantId, moduleKey: "emergency" } }); + + console.log("\n— Abschluss → emergency.completed —"); + ok((await buildEmergencyCompletedData(A.ctx.tech, (await rawWorkOrder(A, "in_progress")).id)) === null, "normaler Auftrag → kein Notdienst-Event"); + await endSession(A.ctx.tech, { workOrderId: r1.workOrderId }); + const report = (await createCompletionReport(A.ctx.tech, { workOrderId: r1.workOrderId })).report; + await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Leitung abgedichtet, Wasser abgepumpt." } }); + await captureSignature(A.ctx.tech, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nachts nicht erreichbar", confirmationText: "Bestätigt" }); + await submitReport(A.ctx.tech, { reportId: report.id }); + const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } }); + ok(afterSubmit.status === "in_review", "Auftragsstatus nach Abschlussbericht: in_review"); + const completedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "emergency.completed", entityId: r1.workOrderId } }); + ok(completedNotes.some((n) => n.userId === A.users.backoffice.id), "Event emergency.completed → Backoffice benachrichtigt"); + const data = await buildEmergencyCompletedData(A.ctx.backoffice, r1.workOrderId); + ok(!!data && data.technician === `tech ${SLUG_A}` && data.customer === "Lutz Meier" && data.status === "in_review", "Daten: Monteur, Kunde, Status"); + ok(!!data && new Date(data.startedAt) <= new Date(data.endedAt), "Daten: Einsatzbeginn ≤ Einsatzende"); + const mails = await prisma.mailLog.findMany({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }).catch(() => null); + if (mails) ok(mails.length >= 1, "Pflicht-E-Mail craftvia_emergency protokolliert"); + + console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`); + } finally { + await cleanupTenants([SLUG_A, SLUG_B]); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + process.exit(failures > 0 || process.exitCode ? 1 : 0); + }); diff --git a/scripts/test-notdienst-review.ts b/scripts/test-notdienst-review.ts new file mode 100644 index 0000000..d82b463 --- /dev/null +++ b/scripts/test-notdienst-review.ts @@ -0,0 +1,170 @@ +// Lane L8 „Notdienst" — Backoffice-Nachbearbeitung: Liste/Detail (Fortschritt), Kunde bestätigen +// (Kundennummer), bestehendem Kunden zuordnen (alles umgehängt, vorläufig → merged), Dublette +// zusammenführen, Objekt korrigieren/zuordnen, Auftrag ergänzen, Abrechnungsfreigabe. +// Rechte: Monteur/Teamleiter können nicht reviewen; Mandant B sieht/ändert nichts von A. +// +// Lauf: npx tsx scripts/test-notdienst-review.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { EmergencyCreateInput } from "../src/lib/emergency/schemas"; +import { createEmergencyOrder } from "../src/server/services/emergency/create"; +import { + assignEmergencySite, + assignEmergencyToCustomer, + completeEmergencyOrderData, + confirmEmergencyCustomer, + correctEmergencySite, + getEmergencyReview, + listEmergencyReviews, + mergeEmergencyCustomer, + releaseEmergencyForBilling, +} from "../src/server/services/emergency/review"; +import { endSession } from "../src/server/services/field/sessions"; +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 type { ServiceCtx } from "../src/server/services/context"; +import { cleanupTenants, createFixture, expectCode, failures, ok } from "./lib-auftraege-fixtures"; + +const SLUG_A = "zz-l8-review-a"; +const SLUG_B = "zz-l8-review-b"; + +function payload(over: Partial = {}): EmergencyCreateInput { + return { + clientIds: { workOrder: randomUUID(), session: randomUUID() }, + customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 7654321", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, + site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, + onSiteContact: { name: "Lutz Meier", phone: "0171 7654321" }, + reason: "Heizung ausgefallen", + ...over, + }; +} + +async function finishWithReport(ctx: ServiceCtx, workOrderId: string): Promise { + await endSession(ctx, { workOrderId }); + const report = (await createCompletionReport(ctx, { workOrderId })).report; + await updateReportTexts(ctx, { reportId: report.id, texts: { workPerformed: "Brenner getauscht." } }); + await captureSignature(ctx, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nicht vor Ort", confirmationText: "Bestätigt" }); + await submitReport(ctx, { reportId: report.id }); + return report.id; +} + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B]); + const A = await createFixture(SLUG_A); + const B = await createFixture(SLUG_B); + const office = A.ctx.backoffice; + try { + const r1 = await createEmergencyOrder(A.ctx.tech, payload()); + const reportId = await finishWithReport(A.ctx.tech, r1.workOrderId); + const r2 = await createEmergencyOrder(A.ctx.tech, payload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 3333" }, onSiteContact: { name: "Frau Sonne", phone: "040 3333" }, reason: "Stromausfall Backstube" })); + const existing = await prisma.customer.create({ + data: { tenantId: A.tenantId, customerNumber: "K-77001", firstName: "Lutz", lastName: "Meier", phone: "+49 171 7654321", city: "Hamburg", street: "Elbchaussee" }, + }); + const existingSite = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: existing.id, name: "Wohnhaus Elbchaussee", street: "Elbchaussee", houseNumber: "12", city: "Hamburg" } }); + + console.log("\n— Rechte & Mandantentrennung —"); + await expectCode(() => listEmergencyReviews(A.ctx.tech), "forbidden", "Monteur: Liste → forbidden"); + await expectCode(() => listEmergencyReviews(A.ctx.lead), "forbidden", "Teamleiter: Liste → forbidden"); + await expectCode(() => getEmergencyReview(A.ctx.tech, r1.workOrderId), "forbidden", "Monteur: Detail → forbidden"); + await expectCode(() => confirmEmergencyCustomer(A.ctx.tech, r2.workOrderId), "forbidden", "Monteur: Kunden bestätigen → forbidden"); + await expectCode(() => assignEmergencyToCustomer(A.ctx.tech, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }), "forbidden", "Monteur: zuordnen → forbidden"); + await expectCode(() => releaseEmergencyForBilling(A.ctx.tech, { workOrderId: r1.workOrderId }), "forbidden", "Monteur: Abrechnung → forbidden"); + ok((await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).length === 0, "Mandant B: Liste enthält keine Notdienste von A"); + await expectCode(() => getEmergencyReview(B.ctx.backoffice, r1.workOrderId), "not_found", "Mandant B: Detail → not_found"); + await expectCode(() => confirmEmergencyCustomer(B.ctx.backoffice, r2.workOrderId), "not_found", "Mandant B: Kunden bestätigen → not_found"); + await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }), "not_found", "Mandant B: zuordnen → not_found"); + await expectCode(() => completeEmergencyOrderData(B.ctx.backoffice, { workOrderId: r1.workOrderId, title: "B" }), "not_found", "Mandant B: Auftrag ergänzen → not_found"); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } })).status === "provisional", "nach fremden Versuchen unverändert vorläufig"); + const regular = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "A-TEST-1", title: "Normal", customerId: A.customerId, status: "in_review" } }); + await expectCode(() => getEmergencyReview(office, regular.id), "not_found", "normaler Auftrag ist kein Notdienst → not_found"); + + console.log("\n— Liste & Fortschritt —"); + const list = await listEmergencyReviews(office); + const item1 = list.find((i) => i.id === r1.workOrderId); + ok(!!item1 && !!list.find((i) => i.id === r2.workOrderId), "Liste enthält Notdienste mit vorläufigen Stammdaten"); + ok(item1?.progress.total === 5 && item1.progress.count === 0 && item1.technician === `tech ${SLUG_A}`, "Fortschritt 0 von 5, Monteur angezeigt"); + const detail = await getEmergencyReview(office, r1.workOrderId); + ok(detail.duplicates.some((d) => d.customerId === existing.id), "Dublettenkandidat über findDuplicateCustomers (Telefon/Name)"); + ok(detail.startedAt instanceof Date && detail.endedAt instanceof Date && detail.startedAt <= detail.endedAt, "Einsatzbeginn/-ende aus Arbeitszeiten"); + + console.log("\n— Bestehendem Kunden zuordnen —"); + await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: r1.customerId }), "invalid", "gleicher Kunde → invalid"); + await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: B.customerId }), "invalid", "Kunde aus Mandant B als Ziel → invalid"); + const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } }); + const assigned = await assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }); + const woAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId }, include: { site: true, contact: true } }); + const src = await prisma.customer.findUniqueOrThrow({ where: { id: r1.customerId } }); + ok(woAfter.customerId === existing.id && woAfter.version === before.version + 1, "Auftrag umgehängt, Version +1"); + ok(woAfter.site?.customerId === existing.id && woAfter.contact?.customerId === existing.id, "Objekt und Ansprechpartner umgehängt"); + ok(src.status === "merged" && src.mergedIntoId === existing.id && !src.isProvisional, "vorläufiger Kunde → merged (mergedIntoId)"); + ok(assigned.moved.workOrders === 1 && assigned.moved.sites === 1, "Zähler der umgehängten Datensätze"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "customer", entityId: { in: [r1.customerId, existing.id] } } })) >= 2, "Audit für Quelle und Ziel"); + await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: A.customerId }), "conflict", "erneutes Zuordnen (nicht mehr vorläufig) → conflict"); + ok((await getEmergencyReview(office, r1.workOrderId)).progress.done.customer, "Prüfschritt Kunde erledigt"); + + console.log("\n— Objekt zuordnen / korrigieren —"); + const oldSiteId = woAfter.siteId!; + const siteRes = await assignEmergencySite(office, { workOrderId: r1.workOrderId, siteId: existingSite.id }); + const woSite = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } }); + ok(woSite.siteId === existingSite.id && siteRes.removed, "Auftrag am bestehenden Objekt"); + ok(!!(await prisma.site.findUniqueOrThrow({ where: { id: oldSiteId } })).deletedAt, "ungenutztes vorläufiges Objekt soft-gelöscht"); + await expectCode(() => assignEmergencySite(office, { workOrderId: r1.workOrderId, siteId: B.siteId }), "invalid", "Objekt aus Mandant B → invalid"); + const corrected = await correctEmergencySite(office, { workOrderId: r2.workOrderId, patch: { name: "Backstube", accessNotes: "Hintereingang" }, confirm: true }); + ok(corrected.status === "active" && corrected.name === "Backstube" && corrected.customerId === r2.customerId, "vorläufiges Objekt korrigiert und bestätigt"); + + console.log("\n— Kunde bestätigen —"); + const confirmed = await confirmEmergencyCustomer(office, r2.workOrderId); + const c2 = await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } }); + ok(/^K-\d+$/.test(confirmed.customerNumber ?? "") && c2.customerNumber === confirmed.customerNumber, `Kundennummer bei Bestätigung vergeben (${confirmed.customerNumber})`); + ok(c2.status === "active" && !c2.isProvisional, "Kunde aktiv"); + await expectCode(() => confirmEmergencyCustomer(office, r2.workOrderId), "conflict", "zweite Bestätigung → conflict"); + + console.log("\n— Dublette zusammenführen —"); + await expectCode(() => mergeEmergencyCustomer(office, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: false }), "invalid", "ohne Bestätigung → invalid"); + await expectCode(() => mergeEmergencyCustomer(A.ctx.tech, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: true }), "forbidden", "Monteur → forbidden"); + await mergeEmergencyCustomer(office, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: true }); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: r2.workOrderId } })).customerId === A.customerId, "Auftrag nach Merge am Zielkunden"); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } })).status === "merged", "Quelle merged"); + + console.log("\n— Auftrag ergänzen & Abrechnung —"); + const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } }); + await expectCode(() => completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "" }), "invalid", "leerer Titel → invalid"); + await completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "Notdienst Heizung", description: "Brenner getauscht", orderTypeId: notdienst.id, billingType: "time_material" }); + const woData = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } }); + ok(woData.title === "Notdienst Heizung" && woData.billingType === "time_material" && woData.orderTypeId === notdienst.id, "Titel, Auftragsart, Abrechnungsart gespeichert"); + await expectCode(() => completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "alt", baseVersion: 1 }), "conflict", "veraltete Version → conflict"); + + const r3 = await createEmergencyOrder(A.ctx.tech, payload({ customer: { mode: "new", lastName: "Offen", phone: "040 4444" } })); + await expectCode(() => releaseEmergencyForBilling(office, { workOrderId: r3.workOrderId }), "blocked", "vorläufige Stammdaten → Abrechnung blocked"); + const blockedErr = await releaseEmergencyForBilling(office, { workOrderId: r3.workOrderId }).catch((e) => e); + ok((blockedErr?.details as { reason?: string })?.reason === "master_data_open", "Grund master_data_open"); + await expectCode(() => releaseEmergencyForBilling(office, { workOrderId: r1.workOrderId }), "blocked", "ohne freigegebenen Abschlussbericht → blocked"); + await approveReport(office, { reportId }, { dispatchPdf: async () => {} }); + const released = await releaseEmergencyForBilling(office, { workOrderId: r1.workOrderId }); + ok(released.status === "released_for_billing", "zur Abrechnung freigegeben"); + const final = await getEmergencyReview(office, r1.workOrderId); + ok(final.progress.count === 5, "Fortschritt 5 von 5"); + ok(!(await listEmergencyReviews(office)).some((i) => i.id === r1.workOrderId), "erledigter Notdienst nicht mehr in „Zu prüfen“"); + ok((await listEmergencyReviews(office, { filter: "all" })).some((i) => i.id === r1.workOrderId), "unter „Alle“ weiterhin sichtbar"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: r1.workOrderId, action: "update" } })) >= 4, "Prüfschritte auditiert"); + + console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`); + } finally { + await cleanupTenants([SLUG_A, SLUG_B]); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + process.exit(failures > 0 || process.exitCode ? 1 : 0); + });