From 2311b35d8ce4044446b8e84a4b3262bb09d0db28 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:29:15 +0200 Subject: [PATCH] =?UTF-8?q?L2=20Auftr=C3=A4ge=20&=20Backoffice:=20Service-?= =?UTF-8?q?Schicht=20Auftr=C3=A4ge=20+=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/lib-auftraege-fixtures.ts | 172 ++++++++++++++ scripts/test-auftraege-core.ts | 204 ++++++++++++++++ scripts/test-auftraege-numbering.ts | 41 ++++ scripts/test-auftraege-scope.ts | 108 +++++++++ scripts/test-auftraege-status.ts | 138 +++++++++++ src/lib/work-orders/action-state.ts | 17 ++ src/lib/work-orders/defaults.ts | 48 ++++ src/lib/work-orders/filters.ts | 126 ++++++++++ src/lib/work-orders/schemas.ts | 219 ++++++++++++++++++ src/lib/work-orders/time.ts | 86 +++++++ src/server/services/work-orders/_shared.ts | 127 ++++++++++ src/server/services/work-orders/assign.ts | 81 +++++++ src/server/services/work-orders/cancel.ts | 10 + src/server/services/work-orders/checklist.ts | 157 +++++++++++++ src/server/services/work-orders/completion.ts | 69 ++++++ src/server/services/work-orders/conflicts.ts | 89 +++++++ src/server/services/work-orders/create.ts | 145 ++++++++++++ src/server/services/work-orders/dashboard.ts | 29 +++ src/server/services/work-orders/detail.ts | 121 ++++++++++ src/server/services/work-orders/documents.ts | Bin 0 -> 4781 bytes src/server/services/work-orders/list.ts | 161 +++++++++++++ src/server/services/work-orders/materials.ts | 133 +++++++++++ src/server/services/work-orders/options.ts | 81 +++++++ .../services/work-orders/release-billing.ts | 34 +++ src/server/services/work-orders/search.ts | 110 +++++++++ src/server/services/work-orders/settings.ts | 142 ++++++++++++ .../services/work-orders/sync-reapply.ts | 25 ++ src/server/services/work-orders/transition.ts | 107 +++++++++ src/server/services/work-orders/update.ts | 83 +++++++ 29 files changed, 2863 insertions(+) create mode 100644 scripts/lib-auftraege-fixtures.ts create mode 100644 scripts/test-auftraege-core.ts create mode 100644 scripts/test-auftraege-numbering.ts create mode 100644 scripts/test-auftraege-scope.ts create mode 100644 scripts/test-auftraege-status.ts create mode 100644 src/lib/work-orders/action-state.ts create mode 100644 src/lib/work-orders/defaults.ts create mode 100644 src/lib/work-orders/filters.ts create mode 100644 src/lib/work-orders/schemas.ts create mode 100644 src/lib/work-orders/time.ts create mode 100644 src/server/services/work-orders/_shared.ts create mode 100644 src/server/services/work-orders/assign.ts create mode 100644 src/server/services/work-orders/cancel.ts create mode 100644 src/server/services/work-orders/checklist.ts create mode 100644 src/server/services/work-orders/completion.ts create mode 100644 src/server/services/work-orders/conflicts.ts create mode 100644 src/server/services/work-orders/create.ts create mode 100644 src/server/services/work-orders/dashboard.ts create mode 100644 src/server/services/work-orders/detail.ts create mode 100644 src/server/services/work-orders/documents.ts create mode 100644 src/server/services/work-orders/list.ts create mode 100644 src/server/services/work-orders/materials.ts create mode 100644 src/server/services/work-orders/options.ts create mode 100644 src/server/services/work-orders/release-billing.ts create mode 100644 src/server/services/work-orders/search.ts create mode 100644 src/server/services/work-orders/settings.ts create mode 100644 src/server/services/work-orders/sync-reapply.ts create mode 100644 src/server/services/work-orders/transition.ts create mode 100644 src/server/services/work-orders/update.ts diff --git a/scripts/lib-auftraege-fixtures.ts b/scripts/lib-auftraege-fixtures.ts new file mode 100644 index 0000000..6894d4e --- /dev/null +++ b/scripts/lib-auftraege-fixtures.ts @@ -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, code: string, msg: string): Promise { + 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(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 { + 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> = {}; + 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 = {}) { + 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) { + 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); + }; +} diff --git a/scripts/test-auftraege-core.ts b/scripts/test-auftraege-core.ts new file mode 100644 index 0000000..29129fc --- /dev/null +++ b/scripts/test-auftraege-core.ts @@ -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]))); diff --git a/scripts/test-auftraege-numbering.ts b/scripts/test-auftraege-numbering.ts new file mode 100644 index 0000000..0e350c1 --- /dev/null +++ b/scripts/test-auftraege-numbering.ts @@ -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>> => 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]))); diff --git a/scripts/test-auftraege-scope.ts b/scripts/test-auftraege-scope.ts new file mode 100644 index 0000000..df244cb --- /dev/null +++ b/scripts/test-auftraege-scope.ts @@ -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]))); diff --git a/scripts/test-auftraege-status.ts b/scripts/test-auftraege-status.ts new file mode 100644 index 0000000..0ec9c83 --- /dev/null +++ b/scripts/test-auftraege-status.ts @@ -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 = { + 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]))); diff --git a/src/lib/work-orders/action-state.ts b/src/lib/work-orders/action-state.ts new file mode 100644 index 0000000..ba1ea84 --- /dev/null +++ b/src/lib/work-orders/action-state.ts @@ -0,0 +1,17 @@ +import type { CompletionBlocker } from "@/lib/work-orders/status"; + +/** Result of a work order server action (client-safe; rendered by components/work-orders/action-form.tsx). */ +export type ActionState = + | { status: "idle" } + | { status: "ok"; at: number } + | { + status: "error"; + /** ServiceError code: not_found | forbidden | invalid | conflict | blocked | internal */ + code: string; + /** message key below `errors.` (falls back to the code) */ + message: string; + blockers?: CompletionBlocker[]; + at: number; + }; + +export const IDLE_STATE: ActionState = { status: "idle" }; diff --git a/src/lib/work-orders/defaults.ts b/src/lib/work-orders/defaults.ts new file mode 100644 index 0000000..2fc1b77 --- /dev/null +++ b/src/lib/work-orders/defaults.ts @@ -0,0 +1,48 @@ +import type { TemplateItem, TemplatePhoto } from "@/lib/work-orders/schemas"; + +/** + * Tenant defaults (client-safe data). Labels are German master data (stored per tenant and + * editable afterwards), not UI chrome — therefore not part of the message catalogue. + */ + +/** Mirrors the defaults of src/server/services/numbering.ts (used before a sequence row exists). */ +export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergency" | "report", string> = { + customer: "K-", + work_order: "A-", + emergency: "N-", + report: "B-", +}; + +/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */ +export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [ + { key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 }, + { key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 }, + { key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 }, + { key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 }, + { key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 }, + { key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 }, + { key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 }, + { key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 }, +]; + +/** Spec §12.4 — suggested checklist items. */ +export const DEFAULT_CHECKLIST_ITEMS: readonly TemplateItem[] = [ + { key: "spannungsfrei", label: "Anlage spannungsfrei geschaltet", required: true, requiresPhoto: false }, + { key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true, requiresPhoto: false }, + { key: "material_geprueft", label: "Material geprüft", required: false, requiresPhoto: false }, + { key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }, + { key: "arbeitsbereich_gereinigt", label: "Arbeitsbereich gereinigt", required: true, requiresPhoto: false }, + { key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false, requiresPhoto: false }, + { key: "pflichtfotos_erstellt", label: "Pflichtfotos erstellt", required: true, requiresPhoto: false }, +]; + +/** Spec §14.2 — suggested required photos (keys match PhotoRequirement.key catalogue). */ +export const DEFAULT_REQUIRED_PHOTOS: readonly TemplatePhoto[] = [ + { key: "ausgangszustand", label: "Ausgangszustand" }, + { key: "typenschild", label: "Typenschild" }, + { key: "leitungsverlauf", label: "Leitungsverlauf" }, + { key: "zwischenschritt", label: "Zwischenschritt" }, + { key: "fertige_montage", label: "Fertige Montage" }, + { key: "funktionspruefung", label: "Funktionsprüfung" }, + { key: "arbeitsbereich_abschluss", label: "Arbeitsbereich nach Abschluss" }, +]; diff --git a/src/lib/work-orders/filters.ts b/src/lib/work-orders/filters.ts new file mode 100644 index 0000000..41503d5 --- /dev/null +++ b/src/lib/work-orders/filters.ts @@ -0,0 +1,126 @@ +import { WORK_ORDER_STATUSES, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { WORK_ORDER_PRIORITIES, type WorkOrderPriority } from "@/lib/work-orders/schemas"; + +/** + * List/dashboard filter model (spec §21) and its URL representation (client-safe). + * The same parser feeds /work-orders, /dashboard and GET /api/v1/work-orders. + */ + +export const STATUS_GROUPS: readonly StatusGroup[] = [ + "new", + "planned", + "en_route", + "in_progress", + "documentation_incomplete", + "in_review", + "ready_for_billing", + "billed", + "cancelled", +]; + +/** Dashboard tiles (§21) — each tile links to the list with `preset=`. */ +export const PRESETS = [ + "open", + "today", + "running", + "not_accepted", + "overdue", + "reports_in_review", + "completed", + "billing", + "emergency_new", + "missing_signatures", +] as const; +export type Preset = (typeof PRESETS)[number]; + +export const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"] as const; +export type SortField = (typeof SORT_FIELDS)[number]; + +export type WorkOrderFilter = { + from?: Date; + to?: Date; + customerId?: string; + siteId?: string; + teamId?: string; + userId?: string; + statuses?: WorkOrderStatus[]; + group?: StatusGroup; + orderTypeId?: string; + priority?: WorkOrderPriority; + preset?: Preset; + q?: string; +}; + +export type ListParams = WorkOrderFilter & { + sort: SortField; + dir: "asc" | "desc"; + page: number; + pageSize: number; +}; + +type Raw = Record | URLSearchParams; + +function get(raw: Raw, key: string): string | undefined { + const v = raw instanceof URLSearchParams ? raw.get(key) ?? undefined : raw[key]; + const s = Array.isArray(v) ? v[0] : v; + return s && s.trim() ? s.trim() : undefined; +} + +function date(v: string | undefined, endOfDay = false): Date | undefined { + if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return undefined; + const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}`); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +const cuidLike = (v: string | undefined) => (v && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : undefined); + +export function parseListParams(raw: Raw): ListParams { + const statusRaw = get(raw, "status"); + const statuses = statusRaw + ?.split(",") + .filter((s): s is WorkOrderStatus => (WORK_ORDER_STATUSES as readonly string[]).includes(s)); + const group = get(raw, "group"); + const priority = get(raw, "priority"); + const preset = get(raw, "preset"); + const sort = get(raw, "sort"); + const q = get(raw, "q"); + return { + from: date(get(raw, "from")), + to: date(get(raw, "to"), true), + customerId: cuidLike(get(raw, "customerId")), + siteId: cuidLike(get(raw, "siteId")), + teamId: cuidLike(get(raw, "teamId")), + userId: cuidLike(get(raw, "userId")), + statuses: statuses?.length ? statuses : undefined, + group: group && (STATUS_GROUPS as readonly string[]).includes(group) ? (group as StatusGroup) : undefined, + orderTypeId: cuidLike(get(raw, "orderTypeId")), + priority: priority && (WORK_ORDER_PRIORITIES as readonly string[]).includes(priority) ? (priority as WorkOrderPriority) : undefined, + preset: preset && (PRESETS as readonly string[]).includes(preset) ? (preset as Preset) : undefined, + q: q ? q.slice(0, 100) : undefined, + sort: sort && (SORT_FIELDS as readonly string[]).includes(sort) ? (sort as SortField) : "plannedStart", + dir: get(raw, "dir") === "desc" ? "desc" : "asc", + page: Math.max(1, Math.min(10_000, Number.parseInt(get(raw, "page") ?? "1", 10) || 1)), + pageSize: Math.max(1, Math.min(100, Number.parseInt(get(raw, "pageSize") ?? "25", 10) || 25)), + }; +} + +/** Serialise filter params back into a query string (drops empty values). */ +export function toQuery(params: Partial>): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + if (v instanceof Date) sp.set(k, isoDay(v)); + else if (Array.isArray(v)) { + if (v.length) sp.set(k, v.join(",")); + } else sp.set(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ""; +} + +export function isoDay(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} diff --git a/src/lib/work-orders/schemas.ts b/src/lib/work-orders/schemas.ts new file mode 100644 index 0000000..c311e86 --- /dev/null +++ b/src/lib/work-orders/schemas.ts @@ -0,0 +1,219 @@ +import { z } from "zod"; +import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; + +/** + * Zod input schemas of the work order module (client-safe). Services parse with these; + * server actions and /api/v1 handlers only map FormData/JSON onto them. + */ + +export const WORK_ORDER_PRIORITIES = ["low", "normal", "high", "urgent"] as const; +export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number]; + +export const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"] as const; +export type BillingType = (typeof BILLING_TYPES)[number]; + +const id = z.string().min(1).max(64); +const optId = id.nullish(); +const text = (max: number) => z.string().trim().max(max); +const optText = (max: number) => + z + .string() + .trim() + .max(max) + .nullish() + .transform((v) => (v ? v : null)); +const optDate = z.coerce.date().nullish(); + +export const materialPlanInputSchema = z.object({ + name: text(200).min(1), + articleNumber: optText(80), + plannedQuantity: z.coerce.number().positive().max(1_000_000), + unit: text(20).min(1), + notes: optText(1000), + sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), +}); +export type MaterialPlanInput = z.input; + +export const checklistItemInputSchema = z.object({ + key: z + .string() + .trim() + .max(60) + .regex(/^[a-z0-9_]+$/) + .optional(), + label: text(200).min(1), + required: z.boolean().default(false), + requiresPhoto: z.boolean().default(false), + sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), +}); +export type ChecklistItemInput = z.input; + +export const photoRequirementInputSchema = z.object({ + key: z + .string() + .trim() + .max(60) + .regex(/^[a-z0-9_]+$/) + .optional(), + label: text(200).min(1), + sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), +}); +export type PhotoRequirementInput = z.input; + +/** Statuses a work order may be created in (import → review_required, emergency → in_progress via L8). */ +export const INITIAL_STATUSES = ["draft", "review_required", "planned", "in_progress"] as const; + +export const createWorkOrderSchema = z + .object({ + title: text(200).min(1), + customerId: id, + siteId: optId, + contactId: optId, + orderTypeId: optId, + priority: z.enum(WORK_ORDER_PRIORITIES).default("normal"), + status: z.enum(INITIAL_STATUSES).default("draft"), + description: optText(10_000), + scope: optText(10_000), + plannedStart: optDate, + plannedEnd: optDate, + /** undefined → taken from the order type (default true) */ + signatureRequired: z.boolean().optional(), + billingType: z.enum(BILLING_TYPES).nullish(), + internalNotes: optText(5000), + technicianNotes: optText(5000), + externalOrderNumber: optText(80), + offerNumber: optText(80), + isEmergency: z.boolean().default(false), + emergencyReason: optText(2000), + sourceImportId: optId, + /** "emergency" allocates from the N- sequence (lane emergency). */ + numberKey: z.enum(["work_order", "emergency"]).default("work_order"), + /** Copy checklist / photo requirements from the order type's active template (default true). */ + applyTemplate: z.boolean().default(true), + materials: z.array(materialPlanInputSchema).max(200).optional(), + checklistItems: z.array(checklistItemInputSchema).max(200).optional(), + photoRequirements: z.array(photoRequirementInputSchema).max(50).optional(), + }) + .refine((v) => !v.plannedStart || !v.plannedEnd || v.plannedEnd >= v.plannedStart, { + path: ["plannedEnd"], + message: "plannedEnd_before_start", + }); + +/** Input type of createWorkOrder — used by lanes imports (L3) and emergency (L8). */ +export type CreateWorkOrderInput = z.input; + +export const updateWorkOrderSchema = z + .object({ + title: text(200).min(1).optional(), + customerId: id.optional(), + siteId: optId, + contactId: optId, + orderTypeId: optId, + priority: z.enum(WORK_ORDER_PRIORITIES).optional(), + description: optText(10_000).optional(), + scope: optText(10_000).optional(), + plannedStart: optDate, + plannedEnd: optDate, + signatureRequired: z.boolean().optional(), + billingType: z.enum(BILLING_TYPES).nullish(), + internalNotes: optText(5000).optional(), + technicianNotes: optText(5000).optional(), + externalOrderNumber: optText(80).optional(), + offerNumber: optText(80).optional(), + emergencyReason: optText(2000).optional(), + }) + .strict(); +export type UpdateWorkOrderInput = z.input; + +export const transitionSchema = z.object({ + workOrderId: id, + to: z.enum(WORK_ORDER_STATUSES), + reason: optText(2000), + baseVersion: z.coerce.number().int().positive().optional(), +}); +export type TransitionInput = z.input & { + /** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */ + eventData?: Record; +}; + +export const assignSchema = z.object({ + workOrderId: id, + teamId: id, + userIds: z.array(id).max(50).default([]), + teamLeadUserId: optId, + baseVersion: z.coerce.number().int().positive().optional(), +}); +export type AssignInput = z.input; + +// ---------- Settings ---------- + +export const orderTypeSchema = z.object({ + key: z + .string() + .trim() + .min(2) + .max(40) + .regex(/^[a-z0-9_]+$/), + name: text(80).min(1), + signatureRequired: z.boolean().default(true), + active: z.boolean().default(true), + sortOrder: z.coerce.number().int().min(0).max(10_000).default(100), +}); +export type OrderTypeInput = z.input; + +export const templateItemSchema = z.object({ + key: z + .string() + .trim() + .min(1) + .max(60) + .regex(/^[a-z0-9_]+$/), + label: text(200).min(1), + required: z.boolean().default(false), + requiresPhoto: z.boolean().default(false), +}); +export const templatePhotoSchema = z.object({ + key: z + .string() + .trim() + .min(1) + .max(60) + .regex(/^[a-z0-9_]+$/), + label: text(200).min(1), +}); +export type TemplateItem = z.output; +export type TemplatePhoto = z.output; + +export const checklistTemplateSchema = z.object({ + name: text(120).min(1), + orderTypeId: optId, + active: z.boolean().default(true), + items: z.array(templateItemSchema).max(100), + requiredPhotos: z.array(templatePhotoSchema).max(30), +}); +export type ChecklistTemplateInput = z.input; + +export const NUMBER_KEYS = ["work_order", "emergency", "customer", "report"] as const; +export const numberingSchema = z.object({ + key: z.enum(NUMBER_KEYS), + prefix: z + .string() + .trim() + .max(12) + .regex(/^[A-Za-z0-9\-_/]*$/), + padding: z.coerce.number().int().min(1).max(10), +}); + +/** Stable key from a free label (umlauts transliterated). */ +export function slugKey(label: string): string { + const s = label + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 60); + return s || "punkt"; +} diff --git a/src/lib/work-orders/time.ts b/src/lib/work-orders/time.ts new file mode 100644 index 0000000..c1b80bd --- /dev/null +++ b/src/lib/work-orders/time.ts @@ -0,0 +1,86 @@ +/** + * Timezone helpers (client-safe, Intl only). Planned dates are entered as wall-clock time in the + * tenant timezone (TenantSettings.timezone) and stored as UTC. + */ + +export const DEFAULT_TIMEZONE = "Europe/Berlin"; + +export function safeTimeZone(tz: string | null | undefined): string { + if (!tz) return DEFAULT_TIMEZONE; + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + return tz; + } catch { + return DEFAULT_TIMEZONE; + } +} + +/** Offset (ms) of `timeZone` relative to UTC at instant `at`. */ +export function tzOffsetMs(at: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(at); + const n = (t: string) => Number(parts.find((p) => p.type === t)?.value); + const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second")); + return asUtc - Math.floor(at.getTime() / 1000) * 1000; +} + +/** "2026-09-14T08:30" (wall time in tz) → UTC Date. Returns undefined for invalid input. */ +export function wallTimeToUtc(value: string, timeZone: string): Date | undefined { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value.trim()); + if (!m) return undefined; + const tz = safeTimeZone(timeZone); + const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0); + const first = guess - tzOffsetMs(new Date(guess), tz); + // second pass corrects DST boundaries + const result = guess - tzOffsetMs(new Date(first), tz); + const d = new Date(result); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +/** UTC Date → "YYYY-MM-DDTHH:mm" wall time in tz (for ). */ +export function toWallTimeInput(date: Date | null | undefined, timeZone: string): string { + if (!date) return ""; + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: safeTimeZone(timeZone), + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).formatToParts(date); + const p = (t: string) => parts.find((x) => x.type === t)?.value ?? "00"; + return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}`; +} + +/** Start/end of the calendar day containing `now` in the given timezone. */ +export function zonedDayBounds(now: Date, timeZone: string): { start: Date; end: Date } { + const tz = safeTimeZone(timeZone); + const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }) + .format(now) + .split("-") + .map(Number); + const guessStart = Date.UTC(y, m - 1, d, 0, 0, 0); + const start = new Date(guessStart - tzOffsetMs(new Date(guessStart), tz)); + const guessEnd = Date.UTC(y, m - 1, d, 23, 59, 59, 999); + const end = new Date(guessEnd - tzOffsetMs(new Date(guessEnd), tz)); + return { start, end }; +} + +export function formatDateTime(date: Date | null | undefined, locale: string, timeZone: string): string { + if (!date) return ""; + return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium", timeStyle: "short" }).format(date); +} + +export function formatDate(date: Date | null | undefined, locale: string, timeZone: string): string { + if (!date) return ""; + return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium" }).format(date); +} diff --git a/src/server/services/work-orders/_shared.ts b/src/server/services/work-orders/_shared.ts new file mode 100644 index 0000000..b7a21f0 --- /dev/null +++ b/src/server/services/work-orders/_shared.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import { safeTimeZone } from "@/lib/work-orders/time"; +import { writeAuditLog } from "@/server/audit"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Parse service input; validation problems become ServiceError("invalid") with flattened issues. */ +export function parseInput(schema: S, input: unknown): z.output { + const res = schema.safeParse(input); + if (!res.success) { + throw new ServiceError( + "invalid", + "validation_failed", + res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })), + ); + } + return res.data; +} + +/** No business data changes at all in these statuses. */ +export const FINAL_STATUSES: readonly WorkOrderStatus[] = ["billed", "cancelled"]; +/** Planning data (checklist, photos, material plan, assignment) is frozen once released for billing. */ +export const PLANNING_LOCKED: readonly WorkOrderStatus[] = ["released_for_billing", "billed", "cancelled"]; + +export type WorkOrderBase = { + id: string; + number: string; + status: WorkOrderStatus; + version: number; + customerId: string; + siteId: string | null; + signatureRequired: boolean; + isEmergency: boolean; + assignedTeamId: string | null; + teamLeadUserId: string | null; +}; + +const BASE_SELECT = { + id: true, + number: true, + status: true, + version: true, + customerId: true, + siteId: true, + signatureRequired: true, + isEmergency: true, + assignedTeamId: true, + teamLeadUserId: true, +} as const; + +/** Load a work order within the caller's visibility scope or throw not_found (existence is never revealed). */ +export async function loadVisibleWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise { + const scope = await workOrderScope(ctx); + const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, select: BASE_SELECT }); + if (!wo) throw new ServiceError("not_found", "work_order_not_found"); + return wo as WorkOrderBase; +} + +export function assertBaseVersion(wo: { version: number }, baseVersion?: number) { + if (baseVersion !== undefined && baseVersion !== wo.version) { + throw new ServiceError("conflict", "version_conflict", { currentVersion: wo.version, baseVersion }); + } +} + +/** + * Optimistic write on the work order row: only succeeds while the version is unchanged and + * always increments `version` (offline sync conflict detection relies on it). + */ +export async function writeWithVersion( + ctx: ServiceCtx, + wo: { id: string; version: number }, + data: Record, +): Promise { + const res = await ctx.db.workOrder.updateMany({ + where: { id: wo.id, version: wo.version, deletedAt: null }, + data: { ...data, version: { increment: 1 } }, + }); + if (res.count !== 1) throw new ServiceError("conflict", "version_conflict", { baseVersion: wo.version }); + return wo.version + 1; +} + +/** Version bump after a mutation of a dependent entity (checklist, material plan, …). */ +export async function touchWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise { + await ctx.db.workOrder.updateMany({ where: { id: workOrderId }, data: { version: { increment: 1 } } }); +} + +export async function auditWorkOrder( + ctx: ServiceCtx, + entry: { action: "create" | "update" | "delete"; workOrderId: string; before?: unknown; after?: unknown }, +) { + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: entry.action, + entity: "work_order", + entityId: entry.workOrderId, + before: entry.before, + after: entry.after, + }); +} + +/** Plain JSON copy for audit before/after (Decimals/Dates → strings). */ +export function snapshot(value: T): unknown { + return JSON.parse(JSON.stringify(value)); +} + +export function assertNotLocked(wo: { status: WorkOrderStatus }, locked: readonly WorkOrderStatus[]) { + if (locked.includes(wo.status)) throw new ServiceError("invalid", "not_editable", { status: wo.status }); +} + +/** Unique stable keys for checklist items / photo requirements. */ +export function uniqueKey(base: string, taken: Set): string { + let key = base; + let i = 2; + while (taken.has(key)) key = `${base}_${i++}`.slice(0, 60); + taken.add(key); + return key; +} + +/** Tenant timezone (TenantSettings.timezone, default Europe/Berlin). */ +export async function tenantTimezone(ctx: ServiceCtx): Promise { + const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } }); + return safeTimeZone(s?.timezone); +} + +export { zonedDayBounds } from "@/lib/work-orders/time"; diff --git a/src/server/services/work-orders/assign.ts b/src/server/services/work-orders/assign.ts new file mode 100644 index 0000000..4578dfb --- /dev/null +++ b/src/server/services/work-orders/assign.ts @@ -0,0 +1,81 @@ +import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { assignSchema, type AssignInput } from "@/lib/work-orders/schemas"; +import { emitEvent } from "@/server/events"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertBaseVersion, + assertNotLocked, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + PLANNING_LOCKED, + writeWithVersion, +} from "@/server/services/work-orders/_shared"; + +/** + * Assign a team (+ optional individual technicians and a responsible team lead) — US-004. + * draft/review_required/planned → assigned; accepted with a changed team → back to assigned. + */ +export async function assignWorkOrder( + ctx: ServiceCtx, + raw: AssignInput, +): Promise<{ id: string; version: number; status: WorkOrderStatus }> { + assertCan(ctx, "work_order:assign"); + const input = parseInput(assignSchema, raw); + const wo = await loadVisibleWorkOrder(ctx, input.workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + assertBaseVersion(wo, input.baseVersion); + + const team = await ctx.db.team.findFirst({ + where: { id: input.teamId, deletedAt: null, status: "active" }, + select: { id: true, name: true, leaderUserId: true }, + }); + if (!team) throw new ServiceError("invalid", "team_not_found"); + + const userIds = [...new Set(input.userIds)]; + const leadId = input.teamLeadUserId ?? team.leaderUserId ?? null; + const toCheck = [...new Set([...userIds, ...(leadId ? [leadId] : [])])]; + if (toCheck.length) { + const found = await ctx.db.user.count({ where: { id: { in: toCheck }, status: "ACTIVE" } }); + if (found !== toCheck.length) throw new ServiceError("invalid", "user_not_found"); + } + + const previous = await ctx.db.workOrderAssignee.findMany({ where: { workOrderId: wo.id }, select: { userId: true } }); + + let to: WorkOrderStatus | null = null; + if (["draft", "review_required", "planned"].includes(wo.status)) to = "assigned"; + else if (wo.status === "accepted" && wo.assignedTeamId !== team.id) to = "assigned"; + if (to && !canTransition(wo.status, to)) to = null; + + const version = await writeWithVersion(ctx, wo, { + assignedTeamId: team.id, + teamLeadUserId: leadId, + ...(to ? { status: to } : {}), + }); + await ctx.db.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id, userId: { notIn: userIds } } }); + if (userIds.length) { + await ctx.db.workOrderAssignee.createMany({ + data: userIds.map((userId) => ({ tenantId: ctx.tenantId, workOrderId: wo.id, userId })), + skipDuplicates: true, + }); + } + if (to) { + await ctx.db.workOrderStatusChange.create({ + data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: wo.status, toStatus: to, actorId: ctx.userId }, + }); + } + + await auditWorkOrder(ctx, { + action: "update", + workOrderId: wo.id, + before: { assignedTeamId: wo.assignedTeamId, teamLeadUserId: wo.teamLeadUserId, userIds: previous.map((p) => p.userId), status: wo.status }, + after: { assignedTeamId: team.id, teamLeadUserId: leadId, userIds, status: to ?? wo.status, version }, + }); + await emitEvent(ctx, { + type: "work_order.assigned", + entityType: "work_order", + entityId: wo.id, + data: { number: wo.number, teamId: team.id, teamName: team.name }, + }); + return { id: wo.id, version, status: to ?? wo.status }; +} diff --git a/src/server/services/work-orders/cancel.ts b/src/server/services/work-orders/cancel.ts new file mode 100644 index 0000000..d180939 --- /dev/null +++ b/src/server/services/work-orders/cancel.ts @@ -0,0 +1,10 @@ +import type { ServiceCtx } from "@/server/services/context"; +import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition"; + +/** Cancel an order (any status except billed) — `work_order:cancel`, reason mandatory. */ +export async function cancelWorkOrder( + ctx: ServiceCtx, + input: { workOrderId: string; reason: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "cancelled", reason: input.reason, baseVersion: input.baseVersion }); +} diff --git a/src/server/services/work-orders/checklist.ts b/src/server/services/work-orders/checklist.ts new file mode 100644 index 0000000..3fe4342 --- /dev/null +++ b/src/server/services/work-orders/checklist.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import { + checklistItemInputSchema, + photoRequirementInputSchema, + slugKey, + templateItemSchema, + templatePhotoSchema, + type ChecklistItemInput, + type PhotoRequirementInput, +} from "@/lib/work-orders/schemas"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertNotLocked, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + PLANNING_LOCKED, + snapshot, + touchWorkOrder, + uniqueKey, +} from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Checklist items and required photos on a single order (backoffice maintenance, spec §12.4/§14.2). */ + +async function editableOrder(ctx: ServiceCtx, workOrderId: string) { + assertCan(ctx, "work_order:write"); + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + return wo; +} + +export async function addChecklistItem(ctx: ServiceCtx, workOrderId: string, raw: ChecklistItemInput) { + const input = parseInput(checklistItemInputSchema, raw); + await editableOrder(ctx, workOrderId); + const existing = await ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }); + const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key))); + const item = await ctx.db.checklistItem.create({ + data: { + tenantId: ctx.tenantId, + workOrderId, + key, + label: input.label, + required: input.required, + requiresPhoto: input.requiresPhoto, + sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10, + }, + }); + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "checklist_item.add", item: snapshot(item) } }); + return item; +} + +export async function updateChecklistItem( + ctx: ServiceCtx, + itemId: string, + raw: { label?: string; required?: boolean; requiresPhoto?: boolean; sortOrder?: number }, +) { + const input = parseInput(checklistItemInputSchema.partial(), raw); + const scope = await workOrderScope(ctx); + const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } }); + if (!before) throw new ServiceError("not_found", "checklist_item_not_found"); + await editableOrder(ctx, before.workOrderId); + const item = await ctx.db.checklistItem.update({ + where: { id: itemId }, + data: { + ...(input.label !== undefined ? { label: input.label } : {}), + ...(raw.required !== undefined ? { required: !!input.required } : {}), + ...(raw.requiresPhoto !== undefined ? { requiresPhoto: !!input.requiresPhoto } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }, + }); + await touchWorkOrder(ctx, before.workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.update", item: snapshot(before) }, after: { item: snapshot(item) } }); + return item; +} + +/** Checked items are evidence and stay. */ +export async function removeChecklistItem(ctx: ServiceCtx, itemId: string): Promise { + const scope = await workOrderScope(ctx); + const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } }); + if (!before) throw new ServiceError("not_found", "checklist_item_not_found"); + await editableOrder(ctx, before.workOrderId); + if (before.checked) throw new ServiceError("invalid", "checklist_item_checked"); + await ctx.db.checklistItem.deleteMany({ where: { id: itemId } }); + await touchWorkOrder(ctx, before.workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.remove", item: snapshot(before) } }); +} + +export async function addPhotoRequirement(ctx: ServiceCtx, workOrderId: string, raw: PhotoRequirementInput) { + const input = parseInput(photoRequirementInputSchema, raw); + await editableOrder(ctx, workOrderId); + const existing = await ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }); + const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key))); + const req = await ctx.db.photoRequirement.create({ + data: { + tenantId: ctx.tenantId, + workOrderId, + key, + label: input.label, + sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10, + }, + }); + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "photo_requirement.add", requirement: snapshot(req) } }); + return req; +} + +/** Requirements that already have photos stay (evidence). */ +export async function removePhotoRequirement(ctx: ServiceCtx, requirementId: string): Promise { + const scope = await workOrderScope(ctx); + const before = await ctx.db.photoRequirement.findFirst({ where: { id: requirementId, workOrder: scope } }); + if (!before) throw new ServiceError("not_found", "photo_requirement_not_found"); + await editableOrder(ctx, before.workOrderId); + const photos = await ctx.db.photo.count({ where: { photoRequirementId: requirementId } }); + if (photos > 0) throw new ServiceError("invalid", "photo_requirement_has_photos"); + await ctx.db.photoRequirement.deleteMany({ where: { id: requirementId } }); + await touchWorkOrder(ctx, before.workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "photo_requirement.remove", requirement: snapshot(before) } }); +} + +/** Add the items/photos of a template that the order does not have yet (by key). */ +export async function applyChecklistTemplate(ctx: ServiceCtx, workOrderId: string, templateId: string) { + await editableOrder(ctx, workOrderId); + const tpl = await ctx.db.checklistTemplate.findFirst({ where: { id: templateId, active: true } }); + if (!tpl) throw new ServiceError("invalid", "template_not_found"); + const items = z.array(templateItemSchema).safeParse(tpl.items).data ?? []; + const photos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? []; + const [haveItems, havePhotos] = await Promise.all([ + ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }), + ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }), + ]); + const itemKeys = new Set(haveItems.map((i) => i.key)); + const photoKeys = new Set(havePhotos.map((i) => i.key)); + let sort = Math.max(0, ...haveItems.map((i) => i.sortOrder)); + const newItems = items.filter((i) => !itemKeys.has(i.key)); + let psort = Math.max(0, ...havePhotos.map((i) => i.sortOrder)); + const newPhotos = photos.filter((p) => !photoKeys.has(p.key)); + if (newItems.length) { + await ctx.db.checklistItem.createMany({ + data: newItems.map((i) => ({ tenantId: ctx.tenantId, workOrderId, key: i.key, label: i.label, required: i.required, requiresPhoto: i.requiresPhoto, sortOrder: (sort += 10) })), + }); + } + if (newPhotos.length) { + await ctx.db.photoRequirement.createMany({ + data: newPhotos.map((p) => ({ tenantId: ctx.tenantId, workOrderId, key: p.key, label: p.label, sortOrder: (psort += 10) })), + skipDuplicates: true, + }); + } + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { + action: "update", + workOrderId, + after: { op: "checklist_template.apply", templateId, items: newItems.length, photos: newPhotos.length }, + }); + return { items: newItems.length, photos: newPhotos.length }; +} diff --git a/src/server/services/work-orders/completion.ts b/src/server/services/work-orders/completion.ts new file mode 100644 index 0000000..717028e --- /dev/null +++ b/src/server/services/work-orders/completion.ts @@ -0,0 +1,69 @@ +import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ServiceCtx } from "@/server/services/context"; +import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared"; + +/** + * Completion guards (ARCHITEKTUR §3): before `technically_completed` every required checklist + * item is checked (with photo where requested), every PhotoRequirement has ≥ 1 photo and no + * WorkSession is still open. + */ +export async function computeCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise { + const [items, requirements, sessions] = await Promise.all([ + ctx.db.checklistItem.findMany({ + where: { workOrderId, required: true }, + select: { id: true, label: true, checked: true, requiresPhoto: true, _count: { select: { photos: true } } }, + orderBy: { sortOrder: "asc" }, + }), + ctx.db.photoRequirement.findMany({ + where: { workOrderId }, + select: { id: true, label: true, _count: { select: { photos: true } } }, + orderBy: { sortOrder: "asc" }, + }), + ctx.db.workSession.findMany({ + where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, + select: { id: true, userId: true }, + }), + ]); + + const blockers: CompletionBlocker[] = []; + for (const i of items) { + if (!i.checked || (i.requiresPhoto && i._count.photos === 0)) { + blockers.push({ kind: "checklist_item", itemId: i.id, label: i.label }); + } + } + for (const r of requirements) { + if (r._count.photos === 0) blockers.push({ kind: "photo_requirement", requirementId: r.id, label: r.label }); + } + for (const s of sessions) blockers.push({ kind: "running_session", sessionId: s.id, userId: s.userId }); + return blockers; +} + +/** Public variant with visibility check (UI, API). */ +export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise { + await loadVisibleWorkOrder(ctx, workOrderId); + return computeCompletionBlockers(ctx, workOrderId); +} + +/** Blockers of a concrete transition (completion, signature, billing release). */ +export async function transitionBlockers( + ctx: ServiceCtx, + wo: { id: string; status: WorkOrderStatus; signatureRequired: boolean }, + to: WorkOrderStatus, +): Promise { + if (to === "technically_completed") return computeCompletionBlockers(ctx, wo.id); + + if (to === "in_review" && (wo.status === "technically_completed" || wo.status === "signature_pending") && wo.signatureRequired) { + // Any recorded outcome counts (signed, refused/absent with reason — spec §18.2). + const signatures = await ctx.db.signature.count({ + where: { report: { workOrderId: wo.id, type: "completion", status: { not: "superseded" } } }, + }); + return signatures > 0 ? [] : [{ kind: "missing_field", field: "signature" }]; + } + + if (to === "released_for_billing" && wo.status === "in_review") { + const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } }); + return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }]; + } + + return []; +} diff --git a/src/server/services/work-orders/conflicts.ts b/src/server/services/work-orders/conflicts.ts new file mode 100644 index 0000000..883dbfa --- /dev/null +++ b/src/server/services/work-orders/conflicts.ts @@ -0,0 +1,89 @@ +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { reapplySyncOperation } from "@/server/services/work-orders/sync-reapply"; + +/** Backoffice list of offline sync conflicts (ARCHITEKTUR §4.6) — `work_order:write`. */ +export async function listSyncConflicts(ctx: ServiceCtx) { + assertCan(ctx, "work_order:write"); + const ops = await ctx.db.syncOperation.findMany({ + where: { status: "conflict", resolvedAt: null }, + orderBy: { receivedAt: "desc" }, + take: 200, + }); + const userIds = [...new Set(ops.map((o) => o.userId))]; + const woIds = [...new Set(ops.filter((o) => o.entityType === "work_order" && o.entityId).map((o) => o.entityId!))]; + const [users, orders] = await Promise.all([ + userIds.length ? ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }) : [], + woIds.length ? ctx.db.workOrder.findMany({ where: { id: { in: woIds } }, select: { id: true, number: true, title: true, status: true, version: true } }) : [], + ]); + const u = new Map(users.map((x) => [x.id, x.name])); + const w = new Map(orders.map((x) => [x.id, x])); + return ops.map((o) => ({ + ...o, + userName: u.get(o.userId) ?? null, + workOrder: o.entityType === "work_order" && o.entityId ? w.get(o.entityId) ?? null : null, + })); +} + +async function loadOpenConflict(ctx: ServiceCtx, opId: string) { + assertCan(ctx, "work_order:write"); + const op = await ctx.db.syncOperation.findFirst({ where: { id: opId, status: "conflict", resolvedAt: null } }); + if (!op) throw new ServiceError("not_found", "sync_conflict_not_found"); + return op; +} + +/** Discard: mark resolved, nothing is applied. */ +export async function discardSyncConflict(ctx: ServiceCtx, opId: string): Promise { + const op = await loadOpenConflict(ctx, opId); + const now = new Date(); + const res = await ctx.db.syncOperation.updateMany({ + where: { id: op.id, resolvedAt: null }, + data: { status: "rejected", resolvedAt: now, resolvedById: ctx.userId, errorCode: "discarded" }, + }); + if (res.count !== 1) throw new ServiceError("conflict", "sync_conflict_already_resolved"); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "sync_operation", + entityId: op.id, + before: { status: op.status, resolvedAt: null }, + after: { status: "rejected", resolution: "discarded", resolvedAt: now.toISOString() }, + }); +} + +/** Load DB-authoritative permissions of the device user (the op is re-applied as that user). */ +async function permissionsOf(ctx: ServiceCtx, userId: string): Promise> { + const user = await ctx.db.user.findFirst({ + where: { id: userId, status: "ACTIVE" }, + select: { userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } } }, + }); + if (!user) throw new ServiceError("invalid", "sync_user_inactive"); + return new Set(user.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))); +} + +/** + * Take over: re-apply the operation against the current state, as the original device user + * (their permissions and scope), then mark it applied + resolved. Audit is written for both + * the domain change (by the service) and the resolution (resolver). + */ +export async function applySyncConflict(ctx: ServiceCtx, opId: string): Promise<{ entityVersion?: number }> { + const op = await loadOpenConflict(ctx, opId); + const opCtx: ServiceCtx = { db: ctx.db, tenantId: ctx.tenantId, userId: op.userId, permissions: await permissionsOf(ctx, op.userId) }; + const result = await reapplySyncOperation(opCtx, op); + const now = new Date(); + await ctx.db.syncOperation.updateMany({ + where: { id: op.id, resolvedAt: null }, + data: { status: "applied", resolvedAt: now, resolvedById: ctx.userId, errorCode: null, result: { reappliedBy: ctx.userId, ...result } }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "sync_operation", + entityId: op.id, + before: { status: op.status, resolvedAt: null }, + after: { status: "applied", resolution: "reapplied", resolvedAt: now.toISOString(), ...result }, + }); + return result; +} diff --git a/src/server/services/work-orders/create.ts b/src/server/services/work-orders/create.ts new file mode 100644 index 0000000..316ef6e --- /dev/null +++ b/src/server/services/work-orders/create.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import { + createWorkOrderSchema, + slugKey, + templateItemSchema, + templatePhotoSchema, + type CreateWorkOrderInput, +} from "@/lib/work-orders/schemas"; +import { nextNumber } from "@/server/services/numbering"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { auditWorkOrder, parseInput, snapshot, uniqueKey } from "@/server/services/work-orders/_shared"; + +export type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; + +export type CreatedWorkOrder = { id: string; number: string; status: string; version: number }; + +/** + * Create a work order (backoffice form, PDF import L3, emergency L8). + * - Permission: `work_order:write`; emergency orders alternatively `emergency:create`. + * - Number from the tenant sequence (`work_order` → A-…, `emergency` → N-…). + * - From the order type: signatureRequired (unless given) and the active checklist template + * (checklist items + required photos) unless explicit lists are passed or applyTemplate=false. + */ +export async function createWorkOrder(ctx: ServiceCtx, raw: CreateWorkOrderInput): Promise { + const input = parseInput(createWorkOrderSchema, raw); + if (!(can(ctx, "work_order:write") || (input.isEmergency && can(ctx, "emergency:create")))) { + assertCan(ctx, "work_order:write"); + } + if (input.status === "in_progress" && !input.isEmergency) { + throw new ServiceError("invalid", "initial_status_not_allowed", { status: input.status }); + } + + const customer = await ctx.db.customer.findFirst({ + where: { id: input.customerId, deletedAt: null, status: { not: "merged" } }, + select: { id: true }, + }); + if (!customer) throw new ServiceError("invalid", "customer_not_found"); + if (input.siteId) { + const site = await ctx.db.site.findFirst({ where: { id: input.siteId, deletedAt: null }, select: { customerId: true } }); + if (!site) throw new ServiceError("invalid", "site_not_found"); + if (site.customerId !== input.customerId) throw new ServiceError("invalid", "site_customer_mismatch"); + } + if (input.contactId) { + const contact = await ctx.db.contact.findFirst({ where: { id: input.contactId, deletedAt: null }, select: { customerId: true } }); + if (!contact) throw new ServiceError("invalid", "contact_not_found"); + if (contact.customerId !== input.customerId) throw new ServiceError("invalid", "contact_customer_mismatch"); + } + + let signatureRequired = input.signatureRequired ?? true; + let templateItems: z.output[] = []; + let templatePhotos: z.output[] = []; + if (input.orderTypeId) { + const ot = await ctx.db.orderType.findFirst({ + where: { id: input.orderTypeId, active: true }, + select: { id: true, signatureRequired: true }, + }); + if (!ot) throw new ServiceError("invalid", "order_type_not_found"); + if (input.signatureRequired === undefined) signatureRequired = ot.signatureRequired; + if (input.applyTemplate) { + const tpl = await ctx.db.checklistTemplate.findFirst({ + where: { orderTypeId: ot.id, active: true }, + orderBy: { updatedAt: "desc" }, + select: { items: true, requiredPhotos: true }, + }); + if (tpl) { + templateItems = z.array(templateItemSchema).safeParse(tpl.items).data ?? []; + templatePhotos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? []; + } + } + } + + const itemKeys = new Set(); + const items = (input.checklistItems ?? templateItems).map((i, idx) => ({ + tenantId: ctx.tenantId, + key: uniqueKey(i.key || slugKey(i.label), itemKeys), + label: i.label, + required: i.required ?? false, + requiresPhoto: i.requiresPhoto ?? false, + sortOrder: ("sortOrder" in i && typeof i.sortOrder === "number" ? i.sortOrder : (idx + 1) * 10), + })); + const photoKeys = new Set(); + const photos = (input.photoRequirements ?? templatePhotos).map((p, idx) => ({ + tenantId: ctx.tenantId, + key: uniqueKey(p.key || slugKey(p.label), photoKeys), + label: p.label, + sortOrder: ("sortOrder" in p && typeof p.sortOrder === "number" ? p.sortOrder : (idx + 1) * 10), + })); + const materials = (input.materials ?? []).map((m, idx) => ({ + tenantId: ctx.tenantId, + name: m.name, + articleNumber: m.articleNumber ?? null, + plannedQuantity: m.plannedQuantity, + unit: m.unit, + notes: m.notes ?? null, + sortOrder: m.sortOrder ?? (idx + 1) * 10, + })); + + const number = await nextNumber(ctx.db, ctx.tenantId, input.numberKey); + const wo = await ctx.db.workOrder.create({ + data: { + tenantId: ctx.tenantId, + number, + title: input.title, + customerId: input.customerId, + siteId: input.siteId ?? null, + contactId: input.contactId ?? null, + orderTypeId: input.orderTypeId ?? null, + priority: input.priority, + status: input.status, + description: input.description, + scope: input.scope, + plannedStart: input.plannedStart ?? null, + plannedEnd: input.plannedEnd ?? null, + signatureRequired, + billingType: input.billingType ?? null, + internalNotes: input.internalNotes, + technicianNotes: input.technicianNotes, + externalOrderNumber: input.externalOrderNumber, + offerNumber: input.offerNumber, + isEmergency: input.isEmergency, + emergencyReason: input.emergencyReason, + sourceImportId: input.sourceImportId ?? null, + createdById: ctx.userId, + checklistItems: { create: items }, + photoRequirements: { create: photos }, + materialPlans: { create: materials }, + statusHistory: { create: { tenantId: ctx.tenantId, fromStatus: null, toStatus: input.status, actorId: ctx.userId } }, + }, + select: { id: true, number: true, status: true, version: true }, + }); + + await auditWorkOrder(ctx, { + action: "create", + workOrderId: wo.id, + after: snapshot({ + ...input, + number, + signatureRequired, + checklistItems: items.length, + photoRequirements: photos.length, + materials: materials.length, + }), + }); + return wo; +} diff --git a/src/server/services/work-orders/dashboard.ts b/src/server/services/work-orders/dashboard.ts new file mode 100644 index 0000000..c5f3f7f --- /dev/null +++ b/src/server/services/work-orders/dashboard.ts @@ -0,0 +1,29 @@ +import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/filters"; +import { can, type ServiceCtx } from "@/server/services/context"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list"; + +export type DashboardTiles = Record & { reportsToReview: number; syncConflicts: number }; + +/** + * Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope. + * "Berichte zur Prüfung" counts reports (submitted / team_approved); "Sync-Konflikte" counts + * unresolved SyncOperations with status conflict. + */ +export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter): Promise { + const pc = { now: new Date(), timeZone: await tenantTimezone(ctx) }; + const base = await buildWorkOrderWhere(ctx, { ...filter, preset: undefined }, { pc }); + + const counts = await Promise.all( + PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })), + ); + const [reportsToReview, syncConflicts] = await Promise.all([ + ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }), + can(ctx, "work_order:write") + ? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } }) + : Promise.resolve(0), + ]); + + const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record; + return { ...tiles, reportsToReview, syncConflicts }; +} diff --git a/src/server/services/work-orders/detail.ts b/src/server/services/work-orders/detail.ts new file mode 100644 index 0000000..60c026f --- /dev/null +++ b/src/server/services/work-orders/detail.ts @@ -0,0 +1,121 @@ +import { allowedTransitions, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; +import { getMaterialOverview } from "@/server/services/work-orders/materials"; +import { mayTransition } from "@/server/services/work-orders/transition"; +import { allowedDocumentVisibility, workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Full read model of /work-orders/[id] and GET /api/v1/work-orders/[id]. */ +export async function getWorkOrderDetail(ctx: ServiceCtx, workOrderId: string) { + const scope = await workOrderScope(ctx); + const wo = await ctx.db.workOrder.findFirst({ + where: { AND: [{ id: workOrderId }, scope] }, + include: { + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, phone: true, email: true, street: true, houseNumber: true, postalCode: true, city: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, accessNotes: true, safetyNotes: true } }, + contact: { select: { id: true, name: true, phone: true, mobile: true, email: true } }, + orderType: { select: { id: true, name: true, key: true } }, + team: { select: { id: true, name: true } }, + teamLead: { select: { id: true, name: true } }, + assignees: { select: { user: { select: { id: true, name: true } } } }, + }, + }); + if (!wo) throw new ServiceError("not_found", "work_order_not_found"); + return wo; +} + +export type WorkOrderDetail = Awaited>; + +/** Transitions the current user may trigger now (UI buttons). */ +export function availableTransitions(ctx: ServiceCtx, status: WorkOrderStatus): WorkOrderStatus[] { + return allowedTransitions(status).filter((to) => mayTransition(ctx, status, to)); +} + +export async function getChecklistTab(ctx: ServiceCtx, workOrderId: string) { + const [items, requirements, blockers] = await Promise.all([ + ctx.db.checklistItem.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }), + ctx.db.photoRequirement.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }), + computeCompletionBlockers(ctx, workOrderId), + ]); + return { items, requirements, blockers: blockers as CompletionBlocker[] }; +} + +export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) { + const sessions = await ctx.db.workSession.findMany({ + where: { workOrderId }, + orderBy: { startedAt: "desc" }, + include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } }, + }); + return sessions.map((s) => { + const minutes = s.entries + .filter((e) => e.type !== "break") + .reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0); + return { ...s, workMinutes: Math.round(minutes) }; + }); +} + +export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) { + const photos = await ctx.db.photo.findMany({ + where: { workOrderId }, + orderBy: { takenAt: "asc" }, + include: { photoRequirement: { select: { label: true } }, checklistItem: { select: { label: true } } }, + }); + const docs = photos.length + ? await ctx.db.document.findMany({ + where: { id: { in: photos.map((p) => p.documentId) }, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } }, + select: { id: true, fileName: true, storageKey: true, previewKey: true }, + }) + : []; + const byId = new Map(docs.map((d) => [d.id, d])); + return photos.filter((p) => byId.has(p.documentId)).map((p) => ({ ...p, document: byId.get(p.documentId)! })); +} + +export async function getNotesTab(ctx: ServiceCtx, workOrderId: string) { + const notes = await ctx.db.activityNote.findMany({ where: { workOrderId, deletedAt: null }, orderBy: { createdAt: "desc" } }); + const names = await userNames(ctx, notes.map((n) => n.authorId)); + return notes.map((n) => ({ ...n, authorName: n.authorId ? names.get(n.authorId) ?? null : null })); +} + +export async function getReportsTab(ctx: ServiceCtx, workOrderId: string) { + return ctx.db.report.findMany({ + where: { workOrderId, status: { not: "superseded" } }, + orderBy: [{ reportDate: "desc" }, { version: "desc" }], + select: { id: true, type: true, reportDate: true, version: true, status: true, approvedAt: true, rejectionReason: true, signature: { select: { outcome: true, signerName: true } } }, + }); +} + +export async function getDocumentsTab(ctx: ServiceCtx, workOrderId: string) { + return ctx.db.document.findMany({ + where: { workOrderId, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: ["photo", "voice_note", "signature"] } }, + orderBy: { createdAt: "desc" }, + select: { id: true, title: true, fileName: true, category: true, visibility: true, version: true, mimeType: true, fileSize: true, storageKey: true, createdAt: true }, + }); +} + +export async function getHistoryTab(ctx: ServiceCtx, workOrderId: string) { + const [changes, audit] = await Promise.all([ + ctx.db.workOrderStatusChange.findMany({ where: { workOrderId }, orderBy: { createdAt: "desc" } }), + can(ctx, "audit:read") || can(ctx, "work_order:read_all") + ? ctx.db.auditLog.findMany({ + where: { entity: "work_order", entityId: workOrderId }, + orderBy: { createdAt: "desc" }, + take: 200, + select: { id: true, action: true, actorId: true, createdAt: true, before: true, after: true }, + }) + : Promise.resolve([]), + ]); + const names = await userNames(ctx, [...changes.map((c) => c.actorId), ...audit.map((a) => a.actorId)]); + return { + changes: changes.map((c) => ({ ...c, actorName: c.actorId ? names.get(c.actorId) ?? null : null })), + audit: audit.map((a) => ({ ...a, actorName: a.actorId ? names.get(a.actorId) ?? null : null })), + }; +} + +export { getMaterialOverview }; + +async function userNames(ctx: ServiceCtx, ids: (string | null)[]): Promise> { + const unique = [...new Set(ids.filter((x): x is string => !!x))]; + if (!unique.length) return new Map(); + const users = await ctx.db.user.findMany({ where: { id: { in: unique } }, select: { id: true, name: true } }); + return new Map(users.map((u) => [u.id, u.name])); +} diff --git a/src/server/services/work-orders/documents.ts b/src/server/services/work-orders/documents.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4fd854c892974f49d02e23bda51868d5c3b6e5e GIT binary patch literal 4781 zcmbVQ?Q+|=742`mL}7Mjhe{R7cH~{J96R;eN#ogMvlH9x53fgtNKi&Y5gY)rtU8|Q z)3oo=N9mLFT!0`YS>ByCKOFM{xL-K;+zZQTrL<)aEY*Tr@t&L89@D(c)b#T5?36vS zT&pRIN|lLWsuz`2(LqQ0Q+81a96eR(Y${570tFML7rjRE7ir{J7Sb*nMSrTaG*jMB z3yCAGt_NnU(tIM?oqZ$pozVSyq0cj3S)rHDaDzQCT=rN>KhAMNrsA#EO81!auNIxy z`wg1?cGFK)X~q4DJA9T&`&XsEeA1bKvO@99#Z!nz`YRgeIQdf1QPUrpTdqY0$2 z$~^xlry_P)7_v)Q+Wlj#`Jy{S^b`vFidc%mh2bL}BPh5304~d%#p9$9<;32yqeHgG zzWIiYuePt*;o%|MzDJl&uoDD(+g`961cQMWylMpBdchzFcC#+i!p?O0G?ANpc;(MG zKIFL9!0#}V>|l@?d#W8 zFG3Lau5)>(E`R>;>G*VXa(waj&!0Y@y*-DAS5&Qzfk)VsO9V<46}c+5BE2n9?Zqh5 ze2yCO;=E8wkKk0jZ7!+4Mw#Rj&8L13^_=OrSd1jNMbBKB8oy+CF6?3ySlbILtupLd zlLmeqmC6dgQGt{-e!XVg)X6q*+2m3f+#t_XSuC2~8oKS(HRz&F1~-c`Wes;cL+df! zrM(}9Zi&`NIdNTLjmHBJ4m_JIv(Ev`Ysj>lGLaV4>4>jdDLB>51;Z!4AF#V-LTFdp zso{WGX$$ct=n6#l*z0s1+co{)ccds}emK|YIZ>Ce?-4RrfCQg&X*YIqf8M1aS?Xg> z0r96_X}xvS8I5`RMddkAI-&y0^Kb`lVu>RR z0%MtF0`Ox|Yi-oZ8;`c)r=SOJ8l3pnaCO~HWSJJTOqe=|a2>zmhAi(u3K=;OcEf!y zg9A-cU2_r{GTXK(pDEXTT3^FP)Nt*^GE^vS{@99i3*9Lkq{MDINgb?d$rE=0;mFDo zx^));6OFNFvmZi4*o5s7&5u-{BR7#?fO7#oQmr(QaDgWD^Mh}&uBQ@NEhS=k{iq&C zu4+H8pGU(Jf0P=bz9tUX%_W2~BvO9D=H7|0#rf~+A-xsp7c-mUR6F{|5r#yJRp8!P zMI2tI+}~!B$%!yFj&8;Ma%s7-kU-$eYiS~jAD9&-FYQ@2B+5y`SI?1$Z#BU=%#1XJ zm-Yf_N5#|NBQQNWHUq(RBR&OrL?48u096T;D8{yVH7I4V@#ep7| z^@HyiKyjtAdQA`D#CnC^pLj2B0M*LHZo-*+y3#EW?{C5S^PElXB3HB0HP7IuYa(8{ zgu6|DQSj))x#cNjYw6HB!+CUmU={YGW@S_y1dd-&M-FWzYz^8f$< literal 0 HcmV?d00001 diff --git a/src/server/services/work-orders/list.ts b/src/server/services/work-orders/list.ts new file mode 100644 index 0000000..63144de --- /dev/null +++ b/src/server/services/work-orders/list.ts @@ -0,0 +1,161 @@ +import type { Prisma } from "@prisma/client"; +import { OPEN_STATUSES, STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ListParams, Preset, WorkOrderFilter } from "@/lib/work-orders/filters"; +import type { ServiceCtx } from "@/server/services/context"; +import { tenantTimezone, zonedDayBounds } from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Work still to be done (no completion yet) — basis of "overdue". */ +const PENDING_WORK: WorkOrderStatus[] = [ + "draft", + "review_required", + "planned", + "assigned", + "accepted", + "en_route", + "in_progress", + "paused", + "waiting_material", + "daily_report_created", +]; + +export type PresetContext = { now: Date; timeZone: string }; + +/** Dashboard tile definitions (§21) — shared by dashboard counts and the list `preset` filter. */ +export function presetWhere(preset: Preset, pc: PresetContext): Prisma.WorkOrderWhereInput { + const open = { status: { in: [...OPEN_STATUSES] } }; + switch (preset) { + case "open": + return open; + case "today": { + const { start, end } = zonedDayBounds(pc.now, pc.timeZone); + return { + ...open, + plannedStart: { lte: end }, + OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }], + }; + } + case "running": + return { status: { in: ["en_route", "in_progress", "paused", "waiting_material"] } }; + case "not_accepted": + return { status: "assigned" }; + case "overdue": + return { status: { in: PENDING_WORK }, plannedEnd: { lt: pc.now } }; + case "reports_in_review": + return { reports: { some: { status: { in: ["submitted", "team_approved"] } } } }; + case "completed": + return { status: { in: ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"] } }; + case "billing": + return { status: "released_for_billing" }; + case "emergency_new": + return { + isEmergency: true, + OR: [ + { status: { in: ["draft", "review_required", "in_review"] } }, + { status: { in: [...OPEN_STATUSES] }, createdAt: { gte: new Date(pc.now.getTime() - 24 * 3600 * 1000) } }, + ], + }; + case "missing_signatures": + return { status: "signature_pending" }; + } +} + +function statusesOfGroup(group: StatusGroup): WorkOrderStatus[] { + return (Object.keys(STATUS_GROUP) as WorkOrderStatus[]).filter((s) => STATUS_GROUP[s] === group); +} + +/** + * Filter → where (always AND-ed with workOrderScope). `omitStatus` drops status/group so the + * status-group tabs can show counts for the remaining filter. + */ +export async function buildWorkOrderWhere( + ctx: ServiceCtx, + f: WorkOrderFilter, + opts: { omitStatus?: boolean; pc?: PresetContext } = {}, +): Promise { + const and: Prisma.WorkOrderWhereInput[] = [await workOrderScope(ctx)]; + if (f.from) and.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] }); + if (f.to) and.push({ plannedStart: { lte: f.to } }); + if (f.customerId) and.push({ customerId: f.customerId }); + if (f.siteId) and.push({ siteId: f.siteId }); + if (f.teamId) and.push({ assignedTeamId: f.teamId }); + if (f.userId) and.push({ OR: [{ assignees: { some: { userId: f.userId } } }, { teamLeadUserId: f.userId }] }); + if (f.orderTypeId) and.push({ orderTypeId: f.orderTypeId }); + if (f.priority) and.push({ priority: f.priority }); + if (!opts.omitStatus) { + if (f.statuses?.length) and.push({ status: { in: f.statuses } }); + if (f.group) and.push({ status: { in: statusesOfGroup(f.group) } }); + } + if (f.preset) { + const pc = opts.pc ?? { now: new Date(), timeZone: await tenantTimezone(ctx) }; + and.push(presetWhere(f.preset, pc)); + } + if (f.q) { + const q = { contains: f.q, mode: "insensitive" as const }; + and.push({ + OR: [ + { number: q }, + { title: q }, + { externalOrderNumber: q }, + { customer: { companyName: q } }, + { customer: { lastName: q } }, + { site: { name: q } }, + { site: { city: q } }, + ], + }); + } + return { AND: and }; +} + +export const LIST_SELECT = { + id: true, + number: true, + title: true, + status: true, + priority: true, + plannedStart: true, + plannedEnd: true, + isEmergency: true, + version: true, + updatedAt: true, + customer: { select: { id: true, companyName: true, firstName: true, lastName: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true } }, + team: { select: { id: true, name: true } }, + orderType: { select: { id: true, name: true } }, + assignees: { select: { user: { select: { id: true, name: true } } } }, +} satisfies Prisma.WorkOrderSelect; + +export type WorkOrderListItem = Prisma.WorkOrderGetPayload<{ select: typeof LIST_SELECT }>; + +export type WorkOrderListResult = { + items: WorkOrderListItem[]; + total: number; + page: number; + pageSize: number; + groupCounts: Record; +}; + +export async function listWorkOrders(ctx: ServiceCtx, p: ListParams): Promise { + const pc = p.preset ? { now: new Date(), timeZone: await tenantTimezone(ctx) } : undefined; + const [where, whereNoStatus] = await Promise.all([ + buildWorkOrderWhere(ctx, p, { pc }), + buildWorkOrderWhere(ctx, p, { omitStatus: true, pc }), + ]); + const orderBy: Prisma.WorkOrderOrderByWithRelationInput[] = + p.sort === "plannedStart" + ? [{ plannedStart: { sort: p.dir, nulls: "last" } }, { number: "asc" }] + : [{ [p.sort]: p.dir }, { number: "asc" }]; + + const [items, total, grouped] = await Promise.all([ + ctx.db.workOrder.findMany({ where, select: LIST_SELECT, orderBy, skip: (p.page - 1) * p.pageSize, take: p.pageSize }), + ctx.db.workOrder.count({ where }), + ctx.db.workOrder.groupBy({ by: ["status"], where: whereNoStatus, _count: { _all: true } }), + ]); + + const groupCounts = Object.fromEntries( + ["new", "planned", "en_route", "in_progress", "documentation_incomplete", "in_review", "ready_for_billing", "billed", "cancelled"].map((g) => [g, 0]), + ) as Record; + for (const row of grouped) groupCounts[STATUS_GROUP[row.status as WorkOrderStatus]] += row._count._all; + + return { items, total, page: p.page, pageSize: p.pageSize, groupCounts }; +} diff --git a/src/server/services/work-orders/materials.ts b/src/server/services/work-orders/materials.ts new file mode 100644 index 0000000..e1661a5 --- /dev/null +++ b/src/server/services/work-orders/materials.ts @@ -0,0 +1,133 @@ +import { materialPlanInputSchema, type MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertNotLocked, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + PLANNING_LOCKED, + snapshot, + touchWorkOrder, +} from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Material plan (Materialvorgabe, spec §13.1) — maintained by the backoffice (`work_order:write`). */ + +async function loadPlan(ctx: ServiceCtx, planId: string) { + const scope = await workOrderScope(ctx); + const plan = await ctx.db.materialPlan.findFirst({ where: { id: planId, workOrder: scope } }); + if (!plan) throw new ServiceError("not_found", "material_plan_not_found"); + return plan; +} + +export async function addMaterialPlan(ctx: ServiceCtx, workOrderId: string, raw: MaterialPlanInput) { + assertCan(ctx, "work_order:write"); + const input = parseInput(materialPlanInputSchema, raw); + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + const count = await ctx.db.materialPlan.count({ where: { workOrderId } }); + const plan = await ctx.db.materialPlan.create({ + data: { + tenantId: ctx.tenantId, + workOrderId, + name: input.name, + articleNumber: input.articleNumber, + plannedQuantity: input.plannedQuantity, + unit: input.unit, + notes: input.notes, + sortOrder: input.sortOrder ?? (count + 1) * 10, + }, + }); + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "material_plan.add", plan: snapshot(plan) } }); + return plan; +} + +export async function updateMaterialPlan(ctx: ServiceCtx, planId: string, raw: MaterialPlanInput) { + assertCan(ctx, "work_order:write"); + const input = parseInput(materialPlanInputSchema, raw); + const before = await loadPlan(ctx, planId); + const wo = await loadVisibleWorkOrder(ctx, before.workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + const plan = await ctx.db.materialPlan.update({ + where: { id: planId }, + data: { + name: input.name, + articleNumber: input.articleNumber, + plannedQuantity: input.plannedQuantity, + unit: input.unit, + notes: input.notes, + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }, + }); + await touchWorkOrder(ctx, wo.id); + await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.update", plan: snapshot(before) }, after: { plan: snapshot(plan) } }); + return plan; +} + +/** MaterialPlan has no deletedAt (planning data). Removal is refused once usages reference it. */ +export async function removeMaterialPlan(ctx: ServiceCtx, planId: string): Promise { + assertCan(ctx, "work_order:write"); + const before = await loadPlan(ctx, planId); + const wo = await loadVisibleWorkOrder(ctx, before.workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + const usages = await ctx.db.materialUsage.count({ where: { materialPlanId: planId } }); + if (usages > 0) throw new ServiceError("invalid", "material_in_use"); + await ctx.db.materialPlan.deleteMany({ where: { id: planId } }); + await touchWorkOrder(ctx, wo.id); + await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.remove", plan: snapshot(before) } }); +} + +export type MaterialRow = { + planId: string | null; + name: string; + articleNumber: string | null; + unit: string; + planned: number | null; + actual: number | null; + deviation: number | null; + statuses: string[]; + reasons: string[]; + notes: string | null; +}; + +/** Planned vs. actual incl. deviations (backoffice read view, US-009). */ +export async function getMaterialOverview(ctx: ServiceCtx, workOrderId: string): Promise { + await loadVisibleWorkOrder(ctx, workOrderId); + const [plans, usages] = await Promise.all([ + ctx.db.materialPlan.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" } }), + ctx.db.materialUsage.findMany({ where: { workOrderId }, orderBy: { createdAt: "asc" } }), + ]); + const rows: MaterialRow[] = plans.map((p) => { + const u = usages.filter((x) => x.materialPlanId === p.id); + const planned = Number(p.plannedQuantity); + const actual = u.length ? u.reduce((s, x) => s + Number(x.actualQuantity), 0) : null; + return { + planId: p.id, + name: p.name, + articleNumber: p.articleNumber, + unit: p.unit, + planned, + actual, + deviation: actual === null ? null : Math.round((actual - planned) * 1000) / 1000, + statuses: [...new Set(u.map((x) => x.usageStatus))], + reasons: u.map((x) => x.deviationReason).filter((r): r is string => !!r), + notes: p.notes, + }; + }); + for (const x of usages.filter((u) => !u.materialPlanId)) { + rows.push({ + planId: null, + name: x.name, + articleNumber: x.articleNumber, + unit: x.unit, + planned: null, + actual: Number(x.actualQuantity), + deviation: Number(x.actualQuantity), + statuses: [x.usageStatus], + reasons: x.deviationReason ? [x.deviationReason] : [], + notes: x.notes, + }); + } + return rows; +} diff --git a/src/server/services/work-orders/options.ts b/src/server/services/work-orders/options.ts new file mode 100644 index 0000000..ccb2b09 --- /dev/null +++ b/src/server/services/work-orders/options.ts @@ -0,0 +1,81 @@ +import type { ServiceCtx } from "@/server/services/context"; +import { customerScope } from "@/server/services/work-orders/visibility"; + +/** Lookup data for the create/assign popups and filter bars (read-only, scoped). */ + +export async function searchCustomerOptions(ctx: ServiceCtx, q: string | undefined) { + const scope = await customerScope(ctx); + const term = q?.trim(); + const like = term ? { contains: term.slice(0, 100), mode: "insensitive" as const } : undefined; + return ctx.db.customer.findMany({ + where: { + AND: [ + scope, + { status: { not: "merged" } }, + like ? { OR: [{ companyName: like }, { lastName: like }, { firstName: like }, { customerNumber: like }, { city: like }, { street: like }] } : {}, + ], + }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true }, + orderBy: { updatedAt: "desc" }, + take: 20, + }); +} + +/** Customer select for filter bars (dashboard / list). */ +export async function customerFilterOptions(ctx: ServiceCtx) { + const scope = await customerScope(ctx); + return ctx.db.customer.findMany({ + where: { AND: [scope, { status: { not: "merged" } }] }, + select: { id: true, companyName: true, firstName: true, lastName: true }, + orderBy: [{ companyName: "asc" }, { lastName: "asc" }], + take: 300, + }); +} + +export async function siteFilterOptions(ctx: ServiceCtx, customerId: string) { + return ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true }, orderBy: { name: "asc" } }); +} + +export async function customerOption(ctx: ServiceCtx, customerId: string) { + const scope = await customerScope(ctx); + const customer = await ctx.db.customer.findFirst({ + where: { AND: [scope, { id: customerId }] }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true }, + }); + if (!customer) return null; + const [sites, contacts] = await Promise.all([ + ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, city: true }, orderBy: { name: "asc" } }), + ctx.db.contact.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, role: true }, orderBy: { name: "asc" } }), + ]); + return { customer, sites, contacts }; +} + +export async function teamOptions(ctx: ServiceCtx) { + const now = new Date(); + return ctx.db.team.findMany({ + where: { deletedAt: null, status: "active" }, + select: { + id: true, + name: true, + leaderUserId: true, + members: { + where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] }, + select: { user: { select: { id: true, name: true } } }, + }, + }, + orderBy: { name: "asc" }, + }); +} + +export async function userOptions(ctx: ServiceCtx) { + return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }); +} + +export async function checklistTemplateOptions(ctx: ServiceCtx) { + return ctx.db.checklistTemplate.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }); +} + +export function customerDisplayName(c: { companyName?: string | null; firstName?: string | null; lastName?: string | null } | null | undefined): string { + if (!c) return ""; + return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—"; +} diff --git a/src/server/services/work-orders/release-billing.ts b/src/server/services/work-orders/release-billing.ts new file mode 100644 index 0000000..5ad97fb --- /dev/null +++ b/src/server/services/work-orders/release-billing.ts @@ -0,0 +1,34 @@ +import type { ServiceCtx } from "@/server/services/context"; +import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition"; + +/** + * Billing workflow (US-009). All paths go through transitionWorkOrder, which enforces + * `work_order:release_billing` and — for the release — an approved completion report. + * The event `work_order.released_for_billing` is emitted by the transition. + */ +export async function releaseForBilling( + ctx: ServiceCtx, + input: { workOrderId: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "released_for_billing", baseVersion: input.baseVersion }); +} + +/** Reject: back to the team for correction (in_review → in_progress), reason mandatory. */ +export async function rejectForCorrection( + ctx: ServiceCtx, + input: { workOrderId: string; reason: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_progress", reason: input.reason, baseVersion: input.baseVersion }); +} + +/** Revoke a billing release (released_for_billing → in_review), reason mandatory. */ +export async function revokeBillingRelease( + ctx: ServiceCtx, + input: { workOrderId: string; reason: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_review", reason: input.reason, baseVersion: input.baseVersion }); +} + +export async function markBilled(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion }); +} diff --git a/src/server/services/work-orders/search.ts b/src/server/services/work-orders/search.ts new file mode 100644 index 0000000..61f7fea --- /dev/null +++ b/src/server/services/work-orders/search.ts @@ -0,0 +1,110 @@ +import type { Prisma } from "@prisma/client"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility"; + +export type SearchFilter = { q: string; from?: Date; to?: Date; statuses?: WorkOrderStatus[]; teamId?: string }; + +const TAKE = 20; + +/** + * Tenant search (spec §25) with ILIKE (`contains` + insensitive) — every section is restricted by + * the same scopes as the detail views (work orders, customers, sites, document visibility). + * Period/status/team filters apply to everything that hangs off a work order. + */ +export async function searchAll(ctx: ServiceCtx, f: SearchFilter) { + const q = f.q.trim(); + const empty = { workOrders: [], customers: [], sites: [], contacts: [], documents: [], notes: [] }; + if (q.length < 2) return empty; + const like = { contains: q.slice(0, 100), mode: "insensitive" as const }; + + const [woScope, cScope, sScope] = await Promise.all([workOrderScope(ctx), customerScope(ctx), siteScope(ctx)]); + const woFilter: Prisma.WorkOrderWhereInput[] = [woScope]; + if (f.from) woFilter.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] }); + if (f.to) woFilter.push({ plannedStart: { lte: f.to } }); + if (f.statuses?.length) woFilter.push({ status: { in: f.statuses } }); + if (f.teamId) woFilter.push({ assignedTeamId: f.teamId }); + const woWhere: Prisma.WorkOrderWhereInput = { AND: woFilter }; + const hasWoFilter = woFilter.length > 1; + + const [workOrders, customers, sites, contacts, documents, notes] = await Promise.all([ + ctx.db.workOrder.findMany({ + where: { + AND: [ + woWhere, + { + OR: [ + { number: like }, + { externalOrderNumber: like }, + { offerNumber: like }, + { title: like }, + { description: like }, + { scope: like }, + { site: { OR: [{ street: like }, { city: like }, { postalCode: like }] } }, + ], + }, + ], + }, + select: { id: true, number: true, title: true, status: true, plannedStart: true, customer: { select: { companyName: true, firstName: true, lastName: true } }, site: { select: { name: true, city: true } }, team: { select: { name: true } } }, + orderBy: { updatedAt: "desc" }, + take: TAKE, + }), + ctx.db.customer.findMany({ + where: { + AND: [ + cScope, + hasWoFilter ? { workOrders: { some: woWhere } } : {}, + { OR: [{ customerNumber: like }, { companyName: like }, { firstName: like }, { lastName: like }, { street: like }, { city: like }, { postalCode: like }, { email: like }, { phone: like }] }, + ], + }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, status: true }, + orderBy: { updatedAt: "desc" }, + take: TAKE, + }), + ctx.db.site.findMany({ + where: { + AND: [ + sScope, + hasWoFilter ? { workOrders: { some: woWhere } } : {}, + { OR: [{ name: like }, { street: like }, { city: like }, { postalCode: like }, { onSiteContact: like }] }, + ], + }, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, customer: { select: { companyName: true, firstName: true, lastName: true } } }, + orderBy: { updatedAt: "desc" }, + take: TAKE, + }), + ctx.db.contact.findMany({ + where: { AND: [{ deletedAt: null }, { customer: cScope }, { OR: [{ name: like }, { email: like }, { phone: like }, { mobile: like }] }] }, + select: { id: true, name: true, role: true, phone: true, email: true, customer: { select: { id: true, companyName: true, firstName: true, lastName: true } } }, + take: TAKE, + }), + ctx.db.document.findMany({ + where: { + AND: [ + { deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } }, + { OR: [{ fileName: like }, { title: like }] }, + // documents on an order follow the order scope; unlinked-to-order documents need the customer/site scope + { + OR: [ + { workOrder: woWhere }, + ...(hasWoFilter ? [] : [{ workOrderId: null, customer: cScope }, { workOrderId: null, customerId: null, site: sScope }]), + ], + }, + ], + }, + select: { id: true, title: true, fileName: true, category: true, workOrderId: true, customerId: true, siteId: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + take: TAKE, + }), + ctx.db.activityNote.findMany({ + where: { deletedAt: null, text: like, workOrder: woWhere }, + select: { id: true, text: true, kind: true, createdAt: true, workOrder: { select: { id: true, number: true, title: true } } }, + orderBy: { createdAt: "desc" }, + take: TAKE, + }), + ]); + + return { workOrders, customers, sites, contacts, documents, notes }; +} + +export type SearchResults = Awaited>; diff --git a/src/server/services/work-orders/settings.ts b/src/server/services/work-orders/settings.ts new file mode 100644 index 0000000..d4fbc99 --- /dev/null +++ b/src/server/services/work-orders/settings.ts @@ -0,0 +1,142 @@ +import { DEFAULT_NUMBER_PREFIX, DEFAULT_ORDER_TYPES } from "@/lib/work-orders/defaults"; +import { + checklistTemplateSchema, + numberingSchema, + NUMBER_KEYS, + orderTypeSchema, + type ChecklistTemplateInput, + type OrderTypeInput, +} from "@/lib/work-orders/schemas"; +import { writeAuditLog } from "@/server/audit"; +import type { TenantDb } from "@/server/db"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { parseInput, snapshot } from "@/server/services/work-orders/_shared"; + +/** + * Tenant settings of the work order module (`settings:templates`): order types (§10.2), + * checklist templates incl. required photos (§12.4/§14.2) and number sequences. + */ + +/** Seed the default order types once per tenant (only if the tenant has none yet). Idempotent. */ +export async function ensureDefaultOrderTypes(db: TenantDb, tenantId: string): Promise { + const existing = await db.orderType.count(); + if (existing > 0) return 0; + const res = await db.orderType.createMany({ + data: DEFAULT_ORDER_TYPES.map((t) => ({ tenantId, ...t })), + skipDuplicates: true, + }); + return res.count; +} + +async function audit(ctx: ServiceCtx, entity: string, entityId: string, action: "create" | "update", before: unknown, after: unknown) { + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after }); +} + +// ---------- Order types ---------- + +export async function listOrderTypes(ctx: ServiceCtx, opts: { activeOnly?: boolean } = {}) { + await ensureDefaultOrderTypes(ctx.db, ctx.tenantId); + return ctx.db.orderType.findMany({ + where: opts.activeOnly ? { active: true } : {}, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); +} + +export async function createOrderType(ctx: ServiceCtx, raw: OrderTypeInput) { + assertCan(ctx, "settings:templates"); + const input = parseInput(orderTypeSchema, raw); + const dup = await ctx.db.orderType.findFirst({ where: { key: input.key }, select: { id: true } }); + if (dup) throw new ServiceError("invalid", "order_type_key_taken"); + const ot = await ctx.db.orderType.create({ data: { tenantId: ctx.tenantId, ...input } }); + await audit(ctx, "order_type", ot.id, "create", undefined, snapshot(ot)); + return ot; +} + +export async function updateOrderType(ctx: ServiceCtx, id: string, raw: Omit) { + assertCan(ctx, "settings:templates"); + const input = parseInput(orderTypeSchema.omit({ key: true }), raw); + const before = await ctx.db.orderType.findFirst({ where: { id } }); + if (!before) throw new ServiceError("not_found", "order_type_not_found"); + const ot = await ctx.db.orderType.update({ where: { id }, data: input }); + await audit(ctx, "order_type", id, "update", snapshot(before), snapshot(ot)); + return ot; +} + +// ---------- Checklist templates ---------- + +export async function listChecklistTemplates(ctx: ServiceCtx) { + return ctx.db.checklistTemplate.findMany({ + orderBy: [{ active: "desc" }, { name: "asc" }], + include: { orderType: { select: { id: true, name: true } } }, + }); +} + +async function assertOrderType(ctx: ServiceCtx, orderTypeId: string | null | undefined) { + if (!orderTypeId) return; + const ot = await ctx.db.orderType.findFirst({ where: { id: orderTypeId }, select: { id: true } }); + if (!ot) throw new ServiceError("invalid", "order_type_not_found"); +} + +function assertUniqueKeys(list: { key: string }[], code: string) { + if (new Set(list.map((i) => i.key)).size !== list.length) throw new ServiceError("invalid", code); +} + +export async function createChecklistTemplate(ctx: ServiceCtx, raw: ChecklistTemplateInput) { + assertCan(ctx, "settings:templates"); + const input = parseInput(checklistTemplateSchema, raw); + await assertOrderType(ctx, input.orderTypeId); + assertUniqueKeys(input.items, "duplicate_item_key"); + assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key"); + const tpl = await ctx.db.checklistTemplate.create({ + data: { tenantId: ctx.tenantId, name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos }, + }); + await audit(ctx, "checklist_template", tpl.id, "create", undefined, snapshot(tpl)); + return tpl; +} + +export async function updateChecklistTemplate(ctx: ServiceCtx, id: string, raw: ChecklistTemplateInput) { + assertCan(ctx, "settings:templates"); + const input = parseInput(checklistTemplateSchema, raw); + const before = await ctx.db.checklistTemplate.findFirst({ where: { id } }); + if (!before) throw new ServiceError("not_found", "template_not_found"); + await assertOrderType(ctx, input.orderTypeId); + assertUniqueKeys(input.items, "duplicate_item_key"); + assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key"); + const tpl = await ctx.db.checklistTemplate.update({ + where: { id }, + data: { name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos }, + }); + await audit(ctx, "checklist_template", id, "update", snapshot(before), snapshot(tpl)); + return tpl; +} + +// ---------- Number sequences ---------- + +export async function listNumberSequences(ctx: ServiceCtx) { + const rows = await ctx.db.numberSequence.findMany(); + return NUMBER_KEYS.map((key) => { + const r = rows.find((x) => x.key === key); + return { key, prefix: r?.prefix ?? DEFAULT_NUMBER_PREFIX[key], padding: r?.padding ?? 5, nextValue: r?.nextValue ?? 1, exists: !!r }; + }); +} + +/** Changes prefix/padding only — the counter itself is never reset (numbers stay unique). */ +export async function updateNumberSequence(ctx: ServiceCtx, raw: { key: string; prefix: string; padding: number | string }) { + assertCan(ctx, "settings:templates"); + const input = parseInput(numberingSchema, raw); + const before = await ctx.db.numberSequence.findFirst({ where: { key: input.key } }); + let row; + if (before) { + row = await ctx.db.numberSequence.update({ where: { id: before.id }, data: { prefix: input.prefix, padding: input.padding } }); + } else { + try { + row = await ctx.db.numberSequence.create({ data: { tenantId: ctx.tenantId, key: input.key, prefix: input.prefix, padding: input.padding } }); + } catch (err) { + if ((err as { code?: string }).code !== "P2002") throw err; + const cur = await ctx.db.numberSequence.findFirst({ where: { key: input.key } }); + row = await ctx.db.numberSequence.update({ where: { id: cur!.id }, data: { prefix: input.prefix, padding: input.padding } }); + } + } + await audit(ctx, "number_sequence", row.id, before ? "update" : "create", before ? snapshot(before) : undefined, snapshot(row)); + return row; +} diff --git a/src/server/services/work-orders/sync-reapply.ts b/src/server/services/work-orders/sync-reapply.ts new file mode 100644 index 0000000..bfc603f --- /dev/null +++ b/src/server/services/work-orders/sync-reapply.ts @@ -0,0 +1,25 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; + +/** + * STUB (lane L2) until lane L4 delivers `src/server/services/sync/apply.ts`. + * Contract (ARCHITEKTUR §4.6): re-dispatch a stored SyncOperation onto the domain services + * against the CURRENT state (no baseVersion). Replace the body with a call to the L4 dispatcher + * after merge; the signature stays. + * + * MVP scope of the stub: only `work_order.transition` (the only conflict-prone op besides + * `report.submit`, which belongs to lane reports). + */ +export async function reapplySyncOperation( + opCtx: ServiceCtx, + op: { opType: string; entityId: string | null; payload: unknown }, +): Promise<{ entityVersion?: number }> { + if (op.opType === "work_order.transition") { + const p = (op.payload ?? {}) as { to?: string; reason?: string; workOrderId?: string }; + const workOrderId = op.entityId ?? p.workOrderId; + if (!workOrderId || !p.to) throw new ServiceError("invalid", "sync_payload_invalid"); + const res = await transitionWorkOrder(opCtx, { workOrderId, to: p.to as never, reason: p.reason ?? null }); + return { entityVersion: res.version }; + } + throw new ServiceError("invalid", "reapply_unsupported", { opType: op.opType }); +} diff --git a/src/server/services/work-orders/transition.ts b/src/server/services/work-orders/transition.ts new file mode 100644 index 0000000..95d0097 --- /dev/null +++ b/src/server/services/work-orders/transition.ts @@ -0,0 +1,107 @@ +import type { EventType } from "@/lib/events"; +import { canTransition, requiredPermission, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { transitionSchema, type TransitionInput } from "@/lib/work-orders/schemas"; +import { emitEvent } from "@/server/events"; +import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertBaseVersion, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + writeWithVersion, + type WorkOrderBase, +} from "@/server/services/work-orders/_shared"; +import { transitionBlockers } from "@/server/services/work-orders/completion"; + +/** Permission decision for a single transition (scope is checked separately by loading the order). */ +export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean { + if (from === "in_review" && to === "in_progress") return can(ctx, "report:approve_team") || can(ctx, "report:approve"); + return can(ctx, requiredPermission(from, to)); +} + +/** Transitions that need a reason (cancellation, correction request, revoking a billing release). */ +export function reasonRequired(from: WorkOrderStatus, to: WorkOrderStatus): boolean { + return to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review"); +} + +function eventFor(from: WorkOrderStatus, to: WorkOrderStatus): EventType { + switch (to) { + case "cancelled": + return "work_order.cancelled"; + case "daily_report_created": + return "work_order.daily_report_created"; + case "technically_completed": + return "work_order.technically_completed"; + case "signature_pending": + return "work_order.signature_missing"; + case "released_for_billing": + return "work_order.released_for_billing"; + case "in_progress": + return ["assigned", "accepted", "en_route"].includes(from) ? "work_order.started" : "work_order.changed"; + default: + return "work_order.changed"; + } +} + +export type TransitionResult = { id: string; status: WorkOrderStatus; version: number; from: WorkOrderStatus }; + +/** + * The ONLY place that changes WorkOrder.status (ARCHITEKTUR §3). + * Order of checks: scope (not_found) → table (invalid) → permission (forbidden) → version (conflict) + * → reason (invalid) → guards (blocked, CompletionBlocker[]) → optimistic write. + */ +export async function transitionWorkOrder(ctx: ServiceCtx, raw: TransitionInput): Promise { + const input = parseInput(transitionSchema, raw); + const wo = await loadVisibleWorkOrder(ctx, input.workOrderId); + return applyTransition(ctx, wo, input.to, { reason: input.reason ?? null, baseVersion: input.baseVersion, eventData: sanitizeEventData(raw.eventData) }); +} + +function sanitizeEventData(v: unknown): Record | undefined { + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + const out: Record = {}; + for (const [k, val] of Object.entries(v).slice(0, 20)) { + if (val === null || typeof val === "boolean" || typeof val === "number") out[k] = val; + else if (typeof val === "string") out[k] = val.slice(0, 500); + } + return out; +} + +/** Internal: transition an already loaded (and scope-checked) order. `extra` is written in the same row update. */ +export async function applyTransition( + ctx: ServiceCtx, + wo: WorkOrderBase, + to: WorkOrderStatus, + opts: { + reason?: string | null; + baseVersion?: number; + extra?: Record; + eventData?: Record; + } = {}, +): Promise { + const from = wo.status; + if (!canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to }); + if (!mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to }); + assertBaseVersion(wo, opts.baseVersion); + if (reasonRequired(from, to) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to }); + + const blockers = await transitionBlockers(ctx, wo, to); + if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers); + + const version = await writeWithVersion(ctx, wo, { status: to, ...(opts.extra ?? {}) }); + await ctx.db.workOrderStatusChange.create({ + data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: to, actorId: ctx.userId, reason: opts.reason ?? null }, + }); + await auditWorkOrder(ctx, { + action: "update", + workOrderId: wo.id, + before: { status: from, version: wo.version }, + after: { status: to, version, reason: opts.reason ?? null, ...(opts.extra ?? {}) }, + }); + await emitEvent(ctx, { + type: eventFor(from, to), + entityType: "work_order", + entityId: wo.id, + data: { ...(opts.eventData ?? {}), number: wo.number, from, to }, + }); + return { id: wo.id, status: to, version, from }; +} diff --git a/src/server/services/work-orders/update.ts b/src/server/services/work-orders/update.ts new file mode 100644 index 0000000..eca9bc3 --- /dev/null +++ b/src/server/services/work-orders/update.ts @@ -0,0 +1,83 @@ +import { updateWorkOrderSchema, type UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { emitEvent } from "@/server/events"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertBaseVersion, + assertNotLocked, + auditWorkOrder, + FINAL_STATUSES, + loadVisibleWorkOrder, + parseInput, + snapshot, + writeWithVersion, +} from "@/server/services/work-orders/_shared"; + +const FIELDS = Object.keys(updateWorkOrderSchema.shape) as (keyof UpdateWorkOrderInput)[]; + +/** Partial update of the order master data (never status — see transition.ts). Only keys present in `raw` change. */ +export async function updateWorkOrder( + ctx: ServiceCtx, + workOrderId: string, + raw: UpdateWorkOrderInput, + baseVersion?: number, +): Promise<{ id: string; version: number }> { + assertCan(ctx, "work_order:write"); + const parsed = parseInput(updateWorkOrderSchema, raw); + const present = FIELDS.filter((k) => Object.prototype.hasOwnProperty.call(raw, k)); + if (present.length === 0) throw new ServiceError("invalid", "nothing_to_update"); + + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + assertNotLocked(wo, FINAL_STATUSES); + assertBaseVersion(wo, baseVersion); + + const full = await ctx.db.workOrder.findFirst({ where: { id: wo.id } }); + if (!full) throw new ServiceError("not_found", "work_order_not_found"); + + const data: Record = {}; + for (const k of present) data[k] = (parsed as Record)[k] ?? null; + if ("title" in data && !data.title) throw new ServiceError("invalid", "validation_failed", [{ path: "title" }]); + if ("priority" in data && !data.priority) delete data.priority; + if ("signatureRequired" in data && typeof data.signatureRequired !== "boolean") delete data.signatureRequired; + if ("customerId" in data && !data.customerId) delete data.customerId; + + const customerId = (data.customerId as string | undefined) ?? full.customerId; + const siteId = "siteId" in data ? (data.siteId as string | null) : full.siteId; + const contactId = "contactId" in data ? (data.contactId as string | null) : full.contactId; + + if (customerId !== full.customerId) { + const c = await ctx.db.customer.findFirst({ where: { id: customerId, deletedAt: null, status: { not: "merged" } }, select: { id: true } }); + if (!c) throw new ServiceError("invalid", "customer_not_found"); + } + if (siteId) { + const s = await ctx.db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: { customerId: true } }); + if (!s) throw new ServiceError("invalid", "site_not_found"); + if (s.customerId !== customerId) throw new ServiceError("invalid", "site_customer_mismatch"); + } + if (contactId) { + const c = await ctx.db.contact.findFirst({ where: { id: contactId, deletedAt: null }, select: { customerId: true } }); + if (!c) throw new ServiceError("invalid", "contact_not_found"); + if (c.customerId !== customerId) throw new ServiceError("invalid", "contact_customer_mismatch"); + } + if ("orderTypeId" in data && data.orderTypeId) { + const ot = await ctx.db.orderType.findFirst({ where: { id: data.orderTypeId as string }, select: { id: true } }); + if (!ot) throw new ServiceError("invalid", "order_type_not_found"); + } + const start = "plannedStart" in data ? (data.plannedStart as Date | null) : full.plannedStart; + const end = "plannedEnd" in data ? (data.plannedEnd as Date | null) : full.plannedEnd; + if (start && end && end < start) throw new ServiceError("invalid", "planned_end_before_start"); + + const before = Object.fromEntries(Object.keys(data).map((k) => [k, (full as Record)[k]])); + const version = await writeWithVersion(ctx, wo, data); + await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: snapshot(before), after: snapshot({ ...data, version }) }); + await emitEvent(ctx, { type: "work_order.changed", entityType: "work_order", entityId: wo.id, data: { number: wo.number } }); + return { id: wo.id, version }; +} + +/** Soft delete (spec §27.5) — only drafts / orders still in review. */ +export async function deleteWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise { + assertCan(ctx, "work_order:write"); + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + if (!["draft", "review_required"].includes(wo.status)) throw new ServiceError("invalid", "delete_only_draft"); + await writeWithVersion(ctx, wo, { deletedAt: new Date() }); + await auditWorkOrder(ctx, { action: "delete", workOrderId: wo.id, before: { status: wo.status, number: wo.number } }); +}