// Lane L12 „Zeiterfassung" — Sync/Offline: neue Ops (time.add_manual, time.propose_correction, // session.stop_day, session.segment, session.start mit switchFromOther) über denselben Pfad wie // die Outbox (applyOperations), Idempotenz (gleiche clientOpId → duplicate), Überlappung → rejected // mit Klartext-Schlüssel, Auto-Wechsel-Konflikt mit Auftragsnummer, optimistische lokale Ansicht, // Mandantentrennung und Scope. // // Lauf: npx tsx scripts/test-zeiterfassung-sync.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import { applyOperations } from "../src/server/services/sync/apply"; import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; import { OP_PAYLOAD_SCHEMAS } from "../src/lib/sync/ops"; import { VERSION_CHANGING_OPS, createEntry, problemKey } from "../src/lib/offline/outbox-core"; import { buildOrderView } from "../src/lib/offline/bundle-core"; import type { BundleOrderData } from "../src/lib/offline/types"; import { otherSessionOf, timeErrorKey } from "../src/lib/field/time-rules"; import { berlinAt, createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; const SLUG_A = "zz-l12-sync-a"; const SLUG_B = "zz-l12-sync-b"; const DEVICE = "device-l12"; const op = (opType: SyncOpType, payload: Record, clientOpId: string = randomUUID()): SyncOperationInput => ({ clientOpId, opType, payload, clientCreatedAt: new Date().toISOString(), }); async function order(A: TenantFixture, status: string) { return prisma.workOrder.create({ data: { tenantId: A.tenantId, number: `S-${randomUUID().slice(0, 6)}`, customerId: A.customerId, siteId: A.siteId, title: "Heizung", status: status as never, assignedTeamId: A.teamId, assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] }, }, }); } runSuite("L12 Zeiterfassung (Sync/Offline)", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); const wo1 = await order(A, "accepted"); const wo2 = await order(A, "accepted"); section("Payload-Schemas"); ok(!OP_PAYLOAD_SCHEMAS["time.add_manual"].safeParse({ workOrderId: wo1.id, startedAt: new Date().toISOString(), durationMinutes: 30 }).success, "time.add_manual ohne Begründung → ungültig"); ok(OP_PAYLOAD_SCHEMAS["session.start"].safeParse({ workOrderId: wo1.id, switchFromOther: true }).success, "session.start akzeptiert switchFromOther"); ok(!OP_PAYLOAD_SCHEMAS["session.segment"].safeParse({ workOrderId: wo1.id, type: "break" }).success, "session.segment nur work/return_travel/material_procurement"); section("Ops angewendet + idempotent"); const manualClient = randomUUID(); const batch = [ op("time.add_manual", { workOrderId: wo1.id, clientId: manualClient, type: "work", startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "Kein Netz" }), op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID(), offline: true }), op("session.segment", { workOrderId: wo1.id, type: "material_procurement" }), op("session.stop_day", { workOrderId: wo1.id }), ]; const first = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch }); ok(first.results.every((r) => r.status === "applied"), `4 Ops applied (${first.results.map((r) => `${r.status}${r.message ? `:${r.message}` : ""}`).join(", ")})`); const manualId = first.results[0].idMap?.[manualClient]; const manual = manualId ? await prisma.timeEntry.findUnique({ where: { id: manualId } }) : null; ok(manual?.approvalStatus === "pending" && manual.source === "manual", "idMap liefert den Nachtrag (pending, manual)"); const wo1After = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo1.id } }); ok(wo1After.status === "paused" && first.results[3].entityVersion === wo1After.version, `stop_day: Auftrag paused, entityVersion ${first.results[3].entityVersion}`); const countBefore = await prisma.timeEntry.count({ where: { tenantId: A.tenantId } }); const again = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch }); ok(again.results.every((r) => r.status === "duplicate"), "gleiche clientOpIds → duplicate"); ok((await prisma.timeEntry.count({ where: { tenantId: A.tenantId } })) === countBefore, "keine Doppelanlage"); ok(again.results[0].idMap?.[manualClient] === manualId, "duplicate liefert gespeicherte idMap"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "time.approval_requested", entityId: manualId } })) === 1, "Event über Sync ausgelöst (Teamleiter)"); section("Überlappung → rejected mit Klartext"); const overlapOp = op("time.add_manual", { workOrderId: wo1.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-1, 8, 30).toISOString(), endedAt: berlinAt(-1, 9, 30).toISOString(), reason: "Kein Netz" }); const [overlap] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [overlapOp] })).results; ok(overlap.status === "rejected" && overlap.errorCode === "invalid" && overlap.message === "overlap", "Überlappung → rejected invalid (overlap)"); ok(timeErrorKey(overlap) === "overlap", "Client-Schlüssel field.myTime.errors.overlap"); ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: overlap.message } }) === "timeOverlap", "/m/sync: Klartext offline.problem.timeOverlap"); ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: "too_old" } }) === "timeWindow", "/m/sync: Zeitfenster → offline.problem.timeWindow"); const stored = await prisma.syncOperation.findFirst({ where: { clientOpId: overlapOp.clientOpId } }); ok(stored?.status === "rejected" && stored.errorCode === "invalid", "SyncOperation rejected gespeichert (deterministisch)"); section("Korrekturvorschlag über Sync"); const workEntry = await prisma.timeEntry.findFirstOrThrow({ where: { userId: A.users.tech.id, source: "tracked", type: "work", workSession: { workOrderId: wo1.id } } }); const [proposal] = ( await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: new Date(workEntry.startedAt.getTime() - 10 * 60_000).toISOString(), endedAt: workEntry.endedAt!.toISOString(), reason: "Start vergessen" })], }) ).results; const proposed = await prisma.timeEntry.findUniqueOrThrow({ where: { id: workEntry.id } }); ok(proposal.status === "applied" && !!proposed.pendingChange && proposed.startedAt.getTime() === workEntry.startedAt.getTime(), "time.propose_correction applied, alte Werte bleiben"); section("Auto-Wechsel über Sync"); const [resume1] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID() })] })).results; ok(resume1.status === "applied", "Arbeit auf Auftrag 1 gestartet"); const conflictOp = op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID() }); const [conflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results; const other = otherSessionOf(conflict); ok(conflict.status === "rejected" && conflict.errorCode === "conflict" && other?.number === wo1.number && other.workOrderId === wo1.id, `ohne Flag → rejected conflict mit Auftragsnummer ${other?.number}`); ok(problemKey({ status: "rejected", opType: "session.start", lastError: { code: "conflict", message: conflict.message } }) === "otherSession", "/m/sync: Klartext offline.problem.otherSession"); const [dupConflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results; ok(dupConflict.status === "duplicate" && dupConflict.errorCode === "conflict", "Wiederholung der abgelehnten Op → duplicate (Ergebnis gespeichert)"); const [switched] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID(), switchFromOther: true })] })).results; const s1 = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: wo1.id, userId: A.users.tech.id, status: { not: "ended" } } }); ok(switched.status === "applied" && s1.status === "paused", "mit switchFromOther → applied, Auftrag 1 pausiert"); ok(VERSION_CHANGING_OPS.includes("session.stop_day") && VERSION_CHANGING_OPS.includes("session.segment"), "Outbox verkettet baseVersion auch nach stop_day/segment"); section("Optimistische lokale Ansicht"); const ctx = { tenantId: A.tenantId, userId: A.users.tech.id }; const now = new Date(); const data = { id: wo2.id, number: wo2.number, title: "Heizung", status: "in_progress", statusGroup: "in_progress", priority: "normal", isEmergency: false, plannedStart: null, plannedEnd: null, version: 3, mySession: { id: "s", status: "running", startedAt: now.toISOString() }, customer: { companyName: "K", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null }, checklistItems: [], photoRequirements: [], materialPlans: [], materialUsages: [], documents: [], siteHistory: [], } as BundleOrderData; const stopEntry = createEntry(ctx, { opType: "session.stop_day", payload: { workOrderId: wo2.id, at: now.toISOString() } }, { clientOpId: randomUUID(), now, existing: [] }); const manualEntry = createEntry(ctx, { opType: "time.add_manual", payload: { workOrderId: wo2.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "Kein Netz" } }, { clientOpId: randomUUID(), now, existing: [stopEntry] }); const view = buildOrderView({ ctxKey: `${ctx.tenantId}:${ctx.userId}`, workOrderId: wo2.id, data, syncedAt: now.toISOString() }, [stopEntry, manualEntry]); ok(view.local.session === null && view.status === "paused", "stop_day offline: Uhr aus, Auftrag pausiert (offen)"); ok(view.local.manualTimes.length === 1 && view.local.manualTimes[0].pending && view.local.manualTimes[0].durationMinutes === 30, "add_manual offline: Nachtrag mit pending-Badge sichtbar"); section("Mandantentrennung + Scope"); const [foreign] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.add_manual", { workOrderId: wo1.id, type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "fremd" })] })).results; ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Mandant B: time.add_manual auf A-Auftrag → not_found"); const [foreignStop] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("session.stop_day", { workOrderId: wo2.id })] })).results; ok(foreignStop.status === "rejected" && foreignStop.errorCode === "not_found", "Mandant B: session.stop_day → not_found"); const [foreignProposal] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "fremd" })] })).results; ok(foreignProposal.status === "rejected" && foreignProposal.errorCode === "not_found", "Mandant B: Korrekturvorschlag → not_found"); const [outsider] = (await applyOperations(A.ctx.outsider, { deviceId: DEVICE, operations: [op("session.segment", { workOrderId: wo2.id, type: "return_travel" })] })).results; ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: session.segment → not_found"); ok((await prisma.timeEntry.count({ where: { tenantId: B.tenantId } })) === 0, "in Mandant B nichts angelegt"); });