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