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>
205 lines
17 KiB
TypeScript
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])));
|