Files
craftvia/scripts/test-auftraege-core.ts
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

205 lines
17 KiB
TypeScript

// 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])));