Files
craftvia/scripts/test-abrechnung-sync.ts
msolarczekandClaude Opus 5 2df785dd14 L14 Abrechnungsübersicht: Tests, Backfill, Smoke und Lane-Bericht
test-abrechnung-service (118 Prüfungen inkl. Mandantentrennung und PDF-Render-Smoke), test-abrechnung-sync (21), Fixture-Zeilen für die neuen Tenant-Modelle, Rollen-Matrix billing:*, smoke-auth um /billing, Detail, Druckansicht, Auftrags-Tab und mobile Meilensteine erweitert, scripts/billing-backfill.ts, docs/craftvia/lanes/abrechnung.md, ABNAHME §3 „Abrechnungsübersicht“ (keine Buchhaltung). Gate 69/69, RLS 69/69.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:36:10 +02:00

104 lines
6.7 KiB
TypeScript

// 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<string, unknown>, 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<string, unknown> = {}) {
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`);
}
});