Merge lane/auftraege in feature/craftvia-mvp
Konflikte gelöst: Header mit Suche (L2) und Glocke (L6), Audit-Labels vereinigt (ohne doppeltes sync_operation), Navigation mit Benachrichtigungen und Auftragsvorlagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// Shared fixtures for scripts/test-auftraege-*.ts (lane L2). Not a test itself (no "test-" prefix).
|
||||
//
|
||||
// Creates isolated zz-test tenants with the four standard roles, a team with lead + technician,
|
||||
// an outside technician, a customer and a site; builds ServiceCtx objects exactly like
|
||||
// ctxFromGuard does (tenant db + DB-authoritative permission set).
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
|
||||
export let failures = 0;
|
||||
export const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
/** Expect a ServiceError with the given code (e.g. "not_found"). Returns the error. */
|
||||
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string): Promise<unknown> {
|
||||
try {
|
||||
await fn();
|
||||
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const c = (e as { code?: string }).code;
|
||||
ok(c === code, `${msg}${c === code ? "" : ` — erhalten ${c ?? (e as Error).message}`}`);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
export type Fixture = {
|
||||
tenantId: string;
|
||||
users: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", { id: string }>;
|
||||
ctx: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", ServiceCtx>;
|
||||
teamId: string;
|
||||
otherTeamId: string;
|
||||
customerId: string;
|
||||
siteId: string;
|
||||
contactId: string;
|
||||
};
|
||||
|
||||
const ROLE_OF = { admin: "tenant-admin", backoffice: "backoffice", lead: "team-lead", tech: "technician", outsider: "technician" } as const;
|
||||
|
||||
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
|
||||
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
|
||||
}
|
||||
|
||||
export async function cleanupTenants(slugs: string[]) {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (!ids.length) return;
|
||||
const w = { where: { tenantId: { in: ids } } };
|
||||
await prisma.syncOperation.deleteMany(w);
|
||||
await prisma.notification.deleteMany(w);
|
||||
await prisma.signature.deleteMany(w);
|
||||
await prisma.report.deleteMany(w);
|
||||
await prisma.photo.deleteMany(w);
|
||||
await prisma.activityNote.deleteMany(w);
|
||||
await prisma.voiceNote.deleteMany(w);
|
||||
await prisma.timeEntry.deleteMany(w);
|
||||
await prisma.materialUsage.deleteMany(w);
|
||||
await prisma.workSession.deleteMany(w);
|
||||
await prisma.materialPlan.deleteMany(w);
|
||||
await prisma.photoRequirement.deleteMany(w);
|
||||
await prisma.checklistItem.deleteMany(w);
|
||||
await prisma.workOrderStatusChange.deleteMany(w);
|
||||
await prisma.workOrderAssignee.deleteMany(w);
|
||||
await prisma.document.deleteMany(w);
|
||||
await prisma.workOrder.deleteMany(w);
|
||||
await prisma.teamMember.deleteMany(w);
|
||||
await prisma.team.deleteMany(w);
|
||||
await prisma.site.deleteMany(w);
|
||||
await prisma.contact.deleteMany(w);
|
||||
await prisma.customer.deleteMany(w);
|
||||
await prisma.checklistTemplate.deleteMany(w);
|
||||
await prisma.orderType.deleteMany(w);
|
||||
await prisma.numberSequence.deleteMany(w);
|
||||
await prisma.tenantSettings.deleteMany(w);
|
||||
await prisma.auditLog.deleteMany(w);
|
||||
await prisma.user.deleteMany(w);
|
||||
await prisma.role.deleteMany(w);
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-auftraege.test" }, memberships: { none: {} } } });
|
||||
}
|
||||
|
||||
async function ensurePermissionIds(keys: readonly string[]) {
|
||||
const rows = await Promise.all(keys.map((key) => prisma.permission.upsert({ where: { key }, update: {}, create: { key } })));
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
export async function createFixture(slug: string): Promise<Fixture> {
|
||||
const tenant = await prisma.tenant.create({ data: { name: `L2 Test ${slug}`, slug } });
|
||||
await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: `L2 Test ${slug}`, timezone: "Europe/Berlin" } });
|
||||
const roleIds: Partial<Record<RoleKey, string>> = {};
|
||||
for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) {
|
||||
const permIds = await ensurePermissionIds(ROLE_DEFS[key].permissions);
|
||||
const role = await prisma.role.create({
|
||||
data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: permIds.map((permissionId) => ({ permissionId })) } },
|
||||
});
|
||||
roleIds[key] = role.id;
|
||||
}
|
||||
|
||||
const users = {} as Fixture["users"];
|
||||
const ctx = {} as Fixture["ctx"];
|
||||
for (const name of Object.keys(ROLE_OF) as (keyof typeof ROLE_OF)[]) {
|
||||
const email = `${name}-${slug}@zz-auftraege.test`;
|
||||
const identity = await prisma.identity.create({ data: { email, passwordHash: "x" } });
|
||||
const user = await prisma.user.create({
|
||||
data: { tenantId: tenant.id, identityId: identity.id, email, name: `${name} ${slug}`, userRoles: { create: [{ roleId: roleIds[ROLE_OF[name]]! }] } },
|
||||
});
|
||||
users[name] = { id: user.id };
|
||||
ctx[name] = ctxFor(tenant.id, user.id, ROLE_OF[name]);
|
||||
}
|
||||
|
||||
const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } });
|
||||
await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team.id, userId: users.tech.id, validFrom: new Date(Date.now() - 86400000) } });
|
||||
const otherTeam = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd" } });
|
||||
await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: otherTeam.id, userId: users.outsider.id, validFrom: new Date(Date.now() - 86400000) } });
|
||||
|
||||
const customer = await prisma.customer.create({ data: { tenantId: tenant.id, companyName: `Kunde ${slug} GmbH`, customerNumber: `K-${slug}`, city: "Hamburg", street: "Hafenweg" } });
|
||||
const site = await prisma.site.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Objekt ${slug}`, city: "Hamburg", street: "Kaistraße", houseNumber: "7" } });
|
||||
const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Frau Kontakt ${slug}` } });
|
||||
|
||||
return { tenantId: tenant.id, users, ctx, teamId: team.id, otherTeamId: otherTeam.id, customerId: customer.id, siteId: site.id, contactId: contact.id };
|
||||
}
|
||||
|
||||
/** Raw work order in a given status, assigned to the fixture team (setup only — bypasses the state machine). */
|
||||
export async function rawWorkOrder(f: Fixture, status: string, extra: Record<string, unknown> = {}) {
|
||||
const n = Math.random().toString(36).slice(2, 10);
|
||||
return prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: f.tenantId,
|
||||
number: `T-${n}`,
|
||||
title: `Testauftrag ${n}`,
|
||||
customerId: f.customerId,
|
||||
siteId: f.siteId,
|
||||
status: status as never,
|
||||
assignedTeamId: f.teamId,
|
||||
teamLeadUserId: f.users.lead.id,
|
||||
signatureRequired: true,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function approvedCompletionReport(f: Fixture, workOrderId: string, opts: { status?: string; signature?: boolean } = {}) {
|
||||
const report = await prisma.report.create({
|
||||
data: {
|
||||
tenantId: f.tenantId,
|
||||
workOrderId,
|
||||
type: "completion",
|
||||
reportDate: new Date(),
|
||||
lineageId: `lin-${workOrderId}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
status: (opts.status ?? "approved") as never,
|
||||
content: {},
|
||||
},
|
||||
});
|
||||
if (opts.signature) {
|
||||
await prisma.signature.create({ data: { tenantId: f.tenantId, reportId: report.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } });
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function finish(cleanup: () => Promise<void>) {
|
||||
return async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
if (failures === 0) console.log("\nOK — alle Nachweise erfüllt.");
|
||||
else console.log(`\n${failures} FEHLER.`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// L2 Aufträge: Kernlogik der Service-Schicht.
|
||||
//
|
||||
// (1) createWorkOrder: Nummer aus Nummernkreis, Übernahme Checkliste/Pflichtfotos/signatureRequired aus Auftragsart
|
||||
// (2) updateWorkOrder: Version +1, baseVersion-Konflikt, Objekt/Kunde-Konsistenz, Audit before/after
|
||||
// (3) assignWorkOrder: Team + Einzelpersonen + Teamleiter, draft → assigned, Audit
|
||||
// (4) Completion-Blocker: Pflicht-Checklistenpunkt, fehlendes Pflichtfoto, laufende Session → blocked
|
||||
// (5) Abrechnungsfreigabe nur mit freigegebenem Abschlussbericht; Unterschrift vor in_review
|
||||
// (6) Materialvorgabe-CRUD + Soll/Ist-Übersicht mit Abweichung
|
||||
// (7) Liste: Filter, Presets, Statusgruppen-Zähler; Dashboard-Kacheln
|
||||
// (8) Sync-Konflikte: verwerfen / übernehmen
|
||||
// (9) Einstellungen: Standard-Auftragsarten idempotent, Vorlage, Nummernkreis-Präfix
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-auftraege-core.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { parseListParams } from "../src/lib/work-orders/filters";
|
||||
import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "../src/lib/work-orders/defaults";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { updateWorkOrder, deleteWorkOrder } from "../src/server/services/work-orders/update";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { getCompletionBlockers } from "../src/server/services/work-orders/completion";
|
||||
import { releaseForBilling, rejectForCorrection } from "../src/server/services/work-orders/release-billing";
|
||||
import { cancelWorkOrder } from "../src/server/services/work-orders/cancel";
|
||||
import { addMaterialPlan, getMaterialOverview, removeMaterialPlan, updateMaterialPlan } from "../src/server/services/work-orders/materials";
|
||||
import { addChecklistItem, addPhotoRequirement, removeChecklistItem } from "../src/server/services/work-orders/checklist";
|
||||
import { listWorkOrders } from "../src/server/services/work-orders/list";
|
||||
import { getDashboardTiles } from "../src/server/services/work-orders/dashboard";
|
||||
import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts";
|
||||
import {
|
||||
createChecklistTemplate,
|
||||
ensureDefaultOrderTypes,
|
||||
listNumberSequences,
|
||||
listOrderTypes,
|
||||
updateNumberSequence,
|
||||
} from "../src/server/services/work-orders/settings";
|
||||
import { approvedCompletionReport, cleanupTenants, createFixture, expectCode, finish, ok, rawWorkOrder } from "./lib-auftraege-fixtures";
|
||||
|
||||
const SLUG = "zz-l2-core";
|
||||
|
||||
async function main() {
|
||||
await cleanupTenants([SLUG]);
|
||||
const f = await createFixture(SLUG);
|
||||
const bo = f.ctx.backoffice;
|
||||
|
||||
console.log("\n— (9a) Standard-Auftragsarten + Vorlage —");
|
||||
const types = await listOrderTypes(bo);
|
||||
ok(types.length === 8 && types.some((t) => t.key === "montage") && types.some((t) => t.key === "notdienst"), "8 Standard-Auftragsarten beim ersten Aufruf angelegt");
|
||||
ok((await ensureDefaultOrderTypes(bo.db, bo.tenantId)) === 0, "ensureDefaultOrderTypes ist idempotent");
|
||||
const besichtigung = types.find((t) => t.key === "besichtigung")!;
|
||||
const montage = types.find((t) => t.key === "montage")!;
|
||||
await expectCode(
|
||||
() => createChecklistTemplate(f.ctx.tech, { name: "X", items: [], requiredPhotos: [] }),
|
||||
"forbidden",
|
||||
"Monteur darf keine Vorlagen pflegen (settings:templates)",
|
||||
);
|
||||
await createChecklistTemplate(bo, {
|
||||
name: "Montage Standard",
|
||||
orderTypeId: montage.id,
|
||||
items: [...DEFAULT_CHECKLIST_ITEMS],
|
||||
requiredPhotos: DEFAULT_REQUIRED_PHOTOS.slice(0, 2),
|
||||
});
|
||||
|
||||
console.log("\n— (1) createWorkOrder —");
|
||||
const a = await createWorkOrder(bo, { title: "Heizung montieren", customerId: f.customerId, siteId: f.siteId, contactId: f.contactId, orderTypeId: montage.id, plannedStart: new Date(Date.now() - 2 * 86400000), plannedEnd: new Date(Date.now() - 86400000) });
|
||||
ok(/^A-\d{5}$/.test(a.number) && a.status === "draft" && a.version === 1, `Nummer ${a.number}, Status draft, Version 1`);
|
||||
const aItems = await prisma.checklistItem.count({ where: { workOrderId: a.id } });
|
||||
const aPhotos = await prisma.photoRequirement.count({ where: { workOrderId: a.id } });
|
||||
ok(aItems === DEFAULT_CHECKLIST_ITEMS.length && aPhotos === 2, "Checkliste und Pflichtfotos aus Auftragsart-Vorlage übernommen");
|
||||
const b = await createWorkOrder(bo, { title: "Besichtigung", customerId: f.customerId, orderTypeId: besichtigung.id });
|
||||
const bRow = await prisma.workOrder.findUnique({ where: { id: b.id } });
|
||||
ok(bRow?.signatureRequired === false && b.number !== a.number, "signatureRequired aus Auftragsart (Besichtigung = false), neue Nummer");
|
||||
ok((await prisma.auditLog.count({ where: { entity: "work_order", entityId: a.id, action: "create" } })) === 1, "Audit-Eintrag create");
|
||||
await expectCode(() => createWorkOrder(f.ctx.tech, { title: "X", customerId: f.customerId }), "forbidden", "Monteur darf keinen regulären Auftrag anlegen");
|
||||
const em = await createWorkOrder(f.ctx.tech, { title: "Rohrbruch", customerId: f.customerId, isEmergency: true, numberKey: "emergency", status: "in_progress" });
|
||||
ok(em.number.startsWith("N-"), "Notdienst mit emergency:create angelegt (Nummer N-…)");
|
||||
const otherSite = await prisma.site.create({ data: { tenantId: f.tenantId, customerId: (await prisma.customer.create({ data: { tenantId: f.tenantId, companyName: "Fremdkunde" } })).id, name: "Fremdobjekt" } });
|
||||
await expectCode(() => createWorkOrder(bo, { title: "X", customerId: f.customerId, siteId: otherSite.id }), "invalid", "Objekt eines anderen Kunden → invalid");
|
||||
|
||||
console.log("\n— (2) updateWorkOrder —");
|
||||
const u1 = await updateWorkOrder(bo, a.id, { title: "Heizung montieren (2 Etagen)", priority: "high" }, 1);
|
||||
ok(u1.version === 2, "Version nach Update 2");
|
||||
await expectCode(() => updateWorkOrder(bo, a.id, { title: "veraltet" }, 1), "conflict", "Update mit veralteter baseVersion → conflict");
|
||||
await expectCode(() => updateWorkOrder(bo, a.id, { siteId: otherSite.id }), "invalid", "Update mit fremdem Objekt → invalid");
|
||||
const auditU = await prisma.auditLog.findFirst({ where: { entity: "work_order", entityId: a.id, action: "update" }, orderBy: { createdAt: "desc" } });
|
||||
ok((auditU?.before as { title?: string })?.title === "Heizung montieren" && (auditU?.after as { title?: string })?.title?.includes("2 Etagen") === true, "Audit before/after enthält Titel alt/neu");
|
||||
const d = await createWorkOrder(bo, { title: "Löschen", customerId: f.customerId });
|
||||
await deleteWorkOrder(bo, d.id);
|
||||
ok((await prisma.workOrder.findUnique({ where: { id: d.id } }))?.deletedAt != null, "Entwurf soft-deleted (deletedAt gesetzt)");
|
||||
|
||||
console.log("\n— (3) assignWorkOrder —");
|
||||
const as = await assignWorkOrder(bo, { workOrderId: a.id, teamId: f.teamId, userIds: [f.users.tech.id] });
|
||||
const aRow = await prisma.workOrder.findUnique({ where: { id: a.id }, include: { assignees: true } });
|
||||
ok(as.status === "assigned" && aRow?.assignedTeamId === f.teamId && aRow.teamLeadUserId === f.users.lead.id && aRow.assignees.length === 1, "Team, Teamleiter (aus Team), Monteur zugewiesen, Status assigned");
|
||||
ok(aRow?.version === 3, "Version nach Zuweisung 3");
|
||||
await expectCode(() => assignWorkOrder(f.ctx.lead, { workOrderId: a.id, teamId: f.teamId }), "forbidden", "Teamleiter ohne work_order:assign → forbidden");
|
||||
|
||||
console.log("\n— (4) Completion-Blocker —");
|
||||
await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "accepted" });
|
||||
await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_progress" });
|
||||
const session = await prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId: a.id, userId: f.users.tech.id, status: "running", startedAt: new Date() } });
|
||||
const err = (await expectCode(() => transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "technically_completed" }), "blocked", "Abschluss mit offenen Punkten → blocked")) as { details?: { kind: string }[] } | null;
|
||||
const kinds = new Set((err?.details ?? []).map((x) => x.kind));
|
||||
ok(kinds.has("checklist_item") && kinds.has("photo_requirement") && kinds.has("running_session"), "Blocker enthalten Checkliste, Pflichtfoto und laufende Session");
|
||||
const requiredItems = await prisma.checklistItem.findMany({ where: { workOrderId: a.id, required: true } });
|
||||
const reqs = await prisma.photoRequirement.findMany({ where: { workOrderId: a.id } });
|
||||
for (const i of requiredItems) {
|
||||
await prisma.checklistItem.update({ where: { id: i.id }, data: { checked: true } });
|
||||
if (i.requiresPhoto) await prisma.photo.create({ data: { tenantId: f.tenantId, workOrderId: a.id, documentId: randomUUID(), checklistItemId: i.id, takenAt: new Date() } });
|
||||
}
|
||||
for (const r of reqs) await prisma.photo.create({ data: { tenantId: f.tenantId, workOrderId: a.id, documentId: randomUUID(), photoRequirementId: r.id, takenAt: new Date() } });
|
||||
const onlySession = await getCompletionBlockers(f.ctx.tech, a.id);
|
||||
ok(onlySession.length === 1 && onlySession[0].kind === "running_session", "Nach Checkliste/Fotos bleibt nur die laufende Session");
|
||||
await prisma.workSession.update({ where: { id: session.id }, data: { status: "ended", endedAt: new Date() } });
|
||||
const done = await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "technically_completed" });
|
||||
ok(done.status === "technically_completed", "Ohne Blocker → technically_completed");
|
||||
|
||||
console.log("\n— (5) Unterschrift + Abrechnungsfreigabe —");
|
||||
await expectCode(() => transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_review" }), "blocked", "in_review ohne Unterschrift (signatureRequired) → blocked");
|
||||
const report = await approvedCompletionReport(f, a.id, { status: "submitted", signature: true });
|
||||
await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_review" });
|
||||
await expectCode(() => releaseForBilling(bo, { workOrderId: a.id }), "blocked", "Freigabe ohne freigegebenen Abschlussbericht → blocked");
|
||||
await expectCode(() => releaseForBilling(f.ctx.lead, { workOrderId: a.id }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben");
|
||||
await expectCode(() => rejectForCorrection(bo, { workOrderId: a.id, reason: "" }), "invalid", "Zurückweisung ohne Grund → invalid");
|
||||
await prisma.report.update({ where: { id: report.id }, data: { status: "approved", approvedAt: new Date() } });
|
||||
const rel = await releaseForBilling(bo, { workOrderId: a.id });
|
||||
ok(rel.status === "released_for_billing", "Mit freigegebenem Abschlussbericht → released_for_billing");
|
||||
ok((await prisma.auditLog.count({ where: { entity: "work_order", entityId: a.id, after: { path: ["status"], equals: "released_for_billing" } } })) === 1, "Freigabe protokolliert");
|
||||
await expectCode(() => addMaterialPlan(bo, a.id, { name: "Rohr", plannedQuantity: 1, unit: "m" }), "invalid", "Nach Freigabe keine Materialvorgabe mehr (not_editable)");
|
||||
|
||||
console.log("\n— (6) Materialvorgabe + Soll/Ist —");
|
||||
const c = await createWorkOrder(bo, { title: "Material", customerId: f.customerId });
|
||||
const plan = await addMaterialPlan(bo, c.id, { name: "Kupferrohr 15mm", plannedQuantity: 10, unit: "m", articleNumber: "CU-15" });
|
||||
const plan2 = await addMaterialPlan(bo, c.id, { name: "Fitting", plannedQuantity: 4, unit: "Stk" });
|
||||
await updateMaterialPlan(bo, plan2.id, { name: "Fitting 15mm", plannedQuantity: 6, unit: "Stk" });
|
||||
ok((await prisma.workOrder.findUnique({ where: { id: c.id } }))?.version === 4, "Jede Materialmutation erhöht die Version (1 → 4)");
|
||||
await prisma.materialUsage.create({ data: { tenantId: f.tenantId, workOrderId: c.id, materialPlanId: plan.id, name: plan.name, actualQuantity: 12.5, unit: "m", usageStatus: "fully_used", deviationReason: "Umweg Leitung" } });
|
||||
await prisma.materialUsage.create({ data: { tenantId: f.tenantId, workOrderId: c.id, name: "Dichtband", actualQuantity: 1, unit: "Rolle", usageStatus: "additional" } });
|
||||
const overview = await getMaterialOverview(bo, c.id);
|
||||
const row = overview.find((r) => r.planId === plan.id);
|
||||
ok(row?.deviation === 2.5 && row.reasons[0] === "Umweg Leitung", "Abweichung +2,5 m inkl. Begründung");
|
||||
ok(overview.some((r) => r.planId === null && r.name === "Dichtband"), "Zusatzmaterial ohne Vorgabe erscheint");
|
||||
await expectCode(() => removeMaterialPlan(bo, plan.id), "invalid", "Vorgabe mit erfasstem Verbrauch nicht löschbar");
|
||||
await removeMaterialPlan(bo, plan2.id);
|
||||
ok((await prisma.materialPlan.count({ where: { workOrderId: c.id } })) === 1, "Unbenutzte Vorgabe entfernt");
|
||||
const item = await addChecklistItem(bo, c.id, { label: "Druckprobe durchgeführt", required: true });
|
||||
await addPhotoRequirement(bo, c.id, { label: "Typenschild" });
|
||||
ok(item.key === "druckprobe_durchgefuehrt", "Checklistenpunkt mit stabilem Schlüssel");
|
||||
await removeChecklistItem(bo, item.id);
|
||||
|
||||
console.log("\n— (7) Liste + Dashboard —");
|
||||
const overdue = await rawWorkOrder(f, "in_progress", { plannedStart: new Date(Date.now() - 5 * 86400000), plannedEnd: new Date(Date.now() - 3600000) });
|
||||
const today = await rawWorkOrder(f, "assigned", { plannedStart: new Date(), plannedEnd: new Date(Date.now() + 3600000), priority: "urgent" });
|
||||
await rawWorkOrder(f, "signature_pending");
|
||||
const all = await listWorkOrders(bo, parseListParams({}));
|
||||
ok(all.total === (await prisma.workOrder.count({ where: { tenantId: f.tenantId, deletedAt: null } })), "Liste zeigt alle nicht gelöschten Aufträge");
|
||||
const od = await listWorkOrders(bo, parseListParams({ preset: "overdue" }));
|
||||
ok(od.items.some((i) => i.id === overdue.id) && !od.items.some((i) => i.id === today.id), "Preset überfällig");
|
||||
const td = await listWorkOrders(bo, parseListParams({ preset: "today" }));
|
||||
ok(td.items.some((i) => i.id === today.id), "Preset heute");
|
||||
const prio = await listWorkOrders(bo, parseListParams({ priority: "urgent", group: "planned" }));
|
||||
ok(prio.total === 1 && prio.items[0].id === today.id, "Filter Priorität + Statusgruppe");
|
||||
ok(prio.groupCounts.in_progress >= 0 && all.groupCounts.documentation_incomplete >= 1, "Statusgruppen-Zähler vorhanden");
|
||||
const byTeam = await listWorkOrders(bo, parseListParams({ teamId: f.otherTeamId }));
|
||||
ok(byTeam.total === 0, "Filter Team (fremdes Team leer)");
|
||||
const paged = await listWorkOrders(bo, parseListParams({ pageSize: "2", page: "2", sort: "number", dir: "desc" }));
|
||||
ok(paged.items.length <= 2 && paged.page === 2, "Paginierung + Sortierung");
|
||||
const tiles = await getDashboardTiles(bo, {});
|
||||
ok(tiles.overdue >= 1 && tiles.today >= 1 && tiles.missing_signatures >= 1 && tiles.billing === 1 && tiles.not_accepted >= 1 && tiles.emergency_new >= 1, "Dashboard-Kacheln zählen korrekt");
|
||||
|
||||
console.log("\n— (8) Sync-Konflikte —");
|
||||
const conflictWo = await rawWorkOrder(f, "assigned");
|
||||
const op1 = await prisma.syncOperation.create({ data: { tenantId: f.tenantId, userId: f.users.tech.id, clientOpId: randomUUID(), opType: "work_order.transition", entityType: "work_order", entityId: conflictWo.id, baseVersion: 99, payload: { to: "accepted" }, status: "conflict", clientCreatedAt: new Date() } });
|
||||
const op2 = await prisma.syncOperation.create({ data: { tenantId: f.tenantId, userId: f.users.tech.id, clientOpId: randomUUID(), opType: "work_order.transition", entityType: "work_order", entityId: conflictWo.id, baseVersion: 98, payload: { to: "en_route" }, status: "conflict", clientCreatedAt: new Date() } });
|
||||
const tilesWithConflicts = await getDashboardTiles(bo, {});
|
||||
ok((await listSyncConflicts(bo)).length === 2 && tilesWithConflicts.syncConflicts === 2, "Konfliktliste + Kachel zeigen 2 offene Konflikte");
|
||||
await expectCode(() => listSyncConflicts(f.ctx.tech), "forbidden", "Monteur sieht keine Konfliktliste");
|
||||
await discardSyncConflict(bo, op2.id);
|
||||
const op2Row = await prisma.syncOperation.findUnique({ where: { id: op2.id } });
|
||||
ok(op2Row?.resolvedAt != null && op2Row.resolvedById === f.users.backoffice.id, "Verwerfen setzt resolvedAt/By");
|
||||
await expectCode(() => discardSyncConflict(bo, op2.id), "not_found", "Bereits aufgelöster Konflikt nicht erneut bearbeitbar");
|
||||
await applySyncConflict(bo, op1.id);
|
||||
ok((await prisma.workOrder.findUnique({ where: { id: conflictWo.id } }))?.status === "accepted", "Übernehmen wendet die Operation als Gerätenutzer an (assigned → accepted)");
|
||||
ok((await prisma.auditLog.count({ where: { entity: "sync_operation", entityId: { in: [op1.id, op2.id] } } })) === 2, "Beide Auflösungen auditiert");
|
||||
|
||||
console.log("\n— (9b) Nummernkreis-Einstellungen, Storno —");
|
||||
await updateNumberSequence(bo, { key: "work_order", prefix: "AU-", padding: 6 });
|
||||
const e = await createWorkOrder(bo, { title: "Präfix", customerId: f.customerId });
|
||||
ok(/^AU-\d{6}$/.test(e.number), `Neues Präfix/Stellen wirken (${e.number})`);
|
||||
const seqs = await listNumberSequences(bo);
|
||||
ok(seqs.find((s) => s.key === "report")?.prefix === "B-" && seqs.find((s) => s.key === "work_order")?.padding === 6, "Nummernkreise inkl. Defaults gelistet");
|
||||
const cancelled = await cancelWorkOrder(bo, { workOrderId: e.id, reason: "Kunde abgesagt" });
|
||||
ok(cancelled.status === "cancelled", "Storno mit Grund");
|
||||
await expectCode(() => updateWorkOrder(bo, e.id, { title: "nach Storno" }), "invalid", "Stornierter Auftrag nicht mehr änderbar");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
ok(false, "unerwarteter Fehler");
|
||||
})
|
||||
.finally(finish(() => cleanupTenants([SLUG])));
|
||||
@@ -0,0 +1,41 @@
|
||||
// L2 Aufträge: Nummernkreis unter Parallelität.
|
||||
//
|
||||
// (1) 20 parallele createWorkOrder auf einem frischen Mandanten (auch die erste Zeile der Sequenz
|
||||
// entsteht im Wettlauf) → 20 eindeutige, lückenlose Nummern A-00001 … A-00020
|
||||
// (2) Zweiter Mandant beginnt unabhängig bei A-00001
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-auftraege-numbering.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { cleanupTenants, createFixture, finish, ok } from "./lib-auftraege-fixtures";
|
||||
|
||||
const SLUG_A = "zz-l2-num-a";
|
||||
const SLUG_B = "zz-l2-num-b";
|
||||
|
||||
async function main() {
|
||||
await cleanupTenants([SLUG_A, SLUG_B]);
|
||||
const A = await createFixture(SLUG_A);
|
||||
const B = await createFixture(SLUG_B);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 20 }, (_, i) => createWorkOrder(A.ctx.backoffice, { title: `Parallel ${i}`, customerId: A.customerId })),
|
||||
);
|
||||
const fulfilled = results.filter((r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof createWorkOrder>>> => r.status === "fulfilled");
|
||||
for (const r of results) if (r.status === "rejected") console.error(r.reason);
|
||||
ok(fulfilled.length === 20, `20 von 20 parallelen Anlagen erfolgreich (${fulfilled.length})`);
|
||||
const numbers = fulfilled.map((r) => r.value.number).sort();
|
||||
ok(new Set(numbers).size === numbers.length, "Alle Nummern eindeutig");
|
||||
const expected = Array.from({ length: 20 }, (_, i) => `A-${String(i + 1).padStart(5, "0")}`);
|
||||
ok(JSON.stringify(numbers) === JSON.stringify(expected), "Lückenlos A-00001 … A-00020");
|
||||
|
||||
const b = await createWorkOrder(B.ctx.backoffice, { title: "B", customerId: B.customerId });
|
||||
ok(b.number === "A-00001", "Mandant B hat einen eigenen Nummernkreis");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
ok(false, "unerwarteter Fehler");
|
||||
})
|
||||
.finally(finish(() => cleanupTenants([SLUG_A, SLUG_B])));
|
||||
@@ -0,0 +1,108 @@
|
||||
// L2 Aufträge: Sichtbarkeit (Rollen/Scope) und Mandantentrennung.
|
||||
//
|
||||
// (1) Monteur eines fremden Teams: Liste leer, Detail/Übergang/Blocker → not_found
|
||||
// (2) Team-Mitglied, Teamleiter und einzeln zugewiesener Monteur sehen den Auftrag
|
||||
// (3) Monteur ohne Rechte: Anlage/Änderung/Zuweisung/Material → forbidden
|
||||
// (4) Mandant B kann Aufträge von A weder lesen (Liste, Detail, Suche, Dashboard) noch ändern
|
||||
// (Update, Übergang, Zuweisung, Material, Konflikt) — auch nicht über den Tenant-Guard direkt
|
||||
// (5) Suche respektiert Scope (Monteur fremdes Team findet nichts) und Mandant
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-auftraege-scope.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { parseListParams } from "../src/lib/work-orders/filters";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { updateWorkOrder } from "../src/server/services/work-orders/update";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { getCompletionBlockers } from "../src/server/services/work-orders/completion";
|
||||
import { addMaterialPlan } from "../src/server/services/work-orders/materials";
|
||||
import { listWorkOrders } from "../src/server/services/work-orders/list";
|
||||
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
|
||||
import { getDashboardTiles } from "../src/server/services/work-orders/dashboard";
|
||||
import { searchAll } from "../src/server/services/work-orders/search";
|
||||
import { discardSyncConflict } from "../src/server/services/work-orders/conflicts";
|
||||
import { cleanupTenants, createFixture, expectCode, finish, ok } from "./lib-auftraege-fixtures";
|
||||
|
||||
const SLUG_A = "zz-l2-scope-a";
|
||||
const SLUG_B = "zz-l2-scope-b";
|
||||
|
||||
async function main() {
|
||||
await cleanupTenants([SLUG_A, SLUG_B]);
|
||||
const A = await createFixture(SLUG_A);
|
||||
const B = await createFixture(SLUG_B);
|
||||
|
||||
const wo = await createWorkOrder(A.ctx.backoffice, { title: "Wärmepumpe Zauberwort", customerId: A.customerId, siteId: A.siteId });
|
||||
await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId });
|
||||
await prisma.activityNote.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, text: "Zauberwort im Keller", authorId: A.users.tech.id } });
|
||||
const solo = await createWorkOrder(A.ctx.backoffice, { title: "Einzelzuweisung", customerId: A.customerId });
|
||||
await assignWorkOrder(A.ctx.backoffice, { workOrderId: solo.id, teamId: A.otherTeamId, userIds: [A.users.tech.id] });
|
||||
|
||||
console.log("\n— (1) Monteur fremdes Team —");
|
||||
const outsiderList = await listWorkOrders(A.ctx.outsider, parseListParams({}));
|
||||
ok(outsiderList.items.every((i) => i.id !== wo.id), "Liste des fremden Monteurs enthält den Auftrag nicht");
|
||||
await expectCode(() => getWorkOrderDetail(A.ctx.outsider, wo.id), "not_found", "Detail → not_found");
|
||||
await expectCode(() => transitionWorkOrder(A.ctx.outsider, { workOrderId: wo.id, to: "accepted" }), "not_found", "Übergang → not_found (Existenz nicht preisgegeben)");
|
||||
await expectCode(() => getCompletionBlockers(A.ctx.outsider, wo.id), "not_found", "Blocker → not_found");
|
||||
|
||||
console.log("\n— (2) Berechtigte Feldrollen —");
|
||||
const techList = await listWorkOrders(A.ctx.tech, parseListParams({}));
|
||||
ok(techList.items.some((i) => i.id === wo.id), "Team-Mitglied sieht den Team-Auftrag");
|
||||
ok(techList.items.some((i) => i.id === solo.id), "Einzeln zugewiesener Monteur sieht Auftrag eines anderen Teams");
|
||||
ok((await getWorkOrderDetail(A.ctx.lead, wo.id)).id === wo.id, "Teamleiter sieht den Auftrag");
|
||||
const accepted = await transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "accepted" });
|
||||
ok(accepted.status === "accepted", "Team-Mitglied darf annehmen");
|
||||
const outsiderSolo = await listWorkOrders(A.ctx.outsider, parseListParams({}));
|
||||
ok(outsiderSolo.items.some((i) => i.id === solo.id), "Mitglied des zugewiesenen Teams sieht den Einzelzuweisungs-Auftrag");
|
||||
|
||||
console.log("\n— (3) Monteur ohne Backoffice-Rechte —");
|
||||
await expectCode(() => createWorkOrder(A.ctx.tech, { title: "X", customerId: A.customerId }), "forbidden", "Anlage → forbidden");
|
||||
await expectCode(() => updateWorkOrder(A.ctx.tech, wo.id, { title: "X" }), "forbidden", "Änderung → forbidden");
|
||||
await expectCode(() => assignWorkOrder(A.ctx.tech, { workOrderId: wo.id, teamId: A.teamId }), "forbidden", "Zuweisung → forbidden");
|
||||
await expectCode(() => addMaterialPlan(A.ctx.tech, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "forbidden", "Materialvorgabe → forbidden");
|
||||
await expectCode(() => transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "forbidden", "Storno → forbidden");
|
||||
|
||||
console.log("\n— (4) Mandantentrennung —");
|
||||
const bList = await listWorkOrders(B.ctx.admin, parseListParams({}));
|
||||
ok(bList.total === 0, "Mandant B: Liste leer");
|
||||
await expectCode(() => getWorkOrderDetail(B.ctx.admin, wo.id), "not_found", "Mandant B: Detail → not_found");
|
||||
await expectCode(() => updateWorkOrder(B.ctx.admin, wo.id, { title: "gekapert" }), "not_found", "Mandant B: Update → not_found");
|
||||
await expectCode(() => transitionWorkOrder(B.ctx.admin, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "not_found", "Mandant B: Übergang → not_found");
|
||||
await expectCode(() => assignWorkOrder(B.ctx.admin, { workOrderId: wo.id, teamId: B.teamId }), "not_found", "Mandant B: Zuweisung → not_found");
|
||||
await expectCode(() => addMaterialPlan(B.ctx.admin, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "not_found", "Mandant B: Materialvorgabe → not_found");
|
||||
await expectCode(() => createWorkOrder(B.ctx.admin, { title: "X", customerId: A.customerId }), "invalid", "Mandant B: Anlage mit Kunde von A → invalid");
|
||||
const tilesB = await getDashboardTiles(B.ctx.admin, {});
|
||||
ok(Object.values(tilesB).every((v) => v === 0), "Mandant B: alle Dashboard-Kacheln 0");
|
||||
const direct = await B.ctx.admin.db.workOrder.findUnique({ where: { id: wo.id } });
|
||||
ok(direct === null, "Tenant-Guard: findUnique über Mandant B → null");
|
||||
const updMany = await B.ctx.admin.db.workOrder.updateMany({ where: { id: wo.id }, data: { title: "gekapert" } });
|
||||
ok(updMany.count === 0, "Tenant-Guard: updateMany über Mandant B ändert 0 Zeilen");
|
||||
const opA = await prisma.syncOperation.create({ data: { tenantId: A.tenantId, userId: A.users.tech.id, clientOpId: crypto.randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } });
|
||||
await expectCode(() => discardSyncConflict(B.ctx.admin, opA.id), "not_found", "Mandant B: Konflikt von A → not_found");
|
||||
const title = (await prisma.workOrder.findUnique({ where: { id: wo.id } }))?.title;
|
||||
ok(title === "Wärmepumpe Zauberwort", "Auftrag von A unverändert");
|
||||
|
||||
console.log("\n— (5) Suche —");
|
||||
const sA = await searchAll(A.ctx.backoffice, { q: "zauberwort" });
|
||||
ok(sA.workOrders.some((w) => w.id === wo.id) && sA.notes.length === 1, "Backoffice A findet Auftrag + Notiz");
|
||||
const sCust = await searchAll(A.ctx.backoffice, { q: "Kaistraße" });
|
||||
ok(sCust.sites.length === 1 && sCust.workOrders.some((w) => w.id === wo.id), "Adresssuche findet Objekt und Auftrag");
|
||||
const sB = await searchAll(B.ctx.admin, { q: "zauberwort" });
|
||||
ok(sB.workOrders.length === 0 && sB.notes.length === 0, "Mandant B findet nichts von A");
|
||||
const sB2 = await searchAll(B.ctx.admin, { q: SLUG_A });
|
||||
ok(sB2.customers.length === 0 && sB2.sites.length === 0 && sB2.contacts.length === 0, "Mandant B findet keine Kunden/Objekte/Kontakte von A");
|
||||
const sOut = await searchAll(A.ctx.outsider, { q: "zauberwort" });
|
||||
ok(sOut.workOrders.length === 0 && sOut.notes.length === 0, "Monteur fremdes Team findet Auftrag/Notiz nicht");
|
||||
const sTech = await searchAll(A.ctx.tech, { q: "zauberwort", statuses: ["accepted"] });
|
||||
ok(sTech.workOrders.length === 1, "Team-Mitglied findet Auftrag (mit Statusfilter)");
|
||||
const sFiltered = await searchAll(A.ctx.backoffice, { q: "zauberwort", statuses: ["billed"] });
|
||||
ok(sFiltered.workOrders.length === 0, "Statusfilter der Suche greift");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
ok(false, "unerwarteter Fehler");
|
||||
})
|
||||
.finally(finish(() => cleanupTenants([SLUG_A, SLUG_B])));
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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])));
|
||||
Reference in New Issue
Block a user