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>
18 KiB
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. |
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 |
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.sqlsrc/lib/billing/{schemas,statement}.tssrc/server/services/billing/{candidates,statement,records,milestones,queries,pdf,events,sync-ops,common}.tssrc/server/actions/billing/{billing,milestones}.tssrc/server/jobs/processors/billing-pdf.ts,src/server/pdf/templates/billing.tsxsrc/app/(app)/billing/{layout,page}.tsx,src/app/(app)/billing/[id]/page.tsx,src/app/(app)/billing/print/page.tsxsrc/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.tssrc/components/billing/{statement-document,work-order-billing-tab,action-form,print-button,ui}.tsx,src/components/field/{milestones,milestone-reach-button}.tsxmessages/{de,en}/billing.jsonscripts/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 anTimeEntry/MaterialUsage, 2 Relationslisten am Ende vonWorkOrder–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, entityTypemilestone),src/server/events.ts(Billing-Hook nachhandleEvent, eigener try/catch),src/server/services/notifications/recipients.ts(1 Case),handle-event.ts(Linkmilestone)src/server/services/work-orders/transition.ts(OptionbillingVoid, s. o.),src/server/services/work-orders/dashboard.ts(ZählerbillingReady),src/app/(app)/dashboard/page.tsx(Kachel),src/app/(app)/work-orders/[id]/page.tsx(Tabbilling)src/app/(field)/m/(core)/orders/[id]/page.tsx(1 Import + 1 ZeileMilestonesSection)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(Queuebilling-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(Probenbilling:read/billing:write),scripts/smoke-auth.ts(Abrechnung, Tab, mobil, Mandant 2) scripts/lib/demo-seed.ts: Seed-Fix – auf frischer DB brachprisma db seedseit 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 echtereleaseForBilling-Event, Partial-Unique-Index, Tagesbericht überreport.approvedohne 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:readfür Monteur/Teamleiter forbidden, Dashboard-Kachel; Mandantentrennung (Liste, Detail, Aufstellung, abrechnen, stornieren, Rechnungsnummer, Meilenstein anlegen/melden/bestätigen, PDF, Kandidaten-Sync); PDF-Render-Smoke (Dokumentother/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.reachapplied + 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
- 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 Modulbillingaktiv ist (L2-Datei, hier nicht geändert). - Events in Transaktionen: Der Billing-Hook läuft wie
handleEventim Kontext des Aufrufers (beiinTransactioninnerhalb der Transaktion); Anlage perON CONFLICT DO NOTHING, Fehler werden geloggt. Ein unerwarteter SQL-Fehler im Hook würde eine umgebende Postgres-Transaktion dennoch abbrechen. - Partial-Unique-Indizes stehen nur in der Migration (Prisma kennt sie nicht); ein späteres
prisma migrate diffzeigt sie nicht an – bei Schema-Squash erhalten. - Rechnungsnummer nachträglich: Spalte + Anzeige aktualisiert; ein bereits erzeugtes PDF bleibt unverändert (unveränderliches Dokument).
- Recht für Auftragsstatus:
markBilled/voidBillingeines Auftragsabschlusses ändern den Auftragsstatus und brauchen zusätzlichwork_order:release_billing(Standardrollen mitbilling:writehaben es; eigene Rollen ohne das Recht erhaltenforbidden, nichts wird gespeichert). - Sync-Op
milestone.reachprüft das Modulbillingnicht (läuft über/api/v1/sync, Modulfield); die mobile Anzeige erscheint nur, wenn Meilensteine existieren. - 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. - Mandantenlogo im Blatt wie im Bericht noch nicht (kein Logo-Dokument).
- Umgebung: Demo-Seed auf frischer DB brauchte den Seed-Fix (s. §3).
docs/craftvia/API.mdnicht 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).