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>
365 lines
34 KiB
TypeScript
365 lines
34 KiB
TypeScript
// 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;
|
||
});
|