L10b Betrieb & Aufräumen: Sync – Berichts-Ops, Konflikt-Übernahme, eigene Session im Bundle

- Aufräumpunkt j: report.save_draft und report.submit mit Zod-Schemas (lib/sync/ops.ts) und
  Registry-Einträgen → services/reports/sync-ops.ts. report.submit reicht baseVersion als
  expectedWorkOrderVersion und aiReviewed an submitReport durch; Lotse-Entwürfe ohne Bestätigung →
  rejected invalid. signature.capture bleibt unregistriert (Upload-Art für Unterschriftsbild fehlt).
- Aufräumpunkt b: „Übernehmen" in der Konfliktliste delegiert an den Sync-Dispatcher
  (apply.ts#reapplyOperation, ohne baseVersion) statt des L2-Stubs; unterstützt
  work_order.transition und report.submit. Hinweistext der Konfliktliste angepasst.
- Aufräumpunkt c: getFieldBundle liefert je Auftrag mySession (eigene aktive WorkSession); die
  Offline-Ansicht leitet den Zeitstatus daraus ab (alte Bundles: Näherung über Auftragsstatus).
- scripts/test-betrieb-sync.ts (Bundle, clientId je Mandant, Berichts-Ops, Konflikt-Übernahme,
  Mandant B, Monteur ohne Zuweisung); test-einsatz-sync.ts prüft „nicht verfügbare Op" jetzt mit
  signature.capture, weil report.save_draft registriert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent 5f08df324f
commit 85bae832d0
12 changed files with 316 additions and 40 deletions
+9 -3
View File
@@ -64,7 +64,7 @@ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */
export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView {
const data: BundleOrderData = structuredCloneSafe(record.data);
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data.status), pendingOps: 0, conflict: false, rejected: false } };
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), pendingOps: 0, conflict: false, rejected: false } };
const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq);
for (const op of mine) {
@@ -79,8 +79,14 @@ export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderV
return view;
}
function initialSession(status: string): SessionState {
// The bundle carries no sessions; the order status is the best local approximation.
export function initialSession(data: Pick<BundleOrderData, "status" | "mySession">): SessionState {
// L10b: bundles carry the caller's own active session — exact also on team orders.
if (data.mySession !== undefined) {
const s = data.mySession?.status;
return s === "en_route" || s === "running" || s === "paused" ? s : null;
}
// Bundles stored before L10b: the order status is the best local approximation.
const status = data.status;
if (status === "en_route") return "en_route";
if (status === "in_progress") return "running";
if (status === "paused") return "paused";
+2
View File
@@ -92,6 +92,8 @@ export type BundleOrderData = {
scope?: string | null;
technicianNotes?: string | null;
signatureRequired?: boolean;
/** L10b: own active work session of the signed-in user (absent in bundles stored before L10b) */
mySession?: { id: string; status: string; startedAt: string } | null;
orderType?: { name: string } | null;
customer: {
id?: string;
+22 -3
View File
@@ -2,6 +2,7 @@ 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
@@ -113,7 +114,25 @@ export const voiceAttachPayload = z.object({
kind: z.enum(NOTE_KINDS).optional(),
});
/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */
/**
* 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 = {
@@ -127,8 +146,8 @@ export const OP_PAYLOAD_SCHEMAS = {
"material.upsert": materialUpsertPayload,
"photo.attach": photoAttachPayload,
"voice.attach": voiceAttachPayload,
"report.save_draft": passthrough,
"report.submit": passthrough,
"report.save_draft": reportSaveDraftPayload,
"report.submit": reportSubmitPayload,
"signature.capture": passthrough,
"emergency.create": emergencyCreatePayload,
} satisfies Record<SyncOpType, z.ZodType>;
+19 -1
View File
@@ -301,9 +301,27 @@ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) {
const histories = Object.fromEntries(
await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)),
);
// L10b (L7 offene Punkte 3/4): the caller's own running session per order, so the offline view
// shows the correct time actions on team orders with several technicians.
const ownSessions = orders.length
? await ctx.db.workSession.findMany({
where: { workOrderId: { in: orders.map((o) => o.id) }, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } },
orderBy: { startedAt: "desc" },
select: { id: true, workOrderId: true, status: true, startedAt: true },
})
: [];
const mySessions = new Map<string, { id: string; status: string; startedAt: string }>();
for (const s of ownSessions) {
if (!mySessions.has(s.workOrderId)) mySessions.set(s.workOrderId, { id: s.id, status: s.status, startedAt: s.startedAt.toISOString() });
}
return {
serverTime: serverTime.toISOString(),
since: since?.toISOString() ?? null,
orders: orders.map((o) => ({ ...o, statusGroup: STATUS_GROUP[o.status], siteHistory: o.site ? histories[o.site.id] ?? [] : [] })),
orders: orders.map((o) => ({
...o,
statusGroup: STATUS_GROUP[o.status],
siteHistory: o.site ? histories[o.site.id] ?? [] : [],
mySession: mySessions.get(o.id) ?? null,
})),
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { SyncOperationInput } from "@/lib/sync/envelope";
import { reportSaveDraftPayload, reportSubmitPayload } from "@/lib/sync/ops";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import type { ExternalOpResult } from "@/server/services/sync/external-ops";
import { requireVisibleReport } from "./common";
import { updateReportTexts } from "./edit";
import { submitReport } from "./submit";
/**
* Sync ops of the reports module (L10b, ARCHITEKTUR §4.6), dispatched by services/sync/apply.ts:
* - `report.save_draft` → updateReportTexts (additive, no conflict check)
* - `report.submit` → submitReport with `expectedWorkOrderVersion = op.baseVersion` and the
* Lotse review confirmation `aiReviewed` passed through (otherwise Lotse drafts are rejected
* with `invalid` / details.field = "aiReviewed").
* Scope and version pre-check happen in apply.ts on `payload.workOrderId`; the report must belong
* to exactly that order, otherwise the check would have run against the wrong entity.
*/
export async function applySyncOp(ctx: ServiceCtx, op: SyncOperationInput): Promise<ExternalOpResult> {
switch (op.opType) {
case "report.save_draft": {
const p = reportSaveDraftPayload.parse(op.payload);
await requireReportOfOrder(ctx, p.reportId, p.workOrderId);
await updateReportTexts(ctx, { reportId: p.reportId, texts: p.texts });
return {};
}
case "report.submit": {
const p = reportSubmitPayload.parse(op.payload);
await requireReportOfOrder(ctx, p.reportId, p.workOrderId);
await submitReport(ctx, { reportId: p.reportId, expectedWorkOrderVersion: op.baseVersion, aiReviewed: p.aiReviewed });
const wo = await ctx.db.workOrder.findFirst({ where: { id: p.workOrderId }, select: { version: true } });
return { entityVersion: wo?.version };
}
default:
throw new ServiceError("invalid", `operation ${op.opType} is not handled by reports`);
}
}
async function requireReportOfOrder(ctx: ServiceCtx, reportId: string, workOrderId: string) {
const report = await requireVisibleReport(ctx, reportId);
if (report.workOrderId !== workOrderId) throw new ServiceError("invalid", "report does not belong to work order", { field: "reportId" });
return report;
}
+47 -8
View File
@@ -62,6 +62,52 @@ const FIELD_HANDLERS: Partial<Record<SyncOpType, Handler>> = {
class NotAvailable extends Error {}
/** Route a validated op to the field handler or the registered module of another lane. */
async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise<HandlerResult> {
const handler = FIELD_HANDLERS[op.opType];
if (handler) return handler(ctx, payload, op);
const load = EXTERNAL_OPS[op.opType];
const external = load ? await load() : null;
if (!external) throw new NotAvailable(`operation ${op.opType} is not available yet (lane ${EXTERNAL_OP_OWNERS[op.opType] ?? "unknown"})`);
return external(ctx, op);
}
/**
* Backoffice „Übernehmen" of a stored conflict (services/work-orders/conflicts.ts, L10b): the op
* is dispatched again against the CURRENT state — same payload validation and domain services as
* the sync path, but without the baseVersion comparison. Only conflict-prone ops
* (`work_order.transition`, `report.submit`) can end up as conflicts.
*/
export async function reapplyOperation(
ctx: ServiceCtx,
stored: { opType: string; entityType?: string | null; entityId: string | null; payload: unknown },
): Promise<HandlerResult> {
const opType = stored.opType as SyncOpType;
if (!CONFLICTING_OPS.includes(opType)) throw new ServiceError("invalid", "reapply_unsupported", { opType: stored.opType });
const payload: Record<string, unknown> = { ...((stored.payload ?? {}) as Record<string, unknown>) };
// stored ops whose work order is only referenced via entityId (accepted by the former L2 stub)
if (typeof payload.workOrderId !== "string" && stored.entityId && (!stored.entityType || stored.entityType === "work_order")) {
payload.workOrderId = stored.entityId;
}
const parsed = OP_PAYLOAD_SCHEMAS[opType].safeParse(payload);
if (!parsed.success) throw new ServiceError("invalid", "sync_payload_invalid");
const op: SyncOperationInput = {
clientOpId: "00000000-0000-4000-8000-000000000000", // not used by handlers; idempotency stays with the stored op
opType,
entityType: stored.entityType ?? undefined,
entityId: stored.entityId ?? undefined,
baseVersion: undefined,
payload,
clientCreatedAt: new Date().toISOString(),
};
try {
return await dispatch(ctx, parsed.data, op);
} catch (err) {
if (err instanceof NotAvailable) throw new ServiceError("invalid", "reapply_unsupported", { opType });
throw err;
}
}
function workOrderIdOf(op: SyncOperationInput): string | undefined {
const fromPayload = (op.payload as { workOrderId?: unknown }).workOrderId;
if (typeof fromPayload === "string") return fromPayload;
@@ -149,14 +195,7 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
}
// 4. dispatch
let handler = FIELD_HANDLERS[op.opType];
if (!handler) {
const load = EXTERNAL_OPS[op.opType];
const external = load ? await load() : null;
if (!external) throw new NotAvailable(`operation ${op.opType} is not available yet (lane ${EXTERNAL_OP_OWNERS[op.opType] ?? "unknown"})`);
handler = (c, _payload, o) => external(c, o);
}
const result = await handler(ctx, parsed.data, op);
const result = await dispatch(ctx, parsed.data, op);
const rec = await record(ctx, op, deviceId, "applied", { ...result });
if (rec === "duplicate") return { ...base, status: "duplicate", ...result };
return { ...base, status: "applied", ...result };
+3 -3
View File
@@ -23,8 +23,8 @@ export const EXTERNAL_OP_OWNERS: Partial<Record<SyncOpType, string>> = {
};
export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHandler>>> = {
// lane-reports: "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
// lane-reports: "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
// lane-reports: "signature.capture": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
"report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // L10b
"report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // L10b
// not registered: "signature.capture" — needs a signature image upload kind in /api/v1/uploads first (see docs/craftvia/lanes/betrieb.md)
"emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp),
};
@@ -1,25 +1,16 @@
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import type { ServiceCtx } from "@/server/services/context";
import { reapplyOperation } from "@/server/services/sync/apply";
/**
* 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).
* Re-dispatch a stored conflicting SyncOperation against the CURRENT state (ARCHITEKTUR §4.6),
* as the original device user. L10b: delegates to the L4 dispatcher (the former L2 stub only knew
* `work_order.transition`). Supported: `work_order.transition` and `report.submit` (incl. the
* stored Lotse review confirmation `aiReviewed`). Everything else → `invalid reapply_unsupported`.
*/
export async function reapplySyncOperation(
opCtx: ServiceCtx,
op: { opType: string; entityId: string | null; payload: unknown },
op: { opType: string; entityType?: string | null; 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 });
const result = await reapplyOperation(opCtx, op);
return { entityVersion: result.entityVersion };
}