Files
craftvia/scripts/test-abrechnung-service.ts
T
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

365 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
});