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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// 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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user