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:
@@ -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
@@ -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<SyncOpType, z.ZodType>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })) };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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.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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user