diff --git a/docs/craftvia/ABNAHME.md b/docs/craftvia/ABNAHME.md index 4e7774b..679ebcc 100644 --- a/docs/craftvia/ABNAHME.md +++ b/docs/craftvia/ABNAHME.md @@ -61,6 +61,7 @@ | Barcode/QR-Code | offen | Version 2 | | Kundenspezifische Statusmodelle | offen | Statusmodell global, Labels gruppiert (Brandbook §12.3) | | Digitale Einsatzfreigabe durch Teamleiter | erfüllt | L5 `report:approve_team` | +| Abrechnungsübersicht | erfüllt | L14 `/billing` (Tabs Offen · Abgerechnet · Storniert, Filter, Mehrfachdruck), Detail `/billing/[id]` mit Abrechnungsblatt (Auftragsdaten inkl. Abrechnungshinweisen des Kunden, freigegebene Zeiten je Person/Art mit Fahrzeit getrennt und Pausen nur informativ, Anzahl Anfahrten mit Datumsliste, Material verwendet/zusätzlich/nicht verwendet, Zusatzleistungen/Abweichungen/Restarbeiten, Berichts- und Unterschriftsreferenzen), PDF-Abrechnungsblatt (Worker, `backoffice_only`), „Abgerechnet“ mit optionaler Rechnungsnummer (Aufstellung eingefroren, Zeiten/Material nie doppelt abrechenbar), Stornierung mit Grund; Einträge je Auftragsabschluss, bestätigtem Meilenstein bzw. – ohne Meilensteine – freigegebenem Tagesbericht; Meilensteine im Auftragsdetail und mobil (offline `milestone.reach`); Rechte `billing:read`/`billing:write` (Backoffice, Mandantenadmin); `test-abrechnung-service`, `test-abrechnung-sync`. **Abgrenzung: keine Buchhaltung** – keine Preise, Beträge, Steuern, Rechnungserstellung, Zahlungen und kein Export in Buchhaltungsformate (Spec §35); die Rechnung stellt die Buchhaltung außerhalb von Craftvia | ## 4. Offene fachliche Entscheidungen (Spec §44) – getroffene MVP-Annahmen diff --git a/docs/craftvia/lanes/abrechnung.md b/docs/craftvia/lanes/abrechnung.md new file mode 100644 index 0000000..be13d48 --- /dev/null +++ b/docs/craftvia/lanes/abrechnung.md @@ -0,0 +1,88 @@ +# Lane L14 – Abrechnungsübersicht (`lane/abrechnung`) + +Stand: 2026-09-15 · Basis `5ea23a1` (`feature/craftvia-mvp`, L1–L12 integriert) · Spec §10.3, §13, §16/§17, §35 + +Ziel: Abgeschlossene Aufträge und erreichte Meilensteine laufen in einer **Abrechnungsübersicht** auf, damit die Buchhaltung **außerhalb von Craftvia** die Rechnungen stellt. **Keine Buchhaltung:** keine Preise, Beträge, Steuern, Rechnungserstellung, Zahlungen, kein Export in Buchhaltungsformate (kein CSV). + +## 1. Umfang + +| Punkt | Umsetzung | +|---|---| +| Datenmodell | Migration `20260915150000_abrechnung_uebersicht`: Tabellen `work_order_milestones` (`WorkOrderMilestone`: Titel, Beschreibung, Reihenfolge, Status `open/reached/confirmed/billed`, gemeldet/bestätigt/abgelehnt von–am, Notiz, Ablehnungsgrund, `billingRecordId`, Soft Delete) und `billing_records` (`BillingRecord`: Art `order_completion/milestone/daily_report`, Quelle `milestoneId`/`reportId`, Zeitraum, Status `open/billed/voided`, `snapshot` Json, PDF-Dokument + Prüfsumme, Rechnungsnummer, abgerechnet/storniert von–am, Stornogrund). Beide mit `enable_tenant_rls`, in beiden `TENANT_MODELS`-Listen, Personenfelder in `pii-fields.ts`. **Partial-Unique-Indizes** (SQL): höchstens ein nicht stornierter Eintrag je Auftragsabschluss / Meilenstein / Tagesbericht. L12-Modelle: je **eine** nullable Spalte `billing_record_id` + Index an `time_entries` und `material_usages` (keine FK). | +| Entstehung (`services/billing/candidates.ts#syncBillingCandidates`) | Idempotent, `INSERT … ON CONFLICT DO NOTHING` (hält auch eine umgebende Transaktion intakt). **Event-Weg:** `emitEvent` ruft nach der Benachrichtigung einen Billing-Hook für `work_order.released_for_billing` und `report.approved` auf – Abrechnungsfreigabe (L2) und Berichtsfreigabe (L5) bleiben unverändert. Meilenstein-Bestätigung ruft den Sync in derselben Transaktion. `scripts/billing-backfill.ts` für Bestandsdaten (abgerechnete Aufträge bekommen bewusst keinen offenen Eintrag). | +| Zeitraum-Regeln | **Auftragsabschluss:** vom Ende des letzten abgerechneten Abschnitts (sonst erste dokumentierte Tätigkeit/Auftragsanlage) bis zur Freigabe; enthält **alles noch nicht Abgerechnete** des Auftrags. **Meilenstein:** gleicher Beginn bis **Bestätigungszeitpunkt**; enthält alle noch nicht abgerechneten Positionen, die vor der Bestätigung erfasst wurden. **Tagesbericht** (nur Aufträge **ohne** definierte Meilensteine): Kalendertag des Berichts in der Mandanten-Zeitzone; eine neue Berichtsversion ersetzt den Bericht eines noch offenen Eintrags. Positionen = Zeiteinträge (nach `startedAt`) und Materialerfassungen (nach `createdAt`) mit `billingRecordId = null`. | +| Aufstellung (`services/billing/statement.ts`) | Kopf: Mandant (Name/Adresse wie Bericht; Logo wie im Bericht noch ohne Dokument), Kunde (Nr., Name, Rechnungsadresse = Kundenadresse, **Abrechnungshinweise** `billingNotes`), Objekt, Auftrag (Nr., externe Nr., Angebotsnr., Titel, Auftragsart, Abrechnungsart, Team), Abschnitt (Art, Meilenstein-Titel/Berichtsdatum, Zeitraum, Einsatztage). **Zeiten** je Person: Arbeit, Fahrzeit (Anfahrt + Rückfahrt), Materialbeschaffung, Summe; Pausen und Unterbrechungen nur informativ, nicht summiert; Kennzeichen „manuell nachgetragen (freigegeben)“. Nur `approved` + beendete Einträge; offene Nachträge als Hinweis „x h offen, nicht enthalten“, laufende Einträge als Hinweis. **Anfahrten:** Anzahl + Datumsliste. **Material:** verwendet, zusätzlich (hervorgehoben), nicht verwendete Planpositionen (informativ), Abweichungsgründe. **Texte** Zusatzleistungen/Abweichungen/offene Restarbeiten aus freigegebenen Berichten des Abschnitts; **Berichtsreferenzen** (Nr./Version/Datum/Freigabe) mit Unterschrift. | +| Anfahrten-Regel | `lib/billing/statement.ts#countTrips`: je Kalendertag bilden Anfahrts-Segmente (`travel`), die sich zeitlich überschneiden **oder innerhalb von 15 min beginnen**, **eine** Fahrt (Kolonne fährt gemeinsam); eine spätere, getrennte Anfahrt am selben Tag zählt erneut. Rückfahrten zählen nicht als Anfahrt. Keine km. | +| Aktionen (`services/billing/records.ts`) | `markBilled` (`billing:write`, Transaktion): Aufstellung neu berechnen → offene Zeiten ohne `confirmPendingExcluded` → `blocked pending_time_entries`; **Snapshot eingefroren**, Rechnungsnummer optional, Positionen per bedingtem `updateMany` zugeordnet (Mengenabgleich → sonst `conflict positions_already_billed`), Meilenstein → `billed`, Auftragsabschluss → Auftrag `billed` über `applyTransition`; Audit; danach PDF-Job. `voidBilling` (Grund Pflicht, nur `billed`): `voided`, Positionen frei, Meilenstein zurück auf `confirmed`, **neuer offener Eintrag** derselben Quelle, Auftrag `billed → released_for_billing`. `updateInvoiceNumber` (nur `billed`, Audit), `requestBillingPdf`. | +| Statusmodell | `status.ts` **unverändert** (`billed` bleibt Endstatus in der Übergangstabelle). `transition.ts#applyTransition` hat die interne Option `billingVoid`: nur damit ist `billed → released_for_billing` erlaubt (Recht `work_order:release_billing`, Grund Pflicht, Guards der Freigabe, Statushistorie, Audit, Event). Nicht über `transitionSchema`/API/UI erreichbar. | +| PDF | Job `billing-pdf` (Queue in `queues.ts`, Processor-Einzeiler mit `turbopackIgnore`), `services/billing/pdf.ts#generateBillingPdf`: aus dem Snapshot, HTML (`server/pdf/templates/billing.tsx`, gleiche Komponente wie Detail/Druck) → `pdf/render.ts` → `storeFile` Kategorie **`other`**, Titel „Abrechnungsblatt A-… – Art“, Sichtbarkeit `backoffice_only`, am Auftrag/Kunde/Objekt; Prüfsumme am Eintrag; nie ersetzt. Kopf mit Mandant, Fuß mit Eintrag-ID, Erstellt-Datum, Seite x von y. | +| Meilensteine (`services/billing/milestones.ts`) | `createMilestone`/`updateMilestone`/`deleteMilestone` (Soft Delete)/`moveMilestone` (`work_order:write`, Auftrag nicht abgerechnet/storniert; ändern/löschen nur `open`), `markMilestoneReached` (`field:execute` + Auftrag im Scope **oder** `work_order:write`; idempotent), `confirmMilestone` / `rejectMilestone` (Grund Pflicht) mit `billing:write`. Events `milestone.reached` → Nutzer mit `billing:write` (In-App), `milestone.confirmed`/`milestone.rejected` → meldende Person (In-App, Grund im Text); Links Backoffice `/work-orders/[id]?tab=billing`, mobil `/m/orders/[id]`. | +| Rechte / Modul | `billing:read`, `billing:write` → Backoffice (über den bestehenden Rollenfilter) und Mandantenadmin; keine Feldrolle. Modul `billing` in `lib/modules.ts` (Layout `requireModule`, Actions `server/actions/billing/*` mit `moduleGuard("billing")`). `scripts/sync-role-permissions.ts` zieht Bestandsmandanten nach (lokal +8 Zuordnungen). | +| Sync | Op `milestone.reach` (Envelope, `ops.ts` passthrough, Registry `external-ops.ts` → `services/billing/sync-ops.ts`, Zod `lib/billing/schemas.ts#milestoneReachPayload`), additiv/konfliktfrei, idempotent über `clientOpId` und fachlich. | +| API | `GET /api/v1/billing`, `GET /api/v1/billing/{id}`, `POST /api/v1/billing/{id}/billed`, `POST /api/v1/billing/{id}/void`, `GET`/`POST /api/v1/work-orders/{id}/milestones`, `POST /api/v1/milestones/{id}/reach|confirm|reject` – `requireApiContext("billing", …)` + `withApi`; OpenAPI-Tag „Abrechnung“. | + +## 2. Screens / Routen + +| Route | Inhalt | +|---|---| +| `/billing` | Nav „Abrechnung“ (Modul `billing`, `billing:read`). Tabs **Offen** (Standard) · Abgerechnet · Storniert mit Zählern; Filter Zeitraum, Kunde, Team, Art, Suche Auftragsnr./Kunde; Tabelle Auftrag, Kunde/Objekt, Art + Abschnitt, Zeitraum, Arbeitszeit, Fahrzeit, Anfahrten, Materialpositionen, Warnbadge „x h offene Zeiten“ (Text + Icon), „Bereit seit“ (bzw. abgerechnet am + Rechnungsnr. / storniert am + Grund); Mehrfachauswahl → „Abrechnungsblätter drucken“; Paginierung. Ohne Recht: Hinweis. | +| `/billing/[id]` | Kopf mit Status/Art, Aktionen „Als abgerechnet markieren“ (Popup: Rechnungsnummer optional, bei offenen Zeiten Warnung + Checkbox), „PDF“ (sobald erzeugt, `/files/`), „Druckansicht“, „Rechnungsnummer ändern“, „Stornieren“ (Popup, Grund Pflicht); Links Auftrag, Fotos, Berichte; Aufstellung als Abrechnungsblatt. | +| `/billing/print?ids=…` | Druckansicht (bis 50 Blätter, Seitenumbruch je Blatt, Navigation/Buttons per Print-CSS ausgeblendet), Button „Drucken“. Kein Export. | +| `/work-orders/[id]?tab=billing` | Tab „Meilensteine & Abrechnung“: Meilensteine anlegen, nach oben/unten, löschen (offen), Status (Text + Icon), gemeldet/bestätigt von–am, Notiz, letzter Ablehnungsgrund; Bestätigen/Ablehnen (Grund); Liste der Abrechnungseinträge mit Status und Link. | +| `/m/orders/[id]` | Abschnitt „Meilensteine“ (nur wenn vorhanden): Status-Badges, großer Button „Erreicht melden“ (optionale Notiz, 48 px), offline über die Outbox („wird übertragen“), Ablehnungsgrund, „wartet auf Bestätigung“. | +| `/dashboard` | Kachel „Bereit zur Abrechnung“ (offene Einträge, nur mit `billing:read`) → `/billing`. | + +## 3. Dateien + +**Neu** +- `prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql` +- `src/lib/billing/{schemas,statement}.ts` +- `src/server/services/billing/{candidates,statement,records,milestones,queries,pdf,events,sync-ops,common}.ts` +- `src/server/actions/billing/{billing,milestones}.ts` +- `src/server/jobs/processors/billing-pdf.ts`, `src/server/pdf/templates/billing.tsx` +- `src/app/(app)/billing/{layout,page}.tsx`, `src/app/(app)/billing/[id]/page.tsx`, `src/app/(app)/billing/print/page.tsx` +- `src/app/api/v1/billing/route.ts`, `billing/[id]/route.ts`, `billing/[id]/billed/route.ts`, `billing/[id]/void/route.ts`, `work-orders/[id]/milestones/route.ts`, `milestones/[id]/{reach,confirm,reject}/route.ts` +- `src/components/billing/{statement-document,work-order-billing-tab,action-form,print-button,ui}.tsx`, `src/components/field/{milestones,milestone-reach-button}.tsx` +- `messages/{de,en}/billing.json` +- `scripts/test-abrechnung-service.ts`, `scripts/test-abrechnung-sync.ts`, `scripts/billing-backfill.ts` + +**Eingriffe außerhalb von `billing/` (alle additiv, klein)** +- `prisma/schema.prisma` (2 Enums-Blöcke + 2 Modelle am Ende, je 1 Spalte + Index an `TimeEntry`/`MaterialUsage`, 2 Relationslisten am Ende von `WorkOrder` – `plannedDurationMinutes`/L13-Felder unberührt) +- `src/server/db.ts`, `src/server/backup/topology.ts` (TENANT_MODELS), `src/server/dsgvo/pii-fields.ts`, `src/server/rbac.ts` (2 Rechte), `src/lib/modules.ts` (1 Modul), `src/lib/nav.ts` (1 Eintrag), `src/components/audit-trail.tsx` (2 Labels) +- `src/lib/events.ts` (3 Events, entityType `milestone`), `src/server/events.ts` (Billing-Hook nach `handleEvent`, eigener try/catch), `src/server/services/notifications/recipients.ts` (1 Case), `handle-event.ts` (Link `milestone`) +- `src/server/services/work-orders/transition.ts` (Option `billingVoid`, s. o.), `src/server/services/work-orders/dashboard.ts` (Zähler `billingReady`), `src/app/(app)/dashboard/page.tsx` (Kachel), `src/app/(app)/work-orders/[id]/page.tsx` (Tab `billing`) +- `src/app/(field)/m/(core)/orders/[id]/page.tsx` (1 Import + 1 Zeile `MilestonesSection`) +- `src/lib/sync/envelope.ts` (Op-Typ), `src/lib/sync/ops.ts` (passthrough), `src/server/services/sync/external-ops.ts` (Registry), `src/server/jobs/queues.ts` (Queue `billing-pdf`), `src/server/jobs/processors/index.ts` (1 Zeile) +- `src/lib/api/openapi.ts` (Tag + 8 Pfade) +- `messages/{de,en}/{nav,modules,dashboard,workOrders,notifications}.json` (additive Schlüssel) +- Tests: `scripts/test-e2e-tenant-isolation.ts` (Fixture-Zeilen für die 2 neuen Tenant-Modelle), `scripts/test-security-roles.ts` (Proben `billing:read`/`billing:write`), `scripts/smoke-auth.ts` (Abrechnung, Tab, mobil, Mandant 2) +- `scripts/lib/demo-seed.ts`: **Seed-Fix** – auf frischer DB brach `prisma db seed` seit L12 ab (`other_session_running`: DEMO-07/DEMO-06 hatten laufende Uhren, spätere vergangene Einsätze derselben Monteure kollidierten). DEMO-06/DEMO-07 werden jetzt nach DEMO-19 angelegt (Inhalt unverändert). + +Keine neuen npm-Abhängigkeiten, keine Stubs. + +## 4. Tests + +- `scripts/test-abrechnung-service.ts` – **118 Prüfungen**: Kandidaten je Art (Auftragsabschluss idempotent, über das echte `releaseForBilling`-Event, Partial-Unique-Index, Tagesbericht über `report.approved` ohne Doppel, Zeitraum = Kalendertag, neue Berichtsversion ersetzt, Abschlussbericht erzeugt keinen Abschnitt, Auftrag mit Meilensteinen ohne Tagesbericht-Abschnitt); Anfahrten-Zählung (Kolonne 2 Personen gleichzeitig = 1, zwei Tage = 2, getrennt am selben Tag = 2); Aufstellung (Arbeit/Fahrzeit/Materialbeschaffung je Person, Pausen nicht summiert, manuell-Kennzeichen, Summen, offener Nachtrag nur als Hinweis, abgelehnte/laufende nicht enthalten, Material verwendet/zusätzlich/nicht verwendet, Abrechnungshinweise, Nummern, Berichtstexte, keine Preis-/Betragsfelder); markBilled (Teamleiter/Monteur forbidden, offene Zeiten blocked, Rechnungsnummer, PDF-Job, Auftrag → billed, Positionen zugeordnet, Audit, doppelt → conflict, **Snapshot eingefroren** trotz späterer Änderung, **zweiter Abschnitt enthält abgerechnete Positionen nicht**, Rechnungsnummer ändern); voidBilling (Grund Pflicht, Rechte, nur billed, Positionen frei, neuer offener Eintrag, Auftrag zurück + Statushistorie, genau ein offener Eintrag, zweites Storno conflict); Meilenstein-Flow (Anlegen nur Backoffice, Sortieren, Monteur ohne Scope not_found, melden + Event an Backoffice, idempotent, gemeldet nicht änder-/löschbar, Monteur/Teamleiter können nicht bestätigen, Ablehnen mit Grund + Event/Link an Meldenden, Bestätigen → Eintrag + Event, Zeiten nach Bestätigung nicht enthalten, abgerechnet → Meilenstein billed, nächster Meilenstein ohne abgerechnete Zeiten, Soft Delete, Sichtbarkeit mobil); Liste/Filter/Sortierung/Summen, Detail, `billing:read` für Monteur/Teamleiter forbidden, Dashboard-Kachel; **Mandantentrennung** (Liste, Detail, Aufstellung, abrechnen, stornieren, Rechnungsnummer, Meilenstein anlegen/melden/bestätigen, PDF, Kandidaten-Sync); **PDF-Render-Smoke** (Dokument `other`/`backoffice_only`/Titel, Prüfsumme, Bytes `%PDF`, nicht ersetzt; Skip ohne Chromium – lokal über Google Chrome gerendert). +- `scripts/test-abrechnung-sync.ts` – **21 Prüfungen**: Registry/Envelope, Payload-Schema, `milestone.reach` applied + Event, gleiche clientOpId → duplicate, neue clientOpId für gemeldeten Meilenstein → applied ohne Änderung, ungültige Payload → rejected invalid, Meilenstein eines anderen Auftrags → not_found, Monteur ohne Zuweisung / Mandant B → not_found ohne Wirkung, Teamleiter des Teams darf melden; alle 8 API-Operationen dokumentiert und als Route vorhanden. +- Angepasst: `test-e2e-tenant-isolation.ts` (163 ✓, inkl. RLS je neuer Tabelle), `test-security-roles.ts` (129 ✓, Matrix-Abdeckung der neuen Rechte). + +**Gate** `npm run gate` (Vordergrund): prisma generate, tsc, lint (0 Fehler, 3 bestehende Warnungen außerhalb L14), build inkl. Modul-Guard-Check (35 Action-Dateien), **69/69 Testskripte grün**. +**RLS-Lauf** `RLS_ENFORCED=true npm run test` (`RLS_DATABASE_URL` auf `craftvia_abrechnung`): **69/69 grün**. + +**HTTP-Smoke** (`scripts/smoke-auth.ts`, Dev-Server :3115, frischer Demo-Seed, `sync-role-permissions`, `billing-backfill`): 120 Prüfungen, alle grün (eine Monteur-Prüfung war zunächst falsch formuliert – Seitentexte stehen über die serialisierten Messages immer im HTML – und wurde auf den datengebundenen Eintrag-Link umgestellt, Monteur-Lauf danach 31/31). Neu: Backoffice `/billing`, `?status=billed`, Suche, `/billing/[id]` (Abrechnungsblatt, Anfahrten, Aktion), `/billing/print`, Dashboard-Kachel, Auftrags-Tab mit Meilenstein; Monteur mobil „Meilensteine / Erreicht melden“, `/billing` ohne Recht (Hinweis, kein Eintrag), Detail 404; Mandant 2: Liste ohne Demo-Einträge, Detail 404, Druckansicht leer, Auftrags-Tab 404. Das Abrechnungsblatt wurde zusätzlich als PDF/PNG gerendert und visuell geprüft. Browser-Prüfung mit Anmeldung nicht durchgeführt (keine Passwort-/Token-Eingabe durch den Agenten). + +## 5. Bekannte Lücken / Hinweise + +1. **Bestehender Button „Abgerechnet“ (L2)** im Auftragsdetail (`released_for_billing → billed`) bleibt erhalten; wird er statt der Abrechnungsübersicht benutzt, bleibt der offene Auftragsabschluss-Eintrag stehen (kein Snapshot, keine Positionszuordnung). Empfehlung: Button ausblenden, sobald das Modul `billing` aktiv ist (L2-Datei, hier nicht geändert). +2. **Events in Transaktionen:** Der Billing-Hook läuft wie `handleEvent` im Kontext des Aufrufers (bei `inTransaction` innerhalb der Transaktion); Anlage per `ON CONFLICT DO NOTHING`, Fehler werden geloggt. Ein unerwarteter SQL-Fehler im Hook würde eine umgebende Postgres-Transaktion dennoch abbrechen. +3. **Partial-Unique-Indizes** stehen nur in der Migration (Prisma kennt sie nicht); ein späteres `prisma migrate diff` zeigt sie nicht an – bei Schema-Squash erhalten. +4. **Rechnungsnummer nachträglich:** Spalte + Anzeige aktualisiert; ein bereits erzeugtes PDF bleibt unverändert (unveränderliches Dokument). +5. **Recht für Auftragsstatus:** `markBilled`/`voidBilling` eines Auftragsabschlusses ändern den Auftragsstatus und brauchen zusätzlich `work_order:release_billing` (Standardrollen mit `billing:write` haben es; eigene Rollen ohne das Recht erhalten `forbidden`, nichts wird gespeichert). +6. **Sync-Op `milestone.reach`** prüft das Modul `billing` nicht (läuft über `/api/v1/sync`, Modul `field`); die mobile Anzeige erscheint nur, wenn Meilensteine existieren. +7. **Zeitpunkt der Positionen:** Material wird nach Erfassungszeitpunkt (`createdAt`), Zeiten nach Beginn (`startedAt`) einem Tages-/Meilenstein-Abschnitt zugeordnet; Nachträge nach der Bestätigung eines Meilensteins fallen in den nächsten Abschnitt bzw. den Auftragsabschluss. +8. **Mandantenlogo** im Blatt wie im Bericht noch nicht (kein Logo-Dokument). +9. **Umgebung:** Demo-Seed auf frischer DB brauchte den Seed-Fix (s. §3). `docs/craftvia/API.md` nicht ergänzt (OpenAPI ist maßgeblich). + +## 6. Migration / Deploy + +`20260915150000_abrechnung_uebersicht` – additiv (3 Enums, 2 Tabellen mit RLS, 2 nullable Spalten + Indizes, 3 Partial-Unique-Indizes, 2 FKs mit `ON DELETE CASCADE` auf `work_orders`). Deploy: `prisma migrate deploy` → `npx tsx scripts/sync-role-permissions.ts` (Nutzer neu anmelden) → `npx tsx scripts/billing-backfill.ts` → Worker neu starten (Queue `billing-pdf`). diff --git a/scripts/billing-backfill.ts b/scripts/billing-backfill.ts new file mode 100644 index 0000000..eea28ef --- /dev/null +++ b/scripts/billing-backfill.ts @@ -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 */ + } + }); diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index d2fab2e..e9a087d 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -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>): 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>): 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>): 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>): 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] }, ], }, ]; diff --git a/scripts/test-abrechnung-service.ts b/scripts/test-abrechnung-service.ts new file mode 100644 index 0000000..e07351a --- /dev/null +++ b/scripts/test-abrechnung-service.ts @@ -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 = {}) { + 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 = {}) { + 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; 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; +}); diff --git a/scripts/test-abrechnung-sync.ts b/scripts/test-abrechnung-sync.ts new file mode 100644 index 0000000..1e8612b --- /dev/null +++ b/scripts/test-abrechnung-sync.ts @@ -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, 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 = {}) { + 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`); + } +}); diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index e1c4472..f201d8f 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -133,6 +133,9 @@ async function createModelRows(A: TenantFixture): Promise { 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; } diff --git a/scripts/test-security-roles.ts b/scripts/test-security-roles.ts index 1a556d9..6be65ed 100644 --- a/scripts/test-security-roles.ts +++ b/scripts/test-security-roles.ts @@ -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");