Files
craftvia/scripts/test-auftraege-status.ts
T
msolarczekandClaude Opus 5 2311b35d8c L2 Aufträge & Backoffice: Service-Schicht Aufträge + Tests
Statusmaschine (transitionWorkOrder inkl. eventData), Zuweisung, Completion-Guards,
Materialvorgabe, Checklisten/Pflichtfotos, Liste/Dashboard-Presets, Suche,
Sync-Konflikte, Einstellungen (Auftragsarten, Vorlagen, Nummernkreise).
Tests: Übergangsmatrix je Rolle, Kernlogik, Scope/Mandantentrennung, Nummernkreis-Parallelität.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:29:15 +02:00

139 lines
7.1 KiB
TypeScript

// 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<WorkOrderStatus, WorkOrderStatus[]> = {
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);
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])));