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:
@@ -0,0 +1,17 @@
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
|
||||
/** Result of a work order server action (client-safe; rendered by components/work-orders/action-form.tsx). */
|
||||
export type ActionState =
|
||||
| { status: "idle" }
|
||||
| { status: "ok"; at: number }
|
||||
| {
|
||||
status: "error";
|
||||
/** ServiceError code: not_found | forbidden | invalid | conflict | blocked | internal */
|
||||
code: string;
|
||||
/** message key below `errors.` (falls back to the code) */
|
||||
message: string;
|
||||
blockers?: CompletionBlocker[];
|
||||
at: number;
|
||||
};
|
||||
|
||||
export const IDLE_STATE: ActionState = { status: "idle" };
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { TemplateItem, TemplatePhoto } from "@/lib/work-orders/schemas";
|
||||
|
||||
/**
|
||||
* Tenant defaults (client-safe data). Labels are German master data (stored per tenant and
|
||||
* editable afterwards), not UI chrome — therefore not part of the message catalogue.
|
||||
*/
|
||||
|
||||
/** Mirrors the defaults of src/server/services/numbering.ts (used before a sequence row exists). */
|
||||
export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergency" | "report", string> = {
|
||||
customer: "K-",
|
||||
work_order: "A-",
|
||||
emergency: "N-",
|
||||
report: "B-",
|
||||
};
|
||||
|
||||
/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */
|
||||
export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [
|
||||
{ key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 },
|
||||
{ key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 },
|
||||
{ key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 },
|
||||
{ key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 },
|
||||
{ key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 },
|
||||
{ key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 },
|
||||
{ key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 },
|
||||
{ key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 },
|
||||
];
|
||||
|
||||
/** Spec §12.4 — suggested checklist items. */
|
||||
export const DEFAULT_CHECKLIST_ITEMS: readonly TemplateItem[] = [
|
||||
{ key: "spannungsfrei", label: "Anlage spannungsfrei geschaltet", required: true, requiresPhoto: false },
|
||||
{ key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true, requiresPhoto: false },
|
||||
{ key: "material_geprueft", label: "Material geprüft", required: false, requiresPhoto: false },
|
||||
{ key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true },
|
||||
{ key: "arbeitsbereich_gereinigt", label: "Arbeitsbereich gereinigt", required: true, requiresPhoto: false },
|
||||
{ key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false, requiresPhoto: false },
|
||||
{ key: "pflichtfotos_erstellt", label: "Pflichtfotos erstellt", required: true, requiresPhoto: false },
|
||||
];
|
||||
|
||||
/** Spec §14.2 — suggested required photos (keys match PhotoRequirement.key catalogue). */
|
||||
export const DEFAULT_REQUIRED_PHOTOS: readonly TemplatePhoto[] = [
|
||||
{ key: "ausgangszustand", label: "Ausgangszustand" },
|
||||
{ key: "typenschild", label: "Typenschild" },
|
||||
{ key: "leitungsverlauf", label: "Leitungsverlauf" },
|
||||
{ key: "zwischenschritt", label: "Zwischenschritt" },
|
||||
{ key: "fertige_montage", label: "Fertige Montage" },
|
||||
{ key: "funktionspruefung", label: "Funktionsprüfung" },
|
||||
{ key: "arbeitsbereich_abschluss", label: "Arbeitsbereich nach Abschluss" },
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
import { WORK_ORDER_STATUSES, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { WORK_ORDER_PRIORITIES, type WorkOrderPriority } from "@/lib/work-orders/schemas";
|
||||
|
||||
/**
|
||||
* List/dashboard filter model (spec §21) and its URL representation (client-safe).
|
||||
* The same parser feeds /work-orders, /dashboard and GET /api/v1/work-orders.
|
||||
*/
|
||||
|
||||
export const STATUS_GROUPS: readonly StatusGroup[] = [
|
||||
"new",
|
||||
"planned",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"documentation_incomplete",
|
||||
"in_review",
|
||||
"ready_for_billing",
|
||||
"billed",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
/** Dashboard tiles (§21) — each tile links to the list with `preset=<key>`. */
|
||||
export const PRESETS = [
|
||||
"open",
|
||||
"today",
|
||||
"running",
|
||||
"not_accepted",
|
||||
"overdue",
|
||||
"reports_in_review",
|
||||
"completed",
|
||||
"billing",
|
||||
"emergency_new",
|
||||
"missing_signatures",
|
||||
] as const;
|
||||
export type Preset = (typeof PRESETS)[number];
|
||||
|
||||
export const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"] as const;
|
||||
export type SortField = (typeof SORT_FIELDS)[number];
|
||||
|
||||
export type WorkOrderFilter = {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
customerId?: string;
|
||||
siteId?: string;
|
||||
teamId?: string;
|
||||
userId?: string;
|
||||
statuses?: WorkOrderStatus[];
|
||||
group?: StatusGroup;
|
||||
orderTypeId?: string;
|
||||
priority?: WorkOrderPriority;
|
||||
preset?: Preset;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export type ListParams = WorkOrderFilter & {
|
||||
sort: SortField;
|
||||
dir: "asc" | "desc";
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type Raw = Record<string, string | string[] | undefined> | URLSearchParams;
|
||||
|
||||
function get(raw: Raw, key: string): string | undefined {
|
||||
const v = raw instanceof URLSearchParams ? raw.get(key) ?? undefined : raw[key];
|
||||
const s = Array.isArray(v) ? v[0] : v;
|
||||
return s && s.trim() ? s.trim() : undefined;
|
||||
}
|
||||
|
||||
function date(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return undefined;
|
||||
const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
const cuidLike = (v: string | undefined) => (v && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : undefined);
|
||||
|
||||
export function parseListParams(raw: Raw): ListParams {
|
||||
const statusRaw = get(raw, "status");
|
||||
const statuses = statusRaw
|
||||
?.split(",")
|
||||
.filter((s): s is WorkOrderStatus => (WORK_ORDER_STATUSES as readonly string[]).includes(s));
|
||||
const group = get(raw, "group");
|
||||
const priority = get(raw, "priority");
|
||||
const preset = get(raw, "preset");
|
||||
const sort = get(raw, "sort");
|
||||
const q = get(raw, "q");
|
||||
return {
|
||||
from: date(get(raw, "from")),
|
||||
to: date(get(raw, "to"), true),
|
||||
customerId: cuidLike(get(raw, "customerId")),
|
||||
siteId: cuidLike(get(raw, "siteId")),
|
||||
teamId: cuidLike(get(raw, "teamId")),
|
||||
userId: cuidLike(get(raw, "userId")),
|
||||
statuses: statuses?.length ? statuses : undefined,
|
||||
group: group && (STATUS_GROUPS as readonly string[]).includes(group) ? (group as StatusGroup) : undefined,
|
||||
orderTypeId: cuidLike(get(raw, "orderTypeId")),
|
||||
priority: priority && (WORK_ORDER_PRIORITIES as readonly string[]).includes(priority) ? (priority as WorkOrderPriority) : undefined,
|
||||
preset: preset && (PRESETS as readonly string[]).includes(preset) ? (preset as Preset) : undefined,
|
||||
q: q ? q.slice(0, 100) : undefined,
|
||||
sort: sort && (SORT_FIELDS as readonly string[]).includes(sort) ? (sort as SortField) : "plannedStart",
|
||||
dir: get(raw, "dir") === "desc" ? "desc" : "asc",
|
||||
page: Math.max(1, Math.min(10_000, Number.parseInt(get(raw, "page") ?? "1", 10) || 1)),
|
||||
pageSize: Math.max(1, Math.min(100, Number.parseInt(get(raw, "pageSize") ?? "25", 10) || 25)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialise filter params back into a query string (drops empty values). */
|
||||
export function toQuery(params: Partial<Record<keyof ListParams | "view" | "new" | "status", unknown>>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
if (v instanceof Date) sp.set(k, isoDay(v));
|
||||
else if (Array.isArray(v)) {
|
||||
if (v.length) sp.set(k, v.join(","));
|
||||
} else sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export function isoDay(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Timezone helpers (client-safe, Intl only). Planned dates are entered as wall-clock time in the
|
||||
* tenant timezone (TenantSettings.timezone) and stored as UTC.
|
||||
*/
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export function safeTimeZone(tz: string | null | undefined): string {
|
||||
if (!tz) return DEFAULT_TIMEZONE;
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||
return tz;
|
||||
} catch {
|
||||
return DEFAULT_TIMEZONE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset (ms) of `timeZone` relative to UTC at instant `at`. */
|
||||
export function tzOffsetMs(at: Date, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(at);
|
||||
const n = (t: string) => Number(parts.find((p) => p.type === t)?.value);
|
||||
const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second"));
|
||||
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** "2026-09-14T08:30" (wall time in tz) → UTC Date. Returns undefined for invalid input. */
|
||||
export function wallTimeToUtc(value: string, timeZone: string): Date | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value.trim());
|
||||
if (!m) return undefined;
|
||||
const tz = safeTimeZone(timeZone);
|
||||
const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0);
|
||||
const first = guess - tzOffsetMs(new Date(guess), tz);
|
||||
// second pass corrects DST boundaries
|
||||
const result = guess - tzOffsetMs(new Date(first), tz);
|
||||
const d = new Date(result);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
/** UTC Date → "YYYY-MM-DDTHH:mm" wall time in tz (for <input type="datetime-local">). */
|
||||
export function toWallTimeInput(date: Date | null | undefined, timeZone: string): string {
|
||||
if (!date) return "";
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: safeTimeZone(timeZone),
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const p = (t: string) => parts.find((x) => x.type === t)?.value ?? "00";
|
||||
return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}`;
|
||||
}
|
||||
|
||||
/** Start/end of the calendar day containing `now` in the given timezone. */
|
||||
export function zonedDayBounds(now: Date, timeZone: string): { start: Date; end: Date } {
|
||||
const tz = safeTimeZone(timeZone);
|
||||
const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" })
|
||||
.format(now)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const guessStart = Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||
const start = new Date(guessStart - tzOffsetMs(new Date(guessStart), tz));
|
||||
const guessEnd = Date.UTC(y, m - 1, d, 23, 59, 59, 999);
|
||||
const end = new Date(guessEnd - tzOffsetMs(new Date(guessEnd), tz));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | null | undefined, locale: string, timeZone: string): string {
|
||||
if (!date) return "";
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | null | undefined, locale: string, timeZone: string): string {
|
||||
if (!date) return "";
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium" }).format(date);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { z } from "zod";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { safeTimeZone } from "@/lib/work-orders/time";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Parse service input; validation problems become ServiceError("invalid") with flattened issues. */
|
||||
export function parseInput<S extends z.ZodType>(schema: S, input: unknown): z.output<S> {
|
||||
const res = schema.safeParse(input);
|
||||
if (!res.success) {
|
||||
throw new ServiceError(
|
||||
"invalid",
|
||||
"validation_failed",
|
||||
res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
||||
);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** No business data changes at all in these statuses. */
|
||||
export const FINAL_STATUSES: readonly WorkOrderStatus[] = ["billed", "cancelled"];
|
||||
/** Planning data (checklist, photos, material plan, assignment) is frozen once released for billing. */
|
||||
export const PLANNING_LOCKED: readonly WorkOrderStatus[] = ["released_for_billing", "billed", "cancelled"];
|
||||
|
||||
export type WorkOrderBase = {
|
||||
id: string;
|
||||
number: string;
|
||||
status: WorkOrderStatus;
|
||||
version: number;
|
||||
customerId: string;
|
||||
siteId: string | null;
|
||||
signatureRequired: boolean;
|
||||
isEmergency: boolean;
|
||||
assignedTeamId: string | null;
|
||||
teamLeadUserId: string | null;
|
||||
};
|
||||
|
||||
const BASE_SELECT = {
|
||||
id: true,
|
||||
number: true,
|
||||
status: true,
|
||||
version: true,
|
||||
customerId: true,
|
||||
siteId: true,
|
||||
signatureRequired: true,
|
||||
isEmergency: true,
|
||||
assignedTeamId: true,
|
||||
teamLeadUserId: true,
|
||||
} as const;
|
||||
|
||||
/** Load a work order within the caller's visibility scope or throw not_found (existence is never revealed). */
|
||||
export async function loadVisibleWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise<WorkOrderBase> {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, select: BASE_SELECT });
|
||||
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
|
||||
return wo as WorkOrderBase;
|
||||
}
|
||||
|
||||
export function assertBaseVersion(wo: { version: number }, baseVersion?: number) {
|
||||
if (baseVersion !== undefined && baseVersion !== wo.version) {
|
||||
throw new ServiceError("conflict", "version_conflict", { currentVersion: wo.version, baseVersion });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic write on the work order row: only succeeds while the version is unchanged and
|
||||
* always increments `version` (offline sync conflict detection relies on it).
|
||||
*/
|
||||
export async function writeWithVersion(
|
||||
ctx: ServiceCtx,
|
||||
wo: { id: string; version: number },
|
||||
data: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const res = await ctx.db.workOrder.updateMany({
|
||||
where: { id: wo.id, version: wo.version, deletedAt: null },
|
||||
data: { ...data, version: { increment: 1 } },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "version_conflict", { baseVersion: wo.version });
|
||||
return wo.version + 1;
|
||||
}
|
||||
|
||||
/** Version bump after a mutation of a dependent entity (checklist, material plan, …). */
|
||||
export async function touchWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise<void> {
|
||||
await ctx.db.workOrder.updateMany({ where: { id: workOrderId }, data: { version: { increment: 1 } } });
|
||||
}
|
||||
|
||||
export async function auditWorkOrder(
|
||||
ctx: ServiceCtx,
|
||||
entry: { action: "create" | "update" | "delete"; workOrderId: string; before?: unknown; after?: unknown },
|
||||
) {
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: entry.action,
|
||||
entity: "work_order",
|
||||
entityId: entry.workOrderId,
|
||||
before: entry.before,
|
||||
after: entry.after,
|
||||
});
|
||||
}
|
||||
|
||||
/** Plain JSON copy for audit before/after (Decimals/Dates → strings). */
|
||||
export function snapshot<T>(value: T): unknown {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function assertNotLocked(wo: { status: WorkOrderStatus }, locked: readonly WorkOrderStatus[]) {
|
||||
if (locked.includes(wo.status)) throw new ServiceError("invalid", "not_editable", { status: wo.status });
|
||||
}
|
||||
|
||||
/** Unique stable keys for checklist items / photo requirements. */
|
||||
export function uniqueKey(base: string, taken: Set<string>): string {
|
||||
let key = base;
|
||||
let i = 2;
|
||||
while (taken.has(key)) key = `${base}_${i++}`.slice(0, 60);
|
||||
taken.add(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Tenant timezone (TenantSettings.timezone, default Europe/Berlin). */
|
||||
export async function tenantTimezone(ctx: ServiceCtx): Promise<string> {
|
||||
const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } });
|
||||
return safeTimeZone(s?.timezone);
|
||||
}
|
||||
|
||||
export { zonedDayBounds } from "@/lib/work-orders/time";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { assignSchema, type AssignInput } from "@/lib/work-orders/schemas";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import {
|
||||
assertBaseVersion,
|
||||
assertNotLocked,
|
||||
auditWorkOrder,
|
||||
loadVisibleWorkOrder,
|
||||
parseInput,
|
||||
PLANNING_LOCKED,
|
||||
writeWithVersion,
|
||||
} from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Assign a team (+ optional individual technicians and a responsible team lead) — US-004.
|
||||
* draft/review_required/planned → assigned; accepted with a changed team → back to assigned.
|
||||
*/
|
||||
export async function assignWorkOrder(
|
||||
ctx: ServiceCtx,
|
||||
raw: AssignInput,
|
||||
): Promise<{ id: string; version: number; status: WorkOrderStatus }> {
|
||||
assertCan(ctx, "work_order:assign");
|
||||
const input = parseInput(assignSchema, raw);
|
||||
const wo = await loadVisibleWorkOrder(ctx, input.workOrderId);
|
||||
assertNotLocked(wo, PLANNING_LOCKED);
|
||||
assertBaseVersion(wo, input.baseVersion);
|
||||
|
||||
const team = await ctx.db.team.findFirst({
|
||||
where: { id: input.teamId, deletedAt: null, status: "active" },
|
||||
select: { id: true, name: true, leaderUserId: true },
|
||||
});
|
||||
if (!team) throw new ServiceError("invalid", "team_not_found");
|
||||
|
||||
const userIds = [...new Set(input.userIds)];
|
||||
const leadId = input.teamLeadUserId ?? team.leaderUserId ?? null;
|
||||
const toCheck = [...new Set([...userIds, ...(leadId ? [leadId] : [])])];
|
||||
if (toCheck.length) {
|
||||
const found = await ctx.db.user.count({ where: { id: { in: toCheck }, status: "ACTIVE" } });
|
||||
if (found !== toCheck.length) throw new ServiceError("invalid", "user_not_found");
|
||||
}
|
||||
|
||||
const previous = await ctx.db.workOrderAssignee.findMany({ where: { workOrderId: wo.id }, select: { userId: true } });
|
||||
|
||||
let to: WorkOrderStatus | null = null;
|
||||
if (["draft", "review_required", "planned"].includes(wo.status)) to = "assigned";
|
||||
else if (wo.status === "accepted" && wo.assignedTeamId !== team.id) to = "assigned";
|
||||
if (to && !canTransition(wo.status, to)) to = null;
|
||||
|
||||
const version = await writeWithVersion(ctx, wo, {
|
||||
assignedTeamId: team.id,
|
||||
teamLeadUserId: leadId,
|
||||
...(to ? { status: to } : {}),
|
||||
});
|
||||
await ctx.db.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id, userId: { notIn: userIds } } });
|
||||
if (userIds.length) {
|
||||
await ctx.db.workOrderAssignee.createMany({
|
||||
data: userIds.map((userId) => ({ tenantId: ctx.tenantId, workOrderId: wo.id, userId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
if (to) {
|
||||
await ctx.db.workOrderStatusChange.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: wo.status, toStatus: to, actorId: ctx.userId },
|
||||
});
|
||||
}
|
||||
|
||||
await auditWorkOrder(ctx, {
|
||||
action: "update",
|
||||
workOrderId: wo.id,
|
||||
before: { assignedTeamId: wo.assignedTeamId, teamLeadUserId: wo.teamLeadUserId, userIds: previous.map((p) => p.userId), status: wo.status },
|
||||
after: { assignedTeamId: team.id, teamLeadUserId: leadId, userIds, status: to ?? wo.status, version },
|
||||
});
|
||||
await emitEvent(ctx, {
|
||||
type: "work_order.assigned",
|
||||
entityType: "work_order",
|
||||
entityId: wo.id,
|
||||
data: { number: wo.number, teamId: team.id, teamName: team.name },
|
||||
});
|
||||
return { id: wo.id, version, status: to ?? wo.status };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition";
|
||||
|
||||
/** Cancel an order (any status except billed) — `work_order:cancel`, reason mandatory. */
|
||||
export async function cancelWorkOrder(
|
||||
ctx: ServiceCtx,
|
||||
input: { workOrderId: string; reason: string; baseVersion?: number },
|
||||
): Promise<TransitionResult> {
|
||||
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "cancelled", reason: input.reason, baseVersion: input.baseVersion });
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
checklistItemInputSchema,
|
||||
photoRequirementInputSchema,
|
||||
slugKey,
|
||||
templateItemSchema,
|
||||
templatePhotoSchema,
|
||||
type ChecklistItemInput,
|
||||
type PhotoRequirementInput,
|
||||
} from "@/lib/work-orders/schemas";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import {
|
||||
assertNotLocked,
|
||||
auditWorkOrder,
|
||||
loadVisibleWorkOrder,
|
||||
parseInput,
|
||||
PLANNING_LOCKED,
|
||||
snapshot,
|
||||
touchWorkOrder,
|
||||
uniqueKey,
|
||||
} from "@/server/services/work-orders/_shared";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Checklist items and required photos on a single order (backoffice maintenance, spec §12.4/§14.2). */
|
||||
|
||||
async function editableOrder(ctx: ServiceCtx, workOrderId: string) {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
assertNotLocked(wo, PLANNING_LOCKED);
|
||||
return wo;
|
||||
}
|
||||
|
||||
export async function addChecklistItem(ctx: ServiceCtx, workOrderId: string, raw: ChecklistItemInput) {
|
||||
const input = parseInput(checklistItemInputSchema, raw);
|
||||
await editableOrder(ctx, workOrderId);
|
||||
const existing = await ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } });
|
||||
const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key)));
|
||||
const item = await ctx.db.checklistItem.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
workOrderId,
|
||||
key,
|
||||
label: input.label,
|
||||
required: input.required,
|
||||
requiresPhoto: input.requiresPhoto,
|
||||
sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10,
|
||||
},
|
||||
});
|
||||
await touchWorkOrder(ctx, workOrderId);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "checklist_item.add", item: snapshot(item) } });
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateChecklistItem(
|
||||
ctx: ServiceCtx,
|
||||
itemId: string,
|
||||
raw: { label?: string; required?: boolean; requiresPhoto?: boolean; sortOrder?: number },
|
||||
) {
|
||||
const input = parseInput(checklistItemInputSchema.partial(), raw);
|
||||
const scope = await workOrderScope(ctx);
|
||||
const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } });
|
||||
if (!before) throw new ServiceError("not_found", "checklist_item_not_found");
|
||||
await editableOrder(ctx, before.workOrderId);
|
||||
const item = await ctx.db.checklistItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
...(input.label !== undefined ? { label: input.label } : {}),
|
||||
...(raw.required !== undefined ? { required: !!input.required } : {}),
|
||||
...(raw.requiresPhoto !== undefined ? { requiresPhoto: !!input.requiresPhoto } : {}),
|
||||
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
await touchWorkOrder(ctx, before.workOrderId);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.update", item: snapshot(before) }, after: { item: snapshot(item) } });
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Checked items are evidence and stay. */
|
||||
export async function removeChecklistItem(ctx: ServiceCtx, itemId: string): Promise<void> {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } });
|
||||
if (!before) throw new ServiceError("not_found", "checklist_item_not_found");
|
||||
await editableOrder(ctx, before.workOrderId);
|
||||
if (before.checked) throw new ServiceError("invalid", "checklist_item_checked");
|
||||
await ctx.db.checklistItem.deleteMany({ where: { id: itemId } });
|
||||
await touchWorkOrder(ctx, before.workOrderId);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.remove", item: snapshot(before) } });
|
||||
}
|
||||
|
||||
export async function addPhotoRequirement(ctx: ServiceCtx, workOrderId: string, raw: PhotoRequirementInput) {
|
||||
const input = parseInput(photoRequirementInputSchema, raw);
|
||||
await editableOrder(ctx, workOrderId);
|
||||
const existing = await ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } });
|
||||
const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key)));
|
||||
const req = await ctx.db.photoRequirement.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
workOrderId,
|
||||
key,
|
||||
label: input.label,
|
||||
sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10,
|
||||
},
|
||||
});
|
||||
await touchWorkOrder(ctx, workOrderId);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "photo_requirement.add", requirement: snapshot(req) } });
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Requirements that already have photos stay (evidence). */
|
||||
export async function removePhotoRequirement(ctx: ServiceCtx, requirementId: string): Promise<void> {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const before = await ctx.db.photoRequirement.findFirst({ where: { id: requirementId, workOrder: scope } });
|
||||
if (!before) throw new ServiceError("not_found", "photo_requirement_not_found");
|
||||
await editableOrder(ctx, before.workOrderId);
|
||||
const photos = await ctx.db.photo.count({ where: { photoRequirementId: requirementId } });
|
||||
if (photos > 0) throw new ServiceError("invalid", "photo_requirement_has_photos");
|
||||
await ctx.db.photoRequirement.deleteMany({ where: { id: requirementId } });
|
||||
await touchWorkOrder(ctx, before.workOrderId);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "photo_requirement.remove", requirement: snapshot(before) } });
|
||||
}
|
||||
|
||||
/** Add the items/photos of a template that the order does not have yet (by key). */
|
||||
export async function applyChecklistTemplate(ctx: ServiceCtx, workOrderId: string, templateId: string) {
|
||||
await editableOrder(ctx, workOrderId);
|
||||
const tpl = await ctx.db.checklistTemplate.findFirst({ where: { id: templateId, active: true } });
|
||||
if (!tpl) throw new ServiceError("invalid", "template_not_found");
|
||||
const items = z.array(templateItemSchema).safeParse(tpl.items).data ?? [];
|
||||
const photos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [];
|
||||
const [haveItems, havePhotos] = await Promise.all([
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }),
|
||||
ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }),
|
||||
]);
|
||||
const itemKeys = new Set(haveItems.map((i) => i.key));
|
||||
const photoKeys = new Set(havePhotos.map((i) => i.key));
|
||||
let sort = Math.max(0, ...haveItems.map((i) => i.sortOrder));
|
||||
const newItems = items.filter((i) => !itemKeys.has(i.key));
|
||||
let psort = Math.max(0, ...havePhotos.map((i) => i.sortOrder));
|
||||
const newPhotos = photos.filter((p) => !photoKeys.has(p.key));
|
||||
if (newItems.length) {
|
||||
await ctx.db.checklistItem.createMany({
|
||||
data: newItems.map((i) => ({ tenantId: ctx.tenantId, workOrderId, key: i.key, label: i.label, required: i.required, requiresPhoto: i.requiresPhoto, sortOrder: (sort += 10) })),
|
||||
});
|
||||
}
|
||||
if (newPhotos.length) {
|
||||
await ctx.db.photoRequirement.createMany({
|
||||
data: newPhotos.map((p) => ({ tenantId: ctx.tenantId, workOrderId, key: p.key, label: p.label, sortOrder: (psort += 10) })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await touchWorkOrder(ctx, workOrderId);
|
||||
await auditWorkOrder(ctx, {
|
||||
action: "update",
|
||||
workOrderId,
|
||||
after: { op: "checklist_template.apply", templateId, items: newItems.length, photos: newPhotos.length },
|
||||
});
|
||||
return { items: newItems.length, photos: newPhotos.length };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Completion guards (ARCHITEKTUR §3): before `technically_completed` every required checklist
|
||||
* item is checked (with photo where requested), every PhotoRequirement has ≥ 1 photo and no
|
||||
* WorkSession is still open.
|
||||
*/
|
||||
export async function computeCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||
const [items, requirements, sessions] = await Promise.all([
|
||||
ctx.db.checklistItem.findMany({
|
||||
where: { workOrderId, required: true },
|
||||
select: { id: true, label: true, checked: true, requiresPhoto: true, _count: { select: { photos: true } } },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
}),
|
||||
ctx.db.photoRequirement.findMany({
|
||||
where: { workOrderId },
|
||||
select: { id: true, label: true, _count: { select: { photos: true } } },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
}),
|
||||
ctx.db.workSession.findMany({
|
||||
where: { workOrderId, status: { in: ["en_route", "running", "paused"] } },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const blockers: CompletionBlocker[] = [];
|
||||
for (const i of items) {
|
||||
if (!i.checked || (i.requiresPhoto && i._count.photos === 0)) {
|
||||
blockers.push({ kind: "checklist_item", itemId: i.id, label: i.label });
|
||||
}
|
||||
}
|
||||
for (const r of requirements) {
|
||||
if (r._count.photos === 0) blockers.push({ kind: "photo_requirement", requirementId: r.id, label: r.label });
|
||||
}
|
||||
for (const s of sessions) blockers.push({ kind: "running_session", sessionId: s.id, userId: s.userId });
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/** Public variant with visibility check (UI, API). */
|
||||
export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||
await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
return computeCompletionBlockers(ctx, workOrderId);
|
||||
}
|
||||
|
||||
/** Blockers of a concrete transition (completion, signature, billing release). */
|
||||
export async function transitionBlockers(
|
||||
ctx: ServiceCtx,
|
||||
wo: { id: string; status: WorkOrderStatus; signatureRequired: boolean },
|
||||
to: WorkOrderStatus,
|
||||
): Promise<CompletionBlocker[]> {
|
||||
if (to === "technically_completed") return computeCompletionBlockers(ctx, wo.id);
|
||||
|
||||
if (to === "in_review" && (wo.status === "technically_completed" || wo.status === "signature_pending") && wo.signatureRequired) {
|
||||
// Any recorded outcome counts (signed, refused/absent with reason — spec §18.2).
|
||||
const signatures = await ctx.db.signature.count({
|
||||
where: { report: { workOrderId: wo.id, type: "completion", status: { not: "superseded" } } },
|
||||
});
|
||||
return signatures > 0 ? [] : [{ kind: "missing_field", field: "signature" }];
|
||||
}
|
||||
|
||||
if (to === "released_for_billing" && wo.status === "in_review") {
|
||||
const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } });
|
||||
return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { reapplySyncOperation } from "@/server/services/work-orders/sync-reapply";
|
||||
|
||||
/** Backoffice list of offline sync conflicts (ARCHITEKTUR §4.6) — `work_order:write`. */
|
||||
export async function listSyncConflicts(ctx: ServiceCtx) {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const ops = await ctx.db.syncOperation.findMany({
|
||||
where: { status: "conflict", resolvedAt: null },
|
||||
orderBy: { receivedAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
const userIds = [...new Set(ops.map((o) => o.userId))];
|
||||
const woIds = [...new Set(ops.filter((o) => o.entityType === "work_order" && o.entityId).map((o) => o.entityId!))];
|
||||
const [users, orders] = await Promise.all([
|
||||
userIds.length ? ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }) : [],
|
||||
woIds.length ? ctx.db.workOrder.findMany({ where: { id: { in: woIds } }, select: { id: true, number: true, title: true, status: true, version: true } }) : [],
|
||||
]);
|
||||
const u = new Map(users.map((x) => [x.id, x.name]));
|
||||
const w = new Map(orders.map((x) => [x.id, x]));
|
||||
return ops.map((o) => ({
|
||||
...o,
|
||||
userName: u.get(o.userId) ?? null,
|
||||
workOrder: o.entityType === "work_order" && o.entityId ? w.get(o.entityId) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadOpenConflict(ctx: ServiceCtx, opId: string) {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const op = await ctx.db.syncOperation.findFirst({ where: { id: opId, status: "conflict", resolvedAt: null } });
|
||||
if (!op) throw new ServiceError("not_found", "sync_conflict_not_found");
|
||||
return op;
|
||||
}
|
||||
|
||||
/** Discard: mark resolved, nothing is applied. */
|
||||
export async function discardSyncConflict(ctx: ServiceCtx, opId: string): Promise<void> {
|
||||
const op = await loadOpenConflict(ctx, opId);
|
||||
const now = new Date();
|
||||
const res = await ctx.db.syncOperation.updateMany({
|
||||
where: { id: op.id, resolvedAt: null },
|
||||
data: { status: "rejected", resolvedAt: now, resolvedById: ctx.userId, errorCode: "discarded" },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "sync_conflict_already_resolved");
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "sync_operation",
|
||||
entityId: op.id,
|
||||
before: { status: op.status, resolvedAt: null },
|
||||
after: { status: "rejected", resolution: "discarded", resolvedAt: now.toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Load DB-authoritative permissions of the device user (the op is re-applied as that user). */
|
||||
async function permissionsOf(ctx: ServiceCtx, userId: string): Promise<Set<string>> {
|
||||
const user = await ctx.db.user.findFirst({
|
||||
where: { id: userId, status: "ACTIVE" },
|
||||
select: { userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } } },
|
||||
});
|
||||
if (!user) throw new ServiceError("invalid", "sync_user_inactive");
|
||||
return new Set(user.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over: re-apply the operation against the current state, as the original device user
|
||||
* (their permissions and scope), then mark it applied + resolved. Audit is written for both
|
||||
* the domain change (by the service) and the resolution (resolver).
|
||||
*/
|
||||
export async function applySyncConflict(ctx: ServiceCtx, opId: string): Promise<{ entityVersion?: number }> {
|
||||
const op = await loadOpenConflict(ctx, opId);
|
||||
const opCtx: ServiceCtx = { db: ctx.db, tenantId: ctx.tenantId, userId: op.userId, permissions: await permissionsOf(ctx, op.userId) };
|
||||
const result = await reapplySyncOperation(opCtx, op);
|
||||
const now = new Date();
|
||||
await ctx.db.syncOperation.updateMany({
|
||||
where: { id: op.id, resolvedAt: null },
|
||||
data: { status: "applied", resolvedAt: now, resolvedById: ctx.userId, errorCode: null, result: { reappliedBy: ctx.userId, ...result } },
|
||||
});
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "sync_operation",
|
||||
entityId: op.id,
|
||||
before: { status: op.status, resolvedAt: null },
|
||||
after: { status: "applied", resolution: "reapplied", resolvedAt: now.toISOString(), ...result },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createWorkOrderSchema,
|
||||
slugKey,
|
||||
templateItemSchema,
|
||||
templatePhotoSchema,
|
||||
type CreateWorkOrderInput,
|
||||
} from "@/lib/work-orders/schemas";
|
||||
import { nextNumber } from "@/server/services/numbering";
|
||||
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { auditWorkOrder, parseInput, snapshot, uniqueKey } from "@/server/services/work-orders/_shared";
|
||||
|
||||
export type { CreateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||
|
||||
export type CreatedWorkOrder = { id: string; number: string; status: string; version: number };
|
||||
|
||||
/**
|
||||
* Create a work order (backoffice form, PDF import L3, emergency L8).
|
||||
* - Permission: `work_order:write`; emergency orders alternatively `emergency:create`.
|
||||
* - Number from the tenant sequence (`work_order` → A-…, `emergency` → N-…).
|
||||
* - From the order type: signatureRequired (unless given) and the active checklist template
|
||||
* (checklist items + required photos) unless explicit lists are passed or applyTemplate=false.
|
||||
*/
|
||||
export async function createWorkOrder(ctx: ServiceCtx, raw: CreateWorkOrderInput): Promise<CreatedWorkOrder> {
|
||||
const input = parseInput(createWorkOrderSchema, raw);
|
||||
if (!(can(ctx, "work_order:write") || (input.isEmergency && can(ctx, "emergency:create")))) {
|
||||
assertCan(ctx, "work_order:write");
|
||||
}
|
||||
if (input.status === "in_progress" && !input.isEmergency) {
|
||||
throw new ServiceError("invalid", "initial_status_not_allowed", { status: input.status });
|
||||
}
|
||||
|
||||
const customer = await ctx.db.customer.findFirst({
|
||||
where: { id: input.customerId, deletedAt: null, status: { not: "merged" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customer) throw new ServiceError("invalid", "customer_not_found");
|
||||
if (input.siteId) {
|
||||
const site = await ctx.db.site.findFirst({ where: { id: input.siteId, deletedAt: null }, select: { customerId: true } });
|
||||
if (!site) throw new ServiceError("invalid", "site_not_found");
|
||||
if (site.customerId !== input.customerId) throw new ServiceError("invalid", "site_customer_mismatch");
|
||||
}
|
||||
if (input.contactId) {
|
||||
const contact = await ctx.db.contact.findFirst({ where: { id: input.contactId, deletedAt: null }, select: { customerId: true } });
|
||||
if (!contact) throw new ServiceError("invalid", "contact_not_found");
|
||||
if (contact.customerId !== input.customerId) throw new ServiceError("invalid", "contact_customer_mismatch");
|
||||
}
|
||||
|
||||
let signatureRequired = input.signatureRequired ?? true;
|
||||
let templateItems: z.output<typeof templateItemSchema>[] = [];
|
||||
let templatePhotos: z.output<typeof templatePhotoSchema>[] = [];
|
||||
if (input.orderTypeId) {
|
||||
const ot = await ctx.db.orderType.findFirst({
|
||||
where: { id: input.orderTypeId, active: true },
|
||||
select: { id: true, signatureRequired: true },
|
||||
});
|
||||
if (!ot) throw new ServiceError("invalid", "order_type_not_found");
|
||||
if (input.signatureRequired === undefined) signatureRequired = ot.signatureRequired;
|
||||
if (input.applyTemplate) {
|
||||
const tpl = await ctx.db.checklistTemplate.findFirst({
|
||||
where: { orderTypeId: ot.id, active: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { items: true, requiredPhotos: true },
|
||||
});
|
||||
if (tpl) {
|
||||
templateItems = z.array(templateItemSchema).safeParse(tpl.items).data ?? [];
|
||||
templatePhotos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemKeys = new Set<string>();
|
||||
const items = (input.checklistItems ?? templateItems).map((i, idx) => ({
|
||||
tenantId: ctx.tenantId,
|
||||
key: uniqueKey(i.key || slugKey(i.label), itemKeys),
|
||||
label: i.label,
|
||||
required: i.required ?? false,
|
||||
requiresPhoto: i.requiresPhoto ?? false,
|
||||
sortOrder: ("sortOrder" in i && typeof i.sortOrder === "number" ? i.sortOrder : (idx + 1) * 10),
|
||||
}));
|
||||
const photoKeys = new Set<string>();
|
||||
const photos = (input.photoRequirements ?? templatePhotos).map((p, idx) => ({
|
||||
tenantId: ctx.tenantId,
|
||||
key: uniqueKey(p.key || slugKey(p.label), photoKeys),
|
||||
label: p.label,
|
||||
sortOrder: ("sortOrder" in p && typeof p.sortOrder === "number" ? p.sortOrder : (idx + 1) * 10),
|
||||
}));
|
||||
const materials = (input.materials ?? []).map((m, idx) => ({
|
||||
tenantId: ctx.tenantId,
|
||||
name: m.name,
|
||||
articleNumber: m.articleNumber ?? null,
|
||||
plannedQuantity: m.plannedQuantity,
|
||||
unit: m.unit,
|
||||
notes: m.notes ?? null,
|
||||
sortOrder: m.sortOrder ?? (idx + 1) * 10,
|
||||
}));
|
||||
|
||||
const number = await nextNumber(ctx.db, ctx.tenantId, input.numberKey);
|
||||
const wo = await ctx.db.workOrder.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
number,
|
||||
title: input.title,
|
||||
customerId: input.customerId,
|
||||
siteId: input.siteId ?? null,
|
||||
contactId: input.contactId ?? null,
|
||||
orderTypeId: input.orderTypeId ?? null,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
description: input.description,
|
||||
scope: input.scope,
|
||||
plannedStart: input.plannedStart ?? null,
|
||||
plannedEnd: input.plannedEnd ?? null,
|
||||
signatureRequired,
|
||||
billingType: input.billingType ?? null,
|
||||
internalNotes: input.internalNotes,
|
||||
technicianNotes: input.technicianNotes,
|
||||
externalOrderNumber: input.externalOrderNumber,
|
||||
offerNumber: input.offerNumber,
|
||||
isEmergency: input.isEmergency,
|
||||
emergencyReason: input.emergencyReason,
|
||||
sourceImportId: input.sourceImportId ?? null,
|
||||
createdById: ctx.userId,
|
||||
checklistItems: { create: items },
|
||||
photoRequirements: { create: photos },
|
||||
materialPlans: { create: materials },
|
||||
statusHistory: { create: { tenantId: ctx.tenantId, fromStatus: null, toStatus: input.status, actorId: ctx.userId } },
|
||||
},
|
||||
select: { id: true, number: true, status: true, version: true },
|
||||
});
|
||||
|
||||
await auditWorkOrder(ctx, {
|
||||
action: "create",
|
||||
workOrderId: wo.id,
|
||||
after: snapshot({
|
||||
...input,
|
||||
number,
|
||||
signatureRequired,
|
||||
checklistItems: items.length,
|
||||
photoRequirements: photos.length,
|
||||
materials: materials.length,
|
||||
}),
|
||||
});
|
||||
return wo;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/filters";
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
||||
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number };
|
||||
|
||||
/**
|
||||
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
||||
* "Berichte zur Prüfung" counts reports (submitted / team_approved); "Sync-Konflikte" counts
|
||||
* unresolved SyncOperations with status conflict.
|
||||
*/
|
||||
export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter): Promise<DashboardTiles> {
|
||||
const pc = { now: new Date(), timeZone: await tenantTimezone(ctx) };
|
||||
const base = await buildWorkOrderWhere(ctx, { ...filter, preset: undefined }, { pc });
|
||||
|
||||
const counts = await Promise.all(
|
||||
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
||||
);
|
||||
const [reportsToReview, syncConflicts] = await Promise.all([
|
||||
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
||||
can(ctx, "work_order:write")
|
||||
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
||||
return { ...tiles, reportsToReview, syncConflicts };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { allowedTransitions, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { computeCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||
import { getMaterialOverview } from "@/server/services/work-orders/materials";
|
||||
import { mayTransition } from "@/server/services/work-orders/transition";
|
||||
import { allowedDocumentVisibility, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Full read model of /work-orders/[id] and GET /api/v1/work-orders/[id]. */
|
||||
export async function getWorkOrderDetail(ctx: ServiceCtx, workOrderId: string) {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const wo = await ctx.db.workOrder.findFirst({
|
||||
where: { AND: [{ id: workOrderId }, scope] },
|
||||
include: {
|
||||
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, phone: true, email: true, street: true, houseNumber: true, postalCode: true, city: true } },
|
||||
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, accessNotes: true, safetyNotes: true } },
|
||||
contact: { select: { id: true, name: true, phone: true, mobile: true, email: true } },
|
||||
orderType: { select: { id: true, name: true, key: true } },
|
||||
team: { select: { id: true, name: true } },
|
||||
teamLead: { select: { id: true, name: true } },
|
||||
assignees: { select: { user: { select: { id: true, name: true } } } },
|
||||
},
|
||||
});
|
||||
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
|
||||
return wo;
|
||||
}
|
||||
|
||||
export type WorkOrderDetail = Awaited<ReturnType<typeof getWorkOrderDetail>>;
|
||||
|
||||
/** Transitions the current user may trigger now (UI buttons). */
|
||||
export function availableTransitions(ctx: ServiceCtx, status: WorkOrderStatus): WorkOrderStatus[] {
|
||||
return allowedTransitions(status).filter((to) => mayTransition(ctx, status, to));
|
||||
}
|
||||
|
||||
export async function getChecklistTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
const [items, requirements, blockers] = await Promise.all([
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }),
|
||||
ctx.db.photoRequirement.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }),
|
||||
computeCompletionBlockers(ctx, workOrderId),
|
||||
]);
|
||||
return { items, requirements, blockers: blockers as CompletionBlocker[] };
|
||||
}
|
||||
|
||||
export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
const sessions = await ctx.db.workSession.findMany({
|
||||
where: { workOrderId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } },
|
||||
});
|
||||
return sessions.map((s) => {
|
||||
const minutes = s.entries
|
||||
.filter((e) => e.type !== "break")
|
||||
.reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0);
|
||||
return { ...s, workMinutes: Math.round(minutes) };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
const photos = await ctx.db.photo.findMany({
|
||||
where: { workOrderId },
|
||||
orderBy: { takenAt: "asc" },
|
||||
include: { photoRequirement: { select: { label: true } }, checklistItem: { select: { label: true } } },
|
||||
});
|
||||
const docs = photos.length
|
||||
? await ctx.db.document.findMany({
|
||||
where: { id: { in: photos.map((p) => p.documentId) }, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||
select: { id: true, fileName: true, storageKey: true, previewKey: true },
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(docs.map((d) => [d.id, d]));
|
||||
return photos.filter((p) => byId.has(p.documentId)).map((p) => ({ ...p, document: byId.get(p.documentId)! }));
|
||||
}
|
||||
|
||||
export async function getNotesTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
const notes = await ctx.db.activityNote.findMany({ where: { workOrderId, deletedAt: null }, orderBy: { createdAt: "desc" } });
|
||||
const names = await userNames(ctx, notes.map((n) => n.authorId));
|
||||
return notes.map((n) => ({ ...n, authorName: n.authorId ? names.get(n.authorId) ?? null : null }));
|
||||
}
|
||||
|
||||
export async function getReportsTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
return ctx.db.report.findMany({
|
||||
where: { workOrderId, status: { not: "superseded" } },
|
||||
orderBy: [{ reportDate: "desc" }, { version: "desc" }],
|
||||
select: { id: true, type: true, reportDate: true, version: true, status: true, approvedAt: true, rejectionReason: true, signature: { select: { outcome: true, signerName: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDocumentsTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
return ctx.db.document.findMany({
|
||||
where: { workOrderId, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: ["photo", "voice_note", "signature"] } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, title: true, fileName: true, category: true, visibility: true, version: true, mimeType: true, fileSize: true, storageKey: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHistoryTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
const [changes, audit] = await Promise.all([
|
||||
ctx.db.workOrderStatusChange.findMany({ where: { workOrderId }, orderBy: { createdAt: "desc" } }),
|
||||
can(ctx, "audit:read") || can(ctx, "work_order:read_all")
|
||||
? ctx.db.auditLog.findMany({
|
||||
where: { entity: "work_order", entityId: workOrderId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
select: { id: true, action: true, actorId: true, createdAt: true, before: true, after: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const names = await userNames(ctx, [...changes.map((c) => c.actorId), ...audit.map((a) => a.actorId)]);
|
||||
return {
|
||||
changes: changes.map((c) => ({ ...c, actorName: c.actorId ? names.get(c.actorId) ?? null : null })),
|
||||
audit: audit.map((a) => ({ ...a, actorName: a.actorId ? names.get(a.actorId) ?? null : null })),
|
||||
};
|
||||
}
|
||||
|
||||
export { getMaterialOverview };
|
||||
|
||||
async function userNames(ctx: ServiceCtx, ids: (string | null)[]): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(ids.filter((x): x is string => !!x))];
|
||||
if (!unique.length) return new Map();
|
||||
const users = await ctx.db.user.findMany({ where: { id: { in: unique } }, select: { id: true, name: true } });
|
||||
return new Map(users.map((u) => [u.id, u.name]));
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,161 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { OPEN_STATUSES, STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ListParams, Preset, WorkOrderFilter } from "@/lib/work-orders/filters";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone, zonedDayBounds } from "@/server/services/work-orders/_shared";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Work still to be done (no completion yet) — basis of "overdue". */
|
||||
const PENDING_WORK: WorkOrderStatus[] = [
|
||||
"draft",
|
||||
"review_required",
|
||||
"planned",
|
||||
"assigned",
|
||||
"accepted",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"paused",
|
||||
"waiting_material",
|
||||
"daily_report_created",
|
||||
];
|
||||
|
||||
export type PresetContext = { now: Date; timeZone: string };
|
||||
|
||||
/** Dashboard tile definitions (§21) — shared by dashboard counts and the list `preset` filter. */
|
||||
export function presetWhere(preset: Preset, pc: PresetContext): Prisma.WorkOrderWhereInput {
|
||||
const open = { status: { in: [...OPEN_STATUSES] } };
|
||||
switch (preset) {
|
||||
case "open":
|
||||
return open;
|
||||
case "today": {
|
||||
const { start, end } = zonedDayBounds(pc.now, pc.timeZone);
|
||||
return {
|
||||
...open,
|
||||
plannedStart: { lte: end },
|
||||
OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }],
|
||||
};
|
||||
}
|
||||
case "running":
|
||||
return { status: { in: ["en_route", "in_progress", "paused", "waiting_material"] } };
|
||||
case "not_accepted":
|
||||
return { status: "assigned" };
|
||||
case "overdue":
|
||||
return { status: { in: PENDING_WORK }, plannedEnd: { lt: pc.now } };
|
||||
case "reports_in_review":
|
||||
return { reports: { some: { status: { in: ["submitted", "team_approved"] } } } };
|
||||
case "completed":
|
||||
return { status: { in: ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"] } };
|
||||
case "billing":
|
||||
return { status: "released_for_billing" };
|
||||
case "emergency_new":
|
||||
return {
|
||||
isEmergency: true,
|
||||
OR: [
|
||||
{ status: { in: ["draft", "review_required", "in_review"] } },
|
||||
{ status: { in: [...OPEN_STATUSES] }, createdAt: { gte: new Date(pc.now.getTime() - 24 * 3600 * 1000) } },
|
||||
],
|
||||
};
|
||||
case "missing_signatures":
|
||||
return { status: "signature_pending" };
|
||||
}
|
||||
}
|
||||
|
||||
function statusesOfGroup(group: StatusGroup): WorkOrderStatus[] {
|
||||
return (Object.keys(STATUS_GROUP) as WorkOrderStatus[]).filter((s) => STATUS_GROUP[s] === group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter → where (always AND-ed with workOrderScope). `omitStatus` drops status/group so the
|
||||
* status-group tabs can show counts for the remaining filter.
|
||||
*/
|
||||
export async function buildWorkOrderWhere(
|
||||
ctx: ServiceCtx,
|
||||
f: WorkOrderFilter,
|
||||
opts: { omitStatus?: boolean; pc?: PresetContext } = {},
|
||||
): Promise<Prisma.WorkOrderWhereInput> {
|
||||
const and: Prisma.WorkOrderWhereInput[] = [await workOrderScope(ctx)];
|
||||
if (f.from) and.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] });
|
||||
if (f.to) and.push({ plannedStart: { lte: f.to } });
|
||||
if (f.customerId) and.push({ customerId: f.customerId });
|
||||
if (f.siteId) and.push({ siteId: f.siteId });
|
||||
if (f.teamId) and.push({ assignedTeamId: f.teamId });
|
||||
if (f.userId) and.push({ OR: [{ assignees: { some: { userId: f.userId } } }, { teamLeadUserId: f.userId }] });
|
||||
if (f.orderTypeId) and.push({ orderTypeId: f.orderTypeId });
|
||||
if (f.priority) and.push({ priority: f.priority });
|
||||
if (!opts.omitStatus) {
|
||||
if (f.statuses?.length) and.push({ status: { in: f.statuses } });
|
||||
if (f.group) and.push({ status: { in: statusesOfGroup(f.group) } });
|
||||
}
|
||||
if (f.preset) {
|
||||
const pc = opts.pc ?? { now: new Date(), timeZone: await tenantTimezone(ctx) };
|
||||
and.push(presetWhere(f.preset, pc));
|
||||
}
|
||||
if (f.q) {
|
||||
const q = { contains: f.q, mode: "insensitive" as const };
|
||||
and.push({
|
||||
OR: [
|
||||
{ number: q },
|
||||
{ title: q },
|
||||
{ externalOrderNumber: q },
|
||||
{ customer: { companyName: q } },
|
||||
{ customer: { lastName: q } },
|
||||
{ site: { name: q } },
|
||||
{ site: { city: q } },
|
||||
],
|
||||
});
|
||||
}
|
||||
return { AND: and };
|
||||
}
|
||||
|
||||
export const LIST_SELECT = {
|
||||
id: true,
|
||||
number: true,
|
||||
title: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
isEmergency: true,
|
||||
version: true,
|
||||
updatedAt: true,
|
||||
customer: { select: { id: true, companyName: true, firstName: true, lastName: true } },
|
||||
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true } },
|
||||
team: { select: { id: true, name: true } },
|
||||
orderType: { select: { id: true, name: true } },
|
||||
assignees: { select: { user: { select: { id: true, name: true } } } },
|
||||
} satisfies Prisma.WorkOrderSelect;
|
||||
|
||||
export type WorkOrderListItem = Prisma.WorkOrderGetPayload<{ select: typeof LIST_SELECT }>;
|
||||
|
||||
export type WorkOrderListResult = {
|
||||
items: WorkOrderListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groupCounts: Record<StatusGroup, number>;
|
||||
};
|
||||
|
||||
export async function listWorkOrders(ctx: ServiceCtx, p: ListParams): Promise<WorkOrderListResult> {
|
||||
const pc = p.preset ? { now: new Date(), timeZone: await tenantTimezone(ctx) } : undefined;
|
||||
const [where, whereNoStatus] = await Promise.all([
|
||||
buildWorkOrderWhere(ctx, p, { pc }),
|
||||
buildWorkOrderWhere(ctx, p, { omitStatus: true, pc }),
|
||||
]);
|
||||
const orderBy: Prisma.WorkOrderOrderByWithRelationInput[] =
|
||||
p.sort === "plannedStart"
|
||||
? [{ plannedStart: { sort: p.dir, nulls: "last" } }, { number: "asc" }]
|
||||
: [{ [p.sort]: p.dir }, { number: "asc" }];
|
||||
|
||||
const [items, total, grouped] = await Promise.all([
|
||||
ctx.db.workOrder.findMany({ where, select: LIST_SELECT, orderBy, skip: (p.page - 1) * p.pageSize, take: p.pageSize }),
|
||||
ctx.db.workOrder.count({ where }),
|
||||
ctx.db.workOrder.groupBy({ by: ["status"], where: whereNoStatus, _count: { _all: true } }),
|
||||
]);
|
||||
|
||||
const groupCounts = Object.fromEntries(
|
||||
["new", "planned", "en_route", "in_progress", "documentation_incomplete", "in_review", "ready_for_billing", "billed", "cancelled"].map((g) => [g, 0]),
|
||||
) as Record<StatusGroup, number>;
|
||||
for (const row of grouped) groupCounts[STATUS_GROUP[row.status as WorkOrderStatus]] += row._count._all;
|
||||
|
||||
return { items, total, page: p.page, pageSize: p.pageSize, groupCounts };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { materialPlanInputSchema, type MaterialPlanInput } from "@/lib/work-orders/schemas";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import {
|
||||
assertNotLocked,
|
||||
auditWorkOrder,
|
||||
loadVisibleWorkOrder,
|
||||
parseInput,
|
||||
PLANNING_LOCKED,
|
||||
snapshot,
|
||||
touchWorkOrder,
|
||||
} from "@/server/services/work-orders/_shared";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Material plan (Materialvorgabe, spec §13.1) — maintained by the backoffice (`work_order:write`). */
|
||||
|
||||
async function loadPlan(ctx: ServiceCtx, planId: string) {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const plan = await ctx.db.materialPlan.findFirst({ where: { id: planId, workOrder: scope } });
|
||||
if (!plan) throw new ServiceError("not_found", "material_plan_not_found");
|
||||
return plan;
|
||||
}
|
||||
|
||||
export async function addMaterialPlan(ctx: ServiceCtx, workOrderId: string, raw: MaterialPlanInput) {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const input = parseInput(materialPlanInputSchema, raw);
|
||||
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
assertNotLocked(wo, PLANNING_LOCKED);
|
||||
const count = await ctx.db.materialPlan.count({ where: { workOrderId } });
|
||||
const plan = await ctx.db.materialPlan.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
workOrderId,
|
||||
name: input.name,
|
||||
articleNumber: input.articleNumber,
|
||||
plannedQuantity: input.plannedQuantity,
|
||||
unit: input.unit,
|
||||
notes: input.notes,
|
||||
sortOrder: input.sortOrder ?? (count + 1) * 10,
|
||||
},
|
||||
});
|
||||
await touchWorkOrder(ctx, workOrderId);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "material_plan.add", plan: snapshot(plan) } });
|
||||
return plan;
|
||||
}
|
||||
|
||||
export async function updateMaterialPlan(ctx: ServiceCtx, planId: string, raw: MaterialPlanInput) {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const input = parseInput(materialPlanInputSchema, raw);
|
||||
const before = await loadPlan(ctx, planId);
|
||||
const wo = await loadVisibleWorkOrder(ctx, before.workOrderId);
|
||||
assertNotLocked(wo, PLANNING_LOCKED);
|
||||
const plan = await ctx.db.materialPlan.update({
|
||||
where: { id: planId },
|
||||
data: {
|
||||
name: input.name,
|
||||
articleNumber: input.articleNumber,
|
||||
plannedQuantity: input.plannedQuantity,
|
||||
unit: input.unit,
|
||||
notes: input.notes,
|
||||
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
await touchWorkOrder(ctx, wo.id);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.update", plan: snapshot(before) }, after: { plan: snapshot(plan) } });
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** MaterialPlan has no deletedAt (planning data). Removal is refused once usages reference it. */
|
||||
export async function removeMaterialPlan(ctx: ServiceCtx, planId: string): Promise<void> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const before = await loadPlan(ctx, planId);
|
||||
const wo = await loadVisibleWorkOrder(ctx, before.workOrderId);
|
||||
assertNotLocked(wo, PLANNING_LOCKED);
|
||||
const usages = await ctx.db.materialUsage.count({ where: { materialPlanId: planId } });
|
||||
if (usages > 0) throw new ServiceError("invalid", "material_in_use");
|
||||
await ctx.db.materialPlan.deleteMany({ where: { id: planId } });
|
||||
await touchWorkOrder(ctx, wo.id);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.remove", plan: snapshot(before) } });
|
||||
}
|
||||
|
||||
export type MaterialRow = {
|
||||
planId: string | null;
|
||||
name: string;
|
||||
articleNumber: string | null;
|
||||
unit: string;
|
||||
planned: number | null;
|
||||
actual: number | null;
|
||||
deviation: number | null;
|
||||
statuses: string[];
|
||||
reasons: string[];
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
/** Planned vs. actual incl. deviations (backoffice read view, US-009). */
|
||||
export async function getMaterialOverview(ctx: ServiceCtx, workOrderId: string): Promise<MaterialRow[]> {
|
||||
await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
const [plans, usages] = await Promise.all([
|
||||
ctx.db.materialPlan.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" } }),
|
||||
ctx.db.materialUsage.findMany({ where: { workOrderId }, orderBy: { createdAt: "asc" } }),
|
||||
]);
|
||||
const rows: MaterialRow[] = plans.map((p) => {
|
||||
const u = usages.filter((x) => x.materialPlanId === p.id);
|
||||
const planned = Number(p.plannedQuantity);
|
||||
const actual = u.length ? u.reduce((s, x) => s + Number(x.actualQuantity), 0) : null;
|
||||
return {
|
||||
planId: p.id,
|
||||
name: p.name,
|
||||
articleNumber: p.articleNumber,
|
||||
unit: p.unit,
|
||||
planned,
|
||||
actual,
|
||||
deviation: actual === null ? null : Math.round((actual - planned) * 1000) / 1000,
|
||||
statuses: [...new Set(u.map((x) => x.usageStatus))],
|
||||
reasons: u.map((x) => x.deviationReason).filter((r): r is string => !!r),
|
||||
notes: p.notes,
|
||||
};
|
||||
});
|
||||
for (const x of usages.filter((u) => !u.materialPlanId)) {
|
||||
rows.push({
|
||||
planId: null,
|
||||
name: x.name,
|
||||
articleNumber: x.articleNumber,
|
||||
unit: x.unit,
|
||||
planned: null,
|
||||
actual: Number(x.actualQuantity),
|
||||
deviation: Number(x.actualQuantity),
|
||||
statuses: [x.usageStatus],
|
||||
reasons: x.deviationReason ? [x.deviationReason] : [],
|
||||
notes: x.notes,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { customerScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Lookup data for the create/assign popups and filter bars (read-only, scoped). */
|
||||
|
||||
export async function searchCustomerOptions(ctx: ServiceCtx, q: string | undefined) {
|
||||
const scope = await customerScope(ctx);
|
||||
const term = q?.trim();
|
||||
const like = term ? { contains: term.slice(0, 100), mode: "insensitive" as const } : undefined;
|
||||
return ctx.db.customer.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
scope,
|
||||
{ status: { not: "merged" } },
|
||||
like ? { OR: [{ companyName: like }, { lastName: like }, { firstName: like }, { customerNumber: like }, { city: like }, { street: like }] } : {},
|
||||
],
|
||||
},
|
||||
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
|
||||
/** Customer select for filter bars (dashboard / list). */
|
||||
export async function customerFilterOptions(ctx: ServiceCtx) {
|
||||
const scope = await customerScope(ctx);
|
||||
return ctx.db.customer.findMany({
|
||||
where: { AND: [scope, { status: { not: "merged" } }] },
|
||||
select: { id: true, companyName: true, firstName: true, lastName: true },
|
||||
orderBy: [{ companyName: "asc" }, { lastName: "asc" }],
|
||||
take: 300,
|
||||
});
|
||||
}
|
||||
|
||||
export async function siteFilterOptions(ctx: ServiceCtx, customerId: string) {
|
||||
return ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true }, orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
export async function customerOption(ctx: ServiceCtx, customerId: string) {
|
||||
const scope = await customerScope(ctx);
|
||||
const customer = await ctx.db.customer.findFirst({
|
||||
where: { AND: [scope, { id: customerId }] },
|
||||
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true },
|
||||
});
|
||||
if (!customer) return null;
|
||||
const [sites, contacts] = await Promise.all([
|
||||
ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, city: true }, orderBy: { name: "asc" } }),
|
||||
ctx.db.contact.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, role: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
return { customer, sites, contacts };
|
||||
}
|
||||
|
||||
export async function teamOptions(ctx: ServiceCtx) {
|
||||
const now = new Date();
|
||||
return ctx.db.team.findMany({
|
||||
where: { deletedAt: null, status: "active" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
leaderUserId: true,
|
||||
members: {
|
||||
where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
|
||||
select: { user: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function userOptions(ctx: ServiceCtx) {
|
||||
return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
export async function checklistTemplateOptions(ctx: ServiceCtx) {
|
||||
return ctx.db.checklistTemplate.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
export function customerDisplayName(c: { companyName?: string | null; firstName?: string | null; lastName?: string | null } | null | undefined): string {
|
||||
if (!c) return "";
|
||||
return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition";
|
||||
|
||||
/**
|
||||
* Billing workflow (US-009). All paths go through transitionWorkOrder, which enforces
|
||||
* `work_order:release_billing` and — for the release — an approved completion report.
|
||||
* The event `work_order.released_for_billing` is emitted by the transition.
|
||||
*/
|
||||
export async function releaseForBilling(
|
||||
ctx: ServiceCtx,
|
||||
input: { workOrderId: string; baseVersion?: number },
|
||||
): Promise<TransitionResult> {
|
||||
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "released_for_billing", baseVersion: input.baseVersion });
|
||||
}
|
||||
|
||||
/** Reject: back to the team for correction (in_review → in_progress), reason mandatory. */
|
||||
export async function rejectForCorrection(
|
||||
ctx: ServiceCtx,
|
||||
input: { workOrderId: string; reason: string; baseVersion?: number },
|
||||
): Promise<TransitionResult> {
|
||||
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_progress", reason: input.reason, baseVersion: input.baseVersion });
|
||||
}
|
||||
|
||||
/** Revoke a billing release (released_for_billing → in_review), reason mandatory. */
|
||||
export async function revokeBillingRelease(
|
||||
ctx: ServiceCtx,
|
||||
input: { workOrderId: string; reason: string; baseVersion?: number },
|
||||
): Promise<TransitionResult> {
|
||||
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_review", reason: input.reason, baseVersion: input.baseVersion });
|
||||
}
|
||||
|
||||
export async function markBilled(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }): Promise<TransitionResult> {
|
||||
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
export type SearchFilter = { q: string; from?: Date; to?: Date; statuses?: WorkOrderStatus[]; teamId?: string };
|
||||
|
||||
const TAKE = 20;
|
||||
|
||||
/**
|
||||
* Tenant search (spec §25) with ILIKE (`contains` + insensitive) — every section is restricted by
|
||||
* the same scopes as the detail views (work orders, customers, sites, document visibility).
|
||||
* Period/status/team filters apply to everything that hangs off a work order.
|
||||
*/
|
||||
export async function searchAll(ctx: ServiceCtx, f: SearchFilter) {
|
||||
const q = f.q.trim();
|
||||
const empty = { workOrders: [], customers: [], sites: [], contacts: [], documents: [], notes: [] };
|
||||
if (q.length < 2) return empty;
|
||||
const like = { contains: q.slice(0, 100), mode: "insensitive" as const };
|
||||
|
||||
const [woScope, cScope, sScope] = await Promise.all([workOrderScope(ctx), customerScope(ctx), siteScope(ctx)]);
|
||||
const woFilter: Prisma.WorkOrderWhereInput[] = [woScope];
|
||||
if (f.from) woFilter.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] });
|
||||
if (f.to) woFilter.push({ plannedStart: { lte: f.to } });
|
||||
if (f.statuses?.length) woFilter.push({ status: { in: f.statuses } });
|
||||
if (f.teamId) woFilter.push({ assignedTeamId: f.teamId });
|
||||
const woWhere: Prisma.WorkOrderWhereInput = { AND: woFilter };
|
||||
const hasWoFilter = woFilter.length > 1;
|
||||
|
||||
const [workOrders, customers, sites, contacts, documents, notes] = await Promise.all([
|
||||
ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
woWhere,
|
||||
{
|
||||
OR: [
|
||||
{ number: like },
|
||||
{ externalOrderNumber: like },
|
||||
{ offerNumber: like },
|
||||
{ title: like },
|
||||
{ description: like },
|
||||
{ scope: like },
|
||||
{ site: { OR: [{ street: like }, { city: like }, { postalCode: like }] } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true, number: true, title: true, status: true, plannedStart: true, customer: { select: { companyName: true, firstName: true, lastName: true } }, site: { select: { name: true, city: true } }, team: { select: { name: true } } },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: TAKE,
|
||||
}),
|
||||
ctx.db.customer.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
cScope,
|
||||
hasWoFilter ? { workOrders: { some: woWhere } } : {},
|
||||
{ OR: [{ customerNumber: like }, { companyName: like }, { firstName: like }, { lastName: like }, { street: like }, { city: like }, { postalCode: like }, { email: like }, { phone: like }] },
|
||||
],
|
||||
},
|
||||
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, status: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: TAKE,
|
||||
}),
|
||||
ctx.db.site.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
sScope,
|
||||
hasWoFilter ? { workOrders: { some: woWhere } } : {},
|
||||
{ OR: [{ name: like }, { street: like }, { city: like }, { postalCode: like }, { onSiteContact: like }] },
|
||||
],
|
||||
},
|
||||
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, customer: { select: { companyName: true, firstName: true, lastName: true } } },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: TAKE,
|
||||
}),
|
||||
ctx.db.contact.findMany({
|
||||
where: { AND: [{ deletedAt: null }, { customer: cScope }, { OR: [{ name: like }, { email: like }, { phone: like }, { mobile: like }] }] },
|
||||
select: { id: true, name: true, role: true, phone: true, email: true, customer: { select: { id: true, companyName: true, firstName: true, lastName: true } } },
|
||||
take: TAKE,
|
||||
}),
|
||||
ctx.db.document.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||
{ OR: [{ fileName: like }, { title: like }] },
|
||||
// documents on an order follow the order scope; unlinked-to-order documents need the customer/site scope
|
||||
{
|
||||
OR: [
|
||||
{ workOrder: woWhere },
|
||||
...(hasWoFilter ? [] : [{ workOrderId: null, customer: cScope }, { workOrderId: null, customerId: null, site: sScope }]),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true, title: true, fileName: true, category: true, workOrderId: true, customerId: true, siteId: true, createdAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: TAKE,
|
||||
}),
|
||||
ctx.db.activityNote.findMany({
|
||||
where: { deletedAt: null, text: like, workOrder: woWhere },
|
||||
select: { id: true, text: true, kind: true, createdAt: true, workOrder: { select: { id: true, number: true, title: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: TAKE,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { workOrders, customers, sites, contacts, documents, notes };
|
||||
}
|
||||
|
||||
export type SearchResults = Awaited<ReturnType<typeof searchAll>>;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { DEFAULT_NUMBER_PREFIX, DEFAULT_ORDER_TYPES } from "@/lib/work-orders/defaults";
|
||||
import {
|
||||
checklistTemplateSchema,
|
||||
numberingSchema,
|
||||
NUMBER_KEYS,
|
||||
orderTypeSchema,
|
||||
type ChecklistTemplateInput,
|
||||
type OrderTypeInput,
|
||||
} from "@/lib/work-orders/schemas";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { parseInput, snapshot } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Tenant settings of the work order module (`settings:templates`): order types (§10.2),
|
||||
* checklist templates incl. required photos (§12.4/§14.2) and number sequences.
|
||||
*/
|
||||
|
||||
/** Seed the default order types once per tenant (only if the tenant has none yet). Idempotent. */
|
||||
export async function ensureDefaultOrderTypes(db: TenantDb, tenantId: string): Promise<number> {
|
||||
const existing = await db.orderType.count();
|
||||
if (existing > 0) return 0;
|
||||
const res = await db.orderType.createMany({
|
||||
data: DEFAULT_ORDER_TYPES.map((t) => ({ tenantId, ...t })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
async function audit(ctx: ServiceCtx, entity: string, entityId: string, action: "create" | "update", before: unknown, after: unknown) {
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
||||
}
|
||||
|
||||
// ---------- Order types ----------
|
||||
|
||||
export async function listOrderTypes(ctx: ServiceCtx, opts: { activeOnly?: boolean } = {}) {
|
||||
await ensureDefaultOrderTypes(ctx.db, ctx.tenantId);
|
||||
return ctx.db.orderType.findMany({
|
||||
where: opts.activeOnly ? { active: true } : {},
|
||||
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrderType(ctx: ServiceCtx, raw: OrderTypeInput) {
|
||||
assertCan(ctx, "settings:templates");
|
||||
const input = parseInput(orderTypeSchema, raw);
|
||||
const dup = await ctx.db.orderType.findFirst({ where: { key: input.key }, select: { id: true } });
|
||||
if (dup) throw new ServiceError("invalid", "order_type_key_taken");
|
||||
const ot = await ctx.db.orderType.create({ data: { tenantId: ctx.tenantId, ...input } });
|
||||
await audit(ctx, "order_type", ot.id, "create", undefined, snapshot(ot));
|
||||
return ot;
|
||||
}
|
||||
|
||||
export async function updateOrderType(ctx: ServiceCtx, id: string, raw: Omit<OrderTypeInput, "key">) {
|
||||
assertCan(ctx, "settings:templates");
|
||||
const input = parseInput(orderTypeSchema.omit({ key: true }), raw);
|
||||
const before = await ctx.db.orderType.findFirst({ where: { id } });
|
||||
if (!before) throw new ServiceError("not_found", "order_type_not_found");
|
||||
const ot = await ctx.db.orderType.update({ where: { id }, data: input });
|
||||
await audit(ctx, "order_type", id, "update", snapshot(before), snapshot(ot));
|
||||
return ot;
|
||||
}
|
||||
|
||||
// ---------- Checklist templates ----------
|
||||
|
||||
export async function listChecklistTemplates(ctx: ServiceCtx) {
|
||||
return ctx.db.checklistTemplate.findMany({
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
include: { orderType: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async function assertOrderType(ctx: ServiceCtx, orderTypeId: string | null | undefined) {
|
||||
if (!orderTypeId) return;
|
||||
const ot = await ctx.db.orderType.findFirst({ where: { id: orderTypeId }, select: { id: true } });
|
||||
if (!ot) throw new ServiceError("invalid", "order_type_not_found");
|
||||
}
|
||||
|
||||
function assertUniqueKeys(list: { key: string }[], code: string) {
|
||||
if (new Set(list.map((i) => i.key)).size !== list.length) throw new ServiceError("invalid", code);
|
||||
}
|
||||
|
||||
export async function createChecklistTemplate(ctx: ServiceCtx, raw: ChecklistTemplateInput) {
|
||||
assertCan(ctx, "settings:templates");
|
||||
const input = parseInput(checklistTemplateSchema, raw);
|
||||
await assertOrderType(ctx, input.orderTypeId);
|
||||
assertUniqueKeys(input.items, "duplicate_item_key");
|
||||
assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key");
|
||||
const tpl = await ctx.db.checklistTemplate.create({
|
||||
data: { tenantId: ctx.tenantId, name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos },
|
||||
});
|
||||
await audit(ctx, "checklist_template", tpl.id, "create", undefined, snapshot(tpl));
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function updateChecklistTemplate(ctx: ServiceCtx, id: string, raw: ChecklistTemplateInput) {
|
||||
assertCan(ctx, "settings:templates");
|
||||
const input = parseInput(checklistTemplateSchema, raw);
|
||||
const before = await ctx.db.checklistTemplate.findFirst({ where: { id } });
|
||||
if (!before) throw new ServiceError("not_found", "template_not_found");
|
||||
await assertOrderType(ctx, input.orderTypeId);
|
||||
assertUniqueKeys(input.items, "duplicate_item_key");
|
||||
assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key");
|
||||
const tpl = await ctx.db.checklistTemplate.update({
|
||||
where: { id },
|
||||
data: { name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos },
|
||||
});
|
||||
await audit(ctx, "checklist_template", id, "update", snapshot(before), snapshot(tpl));
|
||||
return tpl;
|
||||
}
|
||||
|
||||
// ---------- Number sequences ----------
|
||||
|
||||
export async function listNumberSequences(ctx: ServiceCtx) {
|
||||
const rows = await ctx.db.numberSequence.findMany();
|
||||
return NUMBER_KEYS.map((key) => {
|
||||
const r = rows.find((x) => x.key === key);
|
||||
return { key, prefix: r?.prefix ?? DEFAULT_NUMBER_PREFIX[key], padding: r?.padding ?? 5, nextValue: r?.nextValue ?? 1, exists: !!r };
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes prefix/padding only — the counter itself is never reset (numbers stay unique). */
|
||||
export async function updateNumberSequence(ctx: ServiceCtx, raw: { key: string; prefix: string; padding: number | string }) {
|
||||
assertCan(ctx, "settings:templates");
|
||||
const input = parseInput(numberingSchema, raw);
|
||||
const before = await ctx.db.numberSequence.findFirst({ where: { key: input.key } });
|
||||
let row;
|
||||
if (before) {
|
||||
row = await ctx.db.numberSequence.update({ where: { id: before.id }, data: { prefix: input.prefix, padding: input.padding } });
|
||||
} else {
|
||||
try {
|
||||
row = await ctx.db.numberSequence.create({ data: { tenantId: ctx.tenantId, key: input.key, prefix: input.prefix, padding: input.padding } });
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== "P2002") throw err;
|
||||
const cur = await ctx.db.numberSequence.findFirst({ where: { key: input.key } });
|
||||
row = await ctx.db.numberSequence.update({ where: { id: cur!.id }, data: { prefix: input.prefix, padding: input.padding } });
|
||||
}
|
||||
}
|
||||
await audit(ctx, "number_sequence", row.id, before ? "update" : "create", before ? snapshot(before) : undefined, snapshot(row));
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
|
||||
/**
|
||||
* STUB (lane L2) until lane L4 delivers `src/server/services/sync/apply.ts`.
|
||||
* Contract (ARCHITEKTUR §4.6): re-dispatch a stored SyncOperation onto the domain services
|
||||
* against the CURRENT state (no baseVersion). Replace the body with a call to the L4 dispatcher
|
||||
* after merge; the signature stays.
|
||||
*
|
||||
* MVP scope of the stub: only `work_order.transition` (the only conflict-prone op besides
|
||||
* `report.submit`, which belongs to lane reports).
|
||||
*/
|
||||
export async function reapplySyncOperation(
|
||||
opCtx: ServiceCtx,
|
||||
op: { opType: string; entityId: string | null; payload: unknown },
|
||||
): Promise<{ entityVersion?: number }> {
|
||||
if (op.opType === "work_order.transition") {
|
||||
const p = (op.payload ?? {}) as { to?: string; reason?: string; workOrderId?: string };
|
||||
const workOrderId = op.entityId ?? p.workOrderId;
|
||||
if (!workOrderId || !p.to) throw new ServiceError("invalid", "sync_payload_invalid");
|
||||
const res = await transitionWorkOrder(opCtx, { workOrderId, to: p.to as never, reason: p.reason ?? null });
|
||||
return { entityVersion: res.version };
|
||||
}
|
||||
throw new ServiceError("invalid", "reapply_unsupported", { opType: op.opType });
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { EventType } from "@/lib/events";
|
||||
import { canTransition, requiredPermission, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { transitionSchema, type TransitionInput } from "@/lib/work-orders/schemas";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import {
|
||||
assertBaseVersion,
|
||||
auditWorkOrder,
|
||||
loadVisibleWorkOrder,
|
||||
parseInput,
|
||||
writeWithVersion,
|
||||
type WorkOrderBase,
|
||||
} from "@/server/services/work-orders/_shared";
|
||||
import { transitionBlockers } from "@/server/services/work-orders/completion";
|
||||
|
||||
/** Permission decision for a single transition (scope is checked separately by loading the order). */
|
||||
export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean {
|
||||
if (from === "in_review" && to === "in_progress") return can(ctx, "report:approve_team") || can(ctx, "report:approve");
|
||||
return can(ctx, requiredPermission(from, to));
|
||||
}
|
||||
|
||||
/** Transitions that need a reason (cancellation, correction request, revoking a billing release). */
|
||||
export function reasonRequired(from: WorkOrderStatus, to: WorkOrderStatus): boolean {
|
||||
return to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review");
|
||||
}
|
||||
|
||||
function eventFor(from: WorkOrderStatus, to: WorkOrderStatus): EventType {
|
||||
switch (to) {
|
||||
case "cancelled":
|
||||
return "work_order.cancelled";
|
||||
case "daily_report_created":
|
||||
return "work_order.daily_report_created";
|
||||
case "technically_completed":
|
||||
return "work_order.technically_completed";
|
||||
case "signature_pending":
|
||||
return "work_order.signature_missing";
|
||||
case "released_for_billing":
|
||||
return "work_order.released_for_billing";
|
||||
case "in_progress":
|
||||
return ["assigned", "accepted", "en_route"].includes(from) ? "work_order.started" : "work_order.changed";
|
||||
default:
|
||||
return "work_order.changed";
|
||||
}
|
||||
}
|
||||
|
||||
export type TransitionResult = { id: string; status: WorkOrderStatus; version: number; from: WorkOrderStatus };
|
||||
|
||||
/**
|
||||
* The ONLY place that changes WorkOrder.status (ARCHITEKTUR §3).
|
||||
* Order of checks: scope (not_found) → table (invalid) → permission (forbidden) → version (conflict)
|
||||
* → reason (invalid) → guards (blocked, CompletionBlocker[]) → optimistic write.
|
||||
*/
|
||||
export async function transitionWorkOrder(ctx: ServiceCtx, raw: TransitionInput): Promise<TransitionResult> {
|
||||
const input = parseInput(transitionSchema, raw);
|
||||
const wo = await loadVisibleWorkOrder(ctx, input.workOrderId);
|
||||
return applyTransition(ctx, wo, input.to, { reason: input.reason ?? null, baseVersion: input.baseVersion, eventData: sanitizeEventData(raw.eventData) });
|
||||
}
|
||||
|
||||
function sanitizeEventData(v: unknown): Record<string, string | number | boolean | null> | undefined {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||
const out: Record<string, string | number | boolean | null> = {};
|
||||
for (const [k, val] of Object.entries(v).slice(0, 20)) {
|
||||
if (val === null || typeof val === "boolean" || typeof val === "number") out[k] = val;
|
||||
else if (typeof val === "string") out[k] = val.slice(0, 500);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Internal: transition an already loaded (and scope-checked) order. `extra` is written in the same row update. */
|
||||
export async function applyTransition(
|
||||
ctx: ServiceCtx,
|
||||
wo: WorkOrderBase,
|
||||
to: WorkOrderStatus,
|
||||
opts: {
|
||||
reason?: string | null;
|
||||
baseVersion?: number;
|
||||
extra?: Record<string, unknown>;
|
||||
eventData?: Record<string, string | number | boolean | null>;
|
||||
} = {},
|
||||
): Promise<TransitionResult> {
|
||||
const from = wo.status;
|
||||
if (!canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to });
|
||||
if (!mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to });
|
||||
assertBaseVersion(wo, opts.baseVersion);
|
||||
if (reasonRequired(from, to) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to });
|
||||
|
||||
const blockers = await transitionBlockers(ctx, wo, to);
|
||||
if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers);
|
||||
|
||||
const version = await writeWithVersion(ctx, wo, { status: to, ...(opts.extra ?? {}) });
|
||||
await ctx.db.workOrderStatusChange.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: to, actorId: ctx.userId, reason: opts.reason ?? null },
|
||||
});
|
||||
await auditWorkOrder(ctx, {
|
||||
action: "update",
|
||||
workOrderId: wo.id,
|
||||
before: { status: from, version: wo.version },
|
||||
after: { status: to, version, reason: opts.reason ?? null, ...(opts.extra ?? {}) },
|
||||
});
|
||||
await emitEvent(ctx, {
|
||||
type: eventFor(from, to),
|
||||
entityType: "work_order",
|
||||
entityId: wo.id,
|
||||
data: { ...(opts.eventData ?? {}), number: wo.number, from, to },
|
||||
});
|
||||
return { id: wo.id, status: to, version, from };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { updateWorkOrderSchema, type UpdateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import {
|
||||
assertBaseVersion,
|
||||
assertNotLocked,
|
||||
auditWorkOrder,
|
||||
FINAL_STATUSES,
|
||||
loadVisibleWorkOrder,
|
||||
parseInput,
|
||||
snapshot,
|
||||
writeWithVersion,
|
||||
} from "@/server/services/work-orders/_shared";
|
||||
|
||||
const FIELDS = Object.keys(updateWorkOrderSchema.shape) as (keyof UpdateWorkOrderInput)[];
|
||||
|
||||
/** Partial update of the order master data (never status — see transition.ts). Only keys present in `raw` change. */
|
||||
export async function updateWorkOrder(
|
||||
ctx: ServiceCtx,
|
||||
workOrderId: string,
|
||||
raw: UpdateWorkOrderInput,
|
||||
baseVersion?: number,
|
||||
): Promise<{ id: string; version: number }> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const parsed = parseInput(updateWorkOrderSchema, raw);
|
||||
const present = FIELDS.filter((k) => Object.prototype.hasOwnProperty.call(raw, k));
|
||||
if (present.length === 0) throw new ServiceError("invalid", "nothing_to_update");
|
||||
|
||||
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
assertNotLocked(wo, FINAL_STATUSES);
|
||||
assertBaseVersion(wo, baseVersion);
|
||||
|
||||
const full = await ctx.db.workOrder.findFirst({ where: { id: wo.id } });
|
||||
if (!full) throw new ServiceError("not_found", "work_order_not_found");
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const k of present) data[k] = (parsed as Record<string, unknown>)[k] ?? null;
|
||||
if ("title" in data && !data.title) throw new ServiceError("invalid", "validation_failed", [{ path: "title" }]);
|
||||
if ("priority" in data && !data.priority) delete data.priority;
|
||||
if ("signatureRequired" in data && typeof data.signatureRequired !== "boolean") delete data.signatureRequired;
|
||||
if ("customerId" in data && !data.customerId) delete data.customerId;
|
||||
|
||||
const customerId = (data.customerId as string | undefined) ?? full.customerId;
|
||||
const siteId = "siteId" in data ? (data.siteId as string | null) : full.siteId;
|
||||
const contactId = "contactId" in data ? (data.contactId as string | null) : full.contactId;
|
||||
|
||||
if (customerId !== full.customerId) {
|
||||
const c = await ctx.db.customer.findFirst({ where: { id: customerId, deletedAt: null, status: { not: "merged" } }, select: { id: true } });
|
||||
if (!c) throw new ServiceError("invalid", "customer_not_found");
|
||||
}
|
||||
if (siteId) {
|
||||
const s = await ctx.db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: { customerId: true } });
|
||||
if (!s) throw new ServiceError("invalid", "site_not_found");
|
||||
if (s.customerId !== customerId) throw new ServiceError("invalid", "site_customer_mismatch");
|
||||
}
|
||||
if (contactId) {
|
||||
const c = await ctx.db.contact.findFirst({ where: { id: contactId, deletedAt: null }, select: { customerId: true } });
|
||||
if (!c) throw new ServiceError("invalid", "contact_not_found");
|
||||
if (c.customerId !== customerId) throw new ServiceError("invalid", "contact_customer_mismatch");
|
||||
}
|
||||
if ("orderTypeId" in data && data.orderTypeId) {
|
||||
const ot = await ctx.db.orderType.findFirst({ where: { id: data.orderTypeId as string }, select: { id: true } });
|
||||
if (!ot) throw new ServiceError("invalid", "order_type_not_found");
|
||||
}
|
||||
const start = "plannedStart" in data ? (data.plannedStart as Date | null) : full.plannedStart;
|
||||
const end = "plannedEnd" in data ? (data.plannedEnd as Date | null) : full.plannedEnd;
|
||||
if (start && end && end < start) throw new ServiceError("invalid", "planned_end_before_start");
|
||||
|
||||
const before = Object.fromEntries(Object.keys(data).map((k) => [k, (full as Record<string, unknown>)[k]]));
|
||||
const version = await writeWithVersion(ctx, wo, data);
|
||||
await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: snapshot(before), after: snapshot({ ...data, version }) });
|
||||
await emitEvent(ctx, { type: "work_order.changed", entityType: "work_order", entityId: wo.id, data: { number: wo.number } });
|
||||
return { id: wo.id, version };
|
||||
}
|
||||
|
||||
/** Soft delete (spec §27.5) — only drafts / orders still in review. */
|
||||
export async function deleteWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise<void> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
if (!["draft", "review_required"].includes(wo.status)) throw new ServiceError("invalid", "delete_only_draft");
|
||||
await writeWithVersion(ctx, wo, { deletedAt: new Date() });
|
||||
await auditWorkOrder(ctx, { action: "delete", workOrderId: wo.id, before: { status: wo.status, number: wo.number } });
|
||||
}
|
||||
Reference in New Issue
Block a user