From 85bae832d0077b7dbff6350a46e0687027fe6db2 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Sync=20?= =?UTF-8?q?=E2=80=93=20Berichts-Ops,=20Konflikt-=C3=9Cbernahme,=20eigene?= =?UTF-8?q?=20Session=20im=20Bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- messages/de/workOrders.json | 2 +- messages/en/workOrders.json | 2 +- scripts/test-betrieb-sync.ts | 158 ++++++++++++++++++ scripts/test-einsatz-sync.ts | 5 +- src/lib/offline/bundle-core.ts | 12 +- src/lib/offline/types.ts | 2 + src/lib/sync/ops.ts | 25 ++- src/server/services/field/queries.ts | 20 ++- src/server/services/reports/sync-ops.ts | 42 +++++ src/server/services/sync/apply.ts | 55 +++++- src/server/services/sync/external-ops.ts | 6 +- .../services/work-orders/sync-reapply.ts | 27 +-- 12 files changed, 316 insertions(+), 40 deletions(-) create mode 100644 scripts/test-betrieb-sync.ts create mode 100644 src/server/services/reports/sync-ops.ts diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 3c02e50..0cb6433 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -406,7 +406,7 @@ "applied": "Übernommen.", "discarded": "Verworfen.", "applyHint": "Übernehmen wendet den Vorgang erneut auf den aktuellen Stand an – im Namen der Person, die ihn erfasst hat.", - "scopeHint": "Übernehmen ist derzeit nur für Statusänderungen möglich; andere Vorgänge bitte im Auftrag nacharbeiten." + "scopeHint": "Übernehmen ist für Statusänderungen und abgesendete Berichte möglich; andere Vorgänge bitte im Auftrag nacharbeiten." }, "errors": { "not_found": "Nicht gefunden oder keine Berechtigung.", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 24e6292..166e853 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -406,7 +406,7 @@ "applied": "Applied.", "discarded": "Discarded.", "applyHint": "Apply re-runs the operation against the current state – on behalf of the person who recorded it.", - "scopeHint": "Apply currently supports status changes only; please rework other operations in the order." + "scopeHint": "Apply supports status changes and submitted reports; please rework other operations in the order." }, "errors": { "not_found": "Not found or no permission.", diff --git a/scripts/test-betrieb-sync.ts b/scripts/test-betrieb-sync.ts new file mode 100644 index 0000000..5eff294 --- /dev/null +++ b/scripts/test-betrieb-sync.ts @@ -0,0 +1,158 @@ +// Lane L10b „Betrieb & Aufräumen" — Sync-Aufräumpunkte: +// b) Konflikt „Übernehmen" für report.submit (Dispatcher statt L2-Stub) +// c) Bundle mit eigener laufender WorkSession je Auftrag (+ Offline-Ansicht nutzt sie) +// f) clientId eindeutig je Mandant (@@unique([tenantId, clientId])) +// j) Sync-Ops report.save_draft / report.submit inkl. aiReviewed (Lotse-Freigabeprinzip) +// Jeweils mit Mandantentrennung (B) und Scope (Monteur ohne Zuweisung). +// +// Lauf: npx tsx scripts/test-betrieb-sync.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { applyOperations, reapplyOperation } from "../src/server/services/sync/apply"; +import { getFieldBundle } from "../src/server/services/field/queries"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { applySyncConflict } from "../src/server/services/work-orders/conflicts"; +import { initialSession } from "../src/lib/offline/bundle-core"; +import { ROLE_DEFS } from "../src/server/rbac"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createFixture, ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +function op(opType: SyncOpType, payload: Record, extra: Partial = {}): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra }; +} + +async function one(ctx: ServiceCtx, operation: SyncOperationInput) { + const res = await applyOperations(ctx, { deviceId: "l10b-device", operations: [operation] }); + return res.results[0]; +} + +const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version; + +async function main() { + const f = await createFixture("l10bsync"); + const wo = f.orderA.id; + const cleanupReports = async () => { + await prisma.report.deleteMany({ where: { tenantId: { in: [f.tenantA.id, f.tenantB.id] } } }); + }; + try { + // backoffice user in tenant A (resolves conflicts) + const officeIdentity = await prisma.identity.upsert({ where: { email: "office@zz-l10bsync.test" }, update: {}, create: { email: "office@zz-l10bsync.test", passwordHash: "x" } }); + const office = await prisma.user.create({ data: { tenantId: f.tenantA.id, identityId: officeIdentity.id, email: "office@zz-l10bsync.test", name: "Office A" } }); + const ctxOffice = ctxFor(f.tenantA.id, office.id, "backoffice"); + const ctxOfficeB = ctxFor(f.tenantB.id, f.techB.id, "backoffice"); + // „Übernehmen" loads the device user's permissions from the DB → give the technician a real role + const techPerms = await prisma.permission.findMany({ where: { key: { in: [...ROLE_DEFS.technician.permissions] } }, select: { id: true } }); + const techRole = await prisma.role.create({ + data: { tenantId: f.tenantA.id, key: "technician", name: ROLE_DEFS.technician.name, rolePermissions: { create: techPerms.map((p) => ({ permissionId: p.id })) } }, + }); + await prisma.userRole.create({ data: { userId: f.tech.id, roleId: techRole.id } }); + + console.log("\n— c) Bundle: eigene laufende Session —"); + const acc = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: await version(wo) })); + ok(acc.status === "applied", "Auftrag angenommen"); + const sessionClientId = randomUUID(); + const start = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: sessionClientId })); + ok(start.status === "applied", "Session gestartet (Sync)"); + const techBundle = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); + ok(techBundle?.mySession?.status === "running" && techBundle.mySession.id === start.idMap?.[sessionClientId], "Bundle des Monteurs: mySession = laufende eigene Session"); + const leadBundle = (await getFieldBundle(f.ctxLead)).orders.find((o) => o.id === wo); + ok(!!leadBundle && leadBundle.mySession === null, "Teamleiter sieht den Auftrag, aber keine eigene Session (nicht aus dem Status abgeleitet)"); + ok(initialSession({ status: "in_progress", mySession: null }) === null, "Offline-Ansicht: in Arbeit ohne eigene Session → keine Zeitaktion Pause/Ende"); + ok(initialSession({ status: "in_progress", mySession: { id: "s", status: "paused", startedAt: "" } }) === "paused", "Offline-Ansicht: eigene Session pausiert"); + ok(initialSession({ status: "en_route" }) === "en_route", "Offline-Ansicht: altes Bundle ohne mySession → Näherung über Status"); + await one(f.ctxTech, op("session.pause", { workOrderId: wo })); + const paused = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); + ok(paused?.mySession?.status === "paused", "nach Pause: mySession paused"); + await one(f.ctxTech, op("session.resume", { workOrderId: wo })); + ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo), "Mandant B: Auftrag von A nicht im Bundle"); + ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo), "Monteur ohne Zuweisung: Auftrag nicht im Bundle"); + + console.log("\n— f) clientId je Mandant —"); + const sameSession = await one(f.ctxB, op("session.start", { workOrderId: f.orderB.id, mode: "work", clientId: sessionClientId })); + ok(sameSession.status === "applied" && !!sameSession.idMap?.[sessionClientId] && sameSession.idMap[sessionClientId] !== start.idMap?.[sessionClientId], "gleiche Session-clientId in Mandant B → eigene Session (kein interner Fehler)"); + const noteClientId = randomUUID(); + const nA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A" })); + const nB = await one(f.ctxB, op("note.create", { workOrderId: f.orderB.id, clientId: noteClientId, kind: "general", text: "B" })); + ok(nA.status === "applied" && nB.status === "applied" && nA.idMap?.[noteClientId] !== nB.idMap?.[noteClientId], "gleiche Notiz-clientId in A und B → zwei Notizen"); + const replayA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A nochmal" })); + ok(replayA.status === "applied" && replayA.idMap?.[noteClientId] === nA.idMap?.[noteClientId], "Wiederholung in A (neue clientOpId) → dieselbe Notiz (Idempotenz je Mandant)"); + ok((await prisma.activityNote.count({ where: { clientId: noteClientId } })) === 2, "genau eine Notiz je Mandant"); + let dupRejected = false; + try { + await prisma.workSession.create({ data: { tenantId: f.tenantA.id, workOrderId: wo, userId: f.tech.id, status: "ended", startedAt: new Date(), endedAt: new Date(), clientId: sessionClientId } }); + } catch (err) { + dupRejected = (err as { code?: string }).code === "P2002"; + } + ok(dupRejected, "DB: doppelte clientId im selben Mandanten → Unique-Verletzung"); + + console.log("\n— j) report.save_draft / report.submit —"); + const { report } = await createDailyReport(f.ctxTech, { workOrderId: wo }); + await prisma.report.update({ where: { id: report.id }, data: { aiDrafted: true } }); // Lotse-Entwurf simulieren + const saved = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo, reportId: report.id, texts: { workPerformed: "Heizkörper montiert und entlüftet" } })); + const afterSave = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(saved.status === "applied" && (afterSave.content as { texts: { workPerformed: string } }).texts.workPerformed === "Heizkörper montiert und entlüftet", "report.save_draft → Texte gespeichert"); + const badPayload = await one(f.ctxTech, op("report.submit", { workOrderId: wo }, { baseVersion: await version(wo) })); + ok(badPayload.status === "rejected" && badPayload.errorCode === "invalid", "report.submit ohne reportId → rejected invalid"); + + const noReview = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id }, { baseVersion: await version(wo) })); + ok(noReview.status === "rejected" && noReview.errorCode === "invalid" && /reviewed/.test(noReview.message ?? ""), "Lotse-Entwurf offline ohne aiReviewed → rejected invalid"); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf"); + + const foreignB = await one(f.ctxB, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); + ok(foreignB.status === "rejected" && foreignB.errorCode === "not_found", "Mandant B: report.submit auf A → not_found"); + const outsider = await one(f.ctxOutsider, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); + ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: report.submit → not_found"); + const mismatch = await one(f.ctxB, op("report.save_draft", { workOrderId: f.orderB.id, reportId: report.id, texts: { hints: "x" } })); + ok(mismatch.status === "rejected" && mismatch.errorCode === "not_found", "Mandant B: Bericht von A über eigenen Auftrag → not_found"); + + const current = await version(wo); + const stale = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: current - 1 })); + ok(stale.status === "conflict" && stale.entityVersion === current, "veraltete baseVersion → conflict"); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "bei Konflikt nichts abgesendet"); + const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } }); + ok(conflictOp.status === "conflict" && conflictOp.opType === "report.submit", "Konflikt für die Backoffice-Liste gespeichert"); + + console.log("\n— b) Konflikt übernehmen (report.submit) —"); + await expectCode(() => applySyncConflict(ctxOfficeB, conflictOp.id), "not_found", "Mandant B kann den Konflikt von A nicht übernehmen"); + await expectCode(() => applySyncConflict(f.ctxTech, conflictOp.id), "forbidden", "Monteur (ohne work_order:write) kann Konflikte nicht übernehmen"); + await expectCode(() => reapplyOperation(f.ctxTech, { opType: "note.create", entityId: wo, payload: { workOrderId: wo, kind: "general", text: "x" } }), "invalid", "Übernehmen nur für konfliktbehaftete Ops"); + const taken = await applySyncConflict(ctxOffice, conflictOp.id); + const submitted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(submitted.status === "submitted" && typeof taken.entityVersion === "number", "Übernehmen → Bericht abgesendet (als Gerätenutzer, aiReviewed aus der Op)"); + const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } }); + ok(resolved.status === "applied" && resolved.resolvedById === office.id, "Konflikt als übernommen markiert (resolvedBy Backoffice)"); + await expectCode(() => applySyncConflict(ctxOffice, conflictOp.id), "not_found", "zweites Übernehmen → not_found"); + + // transition conflicts keep working through the dispatcher + const staleTransition = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "paused" }, { baseVersion: 1 })); + ok(staleTransition.status === "conflict", "Statuswechsel mit veralteter Version → conflict"); + const tOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: staleTransition.clientOpId } }); + const statusBefore = (await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status; + const refused = await applySyncConflict(ctxOffice, tOp.id).then( + () => null, + (err: { code?: string }) => err.code ?? "error", + ); + ok(refused === "invalid" || refused === "forbidden", `Übernehmen gegen aktuellen Stand: unzulässiger Übergang wird abgelehnt (${refused})`); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status === statusBefore, "… Auftragsstatus unverändert, Konflikt bleibt offen"); + ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: tOp.id } })).status === "conflict", "… SyncOperation weiterhin conflict"); + } finally { + await cleanupReports().catch((e) => console.error("report cleanup failed", e)); + await f.cleanup().catch((e) => console.error("cleanup failed", e)); + await closeJobQueues(); + await prisma.$disconnect(); + } +} + +main() + .catch((err) => { + console.error(err); + ok(false, `unerwarteter Fehler: ${(err as Error).message}`); + }) + .finally(() => { + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Sync-Aufräumprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-einsatz-sync.ts b/scripts/test-einsatz-sync.ts index 04b06a3..4ed2651 100644 --- a/scripts/test-einsatz-sync.ts +++ b/scripts/test-einsatz-sync.ts @@ -82,8 +82,9 @@ async function main() { ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis"); console.log("\n— Ops fremder Lanes —"); - const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo })); - ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis"); + // L10b: report.save_draft/report.submit are registered now (test-betrieb-sync.ts); signature.capture is still unregistered + const report = await one(f.ctxTech, op("signature.capture", { workOrderId: wo })); + ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "signature.capture ohne Implementierung → rejected invalid mit Hinweis"); ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)"); console.log("\n— Uploads & Mandantentrennung —"); diff --git a/src/lib/offline/bundle-core.ts b/src/lib/offline/bundle-core.ts index 0e08616..9917b24 100644 --- a/src/lib/offline/bundle-core.ts +++ b/src/lib/offline/bundle-core.ts @@ -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): 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"; diff --git a/src/lib/offline/types.ts b/src/lib/offline/types.ts index 3828eb3..eb52769 100644 --- a/src/lib/offline/types.ts +++ b/src/lib/offline/types.ts @@ -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; diff --git a/src/lib/sync/ops.ts b/src/lib/sync/ops.ts index 6bcd6e6..e09d0c3 100644 --- a/src/lib/sync/ops.ts +++ b/src/lib/sync/ops.ts @@ -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; diff --git a/src/server/services/field/queries.ts b/src/server/services/field/queries.ts index b106916..48d07c6 100644 --- a/src/server/services/field/queries.ts +++ b/src/server/services/field/queries.ts @@ -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(); + 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, + })), }; } diff --git a/src/server/services/reports/sync-ops.ts b/src/server/services/reports/sync-ops.ts new file mode 100644 index 0000000..dce4cc8 --- /dev/null +++ b/src/server/services/reports/sync-ops.ts @@ -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 { + 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; +} diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts index b44fa08..de9aaf6 100644 --- a/src/server/services/sync/apply.ts +++ b/src/server/services/sync/apply.ts @@ -62,6 +62,52 @@ const FIELD_HANDLERS: Partial> = { 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 { + 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 { + const opType = stored.opType as SyncOpType; + if (!CONFLICTING_OPS.includes(opType)) throw new ServiceError("invalid", "reapply_unsupported", { opType: stored.opType }); + const payload: Record = { ...((stored.payload ?? {}) as Record) }; + // 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 }; diff --git a/src/server/services/sync/external-ops.ts b/src/server/services/sync/external-ops.ts index 61ef872..ea593d8 100644 --- a/src/server/services/sync/external-ops.ts +++ b/src/server/services/sync/external-ops.ts @@ -23,8 +23,8 @@ export const EXTERNAL_OP_OWNERS: Partial> = { }; export const EXTERNAL_OPS: Partial Promise>> = { - // 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), }; diff --git a/src/server/services/work-orders/sync-reapply.ts b/src/server/services/work-orders/sync-reapply.ts index bfc603f..ea0c527 100644 --- a/src/server/services/work-orders/sync-reapply.ts +++ b/src/server/services/work-orders/sync-reapply.ts @@ -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 }; }