Merge lane/abrechnung in feature/craftvia-mvp

Konflikte gelöst (additiv): Benachrichtigungstexte (Planung + Meilensteine), Event-Typen,
Navigation, Job-Queues/Prozessoren (geocode-site, planning-watch, billing-pdf), OpenAPI-Tags,
Smoke-Prüfungen (zweiter Mandant: Planung + Abrechnung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:37:52 +02:00
co-authored by Claude Opus 5
79 changed files with 4481 additions and 40 deletions
+1
View File
@@ -64,6 +64,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
+88
View File
@@ -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/<id>`), „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`).