syncBillingCandidates (idempotent, Event-Hook für work_order.released_for_billing und report.approved), Aufstellung ohne Preise (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen informativ, Anfahrten-Zählung, Material), markBilled mit eingefrorenem Snapshot und Positionszuordnung, voidBilling mit Grund und neuem offenen Eintrag (billed → released_for_billing nur über applyTransition-Option billingVoid), Meilenstein-Flow mit Events, Job billing-pdf (Dokument other/backoffice_only), Sync-Op milestone.reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
7.5 KiB
TypeScript
208 lines
7.5 KiB
TypeScript
import { z } from "zod";
|
|
import type { SyncOpType } from "./envelope";
|
|
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
|
import { emergencyCreatePayload } from "@/lib/emergency/schemas";
|
|
import { reportTextsSchema } from "@/lib/reports/content";
|
|
|
|
/**
|
|
* 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(),
|
|
/** L12 auto switch: pause the user's running session on another order in the same transaction */
|
|
switchFromOther: z.boolean().optional(),
|
|
});
|
|
|
|
export const sessionControlPayload = z.object({
|
|
workOrderId: id,
|
|
at: isoDate.optional(),
|
|
});
|
|
|
|
/** session.resume (L12): like session.start, may pause a running session on another order. */
|
|
export const sessionResumePayload = sessionControlPayload.extend({ switchFromOther: z.boolean().optional() });
|
|
|
|
/** Segment types a technician can switch to inside a running session (L12). */
|
|
export const SESSION_SEGMENT_TYPES = ["work", "return_travel", "material_procurement"] as const;
|
|
export type SessionSegmentType = (typeof SESSION_SEGMENT_TYPES)[number];
|
|
|
|
export const sessionSegmentPayload = z.object({
|
|
workOrderId: id,
|
|
type: z.enum(SESSION_SEGMENT_TYPES),
|
|
at: isoDate.optional(),
|
|
});
|
|
|
|
/** Time entry types (Prisma enum TimeEntryType). */
|
|
export const TIME_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
|
export type TimeType = (typeof TIME_TYPES)[number];
|
|
|
|
/**
|
|
* time.add_manual (L12): manual time entry. Own entries become `pending` (approval by team lead /
|
|
* back office); with `field:correct_time` for a team member (`forUserId`) they are approved directly.
|
|
* Either `endedAt` or `durationMinutes` is required.
|
|
*/
|
|
export const timeAddManualPayload = z.object({
|
|
workOrderId: id,
|
|
clientId: clientId.optional(),
|
|
type: z.enum(TIME_TYPES).default("work"),
|
|
startedAt: isoDate,
|
|
endedAt: isoDate.optional(),
|
|
durationMinutes: z.number().int().min(1).max(16 * 60).optional(),
|
|
reason: z.string().trim().min(3).max(500),
|
|
note: z.string().trim().max(2000).optional(),
|
|
forUserId: id.optional(),
|
|
});
|
|
|
|
/** time.propose_correction (L12): correction proposal for an own entry; old values count until approval. */
|
|
export const timeProposeCorrectionPayload = z.object({
|
|
workOrderId: id,
|
|
timeEntryId: id,
|
|
type: z.enum(TIME_TYPES).optional(),
|
|
startedAt: isoDate,
|
|
endedAt: isoDate,
|
|
reason: z.string().trim().min(3).max(500),
|
|
});
|
|
|
|
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(),
|
|
});
|
|
|
|
/**
|
|
* report.save_draft / report.submit (L10b, registered in services/sync/external-ops.ts →
|
|
* services/reports/sync-ops.ts). `workOrderId` is required: the sync pipeline checks scope and the
|
|
* conflict version (`baseVersion` = WorkOrder.version seen by the device) on that order.
|
|
*/
|
|
export const reportSaveDraftPayload = z.object({
|
|
workOrderId: id,
|
|
reportId: id,
|
|
texts: reportTextsSchema.partial(),
|
|
});
|
|
|
|
export const reportSubmitPayload = z.object({
|
|
workOrderId: id,
|
|
reportId: id,
|
|
/** L9 Freigabeprinzip: "Ich habe den Vorschlag vom Lotsen geprüft" — mandatory for Lotse drafts */
|
|
aiReviewed: z.boolean().optional(),
|
|
});
|
|
|
|
/** Schemas of ops owned by other lanes are validated there (signature.capture: not offline-capable yet). */
|
|
const passthrough = z.record(z.string(), z.unknown());
|
|
|
|
export const OP_PAYLOAD_SCHEMAS = {
|
|
"session.start": sessionStartPayload,
|
|
"session.pause": sessionControlPayload,
|
|
"session.resume": sessionResumePayload,
|
|
"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": reportSaveDraftPayload,
|
|
"report.submit": reportSubmitPayload,
|
|
"signature.capture": passthrough,
|
|
"emergency.create": emergencyCreatePayload,
|
|
"session.stop_day": sessionControlPayload,
|
|
"session.segment": sessionSegmentPayload,
|
|
"time.add_manual": timeAddManualPayload,
|
|
"time.propose_correction": timeProposeCorrectionPayload,
|
|
"milestone.reach": passthrough, // L14: validated in services/billing/sync-ops.ts (lib/billing/schemas.ts#milestoneReachPayload)
|
|
} 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]>;
|