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>
145 lines
7.5 KiB
TypeScript
145 lines
7.5 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);
|
||
// 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])));
|