diff --git a/docs/craftvia/ABNAHME.md b/docs/craftvia/ABNAHME.md index 7f23d14..bd1de2b 100644 --- a/docs/craftvia/ABNAHME.md +++ b/docs/craftvia/ABNAHME.md @@ -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 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/messages/de/billing.json b/messages/de/billing.json new file mode 100644 index 0000000..5ef7d23 --- /dev/null +++ b/messages/de/billing.json @@ -0,0 +1,250 @@ +{ + "crumb": "Abrechnung", + "title": "Abrechnungsübersicht", + "subtitle": "Abgeschlossene Aufträge, bestätigte Meilensteine und freigegebene Tagesberichte für die Buchhaltung. Keine Preise, keine Rechnungen.", + "hm": "{value} h", + "tabs": { "open": "Offen", "billed": "Abgerechnet", "voided": "Storniert" }, + "filter": { + "title": "Filter", + "q": "Suche", + "qPlaceholder": "Auftragsnummer oder Kunde", + "from": "Zeitraum von", + "to": "Zeitraum bis", + "customer": "Kunde", + "team": "Team", + "kind": "Art", + "any": "Alle", + "apply": "Anwenden", + "reset": "Zurücksetzen" + }, + "list": { + "noPermission": "Die Abrechnungsübersicht ist für Ihre Rolle nicht freigeschaltet.", + "empty": { + "open": "Nichts offen – alle Einträge sind abgerechnet.", + "billed": "Noch keine Einträge abgerechnet.", + "voided": "Keine stornierten Einträge." + }, + "selectHint": "Einträge auswählen und gemeinsam drucken.", + "print": "Abrechnungsblätter drucken", + "select": "Auswahl", + "selectRow": "{number} auswählen", + "order": "Auftrag", + "customerSite": "Kunde / Objekt", + "section": "Art / Abschnitt", + "period": "Zeitraum", + "work": "Arbeitszeit", + "travel": "Fahrzeit", + "trips": "Anfahrten", + "material": "Material", + "readySince": "Bereit seit", + "billedAt": "Abgerechnet am", + "voidedAt": "Storniert am", + "pending": "{value} h offene Zeiten", + "invoice": "Rechnung {number}", + "pagination": "Seiten", + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {page} von {pages}" + }, + "kind": { "order_completion": "Auftragsabschluss", "milestone": "Meilenstein", "daily_report": "Tagesbericht" }, + "status": { "open": "Offen", "billed": "Abgerechnet", "voided": "Storniert" }, + "milestoneStatus": { "open": "Offen", "reached": "Erreicht gemeldet", "confirmed": "Bestätigt", "billed": "Abgerechnet" }, + "detail": { + "back": "Zur Abrechnungsübersicht", + "title": "Abrechnungsblatt {number}", + "markBilled": "Als abgerechnet markieren", + "pdf": "PDF", + "print": "Druckansicht", + "changeInvoice": "Rechnungsnummer ändern", + "void": "Stornieren", + "openOrder": "Zum Auftrag", + "photos": "Fotos", + "report": "Bericht {number} · v{version}", + "done": { + "billed": "Als abgerechnet markiert. Die Aufstellung ist eingefroren, das PDF wird erstellt.", + "voided": "Storniert. Die Positionen sind wieder frei – hier ist der neue offene Eintrag.", + "invoice": "Rechnungsnummer gespeichert." + }, + "pdfPending": "Das PDF-Abrechnungsblatt wird erstellt.", + "pdfRequest": "PDF erneut anstoßen", + "pdfRequested": "PDF-Erstellung angestoßen.", + "voidedInfo": "Storniert am {at} von {by}" + }, + "bill": { + "title": "Als abgerechnet markieren", + "sub": "Die Rechnung stellt Ihre Buchhaltung außerhalb von Craftvia.", + "submit": "Abgerechnet", + "pending": "Wird gespeichert …", + "invoiceNumber": "Rechnungsnummer (optional)", + "invoiceHint": "Nummer aus Ihrer Buchhaltung, falls schon vorhanden.", + "pendingWarning": "{value} h offene Zeiten ({count}) sind noch nicht freigegeben und nicht enthalten.", + "confirmPending": "Trotzdem abrechnen – offene Zeiten bleiben außen vor", + "freezeHint": "Die Aufstellung wird eingefroren; enthaltene Zeiten und Materialien können kein zweites Mal abgerechnet werden." + }, + "invoice": { "title": "Rechnungsnummer ändern", "submit": "Speichern" }, + "void": { + "title": "Abrechnung stornieren", + "sub": "Zeiten und Material werden wieder frei, ein neuer offener Eintrag entsteht.", + "submit": "Stornieren", + "pending": "Wird storniert …", + "reason": "Grund" + }, + "print": { + "back": "Zur Übersicht", + "count": "{count, plural, =1 {1 Abrechnungsblatt} other {# Abrechnungsblätter}}", + "print": "Drucken", + "empty": "Keine Einträge ausgewählt." + }, + "tab": { + "milestones": "Meilensteine", + "milestonesHint": "Erreichte Meilensteine bestätigen – danach erscheinen sie in der Abrechnungsübersicht.", + "noMilestonesHint": "Ohne Meilensteine wird bei mehrtägigen Aufträgen jeder freigegebene Tagesbericht als Abschnitt abrufbar.", + "noMilestones": "Keine Meilensteine definiert.", + "reachedBy": "Erreicht gemeldet von {name} am {at}", + "confirmedBy": "Bestätigt von {name} am {at}", + "rejected": "Zuletzt abgelehnt: {reason}", + "up": "Nach oben", + "down": "Nach unten", + "delete": "Löschen", + "confirm": "Bestätigen", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "add": "Meilenstein anlegen", + "newTitle": "Titel", + "newTitlePlaceholder": "z. B. Rohinstallation abgeschlossen", + "newDescription": "Beschreibung", + "records": "Abrechnungseinträge", + "noRecords": "Noch keine Abrechnungseinträge.", + "open": "Öffnen" + }, + "mobile": { + "title": "Meilensteine", + "rejected": "Vom Büro abgelehnt: {reason}", + "waiting": "Gemeldet – wartet auf Bestätigung durch das Büro.", + "queued": "Gespeichert – wird übertragen, sobald Verbindung besteht.", + "sent": "Erreicht gemeldet.", + "note": "Notiz", + "notePlaceholder": "Notiz (optional)", + "reachAria": "{title} als erreicht melden", + "confirmReach": "Jetzt melden", + "reach": "Erreicht melden", + "cancel": "Abbrechen" + }, + "sheet": { + "title": "Abrechnungsblatt", + "dailyReportOf": "Tagesbericht vom {date}", + "period": "Zeitraum", + "status": "Status", + "invoiceNumber": "Rechnungsnummer", + "noAccounting": "Aufstellung für die Buchhaltung – keine Rechnung, ohne Preise." + }, + "section": { + "order": "Auftragsdaten", + "time": "Zeiten", + "trips": "Anfahrten", + "material": "Material", + "texts": "Zusatzleistungen, Abweichungen, offene Restarbeiten", + "reports": "Berichte und Unterschrift" + }, + "field": { + "customer": "Kunde", + "customerNumber": "Kundennummer", + "billingAddress": "Rechnungsadresse", + "site": "Objekt", + "orderNumber": "Auftragsnummer", + "externalOrderNumber": "Externe Auftragsnummer", + "offerNumber": "Angebotsnummer", + "title": "Auftrag", + "orderType": "Auftragsart", + "billingType": "Abrechnungsart", + "team": "Team", + "section": "Abschnitt", + "workDates": "Einsatztage", + "billedAt": "Abgerechnet", + "billingNotes": "Abrechnungshinweise des Kunden" + }, + "billingType": { "fixed": "Festpreis", "time_material": "Nach Aufwand", "maintenance_contract": "Wartungsvertrag", "warranty": "Gewährleistung" }, + "time": { + "hm": "{value} h", + "empty": "Keine freigegebenen Zeiten in diesem Abschnitt.", + "person": "Person", + "work": "Arbeit", + "travel": "Fahrzeit", + "materialProcurement": "Materialbeschaffung", + "total": "Summe", + "breaks": "Pausen (Info)", + "manual": "davon manuell nachgetragen (freigegeben): {value} h", + "sum": "Gesamt", + "breaksInfo": "Fahrzeit = Anfahrt + Rückfahrt. Pausen sind nur zur Information aufgeführt und nicht in den Summen enthalten.", + "pendingHint": "{value} h offen ({count} Einträge), noch nicht freigegeben – nicht enthalten.", + "runningHint": "Eine Zeit läuft noch und ist nicht enthalten." + }, + "trips": { + "count": "{count, plural, =0 {Keine Anfahrt} =1 {1 Anfahrt} other {# Anfahrten}}", + "rule": "Gemeinsame Anfahrt einer Kolonne zählt als eine Fahrt." + }, + "material": { + "name": "Bezeichnung", + "articleNumber": "Art.-Nr.", + "quantity": "Menge", + "planned": "Geplant", + "status": "Status", + "reason": "Abweichungsgrund", + "undocumented": "Nicht erfasst", + "empty": "Kein Material in diesem Abschnitt.", + "used": "Verwendetes Material", + "additional": "Zusätzlich verwendetes Material", + "notUsed": "Nicht verwendete Planpositionen", + "notUsedInfo": "Nur zur Information." + }, + "materialStatus": { "fully_used": "Vollständig verwendet", "partially_used": "Teilweise verwendet", "not_used": "Nicht verwendet", "additional": "Zusätzlich" }, + "texts": { + "fromReport": "({number})", + "empty": "Keine Angaben in den Berichten.", + "additionalWork": "Zusatzleistungen", + "deviations": "Abweichungen", + "openItems": "Offene Restarbeiten" + }, + "reports": { + "empty": "Kein freigegebener Bericht in diesem Abschnitt.", + "number": "Bericht", + "type": "Art", + "date": "Datum", + "approvedAt": "Freigegeben", + "signature": "Unterschrift", + "noSignature": "Keine Unterschrift" + }, + "reportType": { "daily": "Tagesbericht", "completion": "Abschlussbericht" }, + "outcome": { "signed": "Unterschrieben", "customer_absent": "Kunde nicht anwesend", "refused": "Unterschrift verweigert", "later": "Wird nachgereicht", "not_required": "Nicht erforderlich" }, + "pdf": { + "documentTitle": "Abrechnungsblatt {number} – {kind}", + "page": "Seite {page} von {pages}", + "recordId": "Eintrag-ID", + "created": "Erstellt" + }, + "errors": { + "not_found": "Nicht gefunden oder keine Berechtigung.", + "forbidden": "Dafür fehlt die Berechtigung.", + "invalid": "Eingaben prüfen.", + "conflict": "Der Eintrag wurde inzwischen geändert. Seite neu laden.", + "blocked": "Noch nicht möglich.", + "internal": "Vorgang nicht möglich.", + "validation_failed": "Pflichtfelder prüfen.", + "billing_record_not_found": "Abrechnungseintrag nicht gefunden.", + "billing_record_not_open": "Der Eintrag ist nicht mehr offen.", + "billing_record_not_billed": "Der Eintrag ist nicht abgerechnet.", + "pending_time_entries": "Es gibt noch offene Zeiten. Bitte bestätigen, dass sie nicht enthalten sind.", + "positions_already_billed": "Einige Positionen wurden inzwischen anders abgerechnet. Seite neu laden.", + "milestone_not_found": "Meilenstein nicht gefunden.", + "milestone_not_open": "Nur offene Meilensteine können geändert werden.", + "milestone_not_confirmable": "Dieser Meilenstein kann nicht bestätigt werden.", + "milestone_not_reached": "Nur gemeldete Meilensteine können abgelehnt werden.", + "not_editable": "Der Auftrag kann in diesem Status nicht mehr geändert werden.", + "work_order_not_found": "Auftrag nicht gefunden.", + "work_order_status": "Für diesen Auftrag ist das nicht mehr möglich.", + "transition_blocked": "Der Auftragsstatus kann nicht zurückgesetzt werden – es fehlen Angaben.", + "transition_forbidden": "Diesen Statuswechsel dürfen Sie nicht ausführen.", + "reason_required": "Bitte einen Grund angeben.", + "version_conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden." + } +} diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 7388acd..0067f43 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -17,7 +17,8 @@ "emergency_new": "Neue Notdienste", "missing_signatures": "Fehlende Unterschriften", "sync_conflicts": "Sync-Konflikte", - "time_approvals": "Zeiten zur Freigabe" + "time_approvals": "Zeiten zur Freigabe", + "billing_ready": "Bereit zur Abrechnung" }, "hints": { "open": "Noch nicht abgerechnet oder storniert", @@ -31,7 +32,8 @@ "emergency_new": "Neu oder zur Prüfung", "missing_signatures": "Unterschrift ausstehend", "sync_conflicts": "Offline-Änderungen prüfen", - "time_approvals": "Nachträge und Korrekturen prüfen" + "time_approvals": "Nachträge und Korrekturen prüfen", + "billing_ready": "Offene Einträge der Abrechnungsübersicht" }, "open": "Anzeigen" } diff --git a/messages/de/modules.json b/messages/de/modules.json index b1a1757..2bab86d 100644 --- a/messages/de/modules.json +++ b/messages/de/modules.json @@ -14,5 +14,6 @@ "crumb": "Modul", "status": "Modul in Umsetzung", "hint": "Dieser Bereich wird gerade gebaut. Navigation, Modul-Freischaltung und Berechtigungen sind bereits aktiv." - } + }, + "billing": "Abrechnungsübersicht" } diff --git a/messages/de/nav.json b/messages/de/nav.json index 59e2a72..c3251f5 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -20,5 +20,6 @@ "admin": "Admin-Konsole", "openMenu": "Menü öffnen", "closeMenu": "Menü schließen", - "timeApprovals": "Zeiten zur Freigabe" + "timeApprovals": "Zeiten zur Freigabe", + "billing": "Abrechnung" } diff --git a/messages/de/notifications.json b/messages/de/notifications.json index e87df6f..2ad07f9 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -51,7 +51,10 @@ "time_rejected": "Zeit abgelehnt", "planning_capacity_freed": "Früher fertig", "planning_overrun": "Geplante Dauer überschritten", - "planning_followup_at_risk": "Folgeauftrag gefährdet" + "planning_followup_at_risk": "Folgeauftrag gefährdet", + "milestone_reached": "Meilenstein erreicht", + "milestone_confirmed": "Meilenstein bestätigt", + "milestone_rejected": "Meilenstein abgelehnt" }, "events": { "work_order_assigned": { @@ -145,6 +148,18 @@ "planning_followup_at_risk": { "title": "Folgeauftrag gefährdet: {number}", "message": "{title} – {customer} ({team}) ist gefährdet durch Verzug {blocker}." + }, + "milestone_reached": { + "title": "Meilenstein erreicht: {number}", + "message": "{actor} meldet einen Meilenstein zu {number} als erreicht. Bitte bestätigen." + }, + "milestone_confirmed": { + "title": "Meilenstein bestätigt: {number}", + "message": "{actor} hat Ihren gemeldeten Meilenstein zu {number} bestätigt." + }, + "milestone_rejected": { + "title": "Meilenstein abgelehnt: {number}", + "message": "{actor} hat Ihren gemeldeten Meilenstein zu {number} abgelehnt: {reason}" } }, "fallback": { diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 14810ec..dfeec3a 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -156,7 +156,8 @@ "notes": "Notizen", "reports": "Berichte", "documents": "Dokumente", - "history": "Verlauf" + "history": "Verlauf", + "billing": "Meilensteine & Abrechnung" } }, "overview": { diff --git a/messages/en/billing.json b/messages/en/billing.json new file mode 100644 index 0000000..7ae8a7c --- /dev/null +++ b/messages/en/billing.json @@ -0,0 +1,250 @@ +{ + "crumb": "Billing", + "title": "Billing overview", + "subtitle": "Completed orders, confirmed milestones and approved daily reports for your accounting. No prices, no invoices.", + "hm": "{value} h", + "tabs": { "open": "Open", "billed": "Billed", "voided": "Voided" }, + "filter": { + "title": "Filter", + "q": "Search", + "qPlaceholder": "Order number or customer", + "from": "Period from", + "to": "Period to", + "customer": "Customer", + "team": "Team", + "kind": "Type", + "any": "All", + "apply": "Apply", + "reset": "Reset" + }, + "list": { + "noPermission": "The billing overview is not enabled for your role.", + "empty": { + "open": "Nothing open – all entries are billed.", + "billed": "No entries billed yet.", + "voided": "No voided entries." + }, + "selectHint": "Select entries to print them together.", + "print": "Print billing sheets", + "select": "Selection", + "selectRow": "Select {number}", + "order": "Order", + "customerSite": "Customer / site", + "section": "Type / section", + "period": "Period", + "work": "Working time", + "travel": "Travel time", + "trips": "Trips", + "material": "Material", + "readySince": "Ready since", + "billedAt": "Billed on", + "voidedAt": "Voided on", + "pending": "{value} h pending times", + "invoice": "Invoice {number}", + "pagination": "Pages", + "prev": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" + }, + "kind": { "order_completion": "Order completion", "milestone": "Milestone", "daily_report": "Daily report" }, + "status": { "open": "Open", "billed": "Billed", "voided": "Voided" }, + "milestoneStatus": { "open": "Open", "reached": "Reported as reached", "confirmed": "Confirmed", "billed": "Billed" }, + "detail": { + "back": "Back to billing overview", + "title": "Billing sheet {number}", + "markBilled": "Mark as billed", + "pdf": "PDF", + "print": "Print view", + "changeInvoice": "Change invoice number", + "void": "Void", + "openOrder": "Open order", + "photos": "Photos", + "report": "Report {number} · v{version}", + "done": { + "billed": "Marked as billed. The statement is frozen, the PDF is being created.", + "voided": "Voided. The positions are free again – this is the new open entry.", + "invoice": "Invoice number saved." + }, + "pdfPending": "The PDF billing sheet is being created.", + "pdfRequest": "Request PDF again", + "pdfRequested": "PDF creation requested.", + "voidedInfo": "Voided on {at} by {by}" + }, + "bill": { + "title": "Mark as billed", + "sub": "Your accounting issues the invoice outside of Craftvia.", + "submit": "Billed", + "pending": "Saving …", + "invoiceNumber": "Invoice number (optional)", + "invoiceHint": "Number from your accounting, if already available.", + "pendingWarning": "{value} h pending times ({count}) are not approved yet and not included.", + "confirmPending": "Bill anyway – pending times stay excluded", + "freezeHint": "The statement is frozen; included times and materials cannot be billed a second time." + }, + "invoice": { "title": "Change invoice number", "submit": "Save" }, + "void": { + "title": "Void billing", + "sub": "Times and material become free again, a new open entry is created.", + "submit": "Void", + "pending": "Voiding …", + "reason": "Reason" + }, + "print": { + "back": "Back to overview", + "count": "{count, plural, =1 {1 billing sheet} other {# billing sheets}}", + "print": "Print", + "empty": "No entries selected." + }, + "tab": { + "milestones": "Milestones", + "milestonesHint": "Confirm reached milestones – they then appear in the billing overview.", + "noMilestonesHint": "Without milestones, every approved daily report of a multi-day order becomes a billable section.", + "noMilestones": "No milestones defined.", + "reachedBy": "Reported as reached by {name} on {at}", + "confirmedBy": "Confirmed by {name} on {at}", + "rejected": "Last rejected: {reason}", + "up": "Move up", + "down": "Move down", + "delete": "Delete", + "confirm": "Confirm", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "add": "Add milestone", + "newTitle": "Title", + "newTitlePlaceholder": "e.g. rough installation completed", + "newDescription": "Description", + "records": "Billing entries", + "noRecords": "No billing entries yet.", + "open": "Open" + }, + "mobile": { + "title": "Milestones", + "rejected": "Rejected by the office: {reason}", + "waiting": "Reported – waiting for confirmation by the office.", + "queued": "Saved – will be sent as soon as there is a connection.", + "sent": "Reported as reached.", + "note": "Note", + "notePlaceholder": "Note (optional)", + "reachAria": "Report {title} as reached", + "confirmReach": "Report now", + "reach": "Report as reached", + "cancel": "Cancel" + }, + "sheet": { + "title": "Billing sheet", + "dailyReportOf": "Daily report of {date}", + "period": "Period", + "status": "Status", + "invoiceNumber": "Invoice number", + "noAccounting": "Statement for your accounting – not an invoice, no prices." + }, + "section": { + "order": "Order data", + "time": "Times", + "trips": "Trips", + "material": "Material", + "texts": "Additional work, deviations, open items", + "reports": "Reports and signature" + }, + "field": { + "customer": "Customer", + "customerNumber": "Customer number", + "billingAddress": "Billing address", + "site": "Site", + "orderNumber": "Order number", + "externalOrderNumber": "External order number", + "offerNumber": "Offer number", + "title": "Order", + "orderType": "Order type", + "billingType": "Billing type", + "team": "Team", + "section": "Section", + "workDates": "Work days", + "billedAt": "Billed", + "billingNotes": "Customer billing notes" + }, + "billingType": { "fixed": "Fixed price", "time_material": "Time and material", "maintenance_contract": "Maintenance contract", "warranty": "Warranty" }, + "time": { + "hm": "{value} h", + "empty": "No approved times in this section.", + "person": "Person", + "work": "Work", + "travel": "Travel time", + "materialProcurement": "Material procurement", + "total": "Total", + "breaks": "Breaks (info)", + "manual": "of which recorded manually (approved): {value} h", + "sum": "Total", + "breaksInfo": "Travel time = outbound + return travel. Breaks are listed for information only and are not part of the totals.", + "pendingHint": "{value} h pending ({count} entries), not approved yet – not included.", + "runningHint": "A time entry is still running and is not included." + }, + "trips": { + "count": "{count, plural, =0 {No trip} =1 {1 trip} other {# trips}}", + "rule": "A crew travelling together counts as one trip." + }, + "material": { + "name": "Description", + "articleNumber": "Item no.", + "quantity": "Quantity", + "planned": "Planned", + "status": "Status", + "reason": "Reason for deviation", + "undocumented": "Not recorded", + "empty": "No material in this section.", + "used": "Material used", + "additional": "Additional material", + "notUsed": "Planned items not used", + "notUsedInfo": "For information only." + }, + "materialStatus": { "fully_used": "Fully used", "partially_used": "Partially used", "not_used": "Not used", "additional": "Additional" }, + "texts": { + "fromReport": "({number})", + "empty": "No details in the reports.", + "additionalWork": "Additional work", + "deviations": "Deviations", + "openItems": "Open items" + }, + "reports": { + "empty": "No approved report in this section.", + "number": "Report", + "type": "Type", + "date": "Date", + "approvedAt": "Approved", + "signature": "Signature", + "noSignature": "No signature" + }, + "reportType": { "daily": "Daily report", "completion": "Completion report" }, + "outcome": { "signed": "Signed", "customer_absent": "Customer absent", "refused": "Signature refused", "later": "Signature to follow", "not_required": "Not required" }, + "pdf": { + "documentTitle": "Billing sheet {number} – {kind}", + "page": "Page {page} of {pages}", + "recordId": "Entry ID", + "created": "Created" + }, + "errors": { + "not_found": "Not found or no permission.", + "forbidden": "You are not allowed to do this.", + "invalid": "Please check your input.", + "conflict": "The entry has changed in the meantime. Reload the page.", + "blocked": "Not possible yet.", + "internal": "Action not possible.", + "validation_failed": "Please check the required fields.", + "billing_record_not_found": "Billing entry not found.", + "billing_record_not_open": "The entry is no longer open.", + "billing_record_not_billed": "The entry is not billed.", + "pending_time_entries": "There are pending times. Please confirm that they are excluded.", + "positions_already_billed": "Some positions have been billed elsewhere in the meantime. Reload the page.", + "milestone_not_found": "Milestone not found.", + "milestone_not_open": "Only open milestones can be changed.", + "milestone_not_confirmable": "This milestone cannot be confirmed.", + "milestone_not_reached": "Only reported milestones can be rejected.", + "not_editable": "The order can no longer be changed in this status.", + "work_order_not_found": "Order not found.", + "work_order_status": "This is no longer possible for this order.", + "transition_blocked": "The order status cannot be reset – details are missing.", + "transition_forbidden": "You are not allowed to perform this status change.", + "reason_required": "Please enter a reason.", + "version_conflict": "The order has changed in the meantime. Reload the page." + } +} diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 3f36189..46c5514 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -17,7 +17,8 @@ "emergency_new": "New emergencies", "missing_signatures": "Missing signatures", "sync_conflicts": "Sync conflicts", - "time_approvals": "Time approvals" + "time_approvals": "Time approvals", + "billing_ready": "Ready for billing" }, "hints": { "open": "Not yet billed or cancelled", @@ -31,7 +32,8 @@ "emergency_new": "New or in review", "missing_signatures": "Signature pending", "sync_conflicts": "Review offline changes", - "time_approvals": "Review added time and corrections" + "time_approvals": "Review added time and corrections", + "billing_ready": "Open entries of the billing overview" }, "open": "Show" } diff --git a/messages/en/modules.json b/messages/en/modules.json index 02e47ae..eeab815 100644 --- a/messages/en/modules.json +++ b/messages/en/modules.json @@ -14,5 +14,6 @@ "crumb": "Module", "status": "Module under construction", "hint": "This area is currently being built. Navigation, module activation and permissions are already in place." - } + }, + "billing": "Billing overview" } diff --git a/messages/en/nav.json b/messages/en/nav.json index 765663c..5c2e9dd 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -20,5 +20,6 @@ "admin": "Admin console", "openMenu": "Open menu", "closeMenu": "Close menu", - "timeApprovals": "Time approvals" + "timeApprovals": "Time approvals", + "billing": "Billing" } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 310a3ce..b0db3c4 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -51,7 +51,10 @@ "time_rejected": "Time rejected", "planning_capacity_freed": "Finished early", "planning_overrun": "Planned duration exceeded", - "planning_followup_at_risk": "Follow-up order at risk" + "planning_followup_at_risk": "Follow-up order at risk", + "milestone_reached": "Milestone reached", + "milestone_confirmed": "Milestone confirmed", + "milestone_rejected": "Milestone rejected" }, "events": { "work_order_assigned": { @@ -145,6 +148,18 @@ "planning_followup_at_risk": { "title": "Follow-up order at risk: {number}", "message": "{title} – {customer} ({team}) is at risk due to the delay of {blocker}." + }, + "milestone_reached": { + "title": "Milestone reached: {number}", + "message": "{actor} reports a milestone of {number} as reached. Please confirm." + }, + "milestone_confirmed": { + "title": "Milestone confirmed: {number}", + "message": "{actor} confirmed your reported milestone of {number}." + }, + "milestone_rejected": { + "title": "Milestone rejected: {number}", + "message": "{actor} rejected your reported milestone of {number}: {reason}" } }, "fallback": { diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index b290e0e..8481f68 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -156,7 +156,8 @@ "notes": "Notes", "reports": "Reports", "documents": "Documents", - "history": "History" + "history": "History", + "billing": "Milestones & billing" } }, "overview": { diff --git a/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql b/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql new file mode 100644 index 0000000..5f8b3e5 --- /dev/null +++ b/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql @@ -0,0 +1,100 @@ +-- CreateEnum +CREATE TYPE "MilestoneStatus" AS ENUM ('open', 'reached', 'confirmed', 'billed'); + +-- CreateEnum +CREATE TYPE "BillingRecordKind" AS ENUM ('order_completion', 'milestone', 'daily_report'); + +-- CreateEnum +CREATE TYPE "BillingRecordStatus" AS ENUM ('open', 'billed', 'voided'); + +-- AlterTable +ALTER TABLE "material_usages" ADD COLUMN "billing_record_id" TEXT; + +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "billing_record_id" TEXT; + +-- CreateTable +CREATE TABLE "work_order_milestones" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "status" "MilestoneStatus" NOT NULL DEFAULT 'open', + "reached_by_id" TEXT, + "reached_at" TIMESTAMP(3), + "reached_note" TEXT, + "confirmed_by_id" TEXT, + "confirmed_at" TIMESTAMP(3), + "rejected_by_id" TEXT, + "rejected_at" TIMESTAMP(3), + "rejection_reason" TEXT, + "billing_record_id" TEXT, + "created_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "work_order_milestones_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "billing_records" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "kind" "BillingRecordKind" NOT NULL, + "milestone_id" TEXT, + "report_id" TEXT, + "period_from" TIMESTAMP(3) NOT NULL, + "period_to" TIMESTAMP(3) NOT NULL, + "status" "BillingRecordStatus" NOT NULL DEFAULT 'open', + "snapshot" JSONB, + "pdf_document_id" TEXT, + "pdf_checksum" TEXT, + "invoice_number" TEXT, + "billed_by_id" TEXT, + "billed_at" TIMESTAMP(3), + "voided_by_id" TEXT, + "voided_at" TIMESTAMP(3), + "void_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "billing_records_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "work_order_milestones_tenant_id_work_order_id_idx" ON "work_order_milestones"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "work_order_milestones_tenant_id_status_idx" ON "work_order_milestones"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "billing_records_tenant_id_status_created_at_idx" ON "billing_records"("tenant_id", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "billing_records_tenant_id_work_order_id_idx" ON "billing_records"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "material_usages_tenant_id_billing_record_id_idx" ON "material_usages"("tenant_id", "billing_record_id"); + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_billing_record_id_idx" ON "time_entries"("tenant_id", "billing_record_id"); + +-- AddForeignKey +ALTER TABLE "work_order_milestones" ADD CONSTRAINT "work_order_milestones_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "billing_records" ADD CONSTRAINT "billing_records_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- L14 Abrechnungsübersicht: Mandantentrennung (RLS) für die neuen Tabellen +SELECT enable_tenant_rls('work_order_milestones'); +SELECT enable_tenant_rls('billing_records'); + +-- Höchstens ein nicht stornierter Abrechnungseintrag je Quelle (Stornierung legt einen neuen offenen Eintrag an) +CREATE UNIQUE INDEX "billing_records_order_completion_active_key" ON "billing_records"("tenant_id", "work_order_id") WHERE "kind" = 'order_completion' AND "status" <> 'voided'; +CREATE UNIQUE INDEX "billing_records_milestone_active_key" ON "billing_records"("tenant_id", "milestone_id") WHERE "kind" = 'milestone' AND "status" <> 'voided'; +CREATE UNIQUE INDEX "billing_records_daily_report_active_key" ON "billing_records"("tenant_id", "report_id") WHERE "kind" = 'daily_report' AND "status" <> 'voided'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41b4dfa..7a2ff19 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -750,6 +750,8 @@ model WorkOrder { voiceNotes VoiceNote[] reports Report[] documents Document[] + milestones WorkOrderMilestone[] // L14 + billingRecords BillingRecord[] // L14 @@unique([tenantId, number]) @@index([tenantId, status]) @@ -872,6 +874,8 @@ model MaterialUsage { photoId String? @map("photo_id") recordedById String? @map("recorded_by_id") clientId String? @map("client_id") // offline local id + /// L14: billed billing record containing this position (set on „abgerechnet", cleared on void) + billingRecordId String? @map("billing_record_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -879,6 +883,7 @@ model MaterialUsage { materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull) workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull) + @@index([tenantId, billingRecordId]) @@index([tenantId, workOrderId]) @@unique([tenantId, clientId]) @@map("material_usages") @@ -966,11 +971,14 @@ model TimeEntry { /// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval pendingChange Json? @map("pending_change") note String? + /// L14: billed billing record containing this entry (set on „abgerechnet", cleared on void) + billingRecordId String? @map("billing_record_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade) + @@index([tenantId, billingRecordId]) @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) @@index([tenantId, approvalStatus]) @@ -1341,3 +1349,87 @@ model AiGeneration { @@index([tenantId, entityType, entityId]) @@map("ai_generations") } + +// ---------- L14 Abrechnungsübersicht (keine Buchhaltung: keine Preise/Beträge/Steuern) ---------- + +enum MilestoneStatus { + open + reached // reported by technician / team lead + confirmed // confirmed by back office → billing record + billed +} + +enum BillingRecordKind { + order_completion + milestone + daily_report +} + +enum BillingRecordStatus { + open + billed + voided +} + +/// Optional billing section of a work order (defined by back office, reported by the field, confirmed by back office). +model WorkOrderMilestone { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + title String + description String? + sortOrder Int @default(0) @map("sort_order") + status MilestoneStatus @default(open) + reachedById String? @map("reached_by_id") + reachedAt DateTime? @map("reached_at") + reachedNote String? @map("reached_note") + confirmedById String? @map("confirmed_by_id") + confirmedAt DateTime? @map("confirmed_at") + rejectedById String? @map("rejected_by_id") + rejectedAt DateTime? @map("rejected_at") + rejectionReason String? @map("rejection_reason") + /// billed record this milestone belongs to (plain id, no FK) + billingRecordId String? @map("billing_record_id") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + + @@index([tenantId, workOrderId]) + @@index([tenantId, status]) + @@map("work_order_milestones") +} + +/// Billing overview entry: an order completion, a confirmed milestone or an approved daily report that the +/// accounting (outside Craftvia) can invoice. `snapshot` freezes the statement when marked as billed. +/// Partial unique indexes (SQL, migration `*_abrechnung`): one non-voided record per source. +model BillingRecord { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + kind BillingRecordKind + milestoneId String? @map("milestone_id") + reportId String? @map("report_id") + periodFrom DateTime @map("period_from") + periodTo DateTime @map("period_to") + status BillingRecordStatus @default(open) + snapshot Json? + pdfDocumentId String? @map("pdf_document_id") + pdfChecksum String? @map("pdf_checksum") + invoiceNumber String? @map("invoice_number") + billedById String? @map("billed_by_id") + billedAt DateTime? @map("billed_at") + voidedById String? @map("voided_by_id") + voidedAt DateTime? @map("voided_at") + voidReason String? @map("void_reason") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + + @@index([tenantId, status, createdAt]) + @@index([tenantId, workOrderId]) + @@map("billing_records") +} 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/lib/demo-seed.ts b/scripts/lib/demo-seed.ts index 995ee55..911b1d5 100644 --- a/scripts/lib/demo-seed.ts +++ b/scripts/lib/demo-seed.ts @@ -273,20 +273,8 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis await assign(id, nord, [users.tech2]); await transitionWorkOrder(tech2, { workOrderId: id, to: "accepted" }); }); - await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => { - await assign(id, sued, [users.tech3]); - await travel(tech3, id, day(0, 8, 30)); - }); - await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => { - await assign(id, nord, [users.tech]); - await travel(tech, id, day(0, 6, 45)); - await work(tech, id, day(0, 7, 10)); - await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" }); - await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen."); - await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden."); - await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht"); - await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" }); - }); + // DEMO-06/DEMO-07 (laufende Uhren von Sven Südfeld und Max Monteur) werden erst nach DEMO-19 angelegt: seit + // L12 darf ein Nutzer nur eine laufende Uhr haben – spätere Einsätze derselben Monteure würden sonst kollidieren. await order({ ext: "DEMO-08", customer: "K-D002", site: "Filiale Eppendorf", title: "Undichte Leitung unter der Theke", orderTypeId: type("reparatur"), plannedStart: day(0, 7), plannedEnd: day(0, 11) }, async (id) => { await assign(id, sued, [users.tech3]); await work(tech3, id, day(0, 7, 5)); @@ -376,6 +364,22 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis await markBilled(bo, { workOrderId: id }); }); + // Heute laufende Uhren (nach allen vergangenen Einsätzen derselben Monteure, s. o.) + await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => { + await assign(id, sued, [users.tech3]); + await travel(tech3, id, day(0, 8, 30)); + }); + await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => { + await assign(id, nord, [users.tech]); + await travel(tech, id, day(0, 6, 45)); + await work(tech, id, day(0, 7, 10)); + await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" }); + await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen."); + await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden."); + await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht"); + await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" }); + }); + // Notdienst mit vorläufigem Kunden (feste Client-IDs → idempotent) const emergencyExisting = await bo.db.workSession.findFirst({ where: { clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020" }, select: { workOrderId: true } }); if (!emergencyExisting) { diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index b7a802a..0a9cbfd 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" }, @@ -165,6 +185,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] }, @@ -200,6 +225,10 @@ function plans(x: Awaited>): Plan[] { { path: "/planning", mustNotContain: ["Team Nord", x.d07.number] }, { path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] }, { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, 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"); diff --git a/src/app/(app)/billing/[id]/page.tsx b/src/app/(app)/billing/[id]/page.tsx new file mode 100644 index 0000000..ec31e60 --- /dev/null +++ b/src/app/(app)/billing/[id]/page.tsx @@ -0,0 +1,186 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ArrowLeft, CheckCircle2, ClipboardList, FileDown, Images, Printer } from "lucide-react"; +import { Modal } from "@/components/modal"; +import { BillingActionForm } from "@/components/billing/action-form"; +import { SCREEN_THEME, StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document"; +import { KindLabel, RecordStatusBadge } from "@/components/billing/ui"; +import { buttonCls } from "@/components/work-orders/button-cls"; +import { Field, inputCls, Section } from "@/components/work-orders/ui"; +import { formatHm } from "@/lib/billing/statement"; +import { formatDateTime } from "@/lib/work-orders/time"; +import { requirePageContext } from "@/server/api/context"; +import { markBilledAction, requestBillingPdfAction, updateInvoiceNumberAction, voidBillingAction } from "@/server/actions/billing/billing"; +import { ServiceError } from "@/server/services/context"; +import { getBillingRecordDetail } from "@/server/services/billing/queries"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; + +type SP = Record; +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + +/** `/billing/[id]` — statement (§3.3) with actions: mark billed, PDF, invoice number, void. */ +export default async function BillingDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise }) { + const { id } = await params; + const sp = await searchParams; + const ctx = await requirePageContext("billing"); + if (!ctx.permissions.has("billing:read")) notFound(); + let detail; + try { + detail = await getBillingRecordDetail(ctx, id); + } catch (err) { + if (err instanceof ServiceError && err.code === "not_found") notFound(); + throw err; + } + const [t, tc, locale, tz] = await Promise.all([getTranslations("billing"), getTranslations("common"), getLocale(), tenantTimezone(ctx)]); + const tr: Translate = (key, values) => t(key as never, values as never); + const { record, statement: s } = detail; + const canWrite = ctx.permissions.has("billing:write"); + const self = `/billing/${record.id}`; + const showBill = canWrite && record.status === "open" && one(sp.bill) === "1"; + const showInvoice = canWrite && record.status === "billed" && one(sp.invoice) === "1"; + const showVoid = canWrite && record.status === "billed" && one(sp.void) === "1"; + const done = one(sp.done); + + return ( +
+
+ + + {t("detail.back")} + + +
+
+
+

{s.workOrder.number}

+

{t("detail.title", { number: s.workOrder.number })}

+

+ {s.customer.name} + {s.site && · {s.site.name}} +

+
+ + +
+
+
+ {canWrite && record.status === "open" && ( + + + {t("detail.markBilled")} + + )} + {record.pdfDocumentId ? ( + + + {t("detail.pdf")} + + ) : null} + + + {t("detail.print")} + + {canWrite && record.status === "billed" && ( + <> + + {t("detail.changeInvoice")} + + + {t("detail.void")} + + + )} +
+
+
+ + + {t("detail.openOrder")} + + + + {t("detail.photos")} + + {s.reports.map((r) => ( + + {t("detail.report", { number: r.number ?? "—", version: r.version })} + + ))} +
+
+ + {done && ( +

+ + {t(`detail.done.${done === "voided" ? "voided" : done === "invoice" ? "invoice" : "billed"}`)} +

+ )} + {record.status === "billed" && !record.pdfDocumentId && ( +
+

{t("detail.pdfPending")}

+ {canWrite && + + } +
+ )} + {record.status === "voided" && ( +
+

+ {t("detail.voidedInfo", { at: formatDateTime(record.voidedAt, locale, tz), by: record.voidedByName ?? "—" })} +

+ {record.voidReason &&

{record.voidReason}

} +
+ )} +
+ +
+