diff --git a/src/lib/emergency/schemas.ts b/src/lib/emergency/schemas.ts new file mode 100644 index 0000000..0497cf9 --- /dev/null +++ b/src/lib/emergency/schemas.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +/** + * Client-safe input schemas of the emergency module (lane L8, spec §19). + * `emergencyCreatePayload` is both the sync op payload (`emergency.create`, src/lib/sync/ops.ts) + * and the input of `createEmergencyOrder`. The client ids make the op idempotent: a replay with + * the same `clientIds.session` returns the order that was already created. + */ + +const id = z.string().min(1).max(64); +const clientId = z.string().uuid(); +const req = (max: number) => z.string().trim().min(1).max(max); +const opt = (max: number) => + z + .string() + .trim() + .max(max) + .nullish() + .transform((v) => (v ? v : null)); + +export const EMERGENCY_REASON_MAX = 2000; + +const newCustomer = z.object({ + mode: z.literal("new"), + companyName: opt(200), + firstName: opt(100), + lastName: opt(100), + phone: req(50), + email: z + .string() + .trim() + .max(200) + .nullish() + .transform((v) => (v ? v.toLowerCase() : null)) + .pipe(z.email().nullable()), + street: opt(200), + houseNumber: opt(20), + postalCode: opt(12), + city: opt(100), +}); + +const existingCustomer = z.object({ mode: z.literal("existing"), customerId: id }); + +const newSite = z.object({ + mode: z.literal("new"), + name: opt(200), + street: req(200), + houseNumber: opt(20), + postalCode: opt(12), + city: req(100), +}); + +const existingSite = z.object({ mode: z.literal("existing"), siteId: id }); + +export const emergencyCreatePayload = z + .object({ + clientIds: z.object({ + workOrder: clientId, + session: clientId, + customer: clientId.optional(), + site: clientId.optional(), + }), + customer: z.discriminatedUnion("mode", [existingCustomer, newCustomer]), + site: z.discriminatedUnion("mode", [existingSite, newSite]), + onSiteContact: z.object({ name: req(200), phone: req(50) }), + reason: req(EMERGENCY_REASON_MAX), + /** Einsatzbeginn (default: now) */ + startedAt: z.string().datetime({ offset: true }).optional(), + /** null/undefined → the user's own team (first active team) */ + teamId: id.nullish(), + /** colleagues of the chosen team; the creating user is always assigned */ + assigneeIds: z.array(id).max(20).default([]), + offline: z.boolean().default(false), + deviceInfo: z.string().max(200).optional(), + }) + .superRefine((v, c) => { + if (v.customer.mode === "new" && !v.customer.companyName && !v.customer.lastName) { + c.addIssue({ code: "custom", path: ["customer", "lastName"], message: "name_required" }); + } + }); + +export type EmergencyCreateInput = z.input; +export type EmergencyCreateParsed = z.output; + +/** Result keys of the op's idMap besides the client ids. */ +export type EmergencyCreateResult = { + workOrderId: string; + number: string; + customerId: string; + siteId: string; + contactId: string; + sessionId: string; + version: number; + replayed: boolean; +}; + +/** Backoffice: "Auftrag ergänzen". */ +export const emergencyOrderPatchSchema = z.object({ + title: req(200), + description: opt(10_000), + orderTypeId: id.nullish(), + billingType: z.enum(["fixed", "time_material", "maintenance_contract", "warranty"]).nullish(), +}); + +/** The five review steps shown as "x von 5 Prüfschritten erledigt". */ +export const REVIEW_STEPS = ["customer", "site", "order", "report", "billing"] as const; +export type ReviewStep = (typeof REVIEW_STEPS)[number]; diff --git a/src/lib/sync/ops.ts b/src/lib/sync/ops.ts index 3f8301e..6bcd6e6 100644 --- a/src/lib/sync/ops.ts +++ b/src/lib/sync/ops.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { SyncOpType } from "./envelope"; import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; +import { emergencyCreatePayload } from "@/lib/emergency/schemas"; /** * Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to @@ -129,7 +130,7 @@ export const OP_PAYLOAD_SCHEMAS = { "report.save_draft": passthrough, "report.submit": passthrough, "signature.capture": passthrough, - "emergency.create": passthrough, + "emergency.create": emergencyCreatePayload, } satisfies Record; export type OpPayload = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; diff --git a/src/server/services/emergency/completion.ts b/src/server/services/emergency/completion.ts new file mode 100644 index 0000000..1d58f85 --- /dev/null +++ b/src/server/services/emergency/completion.ts @@ -0,0 +1,66 @@ +import { emitEvent } from "@/server/events"; +import type { ServiceCtx } from "@/server/services/context"; +import { customerDisplayName } from "@/server/services/customers/format"; + +/** + * Spec §19.4: after the completion report of an emergency order was submitted, the backoffice is + * informed automatically (event `emergency.completed`, mandatory mail via L6). + * Data: Monteur, Kunde, Einsatzbeginn, Einsatzende, Status. + */ + +export type EmergencyCompletedData = { + number: string; + technician: string | null; + customer: string; + startedAt: string; + endedAt: string; + status: string; +}; + +export async function buildEmergencyCompletedData(ctx: ServiceCtx, workOrderId: string): Promise { + const wo = await ctx.db.workOrder.findFirst({ + where: { id: workOrderId, isEmergency: true, deletedAt: null }, + select: { + number: true, + status: true, + createdAt: true, + plannedStart: true, + createdById: true, + customer: { select: { companyName: true, firstName: true, lastName: true } }, + workSessions: { select: { startedAt: true, endedAt: true }, orderBy: { startedAt: "asc" } }, + }, + }); + if (!wo) return null; + const creator = wo.createdById ? await ctx.db.user.findFirst({ where: { id: wo.createdById }, select: { name: true } }) : null; + const starts = wo.workSessions.map((s) => s.startedAt.getTime()); + const ends = wo.workSessions.map((s) => (s.endedAt ?? new Date()).getTime()); + const startedAt = starts.length ? new Date(Math.min(...starts)) : (wo.plannedStart ?? wo.createdAt); + const endedAt = ends.length ? new Date(Math.max(...ends)) : new Date(); + return { + number: wo.number, + technician: creator?.name ?? null, + customer: customerDisplayName(wo.customer), + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + status: wo.status, + }; +} + +/** + * Hook called by the reports lane after a completion report (v1) was submitted and the order was + * advanced (→ in_review / signature_pending). No-op for regular orders; never throws. + */ +export async function onCompletionReportSubmitted(ctx: ServiceCtx, workOrderId: string, occurrenceId?: string): Promise { + try { + const data = await buildEmergencyCompletedData(ctx, workOrderId); + if (!data) return; + await emitEvent(ctx, { + type: "emergency.completed", + entityType: "work_order", + entityId: workOrderId, + data: { ...data, ...(occurrenceId ? { occurrenceId } : {}) }, + }); + } catch (err) { + console.error(`[emergency] completion hook for ${workOrderId} failed:`, (err as Error).message); + } +} diff --git a/src/server/services/emergency/create.ts b/src/server/services/emergency/create.ts new file mode 100644 index 0000000..24e42dc --- /dev/null +++ b/src/server/services/emergency/create.ts @@ -0,0 +1,290 @@ +import { ZodError } from "zod"; +import { emergencyCreatePayload, type EmergencyCreateInput, type EmergencyCreateResult } from "@/lib/emergency/schemas"; +import { writeAuditLog } from "@/server/audit"; +import { emitEvent } from "@/server/events"; +import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { startSession } from "@/server/services/field/sessions"; +import { createWorkOrder as realCreateWorkOrder } from "@/server/services/work-orders/create"; +import { ensureDefaultOrderTypes } from "@/server/services/work-orders/settings"; +import { activeTeamIds } from "@/server/services/work-orders/visibility"; + +/** + * Notdienst-Erfassung (spec §19, US-010, ARCHITEKTUR §6 L8). + * + * `createEmergencyOrder` runs in ONE transaction (inTransaction): + * provisional customer (status provisional, no customer number until confirmation) or existing customer + * → provisional site (Einsatzadresse) or existing site of the customer + * → on-site contact (reused if name + phone match) + * → work order via createWorkOrder (isEmergency, order type `notdienst`, number from `emergency`, status in_progress) + * → team / assignees → WorkSession start (L4 startSession, idempotent over clientIds.session). + * Afterwards the event `emergency.created` is emitted. A replay with the same session client id returns + * the order created before (offline sync retries with a new clientOpId). + */ + +export type CreateEmergencyDeps = { createWorkOrder: typeof realCreateWorkOrder }; +const DEFAULT_DEPS: CreateEmergencyDeps = { createWorkOrder: realCreateWorkOrder }; + +function parse(raw: unknown) { + try { + return emergencyCreatePayload.parse(raw); + } catch (err) { + if (err instanceof ZodError) { + throw new ServiceError( + "invalid", + "validation_failed", + err.issues.map((i) => ({ path: i.path.join("."), code: i.message })), + ); + } + throw err; + } +} + +/** Client timestamp of the start; future values (clock skew) are clamped to now. */ +function startTime(at: string | undefined): Date { + const now = new Date(); + if (!at) return now; + const d = new Date(at); + return d.getTime() > now.getTime() + 60_000 ? now : d; +} + +function firstLine(text: string, max = 120): string { + const line = text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? text.trim(); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +async function replay(ctx: ServiceCtx, sessionClientId: string): Promise { + const session = await ctx.db.workSession.findFirst({ + where: { clientId: sessionClientId }, + select: { + id: true, + userId: true, + workOrder: { select: { id: true, number: true, customerId: true, siteId: true, contactId: true, version: true, isEmergency: true } }, + }, + }); + if (!session) return null; + const wo = session.workOrder; + if (session.userId !== ctx.userId || !wo.isEmergency) throw new ServiceError("invalid", "client_id_already_used"); + return { + workOrderId: wo.id, + number: wo.number, + customerId: wo.customerId, + siteId: wo.siteId ?? "", + contactId: wo.contactId ?? "", + sessionId: session.id, + version: wo.version, + replayed: true, + }; +} + +async function resolveTeam(ctx: ServiceCtx, requested: string | null | undefined) { + const own = await activeTeamIds(ctx); + let teamId: string | null = requested ?? null; + if (teamId && !own.includes(teamId) && !can(ctx, "work_order:read_all")) { + throw new ServiceError("invalid", "team_not_allowed", { field: "teamId", reason: "team_not_allowed" }); + } + if (!teamId && own.length) { + const teams = await ctx.db.team.findMany({ where: { id: { in: own } }, orderBy: { name: "asc" }, select: { id: true } }); + teamId = teams[0]?.id ?? null; + } + if (!teamId) return null; + const team = await ctx.db.team.findFirst({ + where: { id: teamId, status: "active", deletedAt: null }, + select: { id: true, leaderUserId: true }, + }); + if (!team) throw new ServiceError("invalid", "team_not_allowed", { field: "teamId", reason: "team_not_allowed" }); + return team; +} + +async function resolveAssignees(ctx: ServiceCtx, team: { id: string; leaderUserId: string | null } | null, requested: string[]) { + const others = [...new Set(requested.filter((u) => u !== ctx.userId))]; + if (others.length === 0) return [ctx.userId]; + if (!team) throw new ServiceError("invalid", "assignee_not_allowed", { field: "assigneeIds", reason: "assignee_not_allowed" }); + const now = new Date(); + const members = await ctx.db.teamMember.findMany({ + where: { + teamId: team.id, + userId: { in: others }, + validFrom: { lte: now }, + OR: [{ validTo: null }, { validTo: { gt: now } }], + user: { status: "ACTIVE" }, + }, + select: { userId: true }, + }); + const allowed = new Set(members.map((m) => m.userId)); + if (team.leaderUserId) allowed.add(team.leaderUserId); + if (others.some((u) => !allowed.has(u))) { + throw new ServiceError("invalid", "assignee_not_allowed", { field: "assigneeIds", reason: "assignee_not_allowed" }); + } + return [ctx.userId, ...others]; +} + +export async function createEmergencyOrder( + ctx: ServiceCtx, + raw: EmergencyCreateInput, + deps: CreateEmergencyDeps = DEFAULT_DEPS, +): Promise { + assertCan(ctx, "emergency:create"); + assertCan(ctx, "field:execute"); + const input = parse(raw); + + const prior = await replay(ctx, input.clientIds.session); + if (prior) return prior; + + const startedAt = startTime(input.startedAt); + const team = await resolveTeam(ctx, input.teamId); + const assignees = await resolveAssignees(ctx, team, input.assigneeIds); + await ensureDefaultOrderTypes(ctx.db, ctx.tenantId); + const orderType = await ctx.db.orderType.findFirst({ where: { key: "notdienst", active: true }, select: { id: true } }); + + const audits: Parameters[0][] = []; + const audit = (entity: string, entityId: string, after: unknown) => + audits.push({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity, entityId, after }); + + const result = await inTransaction(ctx, async (tx) => { + // 1. customer + let customerId: string; + if (input.customer.mode === "existing") { + // technicians may pick ANY customer of the tenant for an emergency (searchCustomersForEmergency) + const c = await tx.db.customer.findFirst({ + where: { id: input.customer.customerId, deletedAt: null, status: { in: ["active", "provisional"] } }, + select: { id: true }, + }); + if (!c) throw new ServiceError("invalid", "customer_not_found", { field: "customerId", reason: "customer_not_found" }); + customerId = c.id; + } else { + const { mode: _mode, ...fields } = input.customer; + void _mode; + const c = await tx.db.customer.create({ + data: { + ...fields, + tenantId: ctx.tenantId, + customerNumber: null, // allocated on confirmation by the backoffice + country: "DE", + status: "provisional", + isProvisional: true, + createdById: ctx.userId, + }, + }); + customerId = c.id; + audit("customer", c.id, { ...c, source: "emergency" }); + } + + // 2. on-site contact (Ansprechpartner vor Ort) — reused when name and phone match + const contactName = input.onSiteContact.name; + const contactPhone = input.onSiteContact.phone; + const existingContact = await tx.db.contact.findFirst({ + where: { customerId, deletedAt: null, name: { equals: contactName, mode: "insensitive" }, OR: [{ phone: contactPhone }, { mobile: contactPhone }] }, + select: { id: true }, + }); + let contactId = existingContact?.id; + if (!contactId) { + const contact = await tx.db.contact.create({ + data: { tenantId: ctx.tenantId, customerId, name: contactName, phone: contactPhone, preferredChannel: "phone" }, + }); + contactId = contact.id; + audit("contact", contact.id, { ...contact, source: "emergency" }); + } + + // 3. site (Objekt oder Einsatzadresse) + let siteId: string; + if (input.site.mode === "existing") { + const s = await tx.db.site.findFirst({ where: { id: input.site.siteId, customerId, deletedAt: null }, select: { id: true } }); + if (!s) throw new ServiceError("invalid", "site_not_found", { field: "siteId", reason: "site_not_found" }); + siteId = s.id; + } else { + const { mode: _mode, name, ...address } = input.site; + void _mode; + const label = name ?? [[address.street, address.houseNumber].filter(Boolean).join(" "), address.city].filter(Boolean).join(", "); + const s = await tx.db.site.create({ + data: { + ...address, + tenantId: ctx.tenantId, + customerId, + name: label.slice(0, 200), + country: "DE", + contactId, + onSiteContact: contactName, + phone: contactPhone, + status: "provisional", + }, + }); + siteId = s.id; + audit("site", s.id, { ...s, source: "emergency" }); + } + + // 4. work order (L2) — number N-…, status in_progress + const wo = await deps.createWorkOrder(tx, { + title: firstLine(input.reason), + customerId, + siteId, + contactId, + orderTypeId: orderType?.id ?? null, + priority: "urgent", + status: "in_progress", + description: input.reason, + plannedStart: startedAt, + isEmergency: true, + emergencyReason: input.reason, + numberKey: "emergency", + }); + + // 5. team + assignees (the creator is always assigned) + await tx.db.workOrder.update({ + where: { id: wo.id }, + data: { + assignedTeamId: team?.id ?? null, + teamLeadUserId: team?.leaderUserId ?? null, + assignees: { create: assignees.map((userId) => ({ tenantId: ctx.tenantId, userId })) }, + }, + }); + + // 6. work session (L4) — order already in_progress, so no further transition + const session = await startSession(tx, { + workOrderId: wo.id, + clientId: input.clientIds.session, + mode: "work", + at: startedAt.toISOString(), + offline: input.offline, + deviceInfo: input.deviceInfo, + }); + + return { + workOrderId: wo.id, + number: wo.number, + customerId, + siteId, + contactId, + sessionId: session.sessionId, + version: session.workOrderVersion, + replayed: false, + } satisfies EmergencyCreateResult; + }); + + for (const a of audits) await writeAuditLog(a); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "create", + entity: "emergency", + entityId: result.workOrderId, + after: { + number: result.number, + customerId: result.customerId, + customerMode: input.customer.mode, + siteId: result.siteId, + siteMode: input.site.mode, + contactId: result.contactId, + teamId: team?.id ?? null, + assignees, + startedAt, + offline: input.offline, + }, + }); + await emitEvent(ctx, { + type: "emergency.created", + entityType: "work_order", + entityId: result.workOrderId, + data: { number: result.number, startedAt: startedAt.toISOString() }, + }); + return result; +} diff --git a/src/server/services/emergency/lookup.ts b/src/server/services/emergency/lookup.ts new file mode 100644 index 0000000..3970ad1 --- /dev/null +++ b/src/server/services/emergency/lookup.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { activeTeamIds } from "@/server/services/work-orders/visibility"; + +/** + * Slim lookups for the emergency capture (spec §19.2). A technician may search ALL customers of + * the tenant for an emergency call-out, so these services deliberately return a minimal field set + * (name, city, customer number / site address) and audit every access. Tenant isolation comes from + * ctx.db (dbForTenant + RLS). + */ + +export type EmergencyCustomerHit = { id: string; customerNumber: string | null; name: string; city: string | null; provisional: boolean }; +export type EmergencySiteHit = { id: string; name: string; address: string }; + +const querySchema = z.string().trim().min(2).max(100); + +export async function searchCustomersForEmergency(ctx: ServiceCtx, rawQuery: string): Promise { + assertCan(ctx, "emergency:create"); + const parsed = querySchema.safeParse(rawQuery); + if (!parsed.success) return []; + const q = parsed.data; + const contains = { contains: q, mode: "insensitive" as const }; + const rows = await ctx.db.customer.findMany({ + where: { + deletedAt: null, + status: { in: ["active", "provisional"] }, + OR: [{ customerNumber: contains }, { companyName: contains }, { firstName: contains }, { lastName: contains }, { city: contains }], + }, + orderBy: [{ companyName: "asc" }, { lastName: "asc" }], + take: 10, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "export", + entity: "emergency_customer_search", + after: { query: q, resultCount: rows.length, customerIds: rows.map((r) => r.id) }, + }); + return rows.map((r) => ({ + id: r.id, + customerNumber: r.customerNumber, + name: customerDisplayName(r), + city: r.city, + provisional: r.status === "provisional", + })); +} + +export async function listSitesForEmergency(ctx: ServiceCtx, customerId: string): Promise { + assertCan(ctx, "emergency:create"); + const customer = await ctx.db.customer.findFirst({ + where: { id: customerId, deletedAt: null, status: { in: ["active", "provisional"] } }, + select: { id: true }, + }); + if (!customer) throw new ServiceError("not_found", "customer not found"); + const sites = await ctx.db.site.findMany({ + where: { customerId, deletedAt: null, status: { not: "inactive" } }, + orderBy: { name: "asc" }, + take: 50, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "export", + entity: "emergency_site_lookup", + entityId: customerId, + after: { resultCount: sites.length, siteIds: sites.map((s) => s.id) }, + }); + return sites.map((s) => ({ id: s.id, name: s.name, address: formatAddress(s) })); +} + +export type EmergencyTeamOption = { id: string; name: string; members: { id: string; name: string }[] }; + +/** Teams the user may book the call-out on (own active teams) incl. current members. */ +export async function emergencyTeamOptions(ctx: ServiceCtx): Promise { + assertCan(ctx, "emergency:create"); + const ids = await activeTeamIds(ctx); + if (!ids.length) return []; + const now = new Date(); + const teams = await ctx.db.team.findMany({ + where: { id: { in: ids } }, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + leader: { select: { id: true, name: true, status: true } }, + members: { + where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], user: { status: "ACTIVE" } }, + select: { user: { select: { id: true, name: true } } }, + }, + }, + }); + return teams.map((t) => { + const members = new Map(); + if (t.leader && t.leader.status === "ACTIVE") members.set(t.leader.id, t.leader.name ?? ""); + for (const m of t.members) members.set(m.user.id, m.user.name ?? ""); + return { id: t.id, name: t.name, members: [...members].map(([id, name]) => ({ id, name })) }; + }); +} diff --git a/src/server/services/emergency/review.ts b/src/server/services/emergency/review.ts new file mode 100644 index 0000000..779cd8c --- /dev/null +++ b/src/server/services/emergency/review.ts @@ -0,0 +1,316 @@ +import type { Prisma, WorkOrderStatus } from "@prisma/client"; +import { ZodError } from "zod"; +import { emergencyOrderPatchSchema, REVIEW_STEPS, type ReviewStep } from "@/lib/emergency/schemas"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { confirmProvisionalCustomer } from "@/server/services/customers/customers"; +import { findDuplicateCustomers, type DuplicateCandidate } from "@/server/services/customers/duplicates"; +import { customerDisplayName } from "@/server/services/customers/format"; +import { mergeCustomers } from "@/server/services/customers/merge"; +import { nextNumber } from "@/server/services/numbering"; +import { deleteSite, updateSite, type SitePatchInput } from "@/server/services/sites/sites"; +import { releaseForBilling } from "@/server/services/work-orders/release-billing"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * Backoffice-Nachbearbeitung von Notdiensteinsätzen (spec §19.3, §39; route /work-orders/emergency-review). + * Permission `emergency:review` on every function; the individual steps additionally require the + * permission of the service they delegate to (L1 customers/sites, L2 work orders). + */ + +const BILLING_DONE: WorkOrderStatus[] = ["released_for_billing", "billed"]; +const REVIEW_OPEN_STATUSES: WorkOrderStatus[] = ["in_review", "technically_completed", "signature_pending"]; + +export type ReviewProgress = { done: Record; count: number; total: number }; + +type ProgressInput = { + status: WorkOrderStatus; + orderTypeId: string | null; + billingType: string | null; + customer: { status: string }; + site: { status: string } | null; + reports: { type: string; status: string }[]; +}; + +export function reviewProgress(wo: ProgressInput): ReviewProgress { + const done: Record = { + customer: wo.customer.status !== "provisional", + site: !wo.site || wo.site.status !== "provisional", + order: Boolean(wo.orderTypeId && wo.billingType), + report: wo.reports.some((r) => r.type === "completion" && r.status === "approved"), + billing: BILLING_DONE.includes(wo.status), + }; + return { done, count: REVIEW_STEPS.filter((s) => done[s]).length, total: REVIEW_STEPS.length }; +} + +const LIST_SELECT = { + id: true, + number: true, + title: true, + status: true, + version: true, + orderTypeId: true, + billingType: true, + plannedStart: true, + createdAt: true, + createdById: true, + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, status: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true } }, + team: { select: { name: true } }, + reports: { where: { status: { not: "superseded" } }, select: { id: true, type: true, status: true, version: true }, orderBy: { createdAt: "desc" } }, +} satisfies Prisma.WorkOrderSelect; + +export type EmergencyReviewFilter = "open" | "all"; + +export async function listEmergencyReviews(ctx: ServiceCtx, opts: { filter?: EmergencyReviewFilter } = {}) { + assertCan(ctx, "emergency:review"); + const open: Prisma.WorkOrderWhereInput = { + OR: [{ customer: { status: "provisional" } }, { site: { status: "provisional" } }, { status: { in: REVIEW_OPEN_STATUSES } }], + status: { notIn: ["cancelled", "billed"] }, + }; + const rows = await ctx.db.workOrder.findMany({ + where: { AND: [await workOrderScope(ctx), { isEmergency: true }, opts.filter === "all" ? {} : open] }, + orderBy: { createdAt: "desc" }, + take: 200, + select: LIST_SELECT, + }); + const creators = await userNames(ctx, rows.map((r) => r.createdById)); + return rows.map((r) => ({ + ...r, + customerName: customerDisplayName(r.customer), + technician: r.createdById ? (creators.get(r.createdById) ?? null) : null, + progress: reviewProgress(r), + })); +} + +async function userNames(ctx: ServiceCtx, ids: (string | null)[]) { + const unique = [...new Set(ids.filter((i): i is string => !!i))]; + 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 ?? ""])); +} + +/** Loads an emergency order in scope or throws not_found. */ +async function requireEmergencyOrder(ctx: ServiceCtx, workOrderId: string) { + assertCan(ctx, "emergency:review"); + const wo = await ctx.db.workOrder.findFirst({ + where: { AND: [{ id: workOrderId, isEmergency: true }, await workOrderScope(ctx)] }, + select: { + ...LIST_SELECT, + description: true, + emergencyReason: true, + customerId: true, + siteId: true, + contact: { select: { id: true, name: true, phone: true } }, + orderType: { select: { id: true, name: true } }, + customer: { + select: { + id: true, + customerNumber: true, + companyName: true, + firstName: true, + lastName: true, + status: true, + street: true, + houseNumber: true, + postalCode: true, + city: true, + phone: true, + mobile: true, + email: true, + }, + }, + workSessions: { select: { startedAt: true, endedAt: true, userId: true }, orderBy: { startedAt: "asc" } }, + }, + }); + if (!wo) throw new ServiceError("not_found", "emergency order not found"); + return wo; +} + +export async function getEmergencyReview(ctx: ServiceCtx, workOrderId: string) { + const wo = await requireEmergencyOrder(ctx, workOrderId); + const c = wo.customer; + const duplicates: DuplicateCandidate[] = + c.status === "merged" + ? [] + : await findDuplicateCustomers( + ctx, + { companyName: c.companyName, firstName: c.firstName, lastName: c.lastName, street: c.street, houseNumber: c.houseNumber, postalCode: c.postalCode, city: c.city, phone: c.phone, mobile: c.mobile, email: c.email }, + { excludeId: c.id, limit: 10 }, + ); + const siteOptions = await ctx.db.site.findMany({ + where: { customerId: wo.customerId, deletedAt: null, status: { not: "inactive" }, ...(wo.siteId ? { id: { not: wo.siteId } } : {}) }, + orderBy: { name: "asc" }, + take: 100, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true }, + }); + const site = wo.siteId + ? await ctx.db.site.findFirst({ + where: { id: wo.siteId }, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true, onSiteContact: true, phone: true, accessNotes: true }, + }) + : null; + const names = await userNames(ctx, [wo.createdById]); + const starts = wo.workSessions.map((s) => s.startedAt.getTime()); + const ends = wo.workSessions.filter((s) => s.endedAt).map((s) => s.endedAt!.getTime()); + return { + ...wo, + site, + customerName: customerDisplayName(c), + technician: wo.createdById ? (names.get(wo.createdById) ?? null) : null, + startedAt: starts.length ? new Date(Math.min(...starts)) : wo.plannedStart, + endedAt: ends.length && wo.workSessions.every((s) => s.endedAt) ? new Date(Math.max(...ends)) : null, + duplicates, + siteOptions, + progress: reviewProgress({ ...wo, site }), + }; +} +export type EmergencyReviewDetail = Awaited>; + +async function auditReview(ctx: ServiceCtx, workOrderId: string, step: string, before: unknown, after: unknown) { + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "emergency", entityId: workOrderId, before, after: { step, ...(after as object) } }); +} + +/** Allocate the next free customer number (skips numbers entered manually). */ +async function allocateCustomerNumber(ctx: ServiceCtx): Promise { + for (let i = 0; i < 20; i++) { + const candidate = await nextNumber(ctx.db, ctx.tenantId, "customer"); + const taken = await ctx.db.customer.findFirst({ where: { customerNumber: candidate }, select: { id: true } }); + if (!taken) return candidate; + } + throw new ServiceError("conflict", "could not allocate customer number", { reason: "number_allocation" }); +} + +/** Step 1a — Kunden bestätigen: provisional → active, customer number is allocated now. */ +export async function confirmEmergencyCustomer(ctx: ServiceCtx, workOrderId: string) { + assertCan(ctx, "customer:write"); + const wo = await requireEmergencyOrder(ctx, workOrderId); + if (wo.customer.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" }); + const result = await inTransaction(ctx, async (tx) => { + let customerNumber = wo.customer.customerNumber; + if (!customerNumber) { + customerNumber = await allocateCustomerNumber(tx); + const before = await tx.db.customer.findFirstOrThrow({ where: { id: wo.customerId } }); + const after = await tx.db.customer.update({ where: { id: wo.customerId }, data: { customerNumber } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: wo.customerId, before, after }); + } + await confirmProvisionalCustomer(tx, wo.customerId); + return { customerId: wo.customerId, customerNumber }; + }); + await auditReview(ctx, workOrderId, "customer_confirmed", { customerStatus: "provisional" }, { customerStatus: "active", customerNumber: result.customerNumber }); + return result; +} + +/** + * Step 1b — bestehendem Kunden zuordnen: re-parent the provisional customer's sites, contacts, orders and + * documents to the existing customer; the provisional record becomes `merged` (mergedIntoId). Only for + * provisional customers; never automatic. + */ +export async function assignEmergencyToCustomer(ctx: ServiceCtx, input: { workOrderId: string; targetCustomerId: string }) { + assertCan(ctx, "customer:write"); + assertCan(ctx, "work_order:write"); + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + const sourceId = wo.customerId; + if (wo.customer.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" }); + if (input.targetCustomerId === sourceId) throw new ServiceError("invalid", "same customer", { field: "targetCustomerId", reason: "same_customer" }); + const target = await ctx.db.customer.findFirst({ + where: { id: input.targetCustomerId, deletedAt: null, status: { in: ["active", "inactive", "provisional"] } }, + select: { id: true, customerNumber: true }, + }); + if (!target) throw new ServiceError("invalid", "target not found", { field: "targetCustomerId", reason: "target_not_found" }); + + const moved = await inTransaction(ctx, async (tx) => { + const source = await tx.db.customer.findFirstOrThrow({ where: { id: sourceId } }); + const [sites, contacts, workOrders, documents] = [ + await tx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }), + await tx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }), + // version bump: offline clients must not overwrite the re-parented order with stale data + await tx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id, version: { increment: 1 } } }), + await tx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }), + ]; + const after = await tx.db.customer.update({ where: { id: sourceId }, data: { status: "merged", mergedIntoId: target.id, isProvisional: false } }); + const counts = { sites: sites.count, contacts: contacts.count, workOrders: workOrders.count, documents: documents.count }; + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: sourceId, before: source, after: { ...after, assignedTo: target.id, moved: counts } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: target.id, after: { assignedFrom: sourceId, moved: counts } }); + return counts; + }); + await auditReview(ctx, input.workOrderId, "customer_assigned", { customerId: sourceId }, { customerId: target.id, moved }); + return { sourceId, targetId: target.id, moved }; +} + +/** Step 1c — Dublette zusammenführen (L1 merge, `customer:merge` + explicit confirmation). */ +export async function mergeEmergencyCustomer(ctx: ServiceCtx, input: { workOrderId: string; targetCustomerId: string; confirm: boolean }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + if (!input.confirm) throw new ServiceError("invalid", "confirmation required", { field: "confirm", reason: "confirm_required" }); + const result = await mergeCustomers(ctx, { sourceId: wo.customerId, targetId: input.targetCustomerId, confirm: true }); + await auditReview(ctx, input.workOrderId, "customer_merged", { customerId: wo.customerId }, { customerId: input.targetCustomerId, moved: result.moved }); + return result; +} + +/** Step 2a — Objekt korrigieren: update the site fields; `confirm` sets a provisional site to active. */ +export async function correctEmergencySite(ctx: ServiceCtx, input: { workOrderId: string; patch: SitePatchInput; confirm: boolean }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + if (!wo.siteId) throw new ServiceError("invalid", "order has no site", { reason: "no_site" }); + const { customerId: _ignored, status: _status, ...patch } = input.patch; + void _ignored; + void _status; + const after = await updateSite(ctx, wo.siteId, { ...patch, ...(input.confirm ? { status: "active" } : {}) }); + await auditReview(ctx, input.workOrderId, "site_corrected", { siteId: wo.siteId }, { siteId: wo.siteId, status: after.status }); + return after; +} + +/** Step 2b — Objekt zuordnen: move the order to an existing site of its customer; an unused provisional site is soft-deleted. */ +export async function assignEmergencySite(ctx: ServiceCtx, input: { workOrderId: string; siteId: string }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + if (input.siteId === wo.siteId) throw new ServiceError("invalid", "same site", { field: "siteId", reason: "same_site" }); + const oldSiteId = wo.siteId; + const result = await inTransaction(ctx, async (tx) => { + const updated = await updateWorkOrder(tx, wo.id, { siteId: input.siteId }); + let removed = false; + if (oldSiteId) { + const old = await tx.db.site.findFirst({ where: { id: oldSiteId }, select: { status: true, _count: { select: { workOrders: { where: { deletedAt: null } } } } } }); + if (old?.status === "provisional" && old._count.workOrders === 0) { + await deleteSite(tx, oldSiteId); + removed = true; + } + } + return { version: updated.version, removed }; + }); + await auditReview(ctx, input.workOrderId, "site_assigned", { siteId: oldSiteId }, { siteId: input.siteId, provisionalSiteRemoved: result.removed }); + return result; +} + +/** Step 3 — Auftrag ergänzen (title, description, order type, billing type) via L2 updateWorkOrder. */ +export async function completeEmergencyOrderData(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number } & Record) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + let patch; + try { + patch = emergencyOrderPatchSchema.parse(input); + } catch (err) { + if (err instanceof ZodError) throw new ServiceError("invalid", "validation_failed", err.issues.map((i) => ({ path: i.path.join(".") }))); + throw err; + } + const result = await updateWorkOrder( + ctx, + wo.id, + { title: patch.title, description: patch.description, orderTypeId: patch.orderTypeId ?? null, billingType: patch.billingType ?? null }, + input.baseVersion, + ); + await auditReview(ctx, input.workOrderId, "order_completed", { orderTypeId: wo.orderTypeId, billingType: wo.billingType }, { orderTypeId: patch.orderTypeId ?? null, billingType: patch.billingType ?? null }); + return result; +} + +/** + * Step 5 — zur Abrechnung freigeben (L2 releaseForBilling: `work_order:release_billing` + approved completion report). + * Additionally blocked while customer or site are still provisional. + */ +export async function releaseEmergencyForBilling(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + const progress = reviewProgress(wo); + const open = (["customer", "site"] as const).filter((s) => !progress.done[s]); + if (open.length) throw new ServiceError("blocked", "master data still provisional", { reason: "master_data_open", steps: open }); + const result = await releaseForBilling(ctx, { workOrderId: wo.id, baseVersion: input.baseVersion }); + await auditReview(ctx, input.workOrderId, "billing_released", { status: wo.status }, { status: result.status }); + return result; +} diff --git a/src/server/services/emergency/sync-ops.ts b/src/server/services/emergency/sync-ops.ts new file mode 100644 index 0000000..d300970 --- /dev/null +++ b/src/server/services/emergency/sync-ops.ts @@ -0,0 +1,26 @@ +import type { SyncOperationInput } from "@/lib/sync/envelope"; +import { emergencyCreatePayload } from "@/lib/emergency/schemas"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ExternalOpResult } from "@/server/services/sync/external-ops"; +import { createEmergencyOrder } from "./create"; + +/** + * Sync op `emergency.create` (ARCHITEKTUR §4.6), registered in services/sync/external-ops.ts. + * The sync route is gated by the module "field"; the emergency module is checked here. + * idMap maps every client id of the payload to the server id. + */ +export async function applySyncOp(ctx: ServiceCtx, op: SyncOperationInput): Promise { + if (op.opType !== "emergency.create") throw new ServiceError("invalid", `unsupported op ${op.opType}`); + const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "emergency" }, select: { enabled: true } }); + if (moduleRow && !moduleRow.enabled) throw new ServiceError("forbidden", "module emergency disabled"); + + const parsed = emergencyCreatePayload.safeParse(op.payload); + if (!parsed.success) throw new ServiceError("invalid", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 500)); + const r = await createEmergencyOrder(ctx, op.payload as never); + + const ids = parsed.data.clientIds; + const idMap: Record = { [ids.workOrder]: r.workOrderId, [ids.session]: r.sessionId }; + if (ids.customer) idMap[ids.customer] = r.customerId; + if (ids.site && r.siteId) idMap[ids.site] = r.siteId; + return { idMap, entityVersion: r.version }; +} diff --git a/src/server/services/reports/submit.ts b/src/server/services/reports/submit.ts index 79bc2a4..ede2cb2 100644 --- a/src/server/services/reports/submit.ts +++ b/src/server/services/reports/submit.ts @@ -8,6 +8,7 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont import { getCompletionBlockers } from "@/server/services/work-orders/completion"; import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common"; +import { onCompletionReportSubmitted } from "@/server/services/emergency/completion"; export const submitReportSchema = z.object({ reportId: z.string().min(1).max(64), @@ -88,6 +89,7 @@ export async function submitReport(ctx: ServiceCtx, raw: SubmitReportInput): Pro if (report.type === "completion" && report.version === 1) { await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId); + await onCompletionReportSubmitted(ctx, wo.id, occurrenceId); // lane emergency (L8): event emergency.completed, no-op for regular orders const sig = content.signature; if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) { await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } }); diff --git a/src/server/services/sync/external-ops.ts b/src/server/services/sync/external-ops.ts index facc0d7..61ef872 100644 --- a/src/server/services/sync/external-ops.ts +++ b/src/server/services/sync/external-ops.ts @@ -26,5 +26,5 @@ export const EXTERNAL_OPS: Partial Promise import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // lane-reports: "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // lane-reports: "signature.capture": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), - // lane-emergency: "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp), + "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp), };