// Lane L14 „Abrechnungsübersicht" — Offline-/Sync-Tests: Sync-Op `milestone.reach` (Registry, Payload, // applied/duplicate, fachlich idempotent, Scope, Mandantentrennung), Event beim Melden, API-Routen in der // OpenAPI-Beschreibung und als Route-Handler vorhanden. // // Lauf: npx tsx scripts/test-abrechnung-sync.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { prisma } from "../src/server/db"; import type { SyncOperationInput } from "../src/lib/sync/envelope"; import { SYNC_OP_TYPES } from "../src/lib/sync/envelope"; import { milestoneReachPayload } from "../src/lib/billing/schemas"; import { applyOperations } from "../src/server/services/sync/apply"; import { EXTERNAL_OPS } from "../src/server/services/sync/external-ops"; import { createMilestone } from "../src/server/services/billing/milestones"; import { API_OPERATIONS } from "../src/lib/api/openapi"; import { createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; const SLUG_A = "zz-l14-sync-a"; const SLUG_B = "zz-l14-sync-b"; const DEVICE = "zz-l14-device"; const op = (payload: Record, clientOpId = randomUUID()): SyncOperationInput => ({ clientOpId, opType: "milestone.reach", entityType: "work_order", entityId: typeof payload.workOrderId === "string" ? payload.workOrderId : undefined, payload, clientCreatedAt: new Date().toISOString(), }); async function order(A: TenantFixture, extra: Record = {}) { return prisma.workOrder.create({ data: { tenantId: A.tenantId, number: `S-${randomUUID().slice(0, 8)}`, customerId: A.customerId, siteId: A.siteId, title: "Lüftung", status: "in_progress", assignedTeamId: A.teamId, assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] }, ...extra, }, }); } runSuite("L14 Abrechnungsübersicht (Sync/API)", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); section("Registry und Payload"); ok(SYNC_OP_TYPES.includes("milestone.reach") && typeof EXTERNAL_OPS["milestone.reach"] === "function", "Op milestone.reach im Envelope und in der Registry registriert"); ok(!milestoneReachPayload.safeParse({ workOrderId: "x" }).success && milestoneReachPayload.safeParse({ workOrderId: "x", milestoneId: "y" }).success, "Payload: workOrderId + milestoneId Pflicht, Notiz optional"); const wo = await order(A); const m = await createMilestone(A.ctx.backoffice, { workOrderId: wo.id, title: "Kanalnetz montiert" }); const other = await order(A); const mOther = await createMilestone(A.ctx.backoffice, { workOrderId: other.id, title: "Anderer Auftrag" }); section("applied / duplicate / fachlich idempotent"); const first = op({ workOrderId: wo.id, milestoneId: m.id, note: "offline gemeldet" }); const [r1] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [first] })).results; const after1 = await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: m.id } }); ok(r1.status === "applied" && after1.status === "reached" && after1.reachedNote === "offline gemeldet" && after1.reachedById === A.users.tech.id, "milestone.reach → applied, Meilenstein erreicht gemeldet"); ok((await prisma.notification.count({ where: { userId: A.users.backoffice.id, type: "milestone.reached", entityId: m.id } })) === 1, "Event über Sync → Backoffice benachrichtigt"); const [r2] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [first] })).results; ok(r2.status === "duplicate", "gleiche clientOpId → duplicate"); const [r3] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op({ workOrderId: wo.id, milestoneId: m.id, note: "zweites Gerät" })] })).results; const after3 = await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: m.id } }); ok(r3.status === "applied" && after3.reachedAt?.getTime() === after1.reachedAt?.getTime() && after3.reachedNote === "offline gemeldet", "neue clientOpId für bereits gemeldeten Meilenstein → applied ohne Änderung"); ok((await prisma.syncOperation.count({ where: { tenantId: A.tenantId, opType: "milestone.reach" } })) === 2, "zwei gespeicherte Operationen, keine Doppelanlage"); section("Validierung, Scope, Mandantentrennung"); const [bad] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op({ workOrderId: wo.id })] })).results; ok(bad.status === "rejected" && bad.errorCode === "invalid", "ohne milestoneId → rejected invalid"); const [mismatch] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op({ workOrderId: wo.id, milestoneId: mOther.id })] })).results; ok(mismatch.status === "rejected" && mismatch.errorCode === "not_found", "Meilenstein gehört nicht zum Auftrag der Op → not_found"); const [outsider] = (await applyOperations(A.ctx.outsider, { deviceId: DEVICE, operations: [op({ workOrderId: other.id, milestoneId: mOther.id })] })).results; ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung → not_found"); ok((await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: mOther.id } })).status === "open", "abgewiesene Op ändert nichts"); const [foreign] = (await applyOperations(B.ctx.admin, { deviceId: DEVICE, operations: [op({ workOrderId: other.id, milestoneId: mOther.id })] })).results; ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Mandant B → not_found"); const [lead] = (await applyOperations(A.ctx.lead, { deviceId: DEVICE, operations: [op({ workOrderId: other.id, milestoneId: mOther.id })] })).results; ok(lead.status === "applied" && (await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: mOther.id } })).status === "reached", "Teamleiter des Auftrags-Teams kann melden"); section("API-Routen + OpenAPI"); const routes: Array<[string, string]> = [ ["GET /api/v1/billing", "billing/route.ts"], ["GET /api/v1/billing/{id}", "billing/[id]/route.ts"], ["POST /api/v1/billing/{id}/billed", "billing/[id]/billed/route.ts"], ["POST /api/v1/billing/{id}/void", "billing/[id]/void/route.ts"], ["POST /api/v1/work-orders/{id}/milestones", "work-orders/[id]/milestones/route.ts"], ["POST /api/v1/milestones/{id}/reach", "milestones/[id]/reach/route.ts"], ["POST /api/v1/milestones/{id}/confirm", "milestones/[id]/confirm/route.ts"], ["POST /api/v1/milestones/{id}/reject", "milestones/[id]/reject/route.ts"], ]; for (const [operation, file] of routes) { ok(API_OPERATIONS.includes(operation) && existsSync(join(process.cwd(), "src/app/api/v1", file)), `${operation} dokumentiert und vorhanden`); } });