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