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:
2026-09-15 10:36:10 +02:00
co-authored by Claude Opus 5
parent b76c1bdbfd
commit 2df785dd14
8 changed files with 662 additions and 2 deletions
+60
View File
@@ -0,0 +1,60 @@
/**
* L14 Abrechnungsübersicht — Backfill der offenen Abrechnungseinträge für Bestandsdaten.
*
* Legt je Mandant fehlende `open`-Einträge an (idempotent, services/billing/candidates.ts):
* Aufträge in „Zur Abrechnung freigegeben“, bestätigte Meilensteine ohne Eintrag und freigegebene
* Tagesberichte von Aufträgen ohne Meilensteine. Bereits abgerechnete Aufträge (Status billed) bekommen
* bewusst KEINEN offenen Eintrag.
*
* Lauf: npx tsx scripts/billing-backfill.ts (alle Mandanten)
* npx tsx scripts/billing-backfill.ts --tenant demo (ein Mandant)
*/
import "dotenv/config";
import { dbForTenant, prisma } from "../src/server/db";
import type { ServiceCtx } from "../src/server/services/context";
import { syncBillingCandidates } from "../src/server/services/billing/candidates";
async function main() {
const idx = process.argv.indexOf("--tenant");
const slug = idx > 0 ? process.argv[idx + 1] : undefined;
const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true } });
if (slug && tenants.length === 0) throw new Error(`Mandant ${slug} nicht gefunden`);
let total = 0;
for (const t of tenants) {
const ctx: ServiceCtx = { db: dbForTenant(t.id), tenantId: t.id, userId: "system", permissions: new Set() };
const orders = await ctx.db.workOrder.findMany({
where: {
deletedAt: null,
OR: [
{ status: "released_for_billing" },
{ milestones: { some: { status: "confirmed", billingRecordId: null, deletedAt: null } } },
{ reports: { some: { type: "daily", status: "approved" } } },
],
},
select: { id: true },
});
let created = 0;
for (const o of orders) created += (await syncBillingCandidates(ctx, { workOrderId: o.id })).created;
total += created;
console.log(`✔ ${t.slug}: ${orders.length} Aufträge geprüft, ${created} Einträge angelegt`);
}
console.log(`\nFertig — ${total} Abrechnungseinträge angelegt.`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
try {
const { closeJobQueues } = await import("../src/server/jobs/queues");
await closeJobQueues();
const { closeQueues } = await import("../src/server/mail/queue");
await closeQueues();
} catch {
/* queues optional */
}
});
+31 -2
View File
@@ -39,13 +39,32 @@ async function ids() {
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
const imp = await prisma.importJob.findFirst({ where: { tenantId: demo.id, status: "review_required" }, orderBy: { createdAt: "desc" } });
const photo = await prisma.photo.findFirst({ where: { workOrderId: d07.id }, select: { documentId: true } });
return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo };
// L14: open billing record (DEMO-18 released for billing → event hook / scripts/billing-backfill.ts) + smoke milestone on DEMO-07
const billing = await prisma.billingRecord.findFirst({ where: { tenantId: demo.id, workOrderId: d18.id, status: "open" }, select: { id: true } });
const milestone =
(await prisma.workOrderMilestone.findFirst({ where: { tenantId: demo.id, workOrderId: d07.id, title: "Smoke-Meilenstein", deletedAt: null }, select: { id: true } })) ??
(await prisma.workOrderMilestone.create({ data: { tenantId: demo.id, workOrderId: d07.id, title: "Smoke-Meilenstein", sortOrder: 10 }, select: { id: true } }));
return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo, billing, milestone };
}
function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
const wo = (o: { id: string } | null, suffix = "") => (o ? `/work-orders/${o.id}${suffix}` : "/work-orders");
const m = (o: { id: string } | null, suffix = "") => (o ? `/m/orders/${o.id}${suffix}` : "/m/orders");
const tabs = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"];
const tabs = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"];
// L14 Abrechnungsübersicht
const billingBackoffice: Check[] = [
{ path: "/billing", mustContain: ["Abrechnungsübersicht", "Offen"] },
{ path: "/billing?status=billed", mustContain: "Abgerechnet" },
{ path: "/dashboard", mustContain: "Bereit zur Abrechnung" },
{ path: wo(x.d07, "?tab=billing"), mustContain: ["Smoke-Meilenstein", "Abrechnungseinträge"] },
...(x.billing
? [
{ path: `/billing?q=${x.d18.number}`, mustContain: x.d18.number },
{ path: `/billing/${x.billing.id}`, mustContain: ["Abrechnungsblatt", x.d18.number, "Anfahrten", "Als abgerechnet markieren"] },
{ path: `/billing/print?ids=${x.billing.id}`, mustContain: ["Abrechnungsblatt", x.d18.number] },
]
: []),
];
return [
{
email: "admin@demo.example",
@@ -81,6 +100,7 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
{ path: "/work-orders/conflicts" },
...billingBackoffice,
// L12 Zeiterfassung
{ path: "/work-orders/time-approvals", mustContain: "Zeiten zur Freigabe" },
{ path: "/dashboard", mustContain: "Zeiten zur Freigabe" },
@@ -151,6 +171,11 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] },
{ path: "/m/approvals", mustContain: "nicht freigeschaltet" },
{ path: m(x.d07, "/time"), mustContain: x.d07.number },
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
// order numbers appear in the monteur's own notifications and all UI texts in the serialized messages → check the record link
{ path: "/billing", expect: [200, 307], mustContain: "nicht freigeschaltet", mustNotContain: x.billing ? [`/billing/${x.billing.id}`] : [] },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404, 307] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []),
{ path: m(x.d08), expect: [404] },
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
@@ -181,6 +206,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
// L14 Abrechnungsübersicht: keine Einträge von demo
{ path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []),
{ path: wo(x.d07, "?tab=billing"), expect: [404] },
],
},
];
+364
View File
@@ -0,0 +1,364 @@
// Lane L14 „Abrechnungsübersicht" — Service-Tests: Kandidaten je Art (idempotent, Tagesbericht nur ohne
// Meilensteine, neue Berichtsversion), Aufstellung (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen nicht
// summiert, Anfahrten-Zählung, Material verwendet/zusätzlich/nicht verwendet, Hinweise), markBilled (Snapshot
// eingefroren, Positionen nicht doppelt, Auftrag → billed, offene Zeiten nur mit Bestätigung), voidBilling
// (Grund Pflicht, Positionen frei, neuer offener Eintrag, Auftrag zurück), Meilenstein-Flow inkl. Events,
// Rechte, Mandantentrennung, Dashboard-Kachel, PDF-Render-Smoke (Skip ohne Chromium).
//
// Lauf: npx tsx scripts/test-abrechnung-service.ts (lokale Postgres-DB aus .env)
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { Prisma } from "@prisma/client";
import { prisma } from "../src/server/db";
import type { ServiceCtx } from "../src/server/services/context";
import { countTrips } from "../src/lib/billing/statement";
import { dateKeyToDbDate } from "../src/lib/reports/dates";
import { emitEvent } from "../src/server/events";
import { syncBillingCandidates } from "../src/server/services/billing/candidates";
import { buildBillingStatement } from "../src/server/services/billing/statement";
import { markBilled, updateInvoiceNumber, voidBilling } from "../src/server/services/billing/records";
import {
confirmMilestone,
createMilestone,
deleteMilestone,
listMilestones,
markMilestoneReached,
moveMilestone,
rejectMilestone,
updateMilestone,
} from "../src/server/services/billing/milestones";
import { countOpenBillingRecords, getBillingRecordDetail, listBillingRecords, listWorkOrderBillingRecords } from "../src/server/services/billing/queries";
import { generateBillingPdf } from "../src/server/services/billing/pdf";
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
import { getDashboardTiles } from "../src/server/services/work-orders/dashboard";
import { berlinAt, codeOf, createTenant, dayKey, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
const SLUG_A = "zz-l14-bill-a";
const SLUG_B = "zz-l14-bill-b";
const noPdf = { dispatchPdf: async () => undefined };
async function order(A: TenantFixture, status: string, extra: Record<string, unknown> = {}) {
return prisma.workOrder.create({
data: {
tenantId: A.tenantId,
number: `Z-${randomUUID().slice(0, 8)}`,
customerId: A.customerId,
siteId: A.siteId,
title: "Heizungsanlage erneuern",
status: status as never,
assignedTeamId: A.teamId,
billingType: "time_material",
createdAt: berlinAt(-10, 8),
assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }, { tenantId: A.tenantId, userId: A.users.tech2.id }] },
...extra,
},
});
}
async function sessionOf(A: TenantFixture, workOrderId: string, userId: string) {
return prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId, userId, status: "ended", startedAt: berlinAt(-9, 6), endedAt: berlinAt(-9, 7) } });
}
type EntryType = "travel" | "work" | "break" | "material_procurement" | "return_travel" | "interruption";
async function entry(A: TenantFixture, sessionId: string, userId: string, type: EntryType, start: Date, end: Date | null, extra: Record<string, unknown> = {}) {
return prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: sessionId, userId, type, startedAt: start, endedAt: end, ...extra } });
}
async function approvedReport(A: TenantFixture, workOrderId: string, type: "daily" | "completion", key: string, opts: { lineageId?: string; version?: number; texts?: Record<string, string>; number?: string } = {}) {
return prisma.report.create({
data: {
tenantId: A.tenantId,
workOrderId,
type,
reportDate: dateKeyToDbDate(key),
lineageId: opts.lineageId ?? randomUUID(),
version: opts.version ?? 1,
status: "approved",
approvedAt: new Date(),
content: { reportNumber: opts.number ?? "B-00001", texts: { workPerformed: "Arbeiten", additionalWork: "", deviations: "", openItems: "", ...opts.texts } },
},
});
}
const records = (workOrderId: string) => prisma.billingRecord.findMany({ where: { workOrderId }, orderBy: { createdAt: "asc" } });
const notif = (userId: string, type: string, entityId: string) => prisma.notification.findFirst({ where: { userId, type, entityId } });
runSuite("L14 Abrechnungsübersicht (Service)", [SLUG_A, SLUG_B], async () => {
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const bo = A.ctx.backoffice;
// ======================================================================
section("Kandidaten: Auftragsabschluss (idempotent, Event-Weg)");
const woDone = await order(A, "released_for_billing");
const s1 = await syncBillingCandidates(bo, { workOrderId: woDone.id });
const s2 = await syncBillingCandidates(bo, { workOrderId: woDone.id });
const doneRecords = await records(woDone.id);
ok(s1.created === 1 && s2.created === 0 && doneRecords.length === 1 && doneRecords[0].kind === "order_completion" && doneRecords[0].status === "open", "released_for_billing → genau ein offener Auftragsabschluss (zweiter Lauf legt nichts an)");
const woRelease = await order(A, "in_review");
await approvedReport(A, woRelease.id, "completion", dayKey(-1));
await releaseForBilling(bo, { workOrderId: woRelease.id });
const releaseRecords = await records(woRelease.id);
ok(releaseRecords.length === 1 && releaseRecords[0].kind === "order_completion", "bestehende Abrechnungsfreigabe (L2) erzeugt den Eintrag über das Event work_order.released_for_billing");
const dup = await prisma.billingRecord.createMany({ data: [{ tenantId: A.tenantId, workOrderId: woRelease.id, kind: "order_completion", periodFrom: new Date(), periodTo: new Date() }], skipDuplicates: true });
ok(dup.count === 0, "Partial-Unique-Index: kein zweiter nicht stornierter Auftragsabschluss je Auftrag");
section("Kandidaten: Tagesbericht (nur ohne Meilensteine, neue Version ersetzt)");
const woDaily = await order(A, "in_progress");
const lineage = randomUUID();
const daily1 = await approvedReport(A, woDaily.id, "daily", dayKey(-2), { lineageId: lineage });
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: daily1.id });
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: daily1.id });
let dailyRecords = await records(woDaily.id);
ok(dailyRecords.length === 1 && dailyRecords[0].kind === "daily_report" && dailyRecords[0].reportId === daily1.id, "freigegebener Tagesbericht → ein Tagesbericht-Eintrag (Event doppelt → kein Doppel)");
ok(dailyRecords[0].periodTo.getTime() - dailyRecords[0].periodFrom.getTime() >= 23 * 3600_000 && dailyRecords[0].periodFrom.getTime() === berlinAt(-2, 0).getTime(), "Zeitraum = Kalendertag des Berichts (Mandanten-Zeitzone)");
await prisma.report.update({ where: { id: daily1.id }, data: { status: "superseded" } });
const daily2 = await approvedReport(A, woDaily.id, "daily", dayKey(-2), { lineageId: lineage, version: 2 });
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: daily2.id });
dailyRecords = await records(woDaily.id);
ok(dailyRecords.length === 1 && dailyRecords[0].reportId === daily2.id, "neue Berichtsversion: kein zweiter Eintrag, offener Eintrag zeigt auf Version 2");
const completionOnly = await approvedReport(A, woDaily.id, "completion", dayKey(-1));
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: completionOnly.id });
ok((await records(woDaily.id)).length === 1, "Abschlussbericht-Freigabe erzeugt keinen Abschnitt (Auftragsabschluss erst bei Abrechnungsfreigabe)");
const woWithMilestone = await order(A, "in_progress");
await createMilestone(bo, { workOrderId: woWithMilestone.id, title: "Rohinstallation" });
const dailyM = await approvedReport(A, woWithMilestone.id, "daily", dayKey(-1));
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: dailyM.id });
ok((await records(woWithMilestone.id)).length === 0, "Auftrag MIT definierten Meilensteinen: Tagesbericht erzeugt keinen Abschnitt");
// ======================================================================
section("Aufstellung: Zeiten, Anfahrten, Material, Hinweise");
ok(countTrips([{ startedAt: berlinAt(-2, 7), endedAt: berlinAt(-2, 7, 30) }, { startedAt: berlinAt(-2, 7, 5), endedAt: berlinAt(-2, 7, 30) }], "Europe/Berlin").count === 1, "Kolonne: 2 Personen mit zeitgleicher Anfahrt = 1 Fahrt");
ok(countTrips([{ startedAt: berlinAt(-2, 7), endedAt: berlinAt(-2, 7, 30) }, { startedAt: berlinAt(-1, 7), endedAt: berlinAt(-1, 7, 30) }], "Europe/Berlin").count === 2, "zwei Tage mit Anfahrt = 2 Fahrten");
ok(countTrips([{ startedAt: berlinAt(-2, 7), endedAt: berlinAt(-2, 7, 30) }, { startedAt: berlinAt(-2, 13), endedAt: berlinAt(-2, 13, 20) }], "Europe/Berlin").count === 2, "getrennte Anfahrten am selben Tag = 2 Fahrten");
await prisma.customer.update({ where: { id: A.customerId }, data: { billingNotes: "Rechnung nur mit Bestellnummer 4711" } });
const wo = await order(A, "released_for_billing", { externalOrderNumber: "EXT-9", offerNumber: "ANG-3" });
const tech = A.users.tech.id;
const tech2 = A.users.tech2.id;
const st = await sessionOf(A, wo.id, tech);
const st2 = await sessionOf(A, wo.id, tech2);
// day -2: crew travels together
await entry(A, st.id, tech, "travel", berlinAt(-2, 7), berlinAt(-2, 7, 30));
await entry(A, st2.id, tech2, "travel", berlinAt(-2, 7, 5), berlinAt(-2, 7, 30));
await entry(A, st.id, tech, "work", berlinAt(-2, 7, 30), berlinAt(-2, 12)); // 270
await entry(A, st.id, tech, "break", berlinAt(-2, 12), berlinAt(-2, 12, 30)); // 30 info
await entry(A, st.id, tech, "work", berlinAt(-2, 12, 30), berlinAt(-2, 15)); // 150
await entry(A, st.id, tech, "return_travel", berlinAt(-2, 15), berlinAt(-2, 15, 20)); // 20
await entry(A, st2.id, tech2, "work", berlinAt(-2, 7, 30), berlinAt(-2, 11, 30)); // 240
// day -1
await entry(A, st.id, tech, "travel", berlinAt(-1, 8), berlinAt(-1, 8, 20)); // 20
await entry(A, st.id, tech, "work", berlinAt(-1, 8, 20), berlinAt(-1, 10, 20)); // 120
await entry(A, st.id, tech, "material_procurement", berlinAt(-1, 10, 20), berlinAt(-1, 10, 50)); // 30
await entry(A, st.id, tech, "work", berlinAt(-1, 10, 50), berlinAt(-1, 11, 50), { source: "manual", approvalStatus: "approved", correctionReason: "vergessen" }); // 60 manual
const pending = await entry(A, st.id, tech, "work", berlinAt(-1, 12), berlinAt(-1, 13), { source: "manual", approvalStatus: "pending", correctionReason: "nachgetragen" });
await entry(A, st.id, tech, "work", berlinAt(-1, 13), berlinAt(-1, 14), { source: "manual", approvalStatus: "rejected", correctionReason: "falsch" });
await entry(A, st2.id, tech2, "work", new Date(Date.now() - 30 * 60_000), null); // running
const plan1 = await prisma.materialPlan.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Kupferrohr 18 mm", articleNumber: "CU-18", plannedQuantity: new Prisma.Decimal(10), unit: "m" } });
await prisma.materialPlan.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Pressfitting", articleNumber: "PF-18", plannedQuantity: new Prisma.Decimal(5), unit: "Stk", sortOrder: 1 } });
const plan3 = await prisma.materialPlan.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Dichtband", plannedQuantity: new Prisma.Decimal(1), unit: "Rolle", sortOrder: 2 } });
await prisma.materialUsage.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, materialPlanId: plan1.id, name: "Kupferrohr 18 mm", articleNumber: "CU-18", actualQuantity: new Prisma.Decimal(12), unit: "m", usageStatus: "fully_used", deviationReason: "Leitung länger", createdAt: berlinAt(-2, 10) } });
await prisma.materialUsage.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Kugelhahn", articleNumber: "KH-1", actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "additional", deviationReason: "Alter Hahn defekt", createdAt: berlinAt(-1, 9) } });
await prisma.materialUsage.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, materialPlanId: plan3.id, name: "Dichtband", actualQuantity: new Prisma.Decimal(0), unit: "Rolle", usageStatus: "not_used", deviationReason: "nicht nötig", createdAt: berlinAt(-1, 9) } });
await approvedReport(A, wo.id, "daily", dayKey(-2), { number: "B-00077", texts: { additionalWork: "Zusätzlich Heizkörper entlüftet", deviations: "Leitung 2 m länger", openItems: "Verkleidung folgt" } });
await syncBillingCandidates(bo, { workOrderId: wo.id });
const rec = (await records(wo.id)).find((r) => r.kind === "order_completion")!;
const s = await buildBillingStatement(bo, rec.id);
const pTech = s.time.persons.find((p) => p.userId === tech)!;
const pTech2 = s.time.persons.find((p) => p.userId === tech2)!;
ok(pTech.work === 600 && pTech.travel === 70 && pTech.materialProcurement === 30, `Monteur: Arbeit 10:00 h, Fahrzeit (An- + Rückfahrt) 1:10 h, Materialbeschaffung 0:30 h (erhalten ${pTech.work}/${pTech.travel}/${pTech.materialProcurement})`);
ok(pTech.breaks === 30 && pTech.total === 700, "Pausen nur informativ ausgewiesen, nicht in der Summe");
ok(pTech.manualMinutes === 60, "freigegebener Nachtrag als „manuell nachgetragen“ gekennzeichnet");
ok(pTech2.work === 240 && pTech2.travel === 25 && pTech2.total === 265, "zweite Person getrennt ausgewiesen");
ok(s.time.totals.total === 965 && s.time.totals.breaks === 30 && s.time.totals.work === 840 && s.time.totals.travel === 95, "Summen je Art und gesamt");
ok(s.time.pendingMinutes === 60 && s.time.pendingCount === 1, "offener Nachtrag nicht enthalten, aber als Hinweis (1:00 h offen)");
ok(s.time.hasRunningEntries && !s.positions.timeEntryIds.includes(pending.id), "laufende und offene Zeiten sind keine Positionen");
ok(s.positions.timeEntryIds.length === 11, `11 freigegebene, beendete Zeit-Positionen (erhalten ${s.positions.timeEntryIds.length})`);
ok(s.trips.count === 2 && s.trips.dates.join() === [dayKey(-2), dayKey(-1)].join(), "Anfahrten: Kolonne am Tag −2 = 1 Fahrt, Tag −1 = 1 Fahrt → 2 mit Datumsliste");
ok(s.materials.used.length === 1 && s.materials.used[0].quantity === "12" && s.materials.used[0].deviationReason === "Leitung länger", "verwendetes Material mit Menge, Einheit und Abweichungsgrund");
ok(s.materials.additional.length === 1 && s.materials.additional[0].name === "Kugelhahn", "zusätzlich verwendetes Material getrennt");
ok(s.materials.notUsed.length === 2 && s.materials.notUsed.some((m) => m.name === "Pressfitting" && m.status === null), "nicht verwendete Planpositionen informativ (erfasst „nicht verwendet“ + ohne Erfassung)");
ok(s.customer.billingNotes === "Rechnung nur mit Bestellnummer 4711" && s.workOrder.externalOrderNumber === "EXT-9" && s.workOrder.offerNumber === "ANG-3" && s.workOrder.billingType === "time_material", "Kopf: Abrechnungshinweise, externe/Angebotsnummer, Abrechnungsart");
ok(s.texts.additionalWork[0]?.text === "Zusätzlich Heizkörper entlüftet" && s.texts.openItems[0]?.reportNumber === "B-00077" && s.reports.length === 1, "Zusatzleistungen/Abweichungen/Restarbeiten aus Berichten + Berichtsreferenz");
ok(JSON.stringify(s).match(/price|amount|tax|preis|betrag|steuer/i) === null, "Aufstellung enthält keine Preise, Beträge oder Steuern");
// ======================================================================
section("markBilled: Rechte, offene Zeiten, Snapshot, Positionen, Auftrag → billed");
await expectCode(() => markBilled(A.ctx.lead, { recordId: rec.id }, noPdf), "forbidden", "Teamleiter → forbidden");
await expectCode(() => markBilled(A.ctx.tech, { recordId: rec.id }, noPdf), "forbidden", "Monteur → forbidden");
await expectCode(() => markBilled(bo, { recordId: rec.id }, noPdf), "blocked", "offene Zeiten ohne Bestätigung → blocked (pending_time_entries)");
let dispatched = "";
const billed = await markBilled(bo, { recordId: rec.id, invoiceNumber: " RE-2026-001 ", confirmPendingExcluded: true }, { dispatchPdf: async (_c, id) => void (dispatched = id) });
ok(billed.status === "billed" && billed.invoiceNumber === "RE-2026-001" && !!billed.billedAt && billed.billedById === A.users.backoffice.id, "abgerechnet mit Rechnungsnummer (getrimmt), Zeitpunkt, Person");
ok(dispatched === rec.id, "PDF-Job wird angestoßen");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "billed", "Auftragsabschluss → Auftrag „Abgerechnet“ (über transitionWorkOrder)");
ok((await prisma.timeEntry.count({ where: { billingRecordId: rec.id } })) === 11 && (await prisma.timeEntry.findUniqueOrThrow({ where: { id: pending.id } })).billingRecordId === null, "11 Zeit-Positionen zugeordnet, offener Nachtrag bleibt frei");
ok((await prisma.materialUsage.count({ where: { billingRecordId: rec.id } })) === 3, "Material-Positionen zugeordnet");
ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "billing_record", entityId: rec.id, action: "update" } })), "Audit-Eintrag (before/after)");
await expectCode(() => markBilled(bo, { recordId: rec.id, confirmPendingExcluded: true }, noPdf), "conflict", "erneut abrechnen → conflict");
// later change must not alter the snapshot
const firstWork = await prisma.timeEntry.findFirstOrThrow({ where: { billingRecordId: rec.id, type: "work", userId: tech2 } });
await prisma.timeEntry.update({ where: { id: firstWork.id }, data: { endedAt: berlinAt(-2, 16) } });
await prisma.customer.update({ where: { id: A.customerId }, data: { billingNotes: "geändert" } });
const frozen = await buildBillingStatement(bo, rec.id);
ok(frozen.time.totals.total === 965 && frozen.customer.billingNotes === "Rechnung nur mit Bestellnummer 4711" && frozen.record.status === "billed", "Snapshot eingefroren: spätere Änderungen an Zeiten/Kunde ändern die Aufstellung nicht");
// a second section on the same order does not contain billed positions
const lateDaily = await approvedReport(A, wo.id, "daily", dayKey(-1));
await syncBillingCandidates(bo, { workOrderId: wo.id });
const dailyRec = (await records(wo.id)).find((r) => r.kind === "daily_report" && r.reportId === lateDaily.id)!;
const dailyStatement = await buildBillingStatement(bo, dailyRec.id);
ok(dailyStatement.positions.timeEntryIds.length === 0 && dailyStatement.positions.materialUsageIds.length === 0 && dailyStatement.time.pendingMinutes === 60, "zweiter Abschnitt enthält bereits abgerechnete Positionen nicht (nur den offenen Hinweis)");
const inv = await updateInvoiceNumber(bo, { recordId: rec.id, invoiceNumber: "RE-2026-002" });
ok(inv.invoiceNumber === "RE-2026-002" && (await buildBillingStatement(bo, rec.id)).record.invoiceNumber === "RE-2026-002", "Rechnungsnummer nachträglich änderbar (Audit)");
await expectCode(() => updateInvoiceNumber(bo, { recordId: dailyRec.id, invoiceNumber: "X" }), "conflict", "Rechnungsnummer nur für abgerechnete Einträge");
await expectCode(() => updateInvoiceNumber(A.ctx.tech, { recordId: rec.id, invoiceNumber: "X" }), "forbidden", "Monteur ändert keine Rechnungsnummer");
// ======================================================================
section("voidBilling: Grund Pflicht, Positionen frei, neuer offener Eintrag, Auftrag zurück");
await expectCode(() => voidBilling(bo, { recordId: rec.id, reason: " " }), "invalid", "ohne Grund → invalid");
await expectCode(() => voidBilling(A.ctx.lead, { recordId: rec.id, reason: "falsch" }), "forbidden", "Teamleiter → forbidden");
await expectCode(() => voidBilling(bo, { recordId: dailyRec.id, reason: "falsch" }), "conflict", "nur abgerechnete Einträge stornierbar");
const voided = await voidBilling(bo, { recordId: rec.id, reason: "Rechnung an falschen Empfänger" });
ok(voided.voided.status === "voided" && voided.voided.voidReason === "Rechnung an falschen Empfänger" && !!voided.voided.voidedAt, "storniert mit Grund");
ok((await prisma.timeEntry.count({ where: { billingRecordId: rec.id } })) === 0 && (await prisma.materialUsage.count({ where: { billingRecordId: rec.id } })) === 0, "Positionen wieder frei");
ok(!!voided.reopened && voided.reopened.status === "open" && voided.reopened.kind === "order_completion" && voided.reopened.id !== rec.id, "neuer offener Eintrag für dieselbe Quelle");
const woAfterVoid = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
ok(woAfterVoid.status === "released_for_billing", "Auftrag zurück auf „Zur Abrechnung freigegeben“ (nur über Stornierung)");
ok(!!(await prisma.workOrderStatusChange.findFirst({ where: { workOrderId: wo.id, fromStatus: "billed", toStatus: "released_for_billing", reason: "Rechnung an falschen Empfänger" } })), "Statushistorie mit Stornogrund");
ok((await prisma.billingRecord.count({ where: { workOrderId: wo.id, kind: "order_completion", status: "open" } })) === 1, "genau ein offener Auftragsabschluss nach Stornierung (Event-Hook + Stornierung ohne Doppel)");
await expectCode(() => voidBilling(bo, { recordId: rec.id, reason: "nochmal" }), "conflict", "zweites Stornieren → conflict");
const reopenedStatement = await buildBillingStatement(bo, voided.reopened!.id);
ok(reopenedStatement.positions.timeEntryIds.length === 11, "neuer Eintrag enthält die freigegebenen Positionen wieder");
// ======================================================================
section("Meilensteine: anlegen, melden, bestätigen, ablehnen");
const woM = await order(A, "in_progress");
await expectCode(() => createMilestone(A.ctx.tech, { workOrderId: woM.id, title: "X" }), "forbidden", "Monteur legt keine Meilensteine an");
await expectCode(() => createMilestone(A.ctx.lead, { workOrderId: woM.id, title: "X" }), "forbidden", "Teamleiter legt keine Meilensteine an");
await expectCode(() => createMilestone(bo, { workOrderId: woM.id, title: " " }), "invalid", "Titel Pflicht");
const m1 = await createMilestone(bo, { workOrderId: woM.id, title: "Rohinstallation", description: "Leitungen verlegt" });
const m2 = await createMilestone(bo, { workOrderId: woM.id, title: "Inbetriebnahme" });
ok(m1.status === "open" && m2.sortOrder > m1.sortOrder, "Backoffice legt Meilensteine an (Reihenfolge)");
await moveMilestone(bo, { milestoneId: m2.id, direction: "up" });
ok((await listMilestones(bo, woM.id))[0].id === m2.id, "Meilenstein nach oben sortiert");
await moveMilestone(bo, { milestoneId: m2.id, direction: "down" });
const sm = await sessionOf(A, woM.id, tech);
await entry(A, sm.id, tech, "work", berlinAt(-1, 8), berlinAt(-1, 12));
await expectCode(() => markMilestoneReached(A.ctx.outsider, { milestoneId: m1.id }), "not_found", "Monteur ohne Auftrag im Scope → not_found");
const reached = await markMilestoneReached(A.ctx.tech, { milestoneId: m1.id, note: "Druckprobe ok" });
ok(reached.changed && reached.milestone.status === "reached" && reached.milestone.reachedById === tech && reached.milestone.reachedNote === "Druckprobe ok", "Monteur meldet „erreicht“ mit Notiz");
ok(!!(await notif(A.users.backoffice.id, "milestone.reached", m1.id)) && !(await notif(tech, "milestone.reached", m1.id)), "Event milestone.reached → Backoffice In-App (nicht der Meldende)");
ok(!(await markMilestoneReached(A.ctx.tech, { milestoneId: m1.id })).changed, "erneutes Melden ist idempotent");
await expectCode(() => updateMilestone(bo, { milestoneId: m1.id, title: "Neu" }), "invalid", "gemeldeter Meilenstein nicht mehr änderbar");
await expectCode(() => deleteMilestone(bo, m1.id), "invalid", "gemeldeter Meilenstein nicht löschbar");
await expectCode(() => confirmMilestone(A.ctx.tech, m1.id), "forbidden", "Monteur kann nicht bestätigen");
await expectCode(() => confirmMilestone(A.ctx.lead, m1.id), "forbidden", "Teamleiter kann nicht bestätigen");
await expectCode(() => rejectMilestone(bo, { milestoneId: m1.id, reason: "" }), "invalid", "Ablehnen ohne Grund → invalid");
const rejected = await rejectMilestone(bo, { milestoneId: m1.id, reason: "Druckprotokoll fehlt" });
ok(rejected.status === "open" && rejected.rejectionReason === "Druckprotokoll fehlt" && rejected.reachedAt === null, "Ablehnung → wieder offen mit Grund");
const rejNotif = await notif(tech, "milestone.rejected", m1.id);
ok(!!rejNotif && rejNotif.message.includes("Druckprotokoll fehlt") && rejNotif.link === `/m/orders/${woM.id}`, "Event milestone.rejected → Meldender In-App mit Grund und Link");
await expectCode(() => rejectMilestone(bo, { milestoneId: m1.id, reason: "x" }), "conflict", "offener Meilenstein kann nicht abgelehnt werden");
await markMilestoneReached(A.ctx.tech, { milestoneId: m1.id });
const confirmed = await confirmMilestone(bo, m1.id);
ok(confirmed.status === "confirmed" && confirmed.confirmedById === A.users.backoffice.id, "Backoffice bestätigt");
const mRecords = await records(woM.id);
ok(mRecords.length === 1 && mRecords[0].kind === "milestone" && mRecords[0].milestoneId === m1.id && mRecords[0].status === "open", "Bestätigung → offener Meilenstein-Eintrag");
ok(!!(await notif(tech, "milestone.confirmed", m1.id)), "Event milestone.confirmed → Meldender In-App");
await expectCode(() => confirmMilestone(bo, m1.id), "conflict", "zweites Bestätigen → conflict");
await syncBillingCandidates(bo, { workOrderId: woM.id });
ok((await records(woM.id)).length === 1, "Kandidaten-Sync legt für den Meilenstein keinen zweiten Eintrag an");
const ms = await buildBillingStatement(bo, mRecords[0].id);
ok(ms.record.milestoneTitle === "Rohinstallation" && ms.time.totals.work === 240, "Meilenstein-Abschnitt: Titel + Zeiten bis zur Bestätigung");
await entry(A, sm.id, tech, "work", new Date(Date.now() + 60_000), new Date(Date.now() + 120 * 60_000));
ok((await buildBillingStatement(bo, mRecords[0].id)).time.totals.work === 240, "Zeiten nach dem Bestätigungszeitpunkt gehören nicht zum Meilenstein");
await markBilled(bo, { recordId: mRecords[0].id }, noPdf);
ok((await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: m1.id } })).status === "billed", "abgerechneter Meilenstein-Eintrag → Meilenstein „Abgerechnet“");
await confirmMilestone(bo, m2.id);
const m2Record = (await records(woM.id)).find((r) => r.milestoneId === m2.id)!;
ok((await buildBillingStatement(bo, m2Record.id)).positions.timeEntryIds.length === 0, "nächster Meilenstein enthält die bereits abgerechneten Zeiten nicht");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woM.id } })).status === "in_progress", "Meilenstein-Abrechnung ändert den Auftragsstatus nicht");
const m3 = await createMilestone(bo, { workOrderId: woM.id, title: "Wird gelöscht" });
await deleteMilestone(bo, m3.id);
ok(!(await listMilestones(bo, woM.id)).some((m) => m.id === m3.id) && !!(await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: m3.id } })).deletedAt, "offener Meilenstein soft-gelöscht");
ok((await listMilestones(A.ctx.tech, woM.id)).length === 2, "Monteur sieht die Meilensteine seines Auftrags");
await expectCode(() => listMilestones(A.ctx.outsider, woM.id), "not_found", "Monteur fremdes Team sieht keine Meilensteine");
// ======================================================================
section("Liste, Detail, Rechte");
const openList = await listBillingRecords(bo, { status: "open" });
ok(openList.items.some((i) => i.id === voided.reopened!.id) && openList.items.every((i) => i.status === "open"), "Tab Offen listet offene Einträge");
const reopenedItem = openList.items.find((i) => i.id === voided.reopened!.id)!;
// Monteur 600 + zweite Person 510 (Eintrag nach der ersten Abrechnung auf 7:30–16:00 verlängert, nach Storno wieder frei)
ok(reopenedItem.summary.workMinutes === 600 + 510 && reopenedItem.summary.travelMinutes === 95 && reopenedItem.summary.trips === 2 && reopenedItem.summary.materialCount === 2 && reopenedItem.summary.pendingMinutes === 60, `Zeilen-Summen (Arbeitszeit, Anfahrten, Material, offene Zeiten) — erhalten ${JSON.stringify(reopenedItem.summary)}`);
const sorted = openList.items.map((i) => i.readySince.getTime());
ok(sorted.every((v, i) => i === 0 || sorted[i - 1] <= v), "sortiert nach „bereit seit“");
ok((await listBillingRecords(bo, { status: "billed" })).items.some((i) => i.workOrder.id === woM.id), "Tab Abgerechnet");
ok((await listBillingRecords(bo, { status: "voided" })).items.some((i) => i.id === rec.id), "Tab Storniert");
ok((await listBillingRecords(bo, { status: "open", kind: "milestone" })).items.every((i) => i.kind === "milestone"), "Filter Art");
ok((await listBillingRecords(bo, { status: "open", q: wo.number })).items.every((i) => i.workOrder.id === wo.id), "Suche Auftragsnummer");
ok((await listBillingRecords(bo, { status: "open", teamId: A.team2Id })).total === 0, "Filter Team");
ok((await listBillingRecords(bo, { status: "open", from: dayKey(5) })).items.every((i) => i.periodTo > berlinAt(5, 0)), "Filter Zeitraum");
ok((await listWorkOrderBillingRecords(bo, woM.id)).length === 2, "Einträge je Auftrag");
const detail = await getBillingRecordDetail(A.ctx.admin, rec.id);
ok(detail.record.voidReason === "Rechnung an falschen Empfänger" && detail.statement.record.id === rec.id, "Detail mit Aufstellung (Mandantenadmin)");
for (const role of ["tech", "lead", "outsider"] as const) {
await expectCode(() => listBillingRecords(A.ctx[role]), "forbidden", `billing:read – ${role} → forbidden`);
}
await expectCode(() => buildBillingStatement(A.ctx.lead, rec.id), "forbidden", "Aufstellung für Teamleiter → forbidden");
ok((await countOpenBillingRecords(bo)) === openList.total && (await countOpenBillingRecords(A.ctx.tech)) === 0, "Zähler „Bereit zur Abrechnung“ (0 ohne Recht)");
ok((await getDashboardTiles(bo, {})).billingReady === openList.total, "Dashboard-Kachel zählt offene Einträge");
// ======================================================================
section("Mandantentrennung");
const woB = await order(B, "released_for_billing");
await syncBillingCandidates(B.ctx.backoffice, { workOrderId: woB.id });
const listB = await listBillingRecords(B.ctx.backoffice, { status: "open" });
ok(listB.items.length === 1 && listB.items[0].workOrder.id === woB.id, "Liste B enthält nur Einträge von B");
ok(!(await listBillingRecords(B.ctx.backoffice, { status: "voided" })).items.some((i) => i.id === rec.id), "stornierter Eintrag von A nicht in B");
await expectCode(() => getBillingRecordDetail(B.ctx.backoffice, rec.id), "not_found", "Detail A aus B → not_found");
await expectCode(() => buildBillingStatement(B.ctx.backoffice, voided.reopened!.id), "not_found", "Aufstellung A aus B → not_found");
await expectCode(() => markBilled(B.ctx.backoffice, { recordId: voided.reopened!.id, confirmPendingExcluded: true }, noPdf), "not_found", "abrechnen A aus B → not_found");
await expectCode(() => voidBilling(B.ctx.backoffice, { recordId: mRecords[0].id, reason: "x" }), "not_found", "stornieren A aus B → not_found");
await expectCode(() => updateInvoiceNumber(B.ctx.backoffice, { recordId: mRecords[0].id, invoiceNumber: "x" }), "not_found", "Rechnungsnummer A aus B → not_found");
await expectCode(() => createMilestone(B.ctx.backoffice, { workOrderId: woM.id, title: "x" }), "not_found", "Meilenstein an Auftrag A aus B → not_found");
await expectCode(() => markMilestoneReached(B.ctx.admin, { milestoneId: m2.id }), "not_found", "Meilenstein A melden aus B → not_found");
await expectCode(() => confirmMilestone(B.ctx.backoffice, m2.id), "not_found", "Meilenstein A bestätigen aus B → not_found");
await expectCode(() => generateBillingPdf(B.ctx.backoffice, mRecords[0].id), "not_found", "PDF von A aus B → not_found");
ok((await syncBillingCandidates(B.ctx.backoffice, { workOrderId: woDone.id })).created === 0, "Kandidaten-Sync mit Auftrag von A im Kontext B legt nichts an");
ok((await prisma.billingRecord.count({ where: { tenantId: B.tenantId, workOrderId: { in: [wo.id, woM.id, woDone.id] } } })) === 0, "keine Einträge mit Aufträgen von A in B");
ok((await countOpenBillingRecords(B.ctx.backoffice)) === 1, "Zähler B unabhängig");
// ======================================================================
section("PDF-Abrechnungsblatt (Render-Smoke)");
const { pdfRendererAvailable } = await import("../src/server/pdf/render");
const avail = await pdfRendererAvailable();
if (!avail.ok) {
console.log(`↷ übersprungen: kein Chromium verfügbar (${avail.reason.slice(0, 120)})`);
} else {
const worker: ServiceCtx = { ...bo, permissions: new Set(["billing:read", "work_order:read_all", "document:read_internal", "document:write"]) };
const pdf = await generateBillingPdf(worker, mRecords[0].id);
const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdf.documentId } });
ok(!pdf.skipped && doc.category === "other" && doc.visibility === "backoffice_only" && (doc.title ?? "").startsWith("Abrechnungsblatt") && doc.workOrderId === woM.id, "PDF als Dokument (Kategorie other, backoffice_only, Titel „Abrechnungsblatt …“, am Auftrag)");
const stored = await prisma.billingRecord.findUniqueOrThrow({ where: { id: mRecords[0].id } });
ok(stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF-Dokument und Prüfsumme am Eintrag");
if (process.env.S3_ENDPOINT) {
const { readStoredBytes } = await import("../src/server/services/documents/read");
const stored = await readStoredBytes(doc.storageKey);
const bytes = stored ? Buffer.from(stored) : Buffer.alloc(0);
ok(bytes.subarray(0, 4).toString() === "%PDF" && bytes.length > 1000, "PDF-Bytes beginnen mit %PDF");
}
ok((await generateBillingPdf(worker, mRecords[0].id)).skipped, "zweiter Lauf ersetzt das PDF nicht");
await expectCode(() => generateBillingPdf(worker, voided.reopened!.id), "invalid", "offener Eintrag hat kein PDF");
}
void codeOf;
});
+103
View File
@@ -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`);
}
});
+3
View File
@@ -133,6 +133,9 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
put("Notification", (await prisma.notification.create({ data: { tenantId: t, userId: uid, type: "zz", title: "ZZ", message: "ZZ" } })).id, { title: MARK });
put("SyncOperation", (await prisma.syncOperation.create({ data: { tenantId: t, userId: uid, clientOpId: randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } })).id, { errorCode: MARK });
put("AiGeneration", (await prisma.aiGeneration.create({ data: { tenantId: t, kind: "report_draft", provider: "fake", model: "zz" } })).id, { model: MARK });
// L14 Abrechnungsübersicht
put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK });
put("BillingRecord", (await prisma.billingRecord.create({ data: { tenantId: t, workOrderId: wo.id, kind: "order_completion", periodFrom: new Date(Date.now() - 3600_000), periodTo: new Date() } })).id, { invoiceNumber: MARK });
return rows;
}
+12
View File
@@ -38,6 +38,8 @@ import { queryAuditLog } from "../src/server/services/audit/viewer";
import { getAiGenerationContent } from "../src/server/services/lotse/protocol";
import { checkCompleteness } from "../src/server/services/lotse/completeness";
import { noteCreatePayload } from "../src/lib/sync/ops";
import { listBillingRecords } from "../src/server/services/billing/queries";
import { confirmMilestone } from "../src/server/services/billing/milestones";
import { codeOf, createTenant, ok, runSuite, section, type Persona, type TenantFixture } from "./lib/e2e-fixture";
const SLUG = "zz-q-sec-roles";
@@ -123,6 +125,16 @@ runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => {
{ permission: "notification:read", label: "Benachrichtigungen", run: (c) => listNotifications(c, {}) },
{ permission: "settings:templates", label: "Auftragsart anlegen", run: (c) => createOrderType(c, { key: `rolle_${randomUUID().slice(0, 8).replace(/-/g, "")}`, name: "Rollenprüfung" }) },
{ permission: "lotse:use", label: "Lotse-Vollständigkeitsprüfung", run: (c) => checkCompleteness(c, shared.id) },
// L14 Abrechnungsübersicht
{ permission: "billing:read", label: "Abrechnungsübersicht lesen", run: (c) => listBillingRecords(c) },
{
permission: "billing:write",
label: "Meilenstein bestätigen",
run: async (c) => {
const m = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, title: `Rollen ${randomUUID()}`, status: "reached" } });
return confirmMilestone(c, m.id);
},
},
];
section("Matrix-Abdeckung");