// L2 Aufträge: vollständige Übergangsmatrix des Auftrags-Statusmodells (ARCHITEKTUR §3). // // (1) Reine Tabelle: jedes (von, nach)-Paar — erlaubte Paare exakt wie im Vertrag, alle anderen verboten. // (2) Service je Rolle (tenant-admin, backoffice, team-lead, technician) für JEDEN erlaubten Übergang: // Erwartung unabhängig aus der Rechte-Tabelle abgeleitet → Erfolg bzw. forbidden; // zusätzlich Version +1, WorkOrderStatusChange und Audit-Eintrag. // (3) Verbotene Paare über den Service → invalid (Stichprobe über alle Statuswerte). // // Lauf: npx tsx scripts/test-auftraege-status.ts import "dotenv/config"; import { prisma } from "../src/server/db"; import { WORK_ORDER_STATUSES, canTransition, type WorkOrderStatus } from "../src/lib/work-orders/status"; import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; import { approvedCompletionReport, cleanupTenants, createFixture, expectCode, finish, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; const SLUG = "zz-l2-status"; const CONTRACT: Record = { draft: ["review_required", "planned", "assigned", "cancelled"], review_required: ["planned", "assigned", "cancelled"], planned: ["assigned", "cancelled"], assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"], accepted: ["en_route", "in_progress", "assigned", "cancelled"], en_route: ["in_progress", "cancelled"], in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"], paused: ["in_progress", "en_route", "cancelled"], waiting_material: ["in_progress", "en_route", "cancelled"], daily_report_created: ["en_route", "in_progress", "cancelled"], technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"], signature_pending: ["in_review", "cancelled"], in_review: ["released_for_billing", "in_progress", "cancelled"], released_for_billing: ["billed", "in_review"], billed: [], cancelled: [], }; type Role = "admin" | "backoffice" | "lead" | "tech"; /** Expected roles per transition — written from the ARCHITEKTUR §3 rights table, NOT from status.ts. */ function expectedRoles(from: WorkOrderStatus, to: WorkOrderStatus): Role[] { if (to === "cancelled") return ["admin", "backoffice"]; if (to === "released_for_billing" || to === "billed" || (from === "released_for_billing" && to === "in_review")) return ["admin", "backoffice"]; if (from === "in_review" && to === "in_progress") return ["admin", "backoffice", "lead"]; if (["draft", "review_required", "planned"].includes(from)) return ["admin", "backoffice"]; if (from === "assigned" && to === "planned") return ["admin", "backoffice"]; return ["admin", "lead", "tech"]; } const needsReason = (from: WorkOrderStatus, to: WorkOrderStatus) => to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review"); async function main() { await cleanupTenants([SLUG]); const f = await createFixture(SLUG); // role × transition table incl. the direct `billed` transition → billing overview module off (guard: test-billed-guard) await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: f.tenantId, moduleKey: "billing" } }, update: { enabled: false }, create: { tenantId: f.tenantId, moduleKey: "billing", enabled: false }, }); console.log("\n— (1) Übergangstabelle —"); let tableOk = true; for (const from of WORK_ORDER_STATUSES) { for (const to of WORK_ORDER_STATUSES) { if (canTransition(from, to) !== CONTRACT[from].includes(to)) { tableOk = false; console.log(` Abweichung ${from} → ${to}`); } } } ok(tableOk, `Alle ${WORK_ORDER_STATUSES.length ** 2} Paare entsprechen dem Vertrag`); console.log("\n— (2) Erlaubte Übergänge je Rolle —"); let checked = 0; for (const from of WORK_ORDER_STATUSES) { for (const to of CONTRACT[from]) { for (const role of ["admin", "backoffice", "lead", "tech"] as Role[]) { const wo = await rawWorkOrder(f, from); if (to === "in_review" && (from === "technically_completed" || from === "signature_pending")) { await approvedCompletionReport(f, wo.id, { status: "submitted", signature: true }); } if (to === "released_for_billing") await approvedCompletionReport(f, wo.id); const allowed = expectedRoles(from, to).includes(role); const input = { workOrderId: wo.id, to, reason: needsReason(from, to) ? "Testgrund" : undefined, baseVersion: wo.version }; checked++; if (allowed) { try { const res = await transitionWorkOrder(f.ctx[role], input); const after = await prisma.workOrder.findUnique({ where: { id: wo.id } }); const change = await prisma.workOrderStatusChange.count({ where: { workOrderId: wo.id, fromStatus: from, toStatus: to } }); const audit = await prisma.auditLog.count({ where: { tenantId: f.tenantId, entity: "work_order", entityId: wo.id } }); const good = res.status === to && after?.status === to && after.version === wo.version + 1 && change === 1 && audit >= 1; if (!good) ok(false, `${role}: ${from} → ${to} (Status/Version/Historie/Audit)`); } catch (e) { ok(false, `${role}: ${from} → ${to} sollte erlaubt sein — ${(e as { code?: string }).code ?? (e as Error).message}`); } } else { try { await transitionWorkOrder(f.ctx[role], input); ok(false, `${role}: ${from} → ${to} sollte verboten sein`); } catch (e) { if ((e as { code?: string }).code !== "forbidden") ok(false, `${role}: ${from} → ${to} erwartet forbidden, erhalten ${(e as { code?: string }).code ?? (e as Error).message}`); const still = await prisma.workOrder.findUnique({ where: { id: wo.id } }); if (still?.status !== from || still.version !== wo.version) ok(false, `${role}: ${from} → ${to} hat trotz Verbot geschrieben`); } } } } } ok(true, `${checked} Rolle×Übergang-Kombinationen geprüft`); console.log("\n— (3) Verbotene Paare über den Service —"); let invalidOk = true; for (const from of WORK_ORDER_STATUSES) { const forbiddenTargets = WORK_ORDER_STATUSES.filter((t) => t !== from && !CONTRACT[from].includes(t)); const to = forbiddenTargets[(from.length * 7) % forbiddenTargets.length]; const wo = await rawWorkOrder(f, from); try { await transitionWorkOrder(f.ctx.admin, { workOrderId: wo.id, to, reason: "x" }); invalidOk = false; console.log(` ${from} → ${to} wurde nicht abgewiesen`); } catch (e) { if ((e as { code?: string }).code !== "invalid") { invalidOk = false; console.log(` ${from} → ${to}: ${(e as { code?: string }).code}`); } } } ok(invalidOk, "Nicht in der Tabelle enthaltene Übergänge → invalid (je Ausgangsstatus)"); console.log("\n— (4) Begründungspflicht, Versionskonflikt —"); const c1 = await rawWorkOrder(f, "planned"); await expectCode(() => transitionWorkOrder(f.ctx.backoffice, { workOrderId: c1.id, to: "cancelled" }), "invalid", "Stornierung ohne Grund → invalid"); await expectCode(() => transitionWorkOrder(f.ctx.backoffice, { workOrderId: c1.id, to: "assigned", baseVersion: c1.version + 5 }), "conflict", "Veraltete baseVersion → conflict"); } main() .catch((e) => { console.error(e); ok(false, "unerwarteter Fehler"); }) .finally(finish(() => cleanupTenants([SLUG])));