L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- 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>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import type { MaterialUsageStatus } from "@/lib/sync/ops";
|
||||
|
||||
/**
|
||||
* Material confirmation rules (Spec §13.2/§13.3), shared by UI (early feedback) and server
|
||||
* (authoritative, services/field/materials.ts).
|
||||
* Planned position: fully_used (≥ planned) | partially_used (0 < q < planned) | not_used (q = 0);
|
||||
* any deviation from the planned quantity requires a reason.
|
||||
* Additional material: usageStatus additional, name, quantity > 0 and reason are mandatory.
|
||||
* Returns null when valid, otherwise a short technical reason.
|
||||
*/
|
||||
export function validateMaterialUsage(
|
||||
input: { usageStatus: MaterialUsageStatus; quantity: number; name?: string | null; deviationReason?: string | null },
|
||||
plannedQuantity: number | null,
|
||||
): string | null {
|
||||
const reason = input.deviationReason?.trim();
|
||||
if (!Number.isFinite(input.quantity) || input.quantity < 0) return "invalid quantity";
|
||||
if (plannedQuantity === null) {
|
||||
if (input.usageStatus !== "additional") return "additional material requires usageStatus additional";
|
||||
if (!input.name?.trim()) return "name required";
|
||||
if (input.quantity <= 0) return "quantity must be positive";
|
||||
if (!reason) return "reason required for additional material";
|
||||
return null;
|
||||
}
|
||||
if (input.usageStatus === "additional") return "planned position cannot be additional";
|
||||
if (input.usageStatus === "not_used" && input.quantity !== 0) return "not used requires quantity 0";
|
||||
if (input.usageStatus === "partially_used" && (input.quantity <= 0 || input.quantity >= plannedQuantity)) return "partial quantity must be between 0 and planned";
|
||||
if (input.usageStatus === "fully_used" && input.quantity < plannedQuantity) return "fully used requires at least the planned quantity";
|
||||
if (materialDeviates(input, plannedQuantity) && !reason) return "reason required for deviation";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function materialDeviates(input: { usageStatus: MaterialUsageStatus; quantity: number }, plannedQuantity: number | null): boolean {
|
||||
if (plannedQuantity === null) return true;
|
||||
return input.usageStatus !== "fully_used" || input.quantity !== plannedQuantity;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { z } from "zod";
|
||||
import type { SyncOpType } from "./envelope";
|
||||
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||
|
||||
/**
|
||||
* Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to
|
||||
* build ops and by src/server/services/sync/apply.ts to validate them.
|
||||
* `clientId` fields are device-generated ids (uuid) that make additive creates idempotent
|
||||
* and are mapped to server ids in SyncOpResult.idMap.
|
||||
*/
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const clientId = z.string().uuid();
|
||||
const isoDate = z.string().datetime({ offset: true });
|
||||
const lat = z.number().min(-90).max(90);
|
||||
const lng = z.number().min(-180).max(180);
|
||||
const quantity = z.number().min(0).max(1_000_000);
|
||||
|
||||
export const NOTE_KINDS = [
|
||||
"work_done",
|
||||
"deviation",
|
||||
"problem",
|
||||
"additional_work",
|
||||
"not_executable",
|
||||
"follow_up",
|
||||
"recommendation",
|
||||
"customer_note",
|
||||
"general",
|
||||
] as const;
|
||||
export type NoteKind = (typeof NOTE_KINDS)[number];
|
||||
|
||||
export const PHOTO_PHASES = ["before", "during", "after"] as const;
|
||||
export type PhotoPhase = (typeof PHOTO_PHASES)[number];
|
||||
|
||||
export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const;
|
||||
export type MaterialUsageStatus = (typeof MATERIAL_USAGE_STATUSES)[number];
|
||||
|
||||
/** Unit suggestions for the material stepper (free text stays allowed). */
|
||||
export const UNIT_SUGGESTIONS = ["Stk", "m", "m²", "m³", "kg", "l", "Pkg", "Rolle", "Satz", "h"] as const;
|
||||
|
||||
export const sessionStartPayload = z.object({
|
||||
workOrderId: id,
|
||||
clientId: clientId.optional(),
|
||||
/** travel = "Losfahren" (in Anfahrt), work = "Arbeit starten" */
|
||||
mode: z.enum(["travel", "work"]).default("work"),
|
||||
at: isoDate.optional(),
|
||||
latitude: lat.optional(),
|
||||
longitude: lng.optional(),
|
||||
offline: z.boolean().default(false),
|
||||
deviceInfo: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const sessionControlPayload = z.object({
|
||||
workOrderId: id,
|
||||
at: isoDate.optional(),
|
||||
});
|
||||
|
||||
export const workOrderTransitionPayload = z.object({
|
||||
workOrderId: id,
|
||||
to: z.enum(WORK_ORDER_STATUSES),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const noteCreatePayload = z.object({
|
||||
workOrderId: id,
|
||||
clientId: clientId.optional(),
|
||||
kind: z.enum(NOTE_KINDS).default("general"),
|
||||
text: z.string().trim().min(1).max(10_000),
|
||||
});
|
||||
|
||||
export const checklistTogglePayload = z.object({
|
||||
workOrderId: id,
|
||||
itemId: id,
|
||||
checked: z.boolean(),
|
||||
comment: z.string().max(2000).nullish(),
|
||||
});
|
||||
|
||||
export const materialUpsertPayload = z.object({
|
||||
workOrderId: id,
|
||||
clientId: clientId.optional(),
|
||||
materialPlanId: id.nullish(),
|
||||
name: z.string().trim().max(200).optional(),
|
||||
articleNumber: z.string().trim().max(100).nullish(),
|
||||
quantity,
|
||||
unit: z.string().trim().min(1).max(20),
|
||||
usageStatus: z.enum(MATERIAL_USAGE_STATUSES),
|
||||
deviationReason: z.string().trim().max(2000).nullish(),
|
||||
notes: z.string().trim().max(2000).nullish(),
|
||||
photoId: id.nullish(),
|
||||
});
|
||||
|
||||
export const photoAttachPayload = z.object({
|
||||
workOrderId: id,
|
||||
clientId: clientId.optional(),
|
||||
documentId: id,
|
||||
phase: z.enum(PHOTO_PHASES).nullish(),
|
||||
photoRequirementId: id.nullish(),
|
||||
checklistItemId: id.nullish(),
|
||||
comment: z.string().trim().max(2000).nullish(),
|
||||
takenAt: isoDate.optional(),
|
||||
latitude: lat.optional(),
|
||||
longitude: lng.optional(),
|
||||
});
|
||||
|
||||
export const voiceAttachPayload = z.object({
|
||||
workOrderId: id,
|
||||
clientId: clientId.optional(),
|
||||
documentId: id,
|
||||
durationSeconds: z.number().int().min(0).max(300).optional(),
|
||||
recordedAt: isoDate.optional(),
|
||||
/** optional note kind the transcript is filed under */
|
||||
kind: z.enum(NOTE_KINDS).optional(),
|
||||
});
|
||||
|
||||
/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */
|
||||
const passthrough = z.record(z.string(), z.unknown());
|
||||
|
||||
export const OP_PAYLOAD_SCHEMAS = {
|
||||
"session.start": sessionStartPayload,
|
||||
"session.pause": sessionControlPayload,
|
||||
"session.resume": sessionControlPayload,
|
||||
"session.end": sessionControlPayload,
|
||||
"work_order.transition": workOrderTransitionPayload,
|
||||
"note.create": noteCreatePayload,
|
||||
"checklist.toggle": checklistTogglePayload,
|
||||
"material.upsert": materialUpsertPayload,
|
||||
"photo.attach": photoAttachPayload,
|
||||
"voice.attach": voiceAttachPayload,
|
||||
"report.save_draft": passthrough,
|
||||
"report.submit": passthrough,
|
||||
"signature.capture": passthrough,
|
||||
"emergency.create": passthrough,
|
||||
} satisfies Record<SyncOpType, z.ZodType>;
|
||||
|
||||
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||
export type ParsedOpPayload<T extends SyncOpType> = z.output<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||
Reference in New Issue
Block a user