L8 Notdienst: Services für Erfassung, Suche, Abschluss-Event und Sync-Op

createEmergencyOrder in einer Transaktion (vorläufiger Kunde/Objekt, Auftrag N-…
in_progress, Team/Zuweisung, WorkSession), searchCustomersForEmergency mit
minimalem Feldumfang + Audit, emergency.completed nach Abschlussbericht,
Sync-Op emergency.create (Registry + Zod-Schema), Review-Services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 13:12:33 +02:00
co-authored by Claude Opus 5
parent d5c1221ab5
commit f173563424
9 changed files with 912 additions and 2 deletions
+107
View File
@@ -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<typeof emergencyCreatePayload>;
export type EmergencyCreateParsed = z.output<typeof emergencyCreatePayload>;
/** 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];
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { SyncOpType } from "./envelope"; import type { SyncOpType } from "./envelope";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; 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 * 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.save_draft": passthrough,
"report.submit": passthrough, "report.submit": passthrough,
"signature.capture": passthrough, "signature.capture": passthrough,
"emergency.create": passthrough, "emergency.create": emergencyCreatePayload,
} satisfies Record<SyncOpType, z.ZodType>; } satisfies Record<SyncOpType, z.ZodType>;
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
@@ -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<EmergencyCompletedData | null> {
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<void> {
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);
}
}
+290
View File
@@ -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<EmergencyCreateResult | null> {
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<EmergencyCreateResult> {
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<typeof writeAuditLog>[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;
}
+102
View File
@@ -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<EmergencyCustomerHit[]> {
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<EmergencySiteHit[]> {
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<EmergencyTeamOption[]> {
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<string, string>();
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 })) };
});
}
+316
View File
@@ -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<ReviewStep, boolean>; 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<ReviewStep, boolean> = {
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<string, string>();
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<ReturnType<typeof getEmergencyReview>>;
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<string> {
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<string, unknown>) {
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;
}
+26
View File
@@ -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<ExternalOpResult> {
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<string, string> = { [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 };
}
+2
View File
@@ -8,6 +8,7 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont
import { getCompletionBlockers } from "@/server/services/work-orders/completion"; import { getCompletionBlockers } from "@/server/services/work-orders/completion";
import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common"; import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
import { onCompletionReportSubmitted } from "@/server/services/emergency/completion";
export const submitReportSchema = z.object({ export const submitReportSchema = z.object({
reportId: z.string().min(1).max(64), 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) { if (report.type === "completion" && report.version === 1) {
await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId); 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; const sig = content.signature;
if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) { 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 } }); await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } });
+1 -1
View File
@@ -26,5 +26,5 @@ export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHa
// lane-reports: "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // lane-reports: "report.save_draft": () => 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: "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-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),
}; };