- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente, eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit, Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen, Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle - services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung, Registry für Ops anderer Lanes, lane-lokaler requireApiContext - /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id] - lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts - Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3), getSiteHistory (L1) - Processor image-derivatives + Registrierung, Audit-Entity-Labels - Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 lines
2.2 KiB
TypeScript
66 lines
2.2 KiB
TypeScript
import { writeAuditLog } from "@/server/audit";
|
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
|
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
|
|
|
/** Shared helpers of the field services (lane L4). */
|
|
|
|
export type FieldOrder = {
|
|
id: string;
|
|
number: string;
|
|
status: WorkOrderStatus;
|
|
version: number;
|
|
siteId: string | null;
|
|
customerId: string;
|
|
assignedTeamId: string | null;
|
|
};
|
|
|
|
const FIELD_ORDER_SELECT = {
|
|
id: true,
|
|
number: true,
|
|
status: true,
|
|
version: true,
|
|
siteId: true,
|
|
customerId: true,
|
|
assignedTeamId: true,
|
|
} as const;
|
|
|
|
/**
|
|
* Loads a work order for a field mutation: requires `field:execute`, the order must be in the
|
|
* user's visibility scope (otherwise not_found — existence is never revealed) and, when
|
|
* `editable`, in a status that allows field documentation.
|
|
*/
|
|
export async function requireFieldOrder(ctx: ServiceCtx, workOrderId: string, opts: { editable?: boolean } = {}): Promise<FieldOrder> {
|
|
assertCan(ctx, "field:execute");
|
|
const wo = (await requireVisibleWorkOrder(ctx, workOrderId, FIELD_ORDER_SELECT)) as FieldOrder;
|
|
if (opts.editable && !FIELD_EDITABLE.includes(wo.status)) {
|
|
throw new ServiceError("invalid", `work order status ${wo.status} does not allow field documentation`);
|
|
}
|
|
return wo;
|
|
}
|
|
|
|
/** Client timestamp of an operation; future values (clock skew) are clamped to now. */
|
|
export function opTime(at?: string | null): Date {
|
|
const now = new Date();
|
|
if (!at) return now;
|
|
const d = new Date(at);
|
|
if (Number.isNaN(d.getTime())) throw new ServiceError("invalid", "invalid timestamp");
|
|
return d.getTime() > now.getTime() + 60_000 ? now : d;
|
|
}
|
|
|
|
export async function audit(
|
|
ctx: ServiceCtx,
|
|
action: "create" | "update" | "delete",
|
|
entity: string,
|
|
entityId: string,
|
|
before?: unknown,
|
|
after?: unknown,
|
|
): Promise<void> {
|
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
|
}
|
|
|
|
/** Prisma unique-constraint violation (used for idempotent creates under races). */
|
|
export function isUniqueViolation(err: unknown): boolean {
|
|
return (err as { code?: string })?.code === "P2002";
|
|
}
|