Files
craftvia/scripts/test-auftraege-status.ts
msolarczekandClaude Opus 5 6b8cdf543b Abrechnen nur über die Abrechnungsübersicht, solange das Modul aktiv ist
Direkter Übergang nach billed → invalid use_billing_overview (nach der Rechteprüfung),
Button im Auftragsdetail ausgeblendet, markBilled läuft über den Abrechnungseintrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:53:16 +02:00

145 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
// 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])));