L2 Aufträge & Backoffice: Service-Schicht Aufträge + Tests

Statusmaschine (transitionWorkOrder inkl. eventData), Zuweisung, Completion-Guards,
Materialvorgabe, Checklisten/Pflichtfotos, Liste/Dashboard-Presets, Suche,
Sync-Konflikte, Einstellungen (Auftragsarten, Vorlagen, Nummernkreise).
Tests: Übergangsmatrix je Rolle, Kernlogik, Scope/Mandantentrennung, Nummernkreis-Parallelität.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:15 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 2311b35d8c
29 changed files with 2863 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
import { z } from "zod";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
/**
* Zod input schemas of the work order module (client-safe). Services parse with these;
* server actions and /api/v1 handlers only map FormData/JSON onto them.
*/
export const WORK_ORDER_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
export const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"] as const;
export type BillingType = (typeof BILLING_TYPES)[number];
const id = z.string().min(1).max(64);
const optId = id.nullish();
const text = (max: number) => z.string().trim().max(max);
const optText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => (v ? v : null));
const optDate = z.coerce.date().nullish();
export const materialPlanInputSchema = z.object({
name: text(200).min(1),
articleNumber: optText(80),
plannedQuantity: z.coerce.number().positive().max(1_000_000),
unit: text(20).min(1),
notes: optText(1000),
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
});
export type MaterialPlanInput = z.input<typeof materialPlanInputSchema>;
export const checklistItemInputSchema = z.object({
key: z
.string()
.trim()
.max(60)
.regex(/^[a-z0-9_]+$/)
.optional(),
label: text(200).min(1),
required: z.boolean().default(false),
requiresPhoto: z.boolean().default(false),
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
});
export type ChecklistItemInput = z.input<typeof checklistItemInputSchema>;
export const photoRequirementInputSchema = z.object({
key: z
.string()
.trim()
.max(60)
.regex(/^[a-z0-9_]+$/)
.optional(),
label: text(200).min(1),
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
});
export type PhotoRequirementInput = z.input<typeof photoRequirementInputSchema>;
/** Statuses a work order may be created in (import → review_required, emergency → in_progress via L8). */
export const INITIAL_STATUSES = ["draft", "review_required", "planned", "in_progress"] as const;
export const createWorkOrderSchema = z
.object({
title: text(200).min(1),
customerId: id,
siteId: optId,
contactId: optId,
orderTypeId: optId,
priority: z.enum(WORK_ORDER_PRIORITIES).default("normal"),
status: z.enum(INITIAL_STATUSES).default("draft"),
description: optText(10_000),
scope: optText(10_000),
plannedStart: optDate,
plannedEnd: optDate,
/** undefined → taken from the order type (default true) */
signatureRequired: z.boolean().optional(),
billingType: z.enum(BILLING_TYPES).nullish(),
internalNotes: optText(5000),
technicianNotes: optText(5000),
externalOrderNumber: optText(80),
offerNumber: optText(80),
isEmergency: z.boolean().default(false),
emergencyReason: optText(2000),
sourceImportId: optId,
/** "emergency" allocates from the N- sequence (lane emergency). */
numberKey: z.enum(["work_order", "emergency"]).default("work_order"),
/** Copy checklist / photo requirements from the order type's active template (default true). */
applyTemplate: z.boolean().default(true),
materials: z.array(materialPlanInputSchema).max(200).optional(),
checklistItems: z.array(checklistItemInputSchema).max(200).optional(),
photoRequirements: z.array(photoRequirementInputSchema).max(50).optional(),
})
.refine((v) => !v.plannedStart || !v.plannedEnd || v.plannedEnd >= v.plannedStart, {
path: ["plannedEnd"],
message: "plannedEnd_before_start",
});
/** Input type of createWorkOrder — used by lanes imports (L3) and emergency (L8). */
export type CreateWorkOrderInput = z.input<typeof createWorkOrderSchema>;
export const updateWorkOrderSchema = z
.object({
title: text(200).min(1).optional(),
customerId: id.optional(),
siteId: optId,
contactId: optId,
orderTypeId: optId,
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
description: optText(10_000).optional(),
scope: optText(10_000).optional(),
plannedStart: optDate,
plannedEnd: optDate,
signatureRequired: z.boolean().optional(),
billingType: z.enum(BILLING_TYPES).nullish(),
internalNotes: optText(5000).optional(),
technicianNotes: optText(5000).optional(),
externalOrderNumber: optText(80).optional(),
offerNumber: optText(80).optional(),
emergencyReason: optText(2000).optional(),
})
.strict();
export type UpdateWorkOrderInput = z.input<typeof updateWorkOrderSchema>;
export const transitionSchema = z.object({
workOrderId: id,
to: z.enum(WORK_ORDER_STATUSES),
reason: optText(2000),
baseVersion: z.coerce.number().int().positive().optional(),
});
export type TransitionInput = z.input<typeof transitionSchema> & {
/** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */
eventData?: Record<string, string | number | boolean | null>;
};
export const assignSchema = z.object({
workOrderId: id,
teamId: id,
userIds: z.array(id).max(50).default([]),
teamLeadUserId: optId,
baseVersion: z.coerce.number().int().positive().optional(),
});
export type AssignInput = z.input<typeof assignSchema>;
// ---------- Settings ----------
export const orderTypeSchema = z.object({
key: z
.string()
.trim()
.min(2)
.max(40)
.regex(/^[a-z0-9_]+$/),
name: text(80).min(1),
signatureRequired: z.boolean().default(true),
active: z.boolean().default(true),
sortOrder: z.coerce.number().int().min(0).max(10_000).default(100),
});
export type OrderTypeInput = z.input<typeof orderTypeSchema>;
export const templateItemSchema = z.object({
key: z
.string()
.trim()
.min(1)
.max(60)
.regex(/^[a-z0-9_]+$/),
label: text(200).min(1),
required: z.boolean().default(false),
requiresPhoto: z.boolean().default(false),
});
export const templatePhotoSchema = z.object({
key: z
.string()
.trim()
.min(1)
.max(60)
.regex(/^[a-z0-9_]+$/),
label: text(200).min(1),
});
export type TemplateItem = z.output<typeof templateItemSchema>;
export type TemplatePhoto = z.output<typeof templatePhotoSchema>;
export const checklistTemplateSchema = z.object({
name: text(120).min(1),
orderTypeId: optId,
active: z.boolean().default(true),
items: z.array(templateItemSchema).max(100),
requiredPhotos: z.array(templatePhotoSchema).max(30),
});
export type ChecklistTemplateInput = z.input<typeof checklistTemplateSchema>;
export const NUMBER_KEYS = ["work_order", "emergency", "customer", "report"] as const;
export const numberingSchema = z.object({
key: z.enum(NUMBER_KEYS),
prefix: z
.string()
.trim()
.max(12)
.regex(/^[A-Za-z0-9\-_/]*$/),
padding: z.coerce.number().int().min(1).max(10),
});
/** Stable key from a free label (umlauts transliterated). */
export function slugKey(label: string): string {
const s = label
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 60);
return s || "punkt";
}