Merge lane/abrechnung in feature/craftvia-mvp
Konflikte gelöst (additiv): Benachrichtigungstexte (Planung + Meilensteine), Event-Typen, Navigation, Job-Queues/Prozessoren (geocode-site, planning-watch, billing-pdf), OpenAPI-Tags, Smoke-Prüfungen (zweiter Mandant: Planung + Abrechnung). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Lane L14 – Abrechnungsübersicht (`lane/abrechnung`)
|
||||
|
||||
Stand: 2026-09-15 · Basis `5ea23a1` (`feature/craftvia-mvp`, L1–L12 integriert) · Spec §10.3, §13, §16/§17, §35
|
||||
|
||||
Ziel: Abgeschlossene Aufträge und erreichte Meilensteine laufen in einer **Abrechnungsübersicht** auf, damit die Buchhaltung **außerhalb von Craftvia** die Rechnungen stellt. **Keine Buchhaltung:** keine Preise, Beträge, Steuern, Rechnungserstellung, Zahlungen, kein Export in Buchhaltungsformate (kein CSV).
|
||||
|
||||
## 1. Umfang
|
||||
|
||||
| Punkt | Umsetzung |
|
||||
|---|---|
|
||||
| Datenmodell | Migration `20260915150000_abrechnung_uebersicht`: Tabellen `work_order_milestones` (`WorkOrderMilestone`: Titel, Beschreibung, Reihenfolge, Status `open/reached/confirmed/billed`, gemeldet/bestätigt/abgelehnt von–am, Notiz, Ablehnungsgrund, `billingRecordId`, Soft Delete) und `billing_records` (`BillingRecord`: Art `order_completion/milestone/daily_report`, Quelle `milestoneId`/`reportId`, Zeitraum, Status `open/billed/voided`, `snapshot` Json, PDF-Dokument + Prüfsumme, Rechnungsnummer, abgerechnet/storniert von–am, Stornogrund). Beide mit `enable_tenant_rls`, in beiden `TENANT_MODELS`-Listen, Personenfelder in `pii-fields.ts`. **Partial-Unique-Indizes** (SQL): höchstens ein nicht stornierter Eintrag je Auftragsabschluss / Meilenstein / Tagesbericht. L12-Modelle: je **eine** nullable Spalte `billing_record_id` + Index an `time_entries` und `material_usages` (keine FK). |
|
||||
| Entstehung (`services/billing/candidates.ts#syncBillingCandidates`) | Idempotent, `INSERT … ON CONFLICT DO NOTHING` (hält auch eine umgebende Transaktion intakt). **Event-Weg:** `emitEvent` ruft nach der Benachrichtigung einen Billing-Hook für `work_order.released_for_billing` und `report.approved` auf – Abrechnungsfreigabe (L2) und Berichtsfreigabe (L5) bleiben unverändert. Meilenstein-Bestätigung ruft den Sync in derselben Transaktion. `scripts/billing-backfill.ts` für Bestandsdaten (abgerechnete Aufträge bekommen bewusst keinen offenen Eintrag). |
|
||||
| Zeitraum-Regeln | **Auftragsabschluss:** vom Ende des letzten abgerechneten Abschnitts (sonst erste dokumentierte Tätigkeit/Auftragsanlage) bis zur Freigabe; enthält **alles noch nicht Abgerechnete** des Auftrags. **Meilenstein:** gleicher Beginn bis **Bestätigungszeitpunkt**; enthält alle noch nicht abgerechneten Positionen, die vor der Bestätigung erfasst wurden. **Tagesbericht** (nur Aufträge **ohne** definierte Meilensteine): Kalendertag des Berichts in der Mandanten-Zeitzone; eine neue Berichtsversion ersetzt den Bericht eines noch offenen Eintrags. Positionen = Zeiteinträge (nach `startedAt`) und Materialerfassungen (nach `createdAt`) mit `billingRecordId = null`. |
|
||||
| Aufstellung (`services/billing/statement.ts`) | Kopf: Mandant (Name/Adresse wie Bericht; Logo wie im Bericht noch ohne Dokument), Kunde (Nr., Name, Rechnungsadresse = Kundenadresse, **Abrechnungshinweise** `billingNotes`), Objekt, Auftrag (Nr., externe Nr., Angebotsnr., Titel, Auftragsart, Abrechnungsart, Team), Abschnitt (Art, Meilenstein-Titel/Berichtsdatum, Zeitraum, Einsatztage). **Zeiten** je Person: Arbeit, Fahrzeit (Anfahrt + Rückfahrt), Materialbeschaffung, Summe; Pausen und Unterbrechungen nur informativ, nicht summiert; Kennzeichen „manuell nachgetragen (freigegeben)“. Nur `approved` + beendete Einträge; offene Nachträge als Hinweis „x h offen, nicht enthalten“, laufende Einträge als Hinweis. **Anfahrten:** Anzahl + Datumsliste. **Material:** verwendet, zusätzlich (hervorgehoben), nicht verwendete Planpositionen (informativ), Abweichungsgründe. **Texte** Zusatzleistungen/Abweichungen/offene Restarbeiten aus freigegebenen Berichten des Abschnitts; **Berichtsreferenzen** (Nr./Version/Datum/Freigabe) mit Unterschrift. |
|
||||
| Anfahrten-Regel | `lib/billing/statement.ts#countTrips`: je Kalendertag bilden Anfahrts-Segmente (`travel`), die sich zeitlich überschneiden **oder innerhalb von 15 min beginnen**, **eine** Fahrt (Kolonne fährt gemeinsam); eine spätere, getrennte Anfahrt am selben Tag zählt erneut. Rückfahrten zählen nicht als Anfahrt. Keine km. |
|
||||
| Aktionen (`services/billing/records.ts`) | `markBilled` (`billing:write`, Transaktion): Aufstellung neu berechnen → offene Zeiten ohne `confirmPendingExcluded` → `blocked pending_time_entries`; **Snapshot eingefroren**, Rechnungsnummer optional, Positionen per bedingtem `updateMany` zugeordnet (Mengenabgleich → sonst `conflict positions_already_billed`), Meilenstein → `billed`, Auftragsabschluss → Auftrag `billed` über `applyTransition`; Audit; danach PDF-Job. `voidBilling` (Grund Pflicht, nur `billed`): `voided`, Positionen frei, Meilenstein zurück auf `confirmed`, **neuer offener Eintrag** derselben Quelle, Auftrag `billed → released_for_billing`. `updateInvoiceNumber` (nur `billed`, Audit), `requestBillingPdf`. |
|
||||
| Statusmodell | `status.ts` **unverändert** (`billed` bleibt Endstatus in der Übergangstabelle). `transition.ts#applyTransition` hat die interne Option `billingVoid`: nur damit ist `billed → released_for_billing` erlaubt (Recht `work_order:release_billing`, Grund Pflicht, Guards der Freigabe, Statushistorie, Audit, Event). Nicht über `transitionSchema`/API/UI erreichbar. |
|
||||
| PDF | Job `billing-pdf` (Queue in `queues.ts`, Processor-Einzeiler mit `turbopackIgnore`), `services/billing/pdf.ts#generateBillingPdf`: aus dem Snapshot, HTML (`server/pdf/templates/billing.tsx`, gleiche Komponente wie Detail/Druck) → `pdf/render.ts` → `storeFile` Kategorie **`other`**, Titel „Abrechnungsblatt A-… – Art“, Sichtbarkeit `backoffice_only`, am Auftrag/Kunde/Objekt; Prüfsumme am Eintrag; nie ersetzt. Kopf mit Mandant, Fuß mit Eintrag-ID, Erstellt-Datum, Seite x von y. |
|
||||
| Meilensteine (`services/billing/milestones.ts`) | `createMilestone`/`updateMilestone`/`deleteMilestone` (Soft Delete)/`moveMilestone` (`work_order:write`, Auftrag nicht abgerechnet/storniert; ändern/löschen nur `open`), `markMilestoneReached` (`field:execute` + Auftrag im Scope **oder** `work_order:write`; idempotent), `confirmMilestone` / `rejectMilestone` (Grund Pflicht) mit `billing:write`. Events `milestone.reached` → Nutzer mit `billing:write` (In-App), `milestone.confirmed`/`milestone.rejected` → meldende Person (In-App, Grund im Text); Links Backoffice `/work-orders/[id]?tab=billing`, mobil `/m/orders/[id]`. |
|
||||
| Rechte / Modul | `billing:read`, `billing:write` → Backoffice (über den bestehenden Rollenfilter) und Mandantenadmin; keine Feldrolle. Modul `billing` in `lib/modules.ts` (Layout `requireModule`, Actions `server/actions/billing/*` mit `moduleGuard("billing")`). `scripts/sync-role-permissions.ts` zieht Bestandsmandanten nach (lokal +8 Zuordnungen). |
|
||||
| Sync | Op `milestone.reach` (Envelope, `ops.ts` passthrough, Registry `external-ops.ts` → `services/billing/sync-ops.ts`, Zod `lib/billing/schemas.ts#milestoneReachPayload`), additiv/konfliktfrei, idempotent über `clientOpId` und fachlich. |
|
||||
| API | `GET /api/v1/billing`, `GET /api/v1/billing/{id}`, `POST /api/v1/billing/{id}/billed`, `POST /api/v1/billing/{id}/void`, `GET`/`POST /api/v1/work-orders/{id}/milestones`, `POST /api/v1/milestones/{id}/reach|confirm|reject` – `requireApiContext("billing", …)` + `withApi`; OpenAPI-Tag „Abrechnung“. |
|
||||
|
||||
## 2. Screens / Routen
|
||||
|
||||
| Route | Inhalt |
|
||||
|---|---|
|
||||
| `/billing` | Nav „Abrechnung“ (Modul `billing`, `billing:read`). Tabs **Offen** (Standard) · Abgerechnet · Storniert mit Zählern; Filter Zeitraum, Kunde, Team, Art, Suche Auftragsnr./Kunde; Tabelle Auftrag, Kunde/Objekt, Art + Abschnitt, Zeitraum, Arbeitszeit, Fahrzeit, Anfahrten, Materialpositionen, Warnbadge „x h offene Zeiten“ (Text + Icon), „Bereit seit“ (bzw. abgerechnet am + Rechnungsnr. / storniert am + Grund); Mehrfachauswahl → „Abrechnungsblätter drucken“; Paginierung. Ohne Recht: Hinweis. |
|
||||
| `/billing/[id]` | Kopf mit Status/Art, Aktionen „Als abgerechnet markieren“ (Popup: Rechnungsnummer optional, bei offenen Zeiten Warnung + Checkbox), „PDF“ (sobald erzeugt, `/files/<id>`), „Druckansicht“, „Rechnungsnummer ändern“, „Stornieren“ (Popup, Grund Pflicht); Links Auftrag, Fotos, Berichte; Aufstellung als Abrechnungsblatt. |
|
||||
| `/billing/print?ids=…` | Druckansicht (bis 50 Blätter, Seitenumbruch je Blatt, Navigation/Buttons per Print-CSS ausgeblendet), Button „Drucken“. Kein Export. |
|
||||
| `/work-orders/[id]?tab=billing` | Tab „Meilensteine & Abrechnung“: Meilensteine anlegen, nach oben/unten, löschen (offen), Status (Text + Icon), gemeldet/bestätigt von–am, Notiz, letzter Ablehnungsgrund; Bestätigen/Ablehnen (Grund); Liste der Abrechnungseinträge mit Status und Link. |
|
||||
| `/m/orders/[id]` | Abschnitt „Meilensteine“ (nur wenn vorhanden): Status-Badges, großer Button „Erreicht melden“ (optionale Notiz, 48 px), offline über die Outbox („wird übertragen“), Ablehnungsgrund, „wartet auf Bestätigung“. |
|
||||
| `/dashboard` | Kachel „Bereit zur Abrechnung“ (offene Einträge, nur mit `billing:read`) → `/billing`. |
|
||||
|
||||
## 3. Dateien
|
||||
|
||||
**Neu**
|
||||
- `prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql`
|
||||
- `src/lib/billing/{schemas,statement}.ts`
|
||||
- `src/server/services/billing/{candidates,statement,records,milestones,queries,pdf,events,sync-ops,common}.ts`
|
||||
- `src/server/actions/billing/{billing,milestones}.ts`
|
||||
- `src/server/jobs/processors/billing-pdf.ts`, `src/server/pdf/templates/billing.tsx`
|
||||
- `src/app/(app)/billing/{layout,page}.tsx`, `src/app/(app)/billing/[id]/page.tsx`, `src/app/(app)/billing/print/page.tsx`
|
||||
- `src/app/api/v1/billing/route.ts`, `billing/[id]/route.ts`, `billing/[id]/billed/route.ts`, `billing/[id]/void/route.ts`, `work-orders/[id]/milestones/route.ts`, `milestones/[id]/{reach,confirm,reject}/route.ts`
|
||||
- `src/components/billing/{statement-document,work-order-billing-tab,action-form,print-button,ui}.tsx`, `src/components/field/{milestones,milestone-reach-button}.tsx`
|
||||
- `messages/{de,en}/billing.json`
|
||||
- `scripts/test-abrechnung-service.ts`, `scripts/test-abrechnung-sync.ts`, `scripts/billing-backfill.ts`
|
||||
|
||||
**Eingriffe außerhalb von `billing/` (alle additiv, klein)**
|
||||
- `prisma/schema.prisma` (2 Enums-Blöcke + 2 Modelle am Ende, je 1 Spalte + Index an `TimeEntry`/`MaterialUsage`, 2 Relationslisten am Ende von `WorkOrder` – `plannedDurationMinutes`/L13-Felder unberührt)
|
||||
- `src/server/db.ts`, `src/server/backup/topology.ts` (TENANT_MODELS), `src/server/dsgvo/pii-fields.ts`, `src/server/rbac.ts` (2 Rechte), `src/lib/modules.ts` (1 Modul), `src/lib/nav.ts` (1 Eintrag), `src/components/audit-trail.tsx` (2 Labels)
|
||||
- `src/lib/events.ts` (3 Events, entityType `milestone`), `src/server/events.ts` (Billing-Hook nach `handleEvent`, eigener try/catch), `src/server/services/notifications/recipients.ts` (1 Case), `handle-event.ts` (Link `milestone`)
|
||||
- `src/server/services/work-orders/transition.ts` (Option `billingVoid`, s. o.), `src/server/services/work-orders/dashboard.ts` (Zähler `billingReady`), `src/app/(app)/dashboard/page.tsx` (Kachel), `src/app/(app)/work-orders/[id]/page.tsx` (Tab `billing`)
|
||||
- `src/app/(field)/m/(core)/orders/[id]/page.tsx` (1 Import + 1 Zeile `MilestonesSection`)
|
||||
- `src/lib/sync/envelope.ts` (Op-Typ), `src/lib/sync/ops.ts` (passthrough), `src/server/services/sync/external-ops.ts` (Registry), `src/server/jobs/queues.ts` (Queue `billing-pdf`), `src/server/jobs/processors/index.ts` (1 Zeile)
|
||||
- `src/lib/api/openapi.ts` (Tag + 8 Pfade)
|
||||
- `messages/{de,en}/{nav,modules,dashboard,workOrders,notifications}.json` (additive Schlüssel)
|
||||
- Tests: `scripts/test-e2e-tenant-isolation.ts` (Fixture-Zeilen für die 2 neuen Tenant-Modelle), `scripts/test-security-roles.ts` (Proben `billing:read`/`billing:write`), `scripts/smoke-auth.ts` (Abrechnung, Tab, mobil, Mandant 2)
|
||||
- `scripts/lib/demo-seed.ts`: **Seed-Fix** – auf frischer DB brach `prisma db seed` seit L12 ab (`other_session_running`: DEMO-07/DEMO-06 hatten laufende Uhren, spätere vergangene Einsätze derselben Monteure kollidierten). DEMO-06/DEMO-07 werden jetzt nach DEMO-19 angelegt (Inhalt unverändert).
|
||||
|
||||
Keine neuen npm-Abhängigkeiten, keine Stubs.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- `scripts/test-abrechnung-service.ts` – **118 Prüfungen**: Kandidaten je Art (Auftragsabschluss idempotent, über das echte `releaseForBilling`-Event, Partial-Unique-Index, Tagesbericht über `report.approved` ohne Doppel, Zeitraum = Kalendertag, neue Berichtsversion ersetzt, Abschlussbericht erzeugt keinen Abschnitt, Auftrag mit Meilensteinen ohne Tagesbericht-Abschnitt); Anfahrten-Zählung (Kolonne 2 Personen gleichzeitig = 1, zwei Tage = 2, getrennt am selben Tag = 2); Aufstellung (Arbeit/Fahrzeit/Materialbeschaffung je Person, Pausen nicht summiert, manuell-Kennzeichen, Summen, offener Nachtrag nur als Hinweis, abgelehnte/laufende nicht enthalten, Material verwendet/zusätzlich/nicht verwendet, Abrechnungshinweise, Nummern, Berichtstexte, keine Preis-/Betragsfelder); markBilled (Teamleiter/Monteur forbidden, offene Zeiten blocked, Rechnungsnummer, PDF-Job, Auftrag → billed, Positionen zugeordnet, Audit, doppelt → conflict, **Snapshot eingefroren** trotz späterer Änderung, **zweiter Abschnitt enthält abgerechnete Positionen nicht**, Rechnungsnummer ändern); voidBilling (Grund Pflicht, Rechte, nur billed, Positionen frei, neuer offener Eintrag, Auftrag zurück + Statushistorie, genau ein offener Eintrag, zweites Storno conflict); Meilenstein-Flow (Anlegen nur Backoffice, Sortieren, Monteur ohne Scope not_found, melden + Event an Backoffice, idempotent, gemeldet nicht änder-/löschbar, Monteur/Teamleiter können nicht bestätigen, Ablehnen mit Grund + Event/Link an Meldenden, Bestätigen → Eintrag + Event, Zeiten nach Bestätigung nicht enthalten, abgerechnet → Meilenstein billed, nächster Meilenstein ohne abgerechnete Zeiten, Soft Delete, Sichtbarkeit mobil); Liste/Filter/Sortierung/Summen, Detail, `billing:read` für Monteur/Teamleiter forbidden, Dashboard-Kachel; **Mandantentrennung** (Liste, Detail, Aufstellung, abrechnen, stornieren, Rechnungsnummer, Meilenstein anlegen/melden/bestätigen, PDF, Kandidaten-Sync); **PDF-Render-Smoke** (Dokument `other`/`backoffice_only`/Titel, Prüfsumme, Bytes `%PDF`, nicht ersetzt; Skip ohne Chromium – lokal über Google Chrome gerendert).
|
||||
- `scripts/test-abrechnung-sync.ts` – **21 Prüfungen**: Registry/Envelope, Payload-Schema, `milestone.reach` applied + Event, gleiche clientOpId → duplicate, neue clientOpId für gemeldeten Meilenstein → applied ohne Änderung, ungültige Payload → rejected invalid, Meilenstein eines anderen Auftrags → not_found, Monteur ohne Zuweisung / Mandant B → not_found ohne Wirkung, Teamleiter des Teams darf melden; alle 8 API-Operationen dokumentiert und als Route vorhanden.
|
||||
- Angepasst: `test-e2e-tenant-isolation.ts` (163 ✓, inkl. RLS je neuer Tabelle), `test-security-roles.ts` (129 ✓, Matrix-Abdeckung der neuen Rechte).
|
||||
|
||||
**Gate** `npm run gate` (Vordergrund): prisma generate, tsc, lint (0 Fehler, 3 bestehende Warnungen außerhalb L14), build inkl. Modul-Guard-Check (35 Action-Dateien), **69/69 Testskripte grün**.
|
||||
**RLS-Lauf** `RLS_ENFORCED=true npm run test` (`RLS_DATABASE_URL` auf `craftvia_abrechnung`): **69/69 grün**.
|
||||
|
||||
**HTTP-Smoke** (`scripts/smoke-auth.ts`, Dev-Server :3115, frischer Demo-Seed, `sync-role-permissions`, `billing-backfill`): 120 Prüfungen, alle grün (eine Monteur-Prüfung war zunächst falsch formuliert – Seitentexte stehen über die serialisierten Messages immer im HTML – und wurde auf den datengebundenen Eintrag-Link umgestellt, Monteur-Lauf danach 31/31). Neu: Backoffice `/billing`, `?status=billed`, Suche, `/billing/[id]` (Abrechnungsblatt, Anfahrten, Aktion), `/billing/print`, Dashboard-Kachel, Auftrags-Tab mit Meilenstein; Monteur mobil „Meilensteine / Erreicht melden“, `/billing` ohne Recht (Hinweis, kein Eintrag), Detail 404; Mandant 2: Liste ohne Demo-Einträge, Detail 404, Druckansicht leer, Auftrags-Tab 404. Das Abrechnungsblatt wurde zusätzlich als PDF/PNG gerendert und visuell geprüft. Browser-Prüfung mit Anmeldung nicht durchgeführt (keine Passwort-/Token-Eingabe durch den Agenten).
|
||||
|
||||
## 5. Bekannte Lücken / Hinweise
|
||||
|
||||
1. **Bestehender Button „Abgerechnet“ (L2)** im Auftragsdetail (`released_for_billing → billed`) bleibt erhalten; wird er statt der Abrechnungsübersicht benutzt, bleibt der offene Auftragsabschluss-Eintrag stehen (kein Snapshot, keine Positionszuordnung). Empfehlung: Button ausblenden, sobald das Modul `billing` aktiv ist (L2-Datei, hier nicht geändert).
|
||||
2. **Events in Transaktionen:** Der Billing-Hook läuft wie `handleEvent` im Kontext des Aufrufers (bei `inTransaction` innerhalb der Transaktion); Anlage per `ON CONFLICT DO NOTHING`, Fehler werden geloggt. Ein unerwarteter SQL-Fehler im Hook würde eine umgebende Postgres-Transaktion dennoch abbrechen.
|
||||
3. **Partial-Unique-Indizes** stehen nur in der Migration (Prisma kennt sie nicht); ein späteres `prisma migrate diff` zeigt sie nicht an – bei Schema-Squash erhalten.
|
||||
4. **Rechnungsnummer nachträglich:** Spalte + Anzeige aktualisiert; ein bereits erzeugtes PDF bleibt unverändert (unveränderliches Dokument).
|
||||
5. **Recht für Auftragsstatus:** `markBilled`/`voidBilling` eines Auftragsabschlusses ändern den Auftragsstatus und brauchen zusätzlich `work_order:release_billing` (Standardrollen mit `billing:write` haben es; eigene Rollen ohne das Recht erhalten `forbidden`, nichts wird gespeichert).
|
||||
6. **Sync-Op `milestone.reach`** prüft das Modul `billing` nicht (läuft über `/api/v1/sync`, Modul `field`); die mobile Anzeige erscheint nur, wenn Meilensteine existieren.
|
||||
7. **Zeitpunkt der Positionen:** Material wird nach Erfassungszeitpunkt (`createdAt`), Zeiten nach Beginn (`startedAt`) einem Tages-/Meilenstein-Abschnitt zugeordnet; Nachträge nach der Bestätigung eines Meilensteins fallen in den nächsten Abschnitt bzw. den Auftragsabschluss.
|
||||
8. **Mandantenlogo** im Blatt wie im Bericht noch nicht (kein Logo-Dokument).
|
||||
9. **Umgebung:** Demo-Seed auf frischer DB brauchte den Seed-Fix (s. §3). `docs/craftvia/API.md` nicht ergänzt (OpenAPI ist maßgeblich).
|
||||
|
||||
## 6. Migration / Deploy
|
||||
|
||||
`20260915150000_abrechnung_uebersicht` – additiv (3 Enums, 2 Tabellen mit RLS, 2 nullable Spalten + Indizes, 3 Partial-Unique-Indizes, 2 FKs mit `ON DELETE CASCADE` auf `work_orders`). Deploy: `prisma migrate deploy` → `npx tsx scripts/sync-role-permissions.ts` (Nutzer neu anmelden) → `npx tsx scripts/billing-backfill.ts` → Worker neu starten (Queue `billing-pdf`).
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
"admin": "Admin-Konsole",
|
||||
"openMenu": "Menü öffnen",
|
||||
"closeMenu": "Menü schließen",
|
||||
"timeApprovals": "Zeiten zur Freigabe"
|
||||
"timeApprovals": "Zeiten zur Freigabe",
|
||||
"billing": "Abrechnung"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"notes": "Notizen",
|
||||
"reports": "Berichte",
|
||||
"documents": "Dokumente",
|
||||
"history": "Verlauf"
|
||||
"history": "Verlauf",
|
||||
"billing": "Meilensteine & Abrechnung"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
"admin": "Admin console",
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu",
|
||||
"timeApprovals": "Time approvals"
|
||||
"timeApprovals": "Time approvals",
|
||||
"billing": "Billing"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"notes": "Notes",
|
||||
"reports": "Reports",
|
||||
"documents": "Documents",
|
||||
"history": "History"
|
||||
"history": "History",
|
||||
"billing": "Milestones & billing"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -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';
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
});
|
||||
+18
-14
@@ -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) {
|
||||
|
||||
+31
-2
@@ -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<ReturnType<typeof ids>>): 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<ReturnType<typeof ids>>): 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<ReturnType<typeof ids>>): 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<ReturnType<typeof ids>>): 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] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
return prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: A.tenantId,
|
||||
number: `Z-${randomUUID().slice(0, 8)}`,
|
||||
customerId: A.customerId,
|
||||
siteId: A.siteId,
|
||||
title: "Heizungsanlage erneuern",
|
||||
status: status as never,
|
||||
assignedTeamId: A.teamId,
|
||||
billingType: "time_material",
|
||||
createdAt: berlinAt(-10, 8),
|
||||
assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }, { tenantId: A.tenantId, userId: A.users.tech2.id }] },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function sessionOf(A: TenantFixture, workOrderId: string, userId: string) {
|
||||
return prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId, userId, status: "ended", startedAt: berlinAt(-9, 6), endedAt: berlinAt(-9, 7) } });
|
||||
}
|
||||
|
||||
type EntryType = "travel" | "work" | "break" | "material_procurement" | "return_travel" | "interruption";
|
||||
async function entry(A: TenantFixture, sessionId: string, userId: string, type: EntryType, start: Date, end: Date | null, extra: Record<string, unknown> = {}) {
|
||||
return prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: sessionId, userId, type, startedAt: start, endedAt: end, ...extra } });
|
||||
}
|
||||
|
||||
async function approvedReport(A: TenantFixture, workOrderId: string, type: "daily" | "completion", key: string, opts: { lineageId?: string; version?: number; texts?: Record<string, string>; number?: string } = {}) {
|
||||
return prisma.report.create({
|
||||
data: {
|
||||
tenantId: A.tenantId,
|
||||
workOrderId,
|
||||
type,
|
||||
reportDate: dateKeyToDbDate(key),
|
||||
lineageId: opts.lineageId ?? randomUUID(),
|
||||
version: opts.version ?? 1,
|
||||
status: "approved",
|
||||
approvedAt: new Date(),
|
||||
content: { reportNumber: opts.number ?? "B-00001", texts: { workPerformed: "Arbeiten", additionalWork: "", deviations: "", openItems: "", ...opts.texts } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const records = (workOrderId: string) => prisma.billingRecord.findMany({ where: { workOrderId }, orderBy: { createdAt: "asc" } });
|
||||
const notif = (userId: string, type: string, entityId: string) => prisma.notification.findFirst({ where: { userId, type, entityId } });
|
||||
|
||||
runSuite("L14 Abrechnungsübersicht (Service)", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
|
||||
// ======================================================================
|
||||
section("Kandidaten: Auftragsabschluss (idempotent, Event-Weg)");
|
||||
const woDone = await order(A, "released_for_billing");
|
||||
const s1 = await syncBillingCandidates(bo, { workOrderId: woDone.id });
|
||||
const s2 = await syncBillingCandidates(bo, { workOrderId: woDone.id });
|
||||
const doneRecords = await records(woDone.id);
|
||||
ok(s1.created === 1 && s2.created === 0 && doneRecords.length === 1 && doneRecords[0].kind === "order_completion" && doneRecords[0].status === "open", "released_for_billing → genau ein offener Auftragsabschluss (zweiter Lauf legt nichts an)");
|
||||
|
||||
const woRelease = await order(A, "in_review");
|
||||
await approvedReport(A, woRelease.id, "completion", dayKey(-1));
|
||||
await releaseForBilling(bo, { workOrderId: woRelease.id });
|
||||
const releaseRecords = await records(woRelease.id);
|
||||
ok(releaseRecords.length === 1 && releaseRecords[0].kind === "order_completion", "bestehende Abrechnungsfreigabe (L2) erzeugt den Eintrag über das Event work_order.released_for_billing");
|
||||
const dup = await prisma.billingRecord.createMany({ data: [{ tenantId: A.tenantId, workOrderId: woRelease.id, kind: "order_completion", periodFrom: new Date(), periodTo: new Date() }], skipDuplicates: true });
|
||||
ok(dup.count === 0, "Partial-Unique-Index: kein zweiter nicht stornierter Auftragsabschluss je Auftrag");
|
||||
|
||||
section("Kandidaten: Tagesbericht (nur ohne Meilensteine, neue Version ersetzt)");
|
||||
const woDaily = await order(A, "in_progress");
|
||||
const lineage = randomUUID();
|
||||
const daily1 = await approvedReport(A, woDaily.id, "daily", dayKey(-2), { lineageId: lineage });
|
||||
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: daily1.id });
|
||||
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: daily1.id });
|
||||
let dailyRecords = await records(woDaily.id);
|
||||
ok(dailyRecords.length === 1 && dailyRecords[0].kind === "daily_report" && dailyRecords[0].reportId === daily1.id, "freigegebener Tagesbericht → ein Tagesbericht-Eintrag (Event doppelt → kein Doppel)");
|
||||
ok(dailyRecords[0].periodTo.getTime() - dailyRecords[0].periodFrom.getTime() >= 23 * 3600_000 && dailyRecords[0].periodFrom.getTime() === berlinAt(-2, 0).getTime(), "Zeitraum = Kalendertag des Berichts (Mandanten-Zeitzone)");
|
||||
await prisma.report.update({ where: { id: daily1.id }, data: { status: "superseded" } });
|
||||
const daily2 = await approvedReport(A, woDaily.id, "daily", dayKey(-2), { lineageId: lineage, version: 2 });
|
||||
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: daily2.id });
|
||||
dailyRecords = await records(woDaily.id);
|
||||
ok(dailyRecords.length === 1 && dailyRecords[0].reportId === daily2.id, "neue Berichtsversion: kein zweiter Eintrag, offener Eintrag zeigt auf Version 2");
|
||||
const completionOnly = await approvedReport(A, woDaily.id, "completion", dayKey(-1));
|
||||
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: completionOnly.id });
|
||||
ok((await records(woDaily.id)).length === 1, "Abschlussbericht-Freigabe erzeugt keinen Abschnitt (Auftragsabschluss erst bei Abrechnungsfreigabe)");
|
||||
|
||||
const woWithMilestone = await order(A, "in_progress");
|
||||
await createMilestone(bo, { workOrderId: woWithMilestone.id, title: "Rohinstallation" });
|
||||
const dailyM = await approvedReport(A, woWithMilestone.id, "daily", dayKey(-1));
|
||||
await emitEvent(bo, { type: "report.approved", entityType: "report", entityId: dailyM.id });
|
||||
ok((await records(woWithMilestone.id)).length === 0, "Auftrag MIT definierten Meilensteinen: Tagesbericht erzeugt keinen Abschnitt");
|
||||
|
||||
// ======================================================================
|
||||
section("Aufstellung: Zeiten, Anfahrten, Material, Hinweise");
|
||||
ok(countTrips([{ startedAt: berlinAt(-2, 7), endedAt: berlinAt(-2, 7, 30) }, { startedAt: berlinAt(-2, 7, 5), endedAt: berlinAt(-2, 7, 30) }], "Europe/Berlin").count === 1, "Kolonne: 2 Personen mit zeitgleicher Anfahrt = 1 Fahrt");
|
||||
ok(countTrips([{ startedAt: berlinAt(-2, 7), endedAt: berlinAt(-2, 7, 30) }, { startedAt: berlinAt(-1, 7), endedAt: berlinAt(-1, 7, 30) }], "Europe/Berlin").count === 2, "zwei Tage mit Anfahrt = 2 Fahrten");
|
||||
ok(countTrips([{ startedAt: berlinAt(-2, 7), endedAt: berlinAt(-2, 7, 30) }, { startedAt: berlinAt(-2, 13), endedAt: berlinAt(-2, 13, 20) }], "Europe/Berlin").count === 2, "getrennte Anfahrten am selben Tag = 2 Fahrten");
|
||||
|
||||
await prisma.customer.update({ where: { id: A.customerId }, data: { billingNotes: "Rechnung nur mit Bestellnummer 4711" } });
|
||||
const wo = await order(A, "released_for_billing", { externalOrderNumber: "EXT-9", offerNumber: "ANG-3" });
|
||||
const tech = A.users.tech.id;
|
||||
const tech2 = A.users.tech2.id;
|
||||
const st = await sessionOf(A, wo.id, tech);
|
||||
const st2 = await sessionOf(A, wo.id, tech2);
|
||||
// day -2: crew travels together
|
||||
await entry(A, st.id, tech, "travel", berlinAt(-2, 7), berlinAt(-2, 7, 30));
|
||||
await entry(A, st2.id, tech2, "travel", berlinAt(-2, 7, 5), berlinAt(-2, 7, 30));
|
||||
await entry(A, st.id, tech, "work", berlinAt(-2, 7, 30), berlinAt(-2, 12)); // 270
|
||||
await entry(A, st.id, tech, "break", berlinAt(-2, 12), berlinAt(-2, 12, 30)); // 30 info
|
||||
await entry(A, st.id, tech, "work", berlinAt(-2, 12, 30), berlinAt(-2, 15)); // 150
|
||||
await entry(A, st.id, tech, "return_travel", berlinAt(-2, 15), berlinAt(-2, 15, 20)); // 20
|
||||
await entry(A, st2.id, tech2, "work", berlinAt(-2, 7, 30), berlinAt(-2, 11, 30)); // 240
|
||||
// day -1
|
||||
await entry(A, st.id, tech, "travel", berlinAt(-1, 8), berlinAt(-1, 8, 20)); // 20
|
||||
await entry(A, st.id, tech, "work", berlinAt(-1, 8, 20), berlinAt(-1, 10, 20)); // 120
|
||||
await entry(A, st.id, tech, "material_procurement", berlinAt(-1, 10, 20), berlinAt(-1, 10, 50)); // 30
|
||||
await entry(A, st.id, tech, "work", berlinAt(-1, 10, 50), berlinAt(-1, 11, 50), { source: "manual", approvalStatus: "approved", correctionReason: "vergessen" }); // 60 manual
|
||||
const pending = await entry(A, st.id, tech, "work", berlinAt(-1, 12), berlinAt(-1, 13), { source: "manual", approvalStatus: "pending", correctionReason: "nachgetragen" });
|
||||
await entry(A, st.id, tech, "work", berlinAt(-1, 13), berlinAt(-1, 14), { source: "manual", approvalStatus: "rejected", correctionReason: "falsch" });
|
||||
await entry(A, st2.id, tech2, "work", new Date(Date.now() - 30 * 60_000), null); // running
|
||||
const plan1 = await prisma.materialPlan.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Kupferrohr 18 mm", articleNumber: "CU-18", plannedQuantity: new Prisma.Decimal(10), unit: "m" } });
|
||||
await prisma.materialPlan.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Pressfitting", articleNumber: "PF-18", plannedQuantity: new Prisma.Decimal(5), unit: "Stk", sortOrder: 1 } });
|
||||
const plan3 = await prisma.materialPlan.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Dichtband", plannedQuantity: new Prisma.Decimal(1), unit: "Rolle", sortOrder: 2 } });
|
||||
await prisma.materialUsage.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, materialPlanId: plan1.id, name: "Kupferrohr 18 mm", articleNumber: "CU-18", actualQuantity: new Prisma.Decimal(12), unit: "m", usageStatus: "fully_used", deviationReason: "Leitung länger", createdAt: berlinAt(-2, 10) } });
|
||||
await prisma.materialUsage.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, name: "Kugelhahn", articleNumber: "KH-1", actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "additional", deviationReason: "Alter Hahn defekt", createdAt: berlinAt(-1, 9) } });
|
||||
await prisma.materialUsage.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, materialPlanId: plan3.id, name: "Dichtband", actualQuantity: new Prisma.Decimal(0), unit: "Rolle", usageStatus: "not_used", deviationReason: "nicht nötig", createdAt: berlinAt(-1, 9) } });
|
||||
await approvedReport(A, wo.id, "daily", dayKey(-2), { number: "B-00077", texts: { additionalWork: "Zusätzlich Heizkörper entlüftet", deviations: "Leitung 2 m länger", openItems: "Verkleidung folgt" } });
|
||||
|
||||
await syncBillingCandidates(bo, { workOrderId: wo.id });
|
||||
const rec = (await records(wo.id)).find((r) => r.kind === "order_completion")!;
|
||||
const s = await buildBillingStatement(bo, rec.id);
|
||||
const pTech = s.time.persons.find((p) => p.userId === tech)!;
|
||||
const pTech2 = s.time.persons.find((p) => p.userId === tech2)!;
|
||||
ok(pTech.work === 600 && pTech.travel === 70 && pTech.materialProcurement === 30, `Monteur: Arbeit 10:00 h, Fahrzeit (An- + Rückfahrt) 1:10 h, Materialbeschaffung 0:30 h (erhalten ${pTech.work}/${pTech.travel}/${pTech.materialProcurement})`);
|
||||
ok(pTech.breaks === 30 && pTech.total === 700, "Pausen nur informativ ausgewiesen, nicht in der Summe");
|
||||
ok(pTech.manualMinutes === 60, "freigegebener Nachtrag als „manuell nachgetragen“ gekennzeichnet");
|
||||
ok(pTech2.work === 240 && pTech2.travel === 25 && pTech2.total === 265, "zweite Person getrennt ausgewiesen");
|
||||
ok(s.time.totals.total === 965 && s.time.totals.breaks === 30 && s.time.totals.work === 840 && s.time.totals.travel === 95, "Summen je Art und gesamt");
|
||||
ok(s.time.pendingMinutes === 60 && s.time.pendingCount === 1, "offener Nachtrag nicht enthalten, aber als Hinweis (1:00 h offen)");
|
||||
ok(s.time.hasRunningEntries && !s.positions.timeEntryIds.includes(pending.id), "laufende und offene Zeiten sind keine Positionen");
|
||||
ok(s.positions.timeEntryIds.length === 11, `11 freigegebene, beendete Zeit-Positionen (erhalten ${s.positions.timeEntryIds.length})`);
|
||||
ok(s.trips.count === 2 && s.trips.dates.join() === [dayKey(-2), dayKey(-1)].join(), "Anfahrten: Kolonne am Tag −2 = 1 Fahrt, Tag −1 = 1 Fahrt → 2 mit Datumsliste");
|
||||
ok(s.materials.used.length === 1 && s.materials.used[0].quantity === "12" && s.materials.used[0].deviationReason === "Leitung länger", "verwendetes Material mit Menge, Einheit und Abweichungsgrund");
|
||||
ok(s.materials.additional.length === 1 && s.materials.additional[0].name === "Kugelhahn", "zusätzlich verwendetes Material getrennt");
|
||||
ok(s.materials.notUsed.length === 2 && s.materials.notUsed.some((m) => m.name === "Pressfitting" && m.status === null), "nicht verwendete Planpositionen informativ (erfasst „nicht verwendet“ + ohne Erfassung)");
|
||||
ok(s.customer.billingNotes === "Rechnung nur mit Bestellnummer 4711" && s.workOrder.externalOrderNumber === "EXT-9" && s.workOrder.offerNumber === "ANG-3" && s.workOrder.billingType === "time_material", "Kopf: Abrechnungshinweise, externe/Angebotsnummer, Abrechnungsart");
|
||||
ok(s.texts.additionalWork[0]?.text === "Zusätzlich Heizkörper entlüftet" && s.texts.openItems[0]?.reportNumber === "B-00077" && s.reports.length === 1, "Zusatzleistungen/Abweichungen/Restarbeiten aus Berichten + Berichtsreferenz");
|
||||
ok(JSON.stringify(s).match(/price|amount|tax|preis|betrag|steuer/i) === null, "Aufstellung enthält keine Preise, Beträge oder Steuern");
|
||||
|
||||
// ======================================================================
|
||||
section("markBilled: Rechte, offene Zeiten, Snapshot, Positionen, Auftrag → billed");
|
||||
await expectCode(() => markBilled(A.ctx.lead, { recordId: rec.id }, noPdf), "forbidden", "Teamleiter → forbidden");
|
||||
await expectCode(() => markBilled(A.ctx.tech, { recordId: rec.id }, noPdf), "forbidden", "Monteur → forbidden");
|
||||
await expectCode(() => markBilled(bo, { recordId: rec.id }, noPdf), "blocked", "offene Zeiten ohne Bestätigung → blocked (pending_time_entries)");
|
||||
let dispatched = "";
|
||||
const billed = await markBilled(bo, { recordId: rec.id, invoiceNumber: " RE-2026-001 ", confirmPendingExcluded: true }, { dispatchPdf: async (_c, id) => void (dispatched = id) });
|
||||
ok(billed.status === "billed" && billed.invoiceNumber === "RE-2026-001" && !!billed.billedAt && billed.billedById === A.users.backoffice.id, "abgerechnet mit Rechnungsnummer (getrimmt), Zeitpunkt, Person");
|
||||
ok(dispatched === rec.id, "PDF-Job wird angestoßen");
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "billed", "Auftragsabschluss → Auftrag „Abgerechnet“ (über transitionWorkOrder)");
|
||||
ok((await prisma.timeEntry.count({ where: { billingRecordId: rec.id } })) === 11 && (await prisma.timeEntry.findUniqueOrThrow({ where: { id: pending.id } })).billingRecordId === null, "11 Zeit-Positionen zugeordnet, offener Nachtrag bleibt frei");
|
||||
ok((await prisma.materialUsage.count({ where: { billingRecordId: rec.id } })) === 3, "Material-Positionen zugeordnet");
|
||||
ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "billing_record", entityId: rec.id, action: "update" } })), "Audit-Eintrag (before/after)");
|
||||
await expectCode(() => markBilled(bo, { recordId: rec.id, confirmPendingExcluded: true }, noPdf), "conflict", "erneut abrechnen → conflict");
|
||||
|
||||
// later change must not alter the snapshot
|
||||
const firstWork = await prisma.timeEntry.findFirstOrThrow({ where: { billingRecordId: rec.id, type: "work", userId: tech2 } });
|
||||
await prisma.timeEntry.update({ where: { id: firstWork.id }, data: { endedAt: berlinAt(-2, 16) } });
|
||||
await prisma.customer.update({ where: { id: A.customerId }, data: { billingNotes: "geändert" } });
|
||||
const frozen = await buildBillingStatement(bo, rec.id);
|
||||
ok(frozen.time.totals.total === 965 && frozen.customer.billingNotes === "Rechnung nur mit Bestellnummer 4711" && frozen.record.status === "billed", "Snapshot eingefroren: spätere Änderungen an Zeiten/Kunde ändern die Aufstellung nicht");
|
||||
|
||||
// a second section on the same order does not contain billed positions
|
||||
const lateDaily = await approvedReport(A, wo.id, "daily", dayKey(-1));
|
||||
await syncBillingCandidates(bo, { workOrderId: wo.id });
|
||||
const dailyRec = (await records(wo.id)).find((r) => r.kind === "daily_report" && r.reportId === lateDaily.id)!;
|
||||
const dailyStatement = await buildBillingStatement(bo, dailyRec.id);
|
||||
ok(dailyStatement.positions.timeEntryIds.length === 0 && dailyStatement.positions.materialUsageIds.length === 0 && dailyStatement.time.pendingMinutes === 60, "zweiter Abschnitt enthält bereits abgerechnete Positionen nicht (nur den offenen Hinweis)");
|
||||
|
||||
const inv = await updateInvoiceNumber(bo, { recordId: rec.id, invoiceNumber: "RE-2026-002" });
|
||||
ok(inv.invoiceNumber === "RE-2026-002" && (await buildBillingStatement(bo, rec.id)).record.invoiceNumber === "RE-2026-002", "Rechnungsnummer nachträglich änderbar (Audit)");
|
||||
await expectCode(() => updateInvoiceNumber(bo, { recordId: dailyRec.id, invoiceNumber: "X" }), "conflict", "Rechnungsnummer nur für abgerechnete Einträge");
|
||||
await expectCode(() => updateInvoiceNumber(A.ctx.tech, { recordId: rec.id, invoiceNumber: "X" }), "forbidden", "Monteur ändert keine Rechnungsnummer");
|
||||
|
||||
// ======================================================================
|
||||
section("voidBilling: Grund Pflicht, Positionen frei, neuer offener Eintrag, Auftrag zurück");
|
||||
await expectCode(() => voidBilling(bo, { recordId: rec.id, reason: " " }), "invalid", "ohne Grund → invalid");
|
||||
await expectCode(() => voidBilling(A.ctx.lead, { recordId: rec.id, reason: "falsch" }), "forbidden", "Teamleiter → forbidden");
|
||||
await expectCode(() => voidBilling(bo, { recordId: dailyRec.id, reason: "falsch" }), "conflict", "nur abgerechnete Einträge stornierbar");
|
||||
const voided = await voidBilling(bo, { recordId: rec.id, reason: "Rechnung an falschen Empfänger" });
|
||||
ok(voided.voided.status === "voided" && voided.voided.voidReason === "Rechnung an falschen Empfänger" && !!voided.voided.voidedAt, "storniert mit Grund");
|
||||
ok((await prisma.timeEntry.count({ where: { billingRecordId: rec.id } })) === 0 && (await prisma.materialUsage.count({ where: { billingRecordId: rec.id } })) === 0, "Positionen wieder frei");
|
||||
ok(!!voided.reopened && voided.reopened.status === "open" && voided.reopened.kind === "order_completion" && voided.reopened.id !== rec.id, "neuer offener Eintrag für dieselbe Quelle");
|
||||
const woAfterVoid = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
||||
ok(woAfterVoid.status === "released_for_billing", "Auftrag zurück auf „Zur Abrechnung freigegeben“ (nur über Stornierung)");
|
||||
ok(!!(await prisma.workOrderStatusChange.findFirst({ where: { workOrderId: wo.id, fromStatus: "billed", toStatus: "released_for_billing", reason: "Rechnung an falschen Empfänger" } })), "Statushistorie mit Stornogrund");
|
||||
ok((await prisma.billingRecord.count({ where: { workOrderId: wo.id, kind: "order_completion", status: "open" } })) === 1, "genau ein offener Auftragsabschluss nach Stornierung (Event-Hook + Stornierung ohne Doppel)");
|
||||
await expectCode(() => voidBilling(bo, { recordId: rec.id, reason: "nochmal" }), "conflict", "zweites Stornieren → conflict");
|
||||
const reopenedStatement = await buildBillingStatement(bo, voided.reopened!.id);
|
||||
ok(reopenedStatement.positions.timeEntryIds.length === 11, "neuer Eintrag enthält die freigegebenen Positionen wieder");
|
||||
|
||||
// ======================================================================
|
||||
section("Meilensteine: anlegen, melden, bestätigen, ablehnen");
|
||||
const woM = await order(A, "in_progress");
|
||||
await expectCode(() => createMilestone(A.ctx.tech, { workOrderId: woM.id, title: "X" }), "forbidden", "Monteur legt keine Meilensteine an");
|
||||
await expectCode(() => createMilestone(A.ctx.lead, { workOrderId: woM.id, title: "X" }), "forbidden", "Teamleiter legt keine Meilensteine an");
|
||||
await expectCode(() => createMilestone(bo, { workOrderId: woM.id, title: " " }), "invalid", "Titel Pflicht");
|
||||
const m1 = await createMilestone(bo, { workOrderId: woM.id, title: "Rohinstallation", description: "Leitungen verlegt" });
|
||||
const m2 = await createMilestone(bo, { workOrderId: woM.id, title: "Inbetriebnahme" });
|
||||
ok(m1.status === "open" && m2.sortOrder > m1.sortOrder, "Backoffice legt Meilensteine an (Reihenfolge)");
|
||||
await moveMilestone(bo, { milestoneId: m2.id, direction: "up" });
|
||||
ok((await listMilestones(bo, woM.id))[0].id === m2.id, "Meilenstein nach oben sortiert");
|
||||
await moveMilestone(bo, { milestoneId: m2.id, direction: "down" });
|
||||
|
||||
const sm = await sessionOf(A, woM.id, tech);
|
||||
await entry(A, sm.id, tech, "work", berlinAt(-1, 8), berlinAt(-1, 12));
|
||||
await expectCode(() => markMilestoneReached(A.ctx.outsider, { milestoneId: m1.id }), "not_found", "Monteur ohne Auftrag im Scope → not_found");
|
||||
const reached = await markMilestoneReached(A.ctx.tech, { milestoneId: m1.id, note: "Druckprobe ok" });
|
||||
ok(reached.changed && reached.milestone.status === "reached" && reached.milestone.reachedById === tech && reached.milestone.reachedNote === "Druckprobe ok", "Monteur meldet „erreicht“ mit Notiz");
|
||||
ok(!!(await notif(A.users.backoffice.id, "milestone.reached", m1.id)) && !(await notif(tech, "milestone.reached", m1.id)), "Event milestone.reached → Backoffice In-App (nicht der Meldende)");
|
||||
ok(!(await markMilestoneReached(A.ctx.tech, { milestoneId: m1.id })).changed, "erneutes Melden ist idempotent");
|
||||
await expectCode(() => updateMilestone(bo, { milestoneId: m1.id, title: "Neu" }), "invalid", "gemeldeter Meilenstein nicht mehr änderbar");
|
||||
await expectCode(() => deleteMilestone(bo, m1.id), "invalid", "gemeldeter Meilenstein nicht löschbar");
|
||||
await expectCode(() => confirmMilestone(A.ctx.tech, m1.id), "forbidden", "Monteur kann nicht bestätigen");
|
||||
await expectCode(() => confirmMilestone(A.ctx.lead, m1.id), "forbidden", "Teamleiter kann nicht bestätigen");
|
||||
await expectCode(() => rejectMilestone(bo, { milestoneId: m1.id, reason: "" }), "invalid", "Ablehnen ohne Grund → invalid");
|
||||
const rejected = await rejectMilestone(bo, { milestoneId: m1.id, reason: "Druckprotokoll fehlt" });
|
||||
ok(rejected.status === "open" && rejected.rejectionReason === "Druckprotokoll fehlt" && rejected.reachedAt === null, "Ablehnung → wieder offen mit Grund");
|
||||
const rejNotif = await notif(tech, "milestone.rejected", m1.id);
|
||||
ok(!!rejNotif && rejNotif.message.includes("Druckprotokoll fehlt") && rejNotif.link === `/m/orders/${woM.id}`, "Event milestone.rejected → Meldender In-App mit Grund und Link");
|
||||
await expectCode(() => rejectMilestone(bo, { milestoneId: m1.id, reason: "x" }), "conflict", "offener Meilenstein kann nicht abgelehnt werden");
|
||||
|
||||
await markMilestoneReached(A.ctx.tech, { milestoneId: m1.id });
|
||||
const confirmed = await confirmMilestone(bo, m1.id);
|
||||
ok(confirmed.status === "confirmed" && confirmed.confirmedById === A.users.backoffice.id, "Backoffice bestätigt");
|
||||
const mRecords = await records(woM.id);
|
||||
ok(mRecords.length === 1 && mRecords[0].kind === "milestone" && mRecords[0].milestoneId === m1.id && mRecords[0].status === "open", "Bestätigung → offener Meilenstein-Eintrag");
|
||||
ok(!!(await notif(tech, "milestone.confirmed", m1.id)), "Event milestone.confirmed → Meldender In-App");
|
||||
await expectCode(() => confirmMilestone(bo, m1.id), "conflict", "zweites Bestätigen → conflict");
|
||||
await syncBillingCandidates(bo, { workOrderId: woM.id });
|
||||
ok((await records(woM.id)).length === 1, "Kandidaten-Sync legt für den Meilenstein keinen zweiten Eintrag an");
|
||||
const ms = await buildBillingStatement(bo, mRecords[0].id);
|
||||
ok(ms.record.milestoneTitle === "Rohinstallation" && ms.time.totals.work === 240, "Meilenstein-Abschnitt: Titel + Zeiten bis zur Bestätigung");
|
||||
await entry(A, sm.id, tech, "work", new Date(Date.now() + 60_000), new Date(Date.now() + 120 * 60_000));
|
||||
ok((await buildBillingStatement(bo, mRecords[0].id)).time.totals.work === 240, "Zeiten nach dem Bestätigungszeitpunkt gehören nicht zum Meilenstein");
|
||||
await markBilled(bo, { recordId: mRecords[0].id }, noPdf);
|
||||
ok((await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: m1.id } })).status === "billed", "abgerechneter Meilenstein-Eintrag → Meilenstein „Abgerechnet“");
|
||||
await confirmMilestone(bo, m2.id);
|
||||
const m2Record = (await records(woM.id)).find((r) => r.milestoneId === m2.id)!;
|
||||
ok((await buildBillingStatement(bo, m2Record.id)).positions.timeEntryIds.length === 0, "nächster Meilenstein enthält die bereits abgerechneten Zeiten nicht");
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woM.id } })).status === "in_progress", "Meilenstein-Abrechnung ändert den Auftragsstatus nicht");
|
||||
const m3 = await createMilestone(bo, { workOrderId: woM.id, title: "Wird gelöscht" });
|
||||
await deleteMilestone(bo, m3.id);
|
||||
ok(!(await listMilestones(bo, woM.id)).some((m) => m.id === m3.id) && !!(await prisma.workOrderMilestone.findUniqueOrThrow({ where: { id: m3.id } })).deletedAt, "offener Meilenstein soft-gelöscht");
|
||||
ok((await listMilestones(A.ctx.tech, woM.id)).length === 2, "Monteur sieht die Meilensteine seines Auftrags");
|
||||
await expectCode(() => listMilestones(A.ctx.outsider, woM.id), "not_found", "Monteur fremdes Team sieht keine Meilensteine");
|
||||
|
||||
// ======================================================================
|
||||
section("Liste, Detail, Rechte");
|
||||
const openList = await listBillingRecords(bo, { status: "open" });
|
||||
ok(openList.items.some((i) => i.id === voided.reopened!.id) && openList.items.every((i) => i.status === "open"), "Tab Offen listet offene Einträge");
|
||||
const reopenedItem = openList.items.find((i) => i.id === voided.reopened!.id)!;
|
||||
// Monteur 600 + zweite Person 510 (Eintrag nach der ersten Abrechnung auf 7:30–16:00 verlängert, nach Storno wieder frei)
|
||||
ok(reopenedItem.summary.workMinutes === 600 + 510 && reopenedItem.summary.travelMinutes === 95 && reopenedItem.summary.trips === 2 && reopenedItem.summary.materialCount === 2 && reopenedItem.summary.pendingMinutes === 60, `Zeilen-Summen (Arbeitszeit, Anfahrten, Material, offene Zeiten) — erhalten ${JSON.stringify(reopenedItem.summary)}`);
|
||||
const sorted = openList.items.map((i) => i.readySince.getTime());
|
||||
ok(sorted.every((v, i) => i === 0 || sorted[i - 1] <= v), "sortiert nach „bereit seit“");
|
||||
ok((await listBillingRecords(bo, { status: "billed" })).items.some((i) => i.workOrder.id === woM.id), "Tab Abgerechnet");
|
||||
ok((await listBillingRecords(bo, { status: "voided" })).items.some((i) => i.id === rec.id), "Tab Storniert");
|
||||
ok((await listBillingRecords(bo, { status: "open", kind: "milestone" })).items.every((i) => i.kind === "milestone"), "Filter Art");
|
||||
ok((await listBillingRecords(bo, { status: "open", q: wo.number })).items.every((i) => i.workOrder.id === wo.id), "Suche Auftragsnummer");
|
||||
ok((await listBillingRecords(bo, { status: "open", teamId: A.team2Id })).total === 0, "Filter Team");
|
||||
ok((await listBillingRecords(bo, { status: "open", from: dayKey(5) })).items.every((i) => i.periodTo > berlinAt(5, 0)), "Filter Zeitraum");
|
||||
ok((await listWorkOrderBillingRecords(bo, woM.id)).length === 2, "Einträge je Auftrag");
|
||||
const detail = await getBillingRecordDetail(A.ctx.admin, rec.id);
|
||||
ok(detail.record.voidReason === "Rechnung an falschen Empfänger" && detail.statement.record.id === rec.id, "Detail mit Aufstellung (Mandantenadmin)");
|
||||
for (const role of ["tech", "lead", "outsider"] as const) {
|
||||
await expectCode(() => listBillingRecords(A.ctx[role]), "forbidden", `billing:read – ${role} → forbidden`);
|
||||
}
|
||||
await expectCode(() => buildBillingStatement(A.ctx.lead, rec.id), "forbidden", "Aufstellung für Teamleiter → forbidden");
|
||||
ok((await countOpenBillingRecords(bo)) === openList.total && (await countOpenBillingRecords(A.ctx.tech)) === 0, "Zähler „Bereit zur Abrechnung“ (0 ohne Recht)");
|
||||
ok((await getDashboardTiles(bo, {})).billingReady === openList.total, "Dashboard-Kachel zählt offene Einträge");
|
||||
|
||||
// ======================================================================
|
||||
section("Mandantentrennung");
|
||||
const woB = await order(B, "released_for_billing");
|
||||
await syncBillingCandidates(B.ctx.backoffice, { workOrderId: woB.id });
|
||||
const listB = await listBillingRecords(B.ctx.backoffice, { status: "open" });
|
||||
ok(listB.items.length === 1 && listB.items[0].workOrder.id === woB.id, "Liste B enthält nur Einträge von B");
|
||||
ok(!(await listBillingRecords(B.ctx.backoffice, { status: "voided" })).items.some((i) => i.id === rec.id), "stornierter Eintrag von A nicht in B");
|
||||
await expectCode(() => getBillingRecordDetail(B.ctx.backoffice, rec.id), "not_found", "Detail A aus B → not_found");
|
||||
await expectCode(() => buildBillingStatement(B.ctx.backoffice, voided.reopened!.id), "not_found", "Aufstellung A aus B → not_found");
|
||||
await expectCode(() => markBilled(B.ctx.backoffice, { recordId: voided.reopened!.id, confirmPendingExcluded: true }, noPdf), "not_found", "abrechnen A aus B → not_found");
|
||||
await expectCode(() => voidBilling(B.ctx.backoffice, { recordId: mRecords[0].id, reason: "x" }), "not_found", "stornieren A aus B → not_found");
|
||||
await expectCode(() => updateInvoiceNumber(B.ctx.backoffice, { recordId: mRecords[0].id, invoiceNumber: "x" }), "not_found", "Rechnungsnummer A aus B → not_found");
|
||||
await expectCode(() => createMilestone(B.ctx.backoffice, { workOrderId: woM.id, title: "x" }), "not_found", "Meilenstein an Auftrag A aus B → not_found");
|
||||
await expectCode(() => markMilestoneReached(B.ctx.admin, { milestoneId: m2.id }), "not_found", "Meilenstein A melden aus B → not_found");
|
||||
await expectCode(() => confirmMilestone(B.ctx.backoffice, m2.id), "not_found", "Meilenstein A bestätigen aus B → not_found");
|
||||
await expectCode(() => generateBillingPdf(B.ctx.backoffice, mRecords[0].id), "not_found", "PDF von A aus B → not_found");
|
||||
ok((await syncBillingCandidates(B.ctx.backoffice, { workOrderId: woDone.id })).created === 0, "Kandidaten-Sync mit Auftrag von A im Kontext B legt nichts an");
|
||||
ok((await prisma.billingRecord.count({ where: { tenantId: B.tenantId, workOrderId: { in: [wo.id, woM.id, woDone.id] } } })) === 0, "keine Einträge mit Aufträgen von A in B");
|
||||
ok((await countOpenBillingRecords(B.ctx.backoffice)) === 1, "Zähler B unabhängig");
|
||||
|
||||
// ======================================================================
|
||||
section("PDF-Abrechnungsblatt (Render-Smoke)");
|
||||
const { pdfRendererAvailable } = await import("../src/server/pdf/render");
|
||||
const avail = await pdfRendererAvailable();
|
||||
if (!avail.ok) {
|
||||
console.log(`↷ übersprungen: kein Chromium verfügbar (${avail.reason.slice(0, 120)})`);
|
||||
} else {
|
||||
const worker: ServiceCtx = { ...bo, permissions: new Set(["billing:read", "work_order:read_all", "document:read_internal", "document:write"]) };
|
||||
const pdf = await generateBillingPdf(worker, mRecords[0].id);
|
||||
const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdf.documentId } });
|
||||
ok(!pdf.skipped && doc.category === "other" && doc.visibility === "backoffice_only" && (doc.title ?? "").startsWith("Abrechnungsblatt") && doc.workOrderId === woM.id, "PDF als Dokument (Kategorie other, backoffice_only, Titel „Abrechnungsblatt …“, am Auftrag)");
|
||||
const stored = await prisma.billingRecord.findUniqueOrThrow({ where: { id: mRecords[0].id } });
|
||||
ok(stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF-Dokument und Prüfsumme am Eintrag");
|
||||
if (process.env.S3_ENDPOINT) {
|
||||
const { readStoredBytes } = await import("../src/server/services/documents/read");
|
||||
const stored = await readStoredBytes(doc.storageKey);
|
||||
const bytes = stored ? Buffer.from(stored) : Buffer.alloc(0);
|
||||
ok(bytes.subarray(0, 4).toString() === "%PDF" && bytes.length > 1000, "PDF-Bytes beginnen mit %PDF");
|
||||
}
|
||||
ok((await generateBillingPdf(worker, mRecords[0].id)).skipped, "zweiter Lauf ersetzt das PDF nicht");
|
||||
await expectCode(() => generateBillingPdf(worker, voided.reopened!.id), "invalid", "offener Eintrag hat kein PDF");
|
||||
}
|
||||
void codeOf;
|
||||
});
|
||||
@@ -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<string, unknown>, 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<string, unknown> = {}) {
|
||||
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`);
|
||||
}
|
||||
});
|
||||
@@ -133,6 +133,9 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
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<SP> }) {
|
||||
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 (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<div className="no-print">
|
||||
<Link href="/billing" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("detail.back")}
|
||||
</Link>
|
||||
|
||||
<header className="shadow-card mt-1 rounded-xl border border-l-4 bg-card p-4 md:p-5" style={{ borderLeftColor: "var(--ui-primary)" }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs text-muted-foreground">{s.workOrder.number}</p>
|
||||
<h1 className="mt-1 text-[22px] break-words">{t("detail.title", { number: s.workOrder.number })}</h1>
|
||||
<p className="mt-1 text-sm">
|
||||
{s.customer.name}
|
||||
{s.site && <span className="text-muted-foreground"> · {s.site.name}</span>}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<RecordStatusBadge status={record.status} label={t(`status.${record.status}`)} className="text-sm" />
|
||||
<KindLabel kind={record.kind} label={[t(`kind.${record.kind}`), s.record.milestoneTitle].filter(Boolean).join(" · ")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
{canWrite && record.status === "open" && (
|
||||
<Link href={`${self}?bill=1`} scroll={false} className={buttonCls("primary")}>
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{t("detail.markBilled")}
|
||||
</Link>
|
||||
)}
|
||||
{record.pdfDocumentId ? (
|
||||
<a href={`/files/${record.pdfDocumentId}`} target="_blank" rel="noopener noreferrer" className={buttonCls("outline")}>
|
||||
<FileDown className="size-4" aria-hidden />
|
||||
{t("detail.pdf")}
|
||||
</a>
|
||||
) : null}
|
||||
<Link href={`/billing/print?ids=${record.id}`} className={buttonCls("outline")}>
|
||||
<Printer className="size-4" aria-hidden />
|
||||
{t("detail.print")}
|
||||
</Link>
|
||||
{canWrite && record.status === "billed" && (
|
||||
<>
|
||||
<Link href={`${self}?invoice=1`} scroll={false} className={buttonCls("outline")}>
|
||||
{t("detail.changeInvoice")}
|
||||
</Link>
|
||||
<Link href={`${self}?void=1`} scroll={false} className={buttonCls("danger")}>
|
||||
{t("detail.void")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3 text-sm">
|
||||
<Link href={`/work-orders/${s.workOrder.id}?tab=billing`} className={buttonCls("ghost")}>
|
||||
<ClipboardList className="size-4" aria-hidden />
|
||||
{t("detail.openOrder")}
|
||||
</Link>
|
||||
<Link href={`/work-orders/${s.workOrder.id}?tab=photos`} className={buttonCls("ghost")}>
|
||||
<Images className="size-4" aria-hidden />
|
||||
{t("detail.photos")}
|
||||
</Link>
|
||||
{s.reports.map((r) => (
|
||||
<Link key={r.id} href={`/reports/${r.id}`} className={buttonCls("ghost")}>
|
||||
{t("detail.report", { number: r.number ?? "—", version: r.version })}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{done && (
|
||||
<p role="status" className="mt-4 flex items-center gap-2 rounded-lg border border-[var(--ok)] bg-card px-4 py-3 text-sm text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{t(`detail.done.${done === "voided" ? "voided" : done === "invoice" ? "invoice" : "billed"}`)}
|
||||
</p>
|
||||
)}
|
||||
{record.status === "billed" && !record.pdfDocumentId && (
|
||||
<Section className="mt-4">
|
||||
<p className="text-sm text-muted-foreground">{t("detail.pdfPending")}</p>
|
||||
{canWrite && <BillingActionForm action={requestBillingPdfAction} submitLabel={t("detail.pdfRequest")} successText={t("detail.pdfRequested")} variant="outline">
|
||||
<input type="hidden" name="recordId" value={record.id} />
|
||||
</BillingActionForm>}
|
||||
</Section>
|
||||
)}
|
||||
{record.status === "voided" && (
|
||||
<Section className="mt-4">
|
||||
<p className="text-sm">
|
||||
<strong>{t("detail.voidedInfo", { at: formatDateTime(record.voidedAt, locale, tz), by: record.voidedByName ?? "—" })}</strong>
|
||||
</p>
|
||||
{record.voidReason && <p className="mt-1 text-sm whitespace-pre-wrap">{record.voidReason}</p>}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="shadow-card mt-4 rounded-xl border bg-card p-4 md:p-6">
|
||||
<style dangerouslySetInnerHTML={{ __html: statementCss(SCREEN_THEME) }} />
|
||||
<StatementDocument s={s} t={tr} locale={locale} timeZone={tz} />
|
||||
</section>
|
||||
|
||||
{showBill && (
|
||||
<Modal title={t("bill.title")} sub={t("bill.sub")} closeHref={self} closeLabel={tc("close")}>
|
||||
<BillingActionForm action={markBilledAction} submitLabel={t("bill.submit")} pendingLabel={t("bill.pending")} variant="primary" className="grid gap-4 p-5">
|
||||
<input type="hidden" name="recordId" value={record.id} />
|
||||
<Field label={t("bill.invoiceNumber")} htmlFor="bill-invoice" hint={t("bill.invoiceHint")}>
|
||||
<input id="bill-invoice" name="invoiceNumber" maxLength={100} className={inputCls} autoFocus />
|
||||
</Field>
|
||||
{s.time.pendingMinutes > 0 && (
|
||||
<div className="rounded-lg border border-[var(--warn)] px-3 py-2 text-sm">
|
||||
<p className="font-semibold">{t("bill.pendingWarning", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}</p>
|
||||
<label className="mt-2 flex min-h-11 items-center gap-2.5">
|
||||
<input type="checkbox" name="confirmPendingExcluded" className="size-5 accent-[var(--ui-primary)]" />
|
||||
{t("bill.confirmPending")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t("bill.freezeHint")}</p>
|
||||
</BillingActionForm>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showInvoice && (
|
||||
<Modal title={t("invoice.title")} closeHref={self} closeLabel={tc("close")}>
|
||||
<BillingActionForm action={updateInvoiceNumberAction} submitLabel={t("invoice.submit")} className="p-5">
|
||||
<input type="hidden" name="recordId" value={record.id} />
|
||||
<Field label={t("bill.invoiceNumber")} htmlFor="inv-number">
|
||||
<input id="inv-number" name="invoiceNumber" maxLength={100} defaultValue={record.invoiceNumber ?? ""} className={inputCls} autoFocus />
|
||||
</Field>
|
||||
</BillingActionForm>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showVoid && (
|
||||
<Modal title={t("void.title")} sub={t("void.sub")} closeHref={self} closeLabel={tc("close")}>
|
||||
<BillingActionForm action={voidBillingAction} submitLabel={t("void.submit")} pendingLabel={t("void.pending")} variant="danger" className="p-5">
|
||||
<input type="hidden" name="recordId" value={record.id} />
|
||||
<Field label={`${t("void.reason")} *`} htmlFor="void-reason">
|
||||
<textarea id="void-reason" name="reason" required rows={4} maxLength={2000} className={`${inputCls} py-2`} autoFocus />
|
||||
</Field>
|
||||
</BillingActionForm>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Modul-Gate „billing" (L14 Abrechnungsübersicht): deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
|
||||
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
await requireModule("billing");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import Link from "next/link";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { Printer } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { KindLabel, PendingBadge, formatDayKey } from "@/components/billing/ui";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Empty, Field, inputCls, LinkTabs } from "@/components/work-orders/ui";
|
||||
import { BILLING_KINDS, BILLING_STATUSES, type BillingKind, type BillingStatus } from "@/lib/billing/schemas";
|
||||
import { formatHm } from "@/lib/billing/statement";
|
||||
import { formatDate } from "@/lib/work-orders/time";
|
||||
import { requirePageContext } from "@/server/api/context";
|
||||
import { listBillingRecords, type BillingListItem } from "@/server/services/billing/queries";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { customerDisplayName, customerFilterOptions, teamOptions } from "@/server/services/work-orders/options";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) || undefined;
|
||||
|
||||
/**
|
||||
* Backoffice `/billing` (L14): completed orders, confirmed milestones and approved daily reports that
|
||||
* the external accounting can invoice. Tabs Offen · Abgerechnet · Storniert, filters, multi-select →
|
||||
* print view. No prices, no export.
|
||||
*/
|
||||
export default async function BillingOverviewPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const ctx = await requirePageContext("billing");
|
||||
const t = await getTranslations("billing");
|
||||
if (!ctx.permissions.has("billing:read")) {
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} />
|
||||
<Empty>{t("list.noPermission")}</Empty>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const sp = await searchParams;
|
||||
const statusParam = one(sp.status);
|
||||
const status: BillingStatus = (BILLING_STATUSES as readonly string[]).includes(statusParam ?? "") ? (statusParam as BillingStatus) : "open";
|
||||
const kindParam = one(sp.kind);
|
||||
const kind = (BILLING_KINDS as readonly string[]).includes(kindParam ?? "") ? (kindParam as BillingKind) : undefined;
|
||||
const base = { kind, customerId: one(sp.customerId), teamId: one(sp.teamId), from: one(sp.from), to: one(sp.to), q: one(sp.q) };
|
||||
const page = Math.max(1, Math.floor(Number(one(sp.page)) || 1));
|
||||
|
||||
const [list, totals, customers, teams, tz, locale] = await Promise.all([
|
||||
listBillingRecords(ctx, { ...base, status, page }),
|
||||
Promise.all(BILLING_STATUSES.map((s) => listBillingRecords(ctx, { ...base, status: s, pageSize: 1 }).then((r) => r.total))),
|
||||
customerFilterOptions(ctx),
|
||||
teamOptions(ctx),
|
||||
tenantTimezone(ctx),
|
||||
getLocale(),
|
||||
]);
|
||||
|
||||
const qs = (extra: Record<string, string | number | undefined>) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries({ ...base, status, ...extra })) if (v !== undefined && v !== "") p.set(k, String(v));
|
||||
return `/billing?${p.toString()}`;
|
||||
};
|
||||
const period = (i: BillingListItem) =>
|
||||
i.kind === "daily_report" && i.reportDate ? formatDayKey(i.reportDate, locale) : `${formatDate(i.periodFrom, locale, tz)} – ${formatDate(i.periodTo, locale, tz)}`;
|
||||
const section = (i: BillingListItem) => (i.kind === "milestone" ? i.milestoneTitle : null);
|
||||
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle")} />
|
||||
|
||||
<LinkTabs
|
||||
label={t("title")}
|
||||
items={BILLING_STATUSES.map((s, i) => ({ href: qs({ status: s, page: undefined }), label: t(`tabs.${s}`), active: s === status, count: totals[i] }))}
|
||||
/>
|
||||
|
||||
<details className="shadow-card my-4 rounded-xl border bg-card" open={Object.values(base).some(Boolean)}>
|
||||
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filter.title")}</summary>
|
||||
<form method="get" action="/billing" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<input type="hidden" name="status" value={status} />
|
||||
<Field label={t("filter.q")} htmlFor="b-q">
|
||||
<input id="b-q" type="search" name="q" defaultValue={base.q ?? ""} maxLength={100} placeholder={t("filter.qPlaceholder")} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filter.from")} htmlFor="b-from">
|
||||
<input id="b-from" type="date" name="from" defaultValue={base.from ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filter.to")} htmlFor="b-to">
|
||||
<input id="b-to" type="date" name="to" defaultValue={base.to ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filter.customer")} htmlFor="b-customer">
|
||||
<select id="b-customer" name="customerId" defaultValue={base.customerId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{customerDisplayName(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filter.team")} htmlFor="b-team">
|
||||
<select id="b-team" name="teamId" defaultValue={base.teamId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{teams.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filter.kind")} htmlFor="b-kind">
|
||||
<select id="b-kind" name="kind" defaultValue={kind ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{BILLING_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{t(`kind.${k}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="flex items-end gap-2">
|
||||
<button type="submit" className={buttonCls("default")}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
<Link href={`/billing?status=${status}`} className={buttonCls("ghost")}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{list.items.length === 0 ? (
|
||||
<Empty>{t(`list.empty.${status}`)}</Empty>
|
||||
) : (
|
||||
<form method="get" action="/billing/print" className="shadow-card rounded-xl border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-3">
|
||||
<p className="text-sm text-muted-foreground">{t("list.selectHint")}</p>
|
||||
<button type="submit" className={buttonCls("outline")}>
|
||||
<Printer className="size-4" aria-hidden />
|
||||
{t("list.print")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[960px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="p-3">
|
||||
<span className="sr-only">{t("list.select")}</span>
|
||||
</th>
|
||||
<th className="p-3">{t("list.order")}</th>
|
||||
<th className="p-3">{t("list.customerSite")}</th>
|
||||
<th className="p-3">{t("list.section")}</th>
|
||||
<th className="p-3">{t("list.period")}</th>
|
||||
<th className="p-3 text-right">{t("list.work")}</th>
|
||||
<th className="p-3 text-right">{t("list.travel")}</th>
|
||||
<th className="p-3 text-right">{t("list.trips")}</th>
|
||||
<th className="p-3 text-right">{t("list.material")}</th>
|
||||
<th className="p-3">{status === "open" ? t("list.readySince") : status === "billed" ? t("list.billedAt") : t("list.voidedAt")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.items.map((i) => (
|
||||
<tr key={i.id} className="border-b align-top last:border-0">
|
||||
<td className="p-3">
|
||||
<input type="checkbox" name="ids" value={i.id} aria-label={t("list.selectRow", { number: i.workOrder.number })} className="size-5 accent-[var(--ui-primary)]" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link href={`/billing/${i.id}`} className="font-mono font-semibold text-[var(--primary)] hover:underline">
|
||||
{i.workOrder.number}
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground">{i.workOrder.title}</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div>{i.customer.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{i.site?.name ?? "—"}</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<KindLabel kind={i.kind} label={t(`kind.${i.kind}`)} />
|
||||
{section(i) && <div className="text-xs text-muted-foreground">{section(i)}</div>}
|
||||
{i.summary.pendingMinutes > 0 && (
|
||||
<div className="mt-1">
|
||||
<PendingBadge label={t("list.pending", { value: formatHm(i.summary.pendingMinutes) })} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 whitespace-nowrap">{period(i)}</td>
|
||||
<td className="p-3 text-right whitespace-nowrap">{t("hm", { value: formatHm(i.summary.workMinutes) })}</td>
|
||||
<td className="p-3 text-right whitespace-nowrap">{t("hm", { value: formatHm(i.summary.travelMinutes) })}</td>
|
||||
<td className="p-3 text-right">{i.summary.trips}</td>
|
||||
<td className="p-3 text-right">{i.summary.materialCount}</td>
|
||||
<td className="p-3 whitespace-nowrap">
|
||||
{status === "open" && formatDate(i.readySince, locale, tz)}
|
||||
{status === "billed" && (
|
||||
<>
|
||||
{formatDate(i.billedAt, locale, tz)}
|
||||
{i.invoiceNumber && <div className="text-xs text-muted-foreground">{t("list.invoice", { number: i.invoiceNumber })}</div>}
|
||||
</>
|
||||
)}
|
||||
{status === "voided" && (
|
||||
<>
|
||||
{formatDate(i.voidedAt, locale, tz)}
|
||||
{i.voidReason && <div className="max-w-56 truncate text-xs text-muted-foreground">{i.voidReason}</div>}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<nav aria-label={t("list.pagination")} className="mt-4 flex items-center justify-between gap-2">
|
||||
{page > 1 ? (
|
||||
<Link href={qs({ page: page - 1 })} className={buttonCls("outline")}>
|
||||
{t("list.prev")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{t("list.page", { page, pages })}</span>
|
||||
{page < pages ? (
|
||||
<Link href={qs({ page: page + 1 })} className={buttonCls("outline")}>
|
||||
{t("list.next")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { PrintButton } from "@/components/billing/print-button";
|
||||
import { SCREEN_THEME, StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Empty } from "@/components/work-orders/ui";
|
||||
import type { BillingStatement } from "@/lib/billing/statement";
|
||||
import { requirePageContext } from "@/server/api/context";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { buildBillingStatement } from "@/server/services/billing/statement";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const MAX_SHEETS = 50;
|
||||
|
||||
/** `/billing/print?ids=…` — print view of one or more billing sheets (A4, page break per sheet). No export. */
|
||||
export default async function BillingPrintPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const ctx = await requirePageContext("billing");
|
||||
if (!ctx.permissions.has("billing:read")) notFound();
|
||||
const sp = await searchParams;
|
||||
const raw = Array.isArray(sp.ids) ? sp.ids : sp.ids ? [sp.ids] : [];
|
||||
const ids = [...new Set(raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean))].slice(0, MAX_SHEETS);
|
||||
const [t, locale, tz] = await Promise.all([getTranslations("billing"), getLocale(), tenantTimezone(ctx)]);
|
||||
const tr: Translate = (key, values) => t(key as never, values as never);
|
||||
|
||||
const statements: BillingStatement[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
statements.push(await buildBillingStatement(ctx, id));
|
||||
} catch (err) {
|
||||
if (!(err instanceof ServiceError && err.code === "not_found")) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6 print:p-0">
|
||||
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<Link href="/billing" className={buttonCls("ghost")}>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("print.back")}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">{t("print.count", { count: statements.length })}</p>
|
||||
{statements.length > 0 && <PrintButton label={t("print.print")} />}
|
||||
</div>
|
||||
{statements.length === 0 ? (
|
||||
<Empty>{t("print.empty")}</Empty>
|
||||
) : (
|
||||
<div className="rounded-xl border bg-card p-4 md:p-6 print:border-0 print:p-0">
|
||||
<style dangerouslySetInnerHTML={{ __html: statementCss(SCREEN_THEME) }} />
|
||||
{statements.map((s, i) => (
|
||||
<StatementDocument key={s.record.id} s={s} t={tr} locale={locale} timeZone={tz} pageBreak={i > 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,8 @@ import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
|
||||
const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [
|
||||
const TILES: { key: Preset | "sync_conflicts" | "time_approvals" | "billing_ready"; icon: LucideIcon; tone: string }[] = [
|
||||
{ key: "billing_ready", icon: Receipt, tone: "var(--ok)" }, // L14 Abrechnungsübersicht
|
||||
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
|
||||
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
|
||||
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
|
||||
@@ -145,9 +146,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
</details>
|
||||
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => {
|
||||
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
||||
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
||||
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => {
|
||||
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
||||
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
||||
return (
|
||||
<li key={key}>
|
||||
<Link
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ReportsTab,
|
||||
TimesTab,
|
||||
} from "@/components/work-orders/detail-tabs";
|
||||
import { WorkOrderBillingTab } from "@/components/billing/work-order-billing-tab";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { Field, inputCls, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
|
||||
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
|
||||
@@ -30,7 +31,7 @@ import { customerDisplayName, customerOption, teamOptions, userOptions } from "@
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
import { reasonRequired } from "@/server/services/work-orders/transition";
|
||||
|
||||
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"] as const;
|
||||
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
|
||||
type Tab = (typeof TABS)[number];
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
@@ -202,6 +203,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
|
||||
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
|
||||
{tab === "history" && <HistoryTab {...tabProps} />}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { StatusBadge } from "@/components/field/status-badge";
|
||||
import { card, toneClasses } from "@/components/field/ui";
|
||||
import { loadOrder } from "./load";
|
||||
import { LotseCompletenessCard } from "@/components/lotse/completeness-card";
|
||||
import { MilestonesSection } from "@/components/field/milestones";
|
||||
|
||||
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
|
||||
const head = (
|
||||
@@ -168,6 +169,8 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
|
||||
{editable && <LotseCompletenessCard workOrderId={order.id} />}
|
||||
|
||||
<MilestonesSection ctx={ctx} workOrderId={order.id} editable={editable} />{/* L14 Abrechnungsübersicht */}
|
||||
|
||||
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
|
||||
|
||||
{order.site && (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { markBilled } from "@/server/services/billing/records";
|
||||
|
||||
/**
|
||||
* POST /api/v1/billing/:id/billed — mark as billed (freeze statement, assign positions, PDF job).
|
||||
* Body (optional): { invoiceNumber?: string, confirmPendingExcluded?: boolean }.
|
||||
*/
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing", "billing:write");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(req, { allowEmpty: true });
|
||||
const record = await markBilled(ctx, {
|
||||
recordId: id,
|
||||
invoiceNumber: typeof body.invoiceNumber === "string" ? body.invoiceNumber : null,
|
||||
confirmPendingExcluded: body.confirmPendingExcluded === true,
|
||||
});
|
||||
return json({ id: record.id, status: record.status, invoiceNumber: record.invoiceNumber, billedAt: record.billedAt });
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, withApi } from "@/server/api/respond";
|
||||
import { getBillingRecordDetail } from "@/server/services/billing/queries";
|
||||
|
||||
/** GET /api/v1/billing/:id — billing record with its statement (frozen snapshot once billed). */
|
||||
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing", "billing:read");
|
||||
const { id } = await params;
|
||||
return json(await getBillingRecordDetail(ctx, id));
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { voidBilling } from "@/server/services/billing/records";
|
||||
|
||||
/** POST /api/v1/billing/:id/void — void a billed record. Body: { reason: string } (required). */
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing", "billing:write");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(req);
|
||||
const res = await voidBilling(ctx, { recordId: id, reason: typeof body.reason === "string" ? body.reason : "" });
|
||||
return json({ id: res.voided.id, status: res.voided.status, reopenedId: res.reopened?.id ?? null });
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { paginated, parsePagination, withApi } from "@/server/api/respond";
|
||||
import { listBillingRecords } from "@/server/services/billing/queries";
|
||||
|
||||
/**
|
||||
* GET /api/v1/billing — billing overview (L14). Query: status (open|billed|voided, default open), kind,
|
||||
* customerId, teamId, from/to (YYYY-MM-DD), q, page, pageSize. No prices or amounts.
|
||||
*/
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("billing", "billing:read");
|
||||
const url = new URL(req.url);
|
||||
const { page, pageSize } = parsePagination(url);
|
||||
const sp = url.searchParams;
|
||||
const res = await listBillingRecords(ctx, {
|
||||
status: (sp.get("status") ?? undefined) as never,
|
||||
kind: (sp.get("kind") ?? undefined) as never,
|
||||
customerId: sp.get("customerId") ?? undefined,
|
||||
teamId: sp.get("teamId") ?? undefined,
|
||||
from: sp.get("from") ?? undefined,
|
||||
to: sp.get("to") ?? undefined,
|
||||
q: sp.get("q") ?? undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return paginated(res.items, res.total, res.page, res.pageSize);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, withApi } from "@/server/api/respond";
|
||||
import { confirmMilestone } from "@/server/services/billing/milestones";
|
||||
|
||||
/** POST /api/v1/milestones/:id/confirm — confirm a reported milestone → open billing record. */
|
||||
export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing", "billing:write");
|
||||
const { id } = await params;
|
||||
return json(await confirmMilestone(ctx, id));
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { markMilestoneReached } from "@/server/services/billing/milestones";
|
||||
|
||||
/**
|
||||
* POST /api/v1/milestones/:id/reach — report a milestone as reached (field:execute + order in scope,
|
||||
* or work_order:write). Idempotent. Body (optional): { note?: string }. Offline: sync op `milestone.reach`.
|
||||
*/
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(req, { allowEmpty: true });
|
||||
const res = await markMilestoneReached(ctx, { milestoneId: id, note: typeof body.note === "string" ? body.note : null });
|
||||
return json({ milestone: res.milestone, changed: res.changed });
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { rejectMilestone } from "@/server/services/billing/milestones";
|
||||
|
||||
/** POST /api/v1/milestones/:id/reject — reject a reported milestone. Body: { reason: string } (required). */
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing", "billing:write");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(req);
|
||||
return json(await rejectMilestone(ctx, { milestoneId: id, reason: typeof body.reason === "string" ? body.reason : "" }));
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { createMilestone, listMilestones } from "@/server/services/billing/milestones";
|
||||
|
||||
/** GET /api/v1/work-orders/:id/milestones — milestones of a visible order (field roles: scope). */
|
||||
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing");
|
||||
const { id } = await params;
|
||||
return json({ data: await listMilestones(ctx, id) });
|
||||
});
|
||||
|
||||
/** POST /api/v1/work-orders/:id/milestones — define a milestone. Body: { title, description? }. */
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("billing", "work_order:write");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(req);
|
||||
const m = await createMilestone(ctx, { workOrderId: id, title: typeof body.title === "string" ? body.title : "", description: typeof body.description === "string" ? body.description : null });
|
||||
return json(m, { status: 201 });
|
||||
});
|
||||
@@ -71,6 +71,8 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
import_job: "Auftragsimport",
|
||||
material_usage: "Material",
|
||||
time_entry: "Arbeitszeit",
|
||||
billing_record: "Abrechnungseintrag",
|
||||
work_order_milestone: "Meilenstein",
|
||||
work_session: "Einsatzzeit",
|
||||
photo: "Foto",
|
||||
voice_note: "Sprachnotiz",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonCls, type ButtonVariant } from "@/components/work-orders/button-cls";
|
||||
|
||||
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||
|
||||
/** Form bound to a billing server action; errors come from messages billing.errors.* (text + icon). */
|
||||
export function BillingActionForm({
|
||||
action,
|
||||
children,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
successText,
|
||||
variant = "default",
|
||||
className,
|
||||
footerClassName,
|
||||
}: {
|
||||
action: Action;
|
||||
children?: React.ReactNode;
|
||||
submitLabel: string;
|
||||
pendingLabel?: string;
|
||||
successText?: string;
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
footerClassName?: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||
const t = useTranslations("billing");
|
||||
|
||||
let errorText = "";
|
||||
if (state.status === "error") {
|
||||
const key = `errors.${state.message}`;
|
||||
errorText = t.has(key) ? t(key) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal");
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className={className}>
|
||||
{children}
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm font-semibold text-[var(--risk)]">
|
||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "ok" && successText && (
|
||||
<p role="status" className="mt-3 flex items-center gap-2 text-sm text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{successText}
|
||||
</p>
|
||||
)}
|
||||
<div className={cn("mt-3 flex flex-wrap gap-2", footerClassName)}>
|
||||
<button type="submit" disabled={pending} className={buttonCls(variant)}>
|
||||
{pending ? (pendingLabel ?? submitLabel) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Printer } from "lucide-react";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
|
||||
/** Opens the browser print dialog (buttons are hidden in print by globals.css). */
|
||||
export function PrintButton({ label }: { label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={() => window.print()} className={buttonCls("primary")}>
|
||||
<Printer className="size-4" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { Address, BillingStatement, StatementMaterialLine, StatementTextBlock } from "@/lib/billing/statement";
|
||||
import { formatHm } from "@/lib/billing/statement";
|
||||
|
||||
/**
|
||||
* Billing statement document (L14 §3.3) — pure markup without hooks, shared by the detail page,
|
||||
* the print view (`/billing/print`) and the PDF template (react-dom/server). Styling lives in
|
||||
* `statementCss` (scoped `.bs-*` classes), so screen, print and PDF show the same layout.
|
||||
* No prices, amounts or taxes.
|
||||
*/
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
export type StatementTheme = {
|
||||
text: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
rule: string;
|
||||
headBg: string;
|
||||
warn: string;
|
||||
font?: string;
|
||||
};
|
||||
|
||||
/** Token theme for screen/print pages (CSS variables, no hex values). */
|
||||
export const SCREEN_THEME: StatementTheme = {
|
||||
text: "var(--foreground)",
|
||||
muted: "var(--txt-muted)",
|
||||
accent: "var(--ui-primary)",
|
||||
rule: "var(--line)",
|
||||
headBg: "var(--muted)",
|
||||
warn: "var(--warn)",
|
||||
};
|
||||
|
||||
export function statementCss(th: StatementTheme): string {
|
||||
return `
|
||||
.bs{color:${th.text};${th.font ? `font-family:${th.font};` : ""}font-size:13px;line-height:1.45;}
|
||||
.bs+.bs{margin-top:32px;}
|
||||
.bs h1{font-size:20px;margin:0 0 4px;color:${th.accent};}
|
||||
.bs h2{font-size:14px;margin:18px 0 6px;padding-bottom:3px;border-bottom:1px solid ${th.rule};color:${th.accent};break-after:avoid;}
|
||||
.bs h3{font-size:13px;margin:10px 0 4px;break-after:avoid;}
|
||||
.bs-muted{color:${th.muted};}
|
||||
.bs-head{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;border-bottom:2px solid ${th.accent};padding-bottom:8px;}
|
||||
.bs-org{font-weight:700;font-size:15px;color:${th.accent};}
|
||||
.bs-meta{text-align:right;}
|
||||
.bs-note{margin:8px 0 0;font-size:12px;color:${th.muted};}
|
||||
.bs-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:4px 20px;margin:0;}
|
||||
.bs-kv dt{font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:${th.muted};margin:0;}
|
||||
.bs-kv dd{margin:0 0 6px;overflow-wrap:anywhere;}
|
||||
.bs-callout{border:1px solid ${th.warn};border-left-width:4px;padding:8px 10px;margin:10px 0;border-radius:6px;break-inside:avoid;}
|
||||
.bs-scroll{overflow-x:auto;}
|
||||
.bs table{width:100%;border-collapse:collapse;margin:4px 0 8px;}
|
||||
.bs th{background:${th.headBg};text-align:left;font-size:12px;padding:5px 6px;border-bottom:1px solid ${th.rule};}
|
||||
.bs td{padding:5px 6px;border-bottom:1px solid ${th.rule};vertical-align:top;}
|
||||
.bs tr{break-inside:avoid;}
|
||||
.bs .num{text-align:right;white-space:nowrap;}
|
||||
.bs .bs-sum td{font-weight:700;}
|
||||
.bs-badge{display:inline-block;border:1px solid ${th.accent};color:${th.accent};border-radius:999px;padding:0 8px;font-size:12px;font-weight:600;}
|
||||
.bs-flag{font-weight:700;color:${th.warn};}
|
||||
.bs-hint{border-left:4px solid ${th.warn};padding:4px 8px;margin:6px 0;}
|
||||
.bs-text{white-space:pre-wrap;}
|
||||
.bs-add td{font-weight:600;}
|
||||
@media print{.bs-break{break-before:page;}.bs+.bs{margin-top:0;}}
|
||||
`;
|
||||
}
|
||||
|
||||
const addr = (a: Address) => [a.line1, a.line2].filter(Boolean).join(", ");
|
||||
|
||||
function Kv({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialTable({ t, lines, highlight }: { t: Translate; lines: StatementMaterialLine[]; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="bs-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("material.name")}</th>
|
||||
<th>{t("material.articleNumber")}</th>
|
||||
<th className="num">{t("material.quantity")}</th>
|
||||
<th className="num">{t("material.planned")}</th>
|
||||
<th>{t("material.status")}</th>
|
||||
<th>{t("material.reason")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((m, i) => (
|
||||
<tr key={m.usageId ?? m.planId ?? i} className={highlight ? "bs-add" : undefined}>
|
||||
<td>{m.name}</td>
|
||||
<td>{m.articleNumber ?? "—"}</td>
|
||||
<td className="num">{m.quantity ? `${m.quantity} ${m.unit}` : "—"}</td>
|
||||
<td className="num">{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}</td>
|
||||
<td>{m.status ? t(`materialStatus.${m.status}`) : t("material.undocumented")}</td>
|
||||
<td>{m.deviationReason ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextBlocks({ t, title, blocks }: { t: Translate; title: string; blocks: StatementTextBlock[] }) {
|
||||
if (!blocks.length) return null;
|
||||
return (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
{blocks.map((b, i) => (
|
||||
<div key={i} className="bs-text">
|
||||
{b.reportNumber ? <span className="bs-muted">{t("texts.fromReport", { number: b.reportNumber })} </span> : null}
|
||||
{b.text}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatementDocument({ s, t, locale, timeZone, pageBreak }: { s: BillingStatement; t: Translate; locale: string; timeZone: string; pageBreak?: boolean }) {
|
||||
const date = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium" }).format(new Date(iso));
|
||||
const dateTime = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
||||
const day = (key: string) => new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
|
||||
const hm = (m: number) => t("time.hm", { value: formatHm(m) });
|
||||
const r = s.record;
|
||||
// the period end of a daily report is the start of the next day → show the report day
|
||||
const period = r.kind === "daily_report" && r.reportDate ? day(r.reportDate) : `${date(r.periodFrom)} – ${date(r.periodTo)}`;
|
||||
const section = r.kind === "milestone" ? r.milestoneTitle : r.kind === "daily_report" && r.reportDate ? t("sheet.dailyReportOf", { date: day(r.reportDate) }) : null;
|
||||
const tt = s.time.totals;
|
||||
const hasMaterial = s.materials.used.length + s.materials.additional.length + s.materials.notUsed.length > 0;
|
||||
const hasTexts = s.texts.additionalWork.length + s.texts.deviations.length + s.texts.openItems.length > 0;
|
||||
|
||||
return (
|
||||
<article className={`bs${pageBreak ? " bs-break" : ""}`} aria-label={t("sheet.title")}>
|
||||
<header className="bs-head">
|
||||
<div>
|
||||
<div className="bs-org">{s.tenant.name}</div>
|
||||
<div className="bs-muted">{[s.tenant.address, s.tenant.phone, s.tenant.email].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div className="bs-meta">
|
||||
<h1>{t("sheet.title")}</h1>
|
||||
<div>
|
||||
{t(`kind.${r.kind}`)}
|
||||
{section ? ` · ${section}` : ""}
|
||||
</div>
|
||||
<div>
|
||||
{t("sheet.period")}: {period}
|
||||
</div>
|
||||
<div>
|
||||
{t("sheet.status")}: <span className="bs-badge">{t(`status.${r.status}`)}</span>
|
||||
</div>
|
||||
{r.invoiceNumber ? (
|
||||
<div>
|
||||
{t("sheet.invoiceNumber")}: <strong>{r.invoiceNumber}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<p className="bs-note">{t("sheet.noAccounting")}</p>
|
||||
|
||||
<h2>{t("section.order")}</h2>
|
||||
<dl className="bs-kv">
|
||||
<Kv label={t("field.customer")} value={s.customer.name} />
|
||||
<Kv label={t("field.customerNumber")} value={s.customer.number} />
|
||||
<Kv label={t("field.billingAddress")} value={addr(s.customer.billingAddress)} />
|
||||
<Kv label={t("field.site")} value={s.site ? [s.site.name, addr(s.site.address)].filter(Boolean).join(", ") : null} />
|
||||
<Kv label={t("field.orderNumber")} value={s.workOrder.number} />
|
||||
<Kv label={t("field.externalOrderNumber")} value={s.workOrder.externalOrderNumber} />
|
||||
<Kv label={t("field.offerNumber")} value={s.workOrder.offerNumber} />
|
||||
<Kv label={t("field.title")} value={s.workOrder.title} />
|
||||
<Kv label={t("field.orderType")} value={s.workOrder.orderType} />
|
||||
<Kv label={t("field.billingType")} value={s.workOrder.billingType ? t(`billingType.${s.workOrder.billingType}`) : null} />
|
||||
<Kv label={t("field.team")} value={s.workOrder.team} />
|
||||
<Kv label={t("field.section")} value={[t(`kind.${r.kind}`), section].filter(Boolean).join(" · ")} />
|
||||
<Kv label={t("field.workDates")} value={s.workDates.map(day).join(", ")} />
|
||||
<Kv label={t("field.billedAt")} value={r.billedAt ? `${dateTime(r.billedAt)}${r.billedByName ? ` · ${r.billedByName}` : ""}` : null} />
|
||||
</dl>
|
||||
{r.milestoneDescription ? <p className="bs-text">{r.milestoneDescription}</p> : null}
|
||||
{s.customer.billingNotes ? (
|
||||
<div className="bs-callout">
|
||||
<strong>{t("field.billingNotes")}</strong>
|
||||
<div className="bs-text">{s.customer.billingNotes}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h2>{t("section.time")}</h2>
|
||||
{s.time.persons.length === 0 ? (
|
||||
<p className="bs-muted">{t("time.empty")}</p>
|
||||
) : (
|
||||
<div className="bs-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("time.person")}</th>
|
||||
<th className="num">{t("time.work")}</th>
|
||||
<th className="num">{t("time.travel")}</th>
|
||||
<th className="num">{t("time.materialProcurement")}</th>
|
||||
<th className="num">{t("time.total")}</th>
|
||||
<th className="num">{t("time.breaks")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.time.persons.map((p) => (
|
||||
<tr key={p.userId}>
|
||||
<td>
|
||||
{p.name}
|
||||
{p.manualMinutes > 0 ? <div className="bs-flag">{t("time.manual", { value: formatHm(p.manualMinutes) })}</div> : null}
|
||||
</td>
|
||||
<td className="num">{hm(p.work)}</td>
|
||||
<td className="num">{hm(p.travel)}</td>
|
||||
<td className="num">{hm(p.materialProcurement)}</td>
|
||||
<td className="num">{hm(p.total)}</td>
|
||||
<td className="num bs-muted">{hm(p.breaks)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bs-sum">
|
||||
<td>{t("time.sum")}</td>
|
||||
<td className="num">{hm(tt.work)}</td>
|
||||
<td className="num">{hm(tt.travel)}</td>
|
||||
<td className="num">{hm(tt.materialProcurement)}</td>
|
||||
<td className="num">{hm(tt.total)}</td>
|
||||
<td className="num bs-muted">{hm(tt.breaks)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="bs-muted">{t("time.breaksInfo")}</p>
|
||||
{s.time.pendingMinutes > 0 ? <p className="bs-hint">{t("time.pendingHint", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}</p> : null}
|
||||
{s.time.hasRunningEntries ? <p className="bs-hint">{t("time.runningHint")}</p> : null}
|
||||
|
||||
<h2>{t("section.trips")}</h2>
|
||||
<p>
|
||||
<strong>{t("trips.count", { count: s.trips.count })}</strong>
|
||||
{s.trips.dates.length ? <span className="bs-muted"> · {s.trips.dates.map(day).join(", ")}</span> : null}
|
||||
</p>
|
||||
<p className="bs-muted">{t("trips.rule")}</p>
|
||||
|
||||
<h2>{t("section.material")}</h2>
|
||||
{!hasMaterial ? <p className="bs-muted">{t("material.empty")}</p> : null}
|
||||
{s.materials.used.length ? (
|
||||
<>
|
||||
<h3>{t("material.used")}</h3>
|
||||
<MaterialTable t={t} lines={s.materials.used} />
|
||||
</>
|
||||
) : null}
|
||||
{s.materials.additional.length ? (
|
||||
<>
|
||||
<h3 className="bs-flag">{t("material.additional")}</h3>
|
||||
<MaterialTable t={t} lines={s.materials.additional} highlight />
|
||||
</>
|
||||
) : null}
|
||||
{s.materials.notUsed.length ? (
|
||||
<>
|
||||
<h3>{t("material.notUsed")}</h3>
|
||||
<p className="bs-muted">{t("material.notUsedInfo")}</p>
|
||||
<MaterialTable t={t} lines={s.materials.notUsed} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h2>{t("section.texts")}</h2>
|
||||
{!hasTexts ? <p className="bs-muted">{t("texts.empty")}</p> : null}
|
||||
<TextBlocks t={t} title={t("texts.additionalWork")} blocks={s.texts.additionalWork} />
|
||||
<TextBlocks t={t} title={t("texts.deviations")} blocks={s.texts.deviations} />
|
||||
<TextBlocks t={t} title={t("texts.openItems")} blocks={s.texts.openItems} />
|
||||
|
||||
<h2>{t("section.reports")}</h2>
|
||||
{s.reports.length === 0 ? (
|
||||
<p className="bs-muted">{t("reports.empty")}</p>
|
||||
) : (
|
||||
<div className="bs-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("reports.number")}</th>
|
||||
<th>{t("reports.type")}</th>
|
||||
<th>{t("reports.date")}</th>
|
||||
<th>{t("reports.approvedAt")}</th>
|
||||
<th>{t("reports.signature")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.reports.map((rep) => (
|
||||
<tr key={rep.id}>
|
||||
<td>
|
||||
{rep.number ?? "—"} · v{rep.version}
|
||||
</td>
|
||||
<td>{t(`reportType.${rep.type}`)}</td>
|
||||
<td>{day(rep.reportDate)}</td>
|
||||
<td>{rep.approvedAt ? date(rep.approvedAt) : "—"}</td>
|
||||
<td>
|
||||
{rep.signature
|
||||
? `${t(`outcome.${rep.signature.outcome}`)}${rep.signature.signerName ? ` · ${rep.signature.signerName}` : ""} · ${dateTime(rep.signature.signedAt)}`
|
||||
: t("reports.noSignature")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Ban, CheckCheck, CircleDashed, Clock, Flag, Receipt, TriangleAlert, type LucideIcon } from "lucide-react";
|
||||
import type { BillingKind, BillingStatus, MilestoneStatus } from "@/lib/billing/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Status badges of the billing overview — always text + icon, colours only via tokens. */
|
||||
|
||||
const RECORD: Record<BillingStatus, { icon: LucideIcon; color: string }> = {
|
||||
open: { icon: Receipt, color: "var(--info)" },
|
||||
billed: { icon: CheckCheck, color: "var(--ok)" },
|
||||
voided: { icon: Ban, color: "var(--risk)" },
|
||||
};
|
||||
|
||||
const MILESTONE: Record<MilestoneStatus, { icon: LucideIcon; color: string }> = {
|
||||
open: { icon: CircleDashed, color: "var(--txt-muted)" },
|
||||
reached: { icon: Flag, color: "var(--warn)" },
|
||||
confirmed: { icon: Receipt, color: "var(--info)" },
|
||||
billed: { icon: CheckCheck, color: "var(--ok)" },
|
||||
};
|
||||
|
||||
function Badge({ icon: Icon, color, label, className }: { icon: LucideIcon; color: string; label: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap", className)}
|
||||
style={{ color, borderColor: color, background: `color-mix(in oklch, ${color} 10%, transparent)` }}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordStatusBadge({ status, label, className }: { status: BillingStatus; label: string; className?: string }) {
|
||||
return <Badge {...RECORD[status]} label={label} className={className} />;
|
||||
}
|
||||
|
||||
export function MilestoneStatusBadge({ status, label, className }: { status: MilestoneStatus; label: string; className?: string }) {
|
||||
return <Badge {...MILESTONE[status]} label={label} className={className} />;
|
||||
}
|
||||
|
||||
export function PendingBadge({ label }: { label: string }) {
|
||||
return <Badge icon={TriangleAlert} color="var(--warn)" label={label} />;
|
||||
}
|
||||
|
||||
export function KindLabel({ kind, label }: { kind: BillingKind; label: string }) {
|
||||
const Icon = kind === "milestone" ? Flag : kind === "daily_report" ? Clock : Receipt;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a YYYY-MM-DD key as a date without time zone shifts. */
|
||||
export function formatDayKey(key: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { BillingActionForm } from "@/components/billing/action-form";
|
||||
import { KindLabel, MilestoneStatusBadge, RecordStatusBadge } from "@/components/billing/ui";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||
import {
|
||||
confirmMilestoneAction,
|
||||
createMilestoneAction,
|
||||
deleteMilestoneAction,
|
||||
moveMilestoneAction,
|
||||
rejectMilestoneAction,
|
||||
} from "@/server/actions/billing/milestones";
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { listMilestones } from "@/server/services/billing/milestones";
|
||||
import { listWorkOrderBillingRecords } from "@/server/services/billing/queries";
|
||||
import type { WorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||
|
||||
/**
|
||||
* Work order detail tab "Meilensteine & Abrechnung" (L14): define/sort/delete milestones (open only),
|
||||
* confirm/reject reported ones, list the billing records of the order with links.
|
||||
*/
|
||||
export async function WorkOrderBillingTab({ ctx, wo, locale, tz }: { ctx: ServiceCtx; wo: WorkOrderDetail; locale: string; tz: string }) {
|
||||
const t = await getTranslations("billing");
|
||||
const canDefine = can(ctx, "work_order:write") && !["billed", "cancelled"].includes(wo.status);
|
||||
const canDecide = can(ctx, "billing:write");
|
||||
const [milestones, records] = await Promise.all([
|
||||
listMilestones(ctx, wo.id),
|
||||
can(ctx, "billing:read") ? listWorkOrderBillingRecords(ctx, wo.id) : Promise.resolve(null),
|
||||
]);
|
||||
const userIds = [...new Set(milestones.flatMap((m) => [m.reachedById, m.confirmedById, m.rejectedById]).filter((x): x is string => !!x))];
|
||||
const users = userIds.length ? await ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }) : [];
|
||||
const nameOf = new Map(users.map((u) => [u.id, u.name]));
|
||||
const titleOf = new Map(milestones.map((m) => [m.id, m.title]));
|
||||
const hidden = (name: string, value: string) => <input type="hidden" name={name} value={value} />;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[3fr_2fr]">
|
||||
<Section title={t("tab.milestones")}>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{milestones.length ? t("tab.milestonesHint") : t("tab.noMilestonesHint")}</p>
|
||||
{milestones.length === 0 ? (
|
||||
<Empty>{t("tab.noMilestones")}</Empty>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{milestones.map((m, i) => (
|
||||
<li key={m.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
{m.title}
|
||||
<MilestoneStatusBadge status={m.status} label={t(`milestoneStatus.${m.status}`)} />
|
||||
</p>
|
||||
{m.description && <p className="mt-1 text-sm whitespace-pre-wrap text-muted-foreground">{m.description}</p>}
|
||||
{m.reachedAt && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("tab.reachedBy", { name: (m.reachedById && nameOf.get(m.reachedById)) || "—", at: formatDateTime(m.reachedAt, locale, tz) })}
|
||||
{m.reachedNote && ` · ${m.reachedNote}`}
|
||||
</p>
|
||||
)}
|
||||
{m.confirmedAt && (
|
||||
<p className="text-xs text-muted-foreground">{t("tab.confirmedBy", { name: (m.confirmedById && nameOf.get(m.confirmedById)) || "—", at: formatDateTime(m.confirmedAt, locale, tz) })}</p>
|
||||
)}
|
||||
{m.status === "open" && m.rejectionReason && <p className="mt-1 text-xs text-[var(--risk)]">{t("tab.rejected", { reason: m.rejectionReason })}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{canDefine && (
|
||||
<>
|
||||
<BillingActionForm action={moveMilestoneAction} submitLabel={t("tab.up")} variant="ghost" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
{hidden("direction", "up")}
|
||||
</BillingActionForm>
|
||||
<BillingActionForm action={moveMilestoneAction} submitLabel={t("tab.down")} variant="ghost" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
{hidden("direction", "down")}
|
||||
</BillingActionForm>
|
||||
</>
|
||||
)}
|
||||
{canDefine && m.status === "open" && (
|
||||
<BillingActionForm action={deleteMilestoneAction} submitLabel={t("tab.delete")} variant="danger" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
</BillingActionForm>
|
||||
)}
|
||||
{canDecide && (m.status === "reached" || m.status === "open") && (
|
||||
<BillingActionForm action={confirmMilestoneAction} submitLabel={t("tab.confirm")} variant={m.status === "reached" ? "primary" : "outline"} footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
</BillingActionForm>
|
||||
)}
|
||||
</div>
|
||||
{canDecide && m.status === "reached" && (
|
||||
<BillingActionForm action={rejectMilestoneAction} submitLabel={t("tab.reject")} variant="outline" className="flex w-full flex-wrap items-end gap-2" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
<Field label={`${t("tab.rejectReason")} *`} htmlFor={`rej-${m.id}`} className="min-w-56 flex-1">
|
||||
<input id={`rej-${m.id}`} name="reason" required maxLength={2000} className={inputCls} />
|
||||
</Field>
|
||||
</BillingActionForm>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{canDefine && (
|
||||
<BillingActionForm action={createMilestoneAction} submitLabel={t("tab.add")} className="mt-4 grid gap-3 border-t pt-4 md:grid-cols-2">
|
||||
{hidden("workOrderId", wo.id)}
|
||||
<Field label={`${t("tab.newTitle")} *`} htmlFor="ms-title">
|
||||
<input id="ms-title" name="title" required maxLength={200} placeholder={t("tab.newTitlePlaceholder")} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("tab.newDescription")} htmlFor="ms-desc">
|
||||
<input id="ms-desc" name="description" maxLength={2000} className={inputCls} />
|
||||
</Field>
|
||||
</BillingActionForm>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("tab.records")}>
|
||||
{records === null ? (
|
||||
<Empty>{t("list.noPermission")}</Empty>
|
||||
) : records.length === 0 ? (
|
||||
<Empty>{t("tab.noRecords")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{records.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<KindLabel kind={r.kind} label={[t(`kind.${r.kind}`), r.milestoneId ? titleOf.get(r.milestoneId) : null].filter(Boolean).join(" · ")} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(r.periodFrom, locale, tz)} – {formatDate(r.periodTo, locale, tz)}
|
||||
{r.invoiceNumber && ` · ${t("list.invoice", { number: r.invoiceNumber })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RecordStatusBadge status={r.status} label={t(`status.${r.status}`)} />
|
||||
<Link href={`/billing/${r.id}`} className={buttonCls("outline")}>
|
||||
{t("tab.open")}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Flag, TriangleAlert, CheckCircle2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { errorKey, isQueued, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { btnPrimary, btnSecondary, inputClass, noticeError, noticeOk } from "./ui";
|
||||
|
||||
/** Large "Erreicht melden" button with an optional note (L14). Goes through the offline outbox. */
|
||||
export function MilestoneReachButton({ workOrderId, milestoneId, title }: { workOrderId: string; milestoneId: string; title: string }) {
|
||||
const t = useTranslations("billing");
|
||||
const tf = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<"sent" | "queued" | null>(null);
|
||||
|
||||
async function send() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await submitOp({ opType: "milestone.reach", payload: { workOrderId, milestoneId, note: note.trim() || null } });
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
setError(tf(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
setDone(isQueued(result) ? "queued" : "sent");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<p className={noticeOk} role="status">
|
||||
<CheckCircle2 className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t(done === "queued" ? "mobile.queued" : "mobile.sent")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{open && (
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
aria-label={t("mobile.note")}
|
||||
placeholder={t("mobile.notePlaceholder")}
|
||||
className={cn(inputClass, "py-3")}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={() => (open ? send() : setOpen(true))} className={cn(btnPrimary, "w-full")} aria-label={t("mobile.reachAria", { title })}>
|
||||
<Flag className="size-5" aria-hidden />
|
||||
{open ? t("mobile.confirmReach") : t("mobile.reach")}
|
||||
</button>
|
||||
{open && (
|
||||
<button type="button" disabled={busy} onClick={() => setOpen(false)} className={cn(btnSecondary, "w-full")}>
|
||||
{t("mobile.cancel")}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CheckCheck, CircleDashed, Flag, Receipt, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { listMilestones } from "@/server/services/billing/milestones";
|
||||
import { MilestoneReachButton } from "./milestone-reach-button";
|
||||
import { card } from "./ui";
|
||||
|
||||
const STATUS_ICON: Record<string, LucideIcon> = { open: CircleDashed, reached: Flag, confirmed: Receipt, billed: CheckCheck };
|
||||
|
||||
/**
|
||||
* L14 mobile order detail: milestones of the order with status (text + icon) and a large
|
||||
* "Erreicht melden" button for open ones (offline-capable sync op `milestone.reach`).
|
||||
* Renders nothing when the order has no milestones or the billing module is unavailable.
|
||||
*/
|
||||
export async function MilestonesSection({ ctx, workOrderId, editable }: { ctx: ServiceCtx; workOrderId: string; editable: boolean }) {
|
||||
let milestones;
|
||||
try {
|
||||
milestones = await listMilestones(ctx, workOrderId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!milestones.length) return null;
|
||||
const t = await getTranslations("billing");
|
||||
|
||||
return (
|
||||
<section className={card} aria-labelledby="milestones-title">
|
||||
<div className="flex min-h-12 items-center gap-2.5">
|
||||
<Flag className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<h2 id="milestones-title" className="flex-1 text-[17px]">
|
||||
{t("mobile.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<ol className="mt-1 space-y-3">
|
||||
{milestones.map((m) => {
|
||||
const Icon = STATUS_ICON[m.status] ?? CircleDashed;
|
||||
return (
|
||||
<li key={m.id} className="rounded-xl border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[16px] font-semibold leading-snug">{m.title}</p>
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-[13px] font-semibold", m.status === "reached" && "text-[var(--warn)]", m.status === "billed" && "text-[var(--ok)]")}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{t(`milestoneStatus.${m.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
{m.description && <p className="mt-1 text-[14px] whitespace-pre-line text-muted-foreground">{m.description}</p>}
|
||||
{m.status === "open" && m.rejectionReason && <p className="mt-1 text-[14px] text-[var(--risk)]">{t("mobile.rejected", { reason: m.rejectionReason })}</p>}
|
||||
{m.status === "reached" && <p className="mt-1 text-[14px] text-muted-foreground">{t("mobile.waiting")}</p>}
|
||||
{editable && m.status === "open" && (
|
||||
<div className="mt-3">
|
||||
<MilestoneReachButton workOrderId={workOrderId} milestoneId={m.id} title={m.title} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1042,6 +1042,152 @@ const paths: Record<string, Record<string, Schema>> = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
// ---------- L14 Abrechnungsübersicht (keine Preise, Beträge, Steuern oder Rechnungen) ----------
|
||||
"/billing": {
|
||||
get: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "listBillingRecords",
|
||||
summary: "Abrechnungsübersicht: offene, abgerechnete oder stornierte Einträge",
|
||||
description:
|
||||
"Einträge entstehen bei Abrechnungsfreigabe (Auftragsabschluss), bestätigtem Meilenstein oder – ohne Meilensteine – freigegebenem Tagesbericht. Sortierung: offen nach „bereit seit“ (älteste zuerst), abgerechnet/storniert neueste zuerst. `summary` enthält Arbeitszeit, Fahrzeit (Minuten), Anfahrten, Materialpositionen und offene (nicht freigegebene) Minuten.",
|
||||
module: "billing",
|
||||
permissions: ["billing:read"],
|
||||
parameters: [
|
||||
query("status", str({ enum: ["open", "billed", "voided"], default: "open" })),
|
||||
query("kind", str({ enum: ["order_completion", "milestone", "daily_report"] })),
|
||||
query("customerId", str()),
|
||||
query("teamId", str()),
|
||||
query("from", str({ format: "date" }), "Zeitraum-Überschneidung ab YYYY-MM-DD"),
|
||||
query("to", str({ format: "date" }), "Zeitraum-Überschneidung bis YYYY-MM-DD (inkl.)"),
|
||||
query("q", str({ maxLength: 100 }), "Auftrags-/Angebotsnummer, Titel, Kunde"),
|
||||
query("page", int({ minimum: 1, default: 1 })),
|
||||
query("pageSize", int({ minimum: 1, maximum: 100, default: 25 })),
|
||||
],
|
||||
responses: {
|
||||
"200": jsonResponse(
|
||||
"Liste",
|
||||
obj(
|
||||
{
|
||||
data: arr(
|
||||
open(
|
||||
{
|
||||
id: str(),
|
||||
kind: str({ enum: ["order_completion", "milestone", "daily_report"] }),
|
||||
status: str({ enum: ["open", "billed", "voided"] }),
|
||||
periodFrom: dateTime(),
|
||||
periodTo: dateTime(),
|
||||
readySince: dateTime(),
|
||||
invoiceNumber: nstr(),
|
||||
summary: obj({ workMinutes: int(), travelMinutes: int(), trips: int(), materialCount: int(), pendingMinutes: int() }),
|
||||
},
|
||||
["id", "kind", "status", "summary"],
|
||||
),
|
||||
),
|
||||
pagination: ref("Pagination"),
|
||||
},
|
||||
["data", "pagination"],
|
||||
),
|
||||
),
|
||||
...errors(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
"/billing/{id}": {
|
||||
get: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "getBillingRecord",
|
||||
summary: "Abrechnungseintrag mit Aufstellung (Abrechnungsblatt)",
|
||||
description: "Offen: live berechnet aus noch nicht abgerechneten, freigegebenen Zeiten und Material. Abgerechnet/storniert: eingefrorener Snapshot.",
|
||||
module: "billing",
|
||||
permissions: ["billing:read"],
|
||||
parameters: [idParam("des Abrechnungseintrags")],
|
||||
responses: { "200": jsonResponse("Eintrag", open({ record: open({ id: str(), status: str() }), statement: open({}) }, ["record", "statement"])), ...errors("not_found") },
|
||||
}),
|
||||
},
|
||||
"/billing/{id}/billed": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "markBillingRecordBilled",
|
||||
summary: "Als abgerechnet markieren (optional mit Rechnungsnummer der Buchhaltung)",
|
||||
description:
|
||||
"Friert die Aufstellung ein, ordnet Zeiten/Material dem Eintrag zu (nie doppelt abrechenbar), erzeugt das PDF-Abrechnungsblatt im Worker; Auftragsabschluss → Auftrag `billed`. Offene Zeiten → 422 `blocked` (`pending_time_entries`), außer `confirmPendingExcluded: true`. Nicht offen → 409.",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Abrechnungseintrags")],
|
||||
requestBody: jsonBody(obj({ invoiceNumber: str({ maxLength: 100 }), confirmPendingExcluded: bool() }), false),
|
||||
responses: { "200": jsonResponse("Abgerechnet", obj({ id: str(), status: str({ const: "billed" }), invoiceNumber: nstr(), billedAt: dateTime() })), ...errors("not_found", "conflict", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/billing/{id}/void": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "voidBillingRecord",
|
||||
summary: "Abrechnung stornieren (Grund Pflicht)",
|
||||
description: "Nur `billed`: Positionen werden wieder frei, ein neuer offener Eintrag entsteht; Auftragsabschluss → Auftrag zurück auf `released_for_billing`.",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Abrechnungseintrags")],
|
||||
requestBody: jsonBody(obj({ reason: str({ minLength: 1, maxLength: 2000 }) }, ["reason"])),
|
||||
responses: { "200": jsonResponse("Storniert", obj({ id: str(), status: str({ const: "voided" }), reopenedId: nstr() })), ...errors("not_found", "conflict", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/work-orders/{id}/milestones": {
|
||||
get: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "listWorkOrderMilestones",
|
||||
summary: "Meilensteine eines Auftrags (im Sichtbarkeits-Scope)",
|
||||
module: "billing",
|
||||
permissions: [],
|
||||
parameters: [idParam("des Auftrags")],
|
||||
responses: { "200": jsonResponse("Meilensteine", obj({ data: arr(open({ id: str(), title: str(), status: str({ enum: ["open", "reached", "confirmed", "billed"] }) })) }, ["data"])), ...errors("not_found") },
|
||||
}),
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "createWorkOrderMilestone",
|
||||
summary: "Meilenstein anlegen",
|
||||
module: "billing",
|
||||
permissions: ["work_order:write"],
|
||||
parameters: [idParam("des Auftrags")],
|
||||
requestBody: jsonBody(obj({ title: str({ minLength: 1, maxLength: 200 }), description: str({ maxLength: 2000 }) }, ["title"])),
|
||||
responses: { "201": jsonResponse("Angelegt", open({ id: str(), title: str(), status: str() })), ...errors("not_found", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/milestones/{id}/reach": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "reachMilestone",
|
||||
summary: "Meilenstein als erreicht melden (idempotent)",
|
||||
description: "Recht im Service: `field:execute` + Auftrag im Scope oder `work_order:write`. Offline: Sync-Op `milestone.reach`.",
|
||||
module: "billing",
|
||||
permissions: [],
|
||||
parameters: [idParam("des Meilensteins")],
|
||||
requestBody: jsonBody(obj({ note: str({ maxLength: 2000 }) }), false),
|
||||
responses: { "200": jsonResponse("Gemeldet", obj({ milestone: open({ id: str(), status: str() }), changed: bool() })), ...errors("not_found", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/milestones/{id}/confirm": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "confirmMilestone",
|
||||
summary: "Meilenstein bestätigen → offener Abrechnungseintrag",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Meilensteins")],
|
||||
responses: { "200": jsonResponse("Bestätigt", open({ id: str(), status: str() })), ...errors("not_found", "conflict") },
|
||||
}),
|
||||
},
|
||||
"/milestones/{id}/reject": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "rejectMilestone",
|
||||
summary: "Gemeldeten Meilenstein ablehnen (Grund Pflicht)",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Meilensteins")],
|
||||
requestBody: jsonBody(obj({ reason: str({ minLength: 1, maxLength: 2000 }) }, ["reason"])),
|
||||
responses: { "200": jsonResponse("Abgelehnt", open({ id: str(), status: str() })), ...errors("not_found", "conflict", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/sync": {
|
||||
post: op({
|
||||
tag: "Einsatz",
|
||||
@@ -1271,6 +1417,7 @@ export const openApiDocument = {
|
||||
{ name: "Berichte", description: "Tages-/Abschlussberichte" },
|
||||
{ name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" },
|
||||
{ name: "Planung", description: "Plantafel, Live-Lage, Einsatz-Empfehlungen" },
|
||||
{ name: "Abrechnung", description: "Abrechnungsübersicht für die externe Buchhaltung (keine Preise, Beträge, Steuern oder Rechnungen)" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
paths,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* L14 Abrechnungsübersicht — input schemas and constants (client-safe).
|
||||
* Scope: overview for the external accounting. No prices, amounts, taxes, invoices or payments.
|
||||
*/
|
||||
|
||||
export const BILLING_KINDS = ["order_completion", "milestone", "daily_report"] as const;
|
||||
export type BillingKind = (typeof BILLING_KINDS)[number];
|
||||
|
||||
export const BILLING_STATUSES = ["open", "billed", "voided"] as const;
|
||||
export type BillingStatus = (typeof BILLING_STATUSES)[number];
|
||||
|
||||
export const MILESTONE_STATUSES = ["open", "reached", "confirmed", "billed"] as const;
|
||||
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
||||
|
||||
const id = z.string().trim().min(1).max(64);
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
|
||||
export const invoiceNumberField = optionalText(100);
|
||||
|
||||
export const markBilledSchema = z.object({
|
||||
recordId: id,
|
||||
invoiceNumber: invoiceNumberField,
|
||||
/** required when approved-pending time entries exist: they are NOT part of the billed section */
|
||||
confirmPendingExcluded: z.boolean().optional(),
|
||||
});
|
||||
export type MarkBilledInput = z.input<typeof markBilledSchema>;
|
||||
|
||||
export const voidBillingSchema = z.object({ recordId: id, reason: z.string().trim().min(1).max(2000) });
|
||||
export type VoidBillingInput = z.input<typeof voidBillingSchema>;
|
||||
|
||||
export const updateInvoiceNumberSchema = z.object({ recordId: id, invoiceNumber: invoiceNumberField });
|
||||
|
||||
export const createMilestoneSchema = z.object({
|
||||
workOrderId: id,
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: optionalText(2000),
|
||||
});
|
||||
export type CreateMilestoneInput = z.input<typeof createMilestoneSchema>;
|
||||
|
||||
export const updateMilestoneSchema = z.object({
|
||||
milestoneId: id,
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
description: optionalText(2000).optional(),
|
||||
});
|
||||
|
||||
export const moveMilestoneSchema = z.object({ milestoneId: id, direction: z.enum(["up", "down"]) });
|
||||
|
||||
export const rejectMilestoneSchema = z.object({ milestoneId: id, reason: z.string().trim().min(1).max(2000) });
|
||||
|
||||
/** Sync op `milestone.reach` (offline-capable, idempotent: an already reached milestone stays unchanged). */
|
||||
export const milestoneReachPayload = z.object({
|
||||
workOrderId: id,
|
||||
milestoneId: id,
|
||||
note: optionalText(2000),
|
||||
});
|
||||
|
||||
export const billingListFilterSchema = z.object({
|
||||
status: z.enum(BILLING_STATUSES).catch("open").default("open"),
|
||||
kind: z.enum(BILLING_KINDS).optional().catch(undefined),
|
||||
customerId: id.optional().catch(undefined),
|
||||
teamId: id.optional().catch(undefined),
|
||||
/** period overlap (YYYY-MM-DD, tenant time zone) */
|
||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().catch(undefined),
|
||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().catch(undefined),
|
||||
q: z.string().trim().max(100).optional().catch(undefined),
|
||||
page: z.coerce.number().int().min(1).catch(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).catch(25).default(25),
|
||||
});
|
||||
export type BillingListFilter = z.input<typeof billingListFilterSchema>;
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { BillingKind, BillingStatus } from "./schemas";
|
||||
|
||||
/**
|
||||
* L14 billing statement ("Abrechnungsblatt") — client-safe type + pure helpers.
|
||||
* Built by src/server/services/billing/statement.ts; frozen as `BillingRecord.snapshot` when the
|
||||
* record is marked as billed. NO prices, amounts or taxes — only order data, times, trips, material.
|
||||
*/
|
||||
|
||||
export type Address = { line1: string | null; line2: string | null };
|
||||
|
||||
export type StatementMaterialLine = {
|
||||
usageId: string | null;
|
||||
planId: string | null;
|
||||
name: string;
|
||||
articleNumber: string | null;
|
||||
quantity: string | null;
|
||||
plannedQuantity: string | null;
|
||||
unit: string;
|
||||
status: "fully_used" | "partially_used" | "not_used" | "additional" | null;
|
||||
deviationReason: string | null;
|
||||
};
|
||||
|
||||
export type StatementPersonTime = {
|
||||
userId: string;
|
||||
name: string;
|
||||
/** minutes */
|
||||
work: number;
|
||||
/** Anfahrt + Rückfahrt */
|
||||
travel: number;
|
||||
materialProcurement: number;
|
||||
/** informative only, never part of a total */
|
||||
breaks: number;
|
||||
/** informative only (interruptions), never part of a total */
|
||||
interruption: number;
|
||||
/** work + travel + materialProcurement */
|
||||
total: number;
|
||||
/** approved minutes that were recorded manually (L12 Nachtrag) */
|
||||
manualMinutes: number;
|
||||
};
|
||||
|
||||
export type StatementTextBlock = { reportNumber: string | null; text: string };
|
||||
|
||||
export type BillingStatement = {
|
||||
schemaVersion: 1;
|
||||
generatedAt: string;
|
||||
record: {
|
||||
id: string;
|
||||
kind: BillingKind;
|
||||
status: BillingStatus;
|
||||
periodFrom: string;
|
||||
periodTo: string;
|
||||
readySince: string;
|
||||
milestoneTitle: string | null;
|
||||
milestoneDescription: string | null;
|
||||
/** YYYY-MM-DD of the daily report */
|
||||
reportDate: string | null;
|
||||
invoiceNumber: string | null;
|
||||
billedAt: string | null;
|
||||
billedByName: string | null;
|
||||
};
|
||||
tenant: { name: string; address: string | null; phone: string | null; email: string | null; logoDocumentId: string | null };
|
||||
customer: { id: string; number: string | null; name: string; billingAddress: Address; billingNotes: string | null };
|
||||
site: { id: string; name: string; address: Address } | null;
|
||||
workOrder: {
|
||||
id: string;
|
||||
number: string;
|
||||
externalOrderNumber: string | null;
|
||||
offerNumber: string | null;
|
||||
title: string;
|
||||
orderType: string | null;
|
||||
billingType: string | null;
|
||||
team: string | null;
|
||||
};
|
||||
reports: Array<{
|
||||
id: string;
|
||||
number: string | null;
|
||||
type: "daily" | "completion";
|
||||
version: number;
|
||||
reportDate: string;
|
||||
approvedAt: string | null;
|
||||
signature: { outcome: string; signerName: string | null; signedAt: string } | null;
|
||||
}>;
|
||||
/** YYYY-MM-DD (tenant time zone) with billed time */
|
||||
workDates: string[];
|
||||
time: {
|
||||
persons: StatementPersonTime[];
|
||||
totals: Omit<StatementPersonTime, "userId" | "name">;
|
||||
/** pending (not yet approved) minutes in the period — NOT included */
|
||||
pendingMinutes: number;
|
||||
pendingCount: number;
|
||||
/** running entries are never included */
|
||||
hasRunningEntries: boolean;
|
||||
};
|
||||
trips: { count: number; dates: string[] };
|
||||
materials: { used: StatementMaterialLine[]; additional: StatementMaterialLine[]; notUsed: StatementMaterialLine[] };
|
||||
texts: { additionalWork: StatementTextBlock[]; deviations: StatementTextBlock[]; openItems: StatementTextBlock[] };
|
||||
positions: { timeEntryIds: string[]; materialUsageIds: string[] };
|
||||
};
|
||||
|
||||
/** "7:05 h" style (hours:minutes, no locale dependency). */
|
||||
export function formatHm(minutes: number): string {
|
||||
const m = Math.max(0, Math.round(minutes));
|
||||
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD of an instant in `timeZone`. */
|
||||
export function dateKeyIn(instant: Date, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant);
|
||||
}
|
||||
|
||||
/** Travel segments starting within this many ms of a trip's start count as the same trip (crew leaving together). */
|
||||
export const TRIP_TOGETHER_TOLERANCE_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Count trips ("Anfahrten") from outbound travel segments of one work order.
|
||||
* Rule (L14): per local calendar day, segments of different persons that overlap in time — or start
|
||||
* within 15 minutes of each other — are ONE trip (a crew driving together); a later, separate
|
||||
* outbound segment on the same day is another trip. Return travel is not a trip.
|
||||
* Returns one date entry per trip (dates repeat when there are several trips a day).
|
||||
*/
|
||||
export function countTrips(segments: Array<{ startedAt: Date; endedAt: Date | null }>, timeZone: string): { count: number; dates: string[] } {
|
||||
const byDay = new Map<string, Array<{ start: number; end: number }>>();
|
||||
for (const s of segments) {
|
||||
const start = s.startedAt.getTime();
|
||||
const end = Math.max(start, (s.endedAt ?? s.startedAt).getTime());
|
||||
const key = dateKeyIn(s.startedAt, timeZone);
|
||||
const list = byDay.get(key) ?? [];
|
||||
list.push({ start, end });
|
||||
byDay.set(key, list);
|
||||
}
|
||||
const dates: string[] = [];
|
||||
for (const key of [...byDay.keys()].sort()) {
|
||||
const list = byDay.get(key)!.sort((a, b) => a.start - b.start);
|
||||
let tripStart = -Infinity;
|
||||
let tripEnd = -Infinity;
|
||||
for (const seg of list) {
|
||||
const together = seg.start <= tripEnd || seg.start - tripStart <= TRIP_TOGETHER_TOLERANCE_MS;
|
||||
if (!together) {
|
||||
dates.push(key);
|
||||
tripStart = seg.start;
|
||||
tripEnd = seg.end;
|
||||
} else {
|
||||
tripEnd = Math.max(tripEnd, seg.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count: dates.length, dates };
|
||||
}
|
||||
+5
-1
@@ -27,13 +27,17 @@ export const EVENT_TYPES = [
|
||||
"planning.capacity_freed",
|
||||
"planning.overrun",
|
||||
"planning.followup_at_risk",
|
||||
// L14 Abrechnungsübersicht (Meilensteine)
|
||||
"milestone.reached",
|
||||
"milestone.confirmed",
|
||||
"milestone.rejected",
|
||||
] as const;
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
export type DomainEvent = {
|
||||
type: EventType;
|
||||
entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry";
|
||||
entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry" | "milestone";
|
||||
entityId: string;
|
||||
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
||||
data?: Record<string, string | number | boolean | null>;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const MODULES = [
|
||||
{ key: "documents", name: "Dokumente", href: "/documents", nav: "documents" },
|
||||
{ key: "notifications", name: "Benachrichtigungen", href: "/notifications", nav: "notifications" },
|
||||
{ key: "lotse", name: "Lotse (KI-Assistent)", href: null, nav: "lotse" },
|
||||
{ key: "billing", name: "Abrechnungsübersicht", href: "/billing", nav: "billing" },
|
||||
] as const satisfies readonly ModuleDef[];
|
||||
|
||||
export type ModuleKey = (typeof MODULES)[number]["key"];
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CalendarRange,
|
||||
LayoutGrid,
|
||||
MapPinned,
|
||||
Receipt,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
@@ -62,6 +63,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
section: "main",
|
||||
},
|
||||
{ href: "/work-orders/time-approvals", label: "timeApprovals", icon: Timer, module: "work_orders", permissions: ["time:approve"], section: "main" },
|
||||
{ href: "/billing", label: "billing", icon: Receipt, module: "billing", permissions: ["billing:read"], section: "main" }, // L14
|
||||
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
|
||||
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
|
||||
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
|
||||
|
||||
@@ -25,6 +25,8 @@ export const SYNC_OP_TYPES = [
|
||||
"session.segment",
|
||||
"time.add_manual",
|
||||
"time.propose_correction",
|
||||
// L14 Abrechnungsübersicht (additive, conflict-free; registered in services/sync/external-ops.ts)
|
||||
"milestone.reach",
|
||||
] as const;
|
||||
|
||||
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
|
||||
|
||||
@@ -200,6 +200,7 @@ export const OP_PAYLOAD_SCHEMAS = {
|
||||
"session.segment": sessionSegmentPayload,
|
||||
"time.add_manual": timeAddManualPayload,
|
||||
"time.propose_correction": timeProposeCorrectionPayload,
|
||||
"milestone.reach": passthrough, // L14: validated in services/billing/sync-ops.ts (lib/billing/schemas.ts#milestoneReachPayload)
|
||||
} satisfies Record<SyncOpType, z.ZodType>;
|
||||
|
||||
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ok, str, toErrorState } from "@/server/actions/work_orders/_form";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { markBilled, requestBillingPdf, updateInvoiceNumber, voidBilling } from "@/server/services/billing/records";
|
||||
|
||||
/**
|
||||
* L14 billing overview — thin adapters (module "billing"). Permission, validation, positions,
|
||||
* snapshot, audit and status changes live in services/billing/records.ts.
|
||||
*/
|
||||
const guard = moduleGuard("billing");
|
||||
|
||||
function revalidate(recordId?: string) {
|
||||
revalidatePath("/billing");
|
||||
if (recordId) revalidatePath(`/billing/${recordId}`);
|
||||
}
|
||||
|
||||
export async function markBilledAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await markBilled(ctxFromGuard(g), { recordId: id, invoiceNumber: str(fd, "invoiceNumber") ?? null, confirmPendingExcluded: fd.get("confirmPendingExcluded") === "on" });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
redirect(`/billing/${encodeURIComponent(id)}?done=billed`);
|
||||
}
|
||||
|
||||
export async function voidBillingAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
let reopenedId: string | null = null;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
reopenedId = (await voidBilling(ctxFromGuard(g), { recordId: id, reason: str(fd, "reason") ?? "" })).reopened?.id ?? null;
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
redirect(reopenedId ? `/billing/${encodeURIComponent(reopenedId)}?done=voided` : `/billing/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function updateInvoiceNumberAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await updateInvoiceNumber(ctxFromGuard(g), { recordId: id, invoiceNumber: str(fd, "invoiceNumber") ?? null });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
redirect(`/billing/${encodeURIComponent(id)}?done=invoice`);
|
||||
}
|
||||
|
||||
export async function requestBillingPdfAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await requestBillingPdf(ctxFromGuard(g), id);
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
return ok();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ok, str, toErrorState } from "@/server/actions/work_orders/_form";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { confirmMilestone, createMilestone, deleteMilestone, markMilestoneReached, moveMilestone, rejectMilestone } from "@/server/services/billing/milestones";
|
||||
|
||||
/**
|
||||
* L14 milestones in the back office work order detail (tab "Meilensteine & Abrechnung").
|
||||
* Define/sort/delete: `work_order:write`; confirm/reject: `billing:write`; report as reached on
|
||||
* behalf of the field: `work_order:write` (the field uses the sync op `milestone.reach`).
|
||||
*/
|
||||
const guard = moduleGuard("billing");
|
||||
|
||||
function revalidate(fd: FormData) {
|
||||
const workOrderId = str(fd, "workOrderId");
|
||||
if (workOrderId) revalidatePath(`/work-orders/${workOrderId}`);
|
||||
revalidatePath("/billing");
|
||||
}
|
||||
|
||||
export async function createMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await createMilestone(ctxFromGuard(g), { workOrderId: str(fd, "workOrderId") ?? "", title: str(fd, "title") ?? "", description: str(fd, "description") ?? null });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone" });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function moveMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await moveMilestone(ctxFromGuard(g), { milestoneId: id, direction: str(fd, "direction") === "up" ? "up" : "down" });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function deleteMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await deleteMilestone(ctxFromGuard(g), id);
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function reachMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await markMilestoneReached(ctxFromGuard(g), { milestoneId: id, note: str(fd, "note") ?? null });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function confirmMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await confirmMilestone(ctxFromGuard(g), id);
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function rejectMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await rejectMilestone(ctxFromGuard(g), { milestoneId: id, reason: str(fd, "reason") ?? "" });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
@@ -72,6 +72,9 @@ export const TENANT_MODELS: readonly string[] = [
|
||||
"Notification",
|
||||
"SyncOperation",
|
||||
"AiGeneration",
|
||||
// L14 Abrechnungsübersicht
|
||||
"WorkOrderMilestone",
|
||||
"BillingRecord",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,6 +123,9 @@ const TENANT_MODELS = new Set<string>([
|
||||
"Notification",
|
||||
"SyncOperation",
|
||||
"AiGeneration",
|
||||
// L14 Abrechnungsübersicht
|
||||
"WorkOrderMilestone",
|
||||
"BillingRecord",
|
||||
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||
|
||||
@@ -48,6 +48,13 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
||||
{ model: "SyncOperation", field: "userId" },
|
||||
{ model: "SyncOperation", field: "resolvedById" },
|
||||
{ model: "AiGeneration", field: "createdById" },
|
||||
// L14 Abrechnungsübersicht
|
||||
{ model: "WorkOrderMilestone", field: "createdById" },
|
||||
{ model: "WorkOrderMilestone", field: "reachedById" },
|
||||
{ model: "WorkOrderMilestone", field: "confirmedById" },
|
||||
{ model: "WorkOrderMilestone", field: "rejectedById" },
|
||||
{ model: "BillingRecord", field: "billedById" },
|
||||
{ model: "BillingRecord", field: "voidedById" },
|
||||
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
|
||||
// tenant business data under data processing — not part of the employee subject export.
|
||||
];
|
||||
|
||||
@@ -13,4 +13,13 @@ export async function emitEvent(ctx: ServiceCtx, event: DomainEvent): Promise<vo
|
||||
} catch (err) {
|
||||
console.error(`[events] ${event.type} for ${event.entityType}:${event.entityId} failed:`, (err as Error).message);
|
||||
}
|
||||
// L14 Abrechnungsübersicht: billing candidates after billing release / daily report approval (idempotent, never throws)
|
||||
if (event.type === "work_order.released_for_billing" || event.type === "report.approved") {
|
||||
try {
|
||||
const { onBillingEvent } = await import("@/server/services/billing/events");
|
||||
await onBillingEvent(ctx, event);
|
||||
} catch (err) {
|
||||
console.error(`[events] billing hook for ${event.type} ${event.entityId} failed:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { generateBillingPdf } from "@/server/services/billing/pdf";
|
||||
import type { JobPayload } from "../queues";
|
||||
|
||||
/**
|
||||
* Queue "billing-pdf": renders the PDF billing sheet of a billed record (lane L14).
|
||||
* System context: tenant-bound db, stores the sheet via the central document service.
|
||||
*/
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(payload.tenantId),
|
||||
tenantId: payload.tenantId,
|
||||
userId: payload.actorId ?? "system",
|
||||
permissions: new Set(["billing:read", "work_order:read_all", "document:read_internal", "document:write"]),
|
||||
};
|
||||
const res = await generateBillingPdf(ctx, payload.entityId);
|
||||
console.info(`[billing-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
||||
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
|
||||
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
|
||||
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
|
||||
"billing-pdf": () => import(/* turbopackIgnore: true */ "./billing-pdf").then((m) => m.process), // L14: worker-only billing sheet PDF (react-dom/server + Chromium)
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
|
||||
@@ -15,6 +15,7 @@ export const JOB_QUEUES = {
|
||||
aiRetention: "ai-retention",
|
||||
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
|
||||
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
|
||||
billingPdf: "billing-pdf", // L14 Abrechnungsblatt
|
||||
} as const;
|
||||
|
||||
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable @next/next/no-head-element -- standalone print document for Chromium, not a Next.js page */
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { BillingStatement } from "@/lib/billing/statement";
|
||||
import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand";
|
||||
import { StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document";
|
||||
|
||||
/**
|
||||
* L14 billing sheet PDF (React SSR → static HTML → src/server/pdf/render.ts). Same markup as the
|
||||
* detail/print pages (`StatementDocument`), themed with the Craftvia document CD.
|
||||
*/
|
||||
|
||||
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
export function renderBillingHtml(input: { statement: BillingStatement; t: Translate; locale: string; timeZone: string; fontDataUri?: string | null }) {
|
||||
const { statement: s, t } = input;
|
||||
const th = DOCUMENT_THEME;
|
||||
const font = input.fontDataUri ? `"CraftviaInter",${th.bodyFont}` : th.bodyFont;
|
||||
const css =
|
||||
`${input.fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${input.fontDataUri}) format("truetype");font-weight:100 900;}` : ""}` +
|
||||
`@page{size:A4;}*{box-sizing:border-box;}html,body{margin:0;padding:0;background:${th.pageBackground};}` +
|
||||
statementCss({ text: th.text, muted: th.textMuted, accent: th.accent, rule: th.rule, headBg: th.tableHeaderBackground, warn: th.accentStrong, font }).replace(/font-size:13px/, "font-size:9.5pt");
|
||||
const html =
|
||||
"<!doctype html>" +
|
||||
renderToStaticMarkup(
|
||||
<html lang={input.locale}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>{`${t("sheet.title")} ${s.workOrder.number}`}</title>
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
</head>
|
||||
<body>
|
||||
<StatementDocument s={s} t={t} locale={input.locale} timeZone={input.timeZone} />
|
||||
</body>
|
||||
</html>,
|
||||
);
|
||||
const small = `font-family:${th.bodyFont};font-size:7pt;color:${th.textMuted};width:100%;padding:0 16mm;display:flex;justify-content:space-between;gap:6mm;`;
|
||||
const created = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(s.record.billedAt ?? s.generatedAt));
|
||||
const headerHtml = `<div style="${small}"><span>${esc(s.tenant.name)}</span><span>${esc(t("sheet.title"))} ${esc(s.workOrder.number)} · ${esc(t(`kind.${s.record.kind}`))}</span></div>`;
|
||||
const page = esc(t("pdf.page", { page: "__P__", pages: "__N__" }))
|
||||
.replace("__P__", '<span class="pageNumber"></span>')
|
||||
.replace("__N__", '<span class="totalPages"></span>');
|
||||
const footerHtml =
|
||||
`<div style="${small}"><span>${esc(t("pdf.recordId"))}: ${esc(s.record.id)} · ${esc(t("pdf.created"))}: ${esc(created)}<br/>${esc(documentFooterLine())}</span>` +
|
||||
`<span style="white-space:nowrap">${page}</span></div>`;
|
||||
return { html, headerHtml, footerHtml };
|
||||
}
|
||||
@@ -57,6 +57,9 @@ export const PERMISSIONS = [
|
||||
"settings:templates",
|
||||
// Lotse (KI-Assistent)
|
||||
"lotse:use",
|
||||
// L14 Abrechnungsübersicht (keine Buchhaltung): Übersicht lesen / abgerechnet markieren, stornieren, Meilensteine bestätigen
|
||||
"billing:read",
|
||||
"billing:write",
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[number];
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { dayWindow, dbDateToKey } from "@/lib/reports/dates";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Create missing `open` billing records of a work order (idempotent, L14 §3.2). Internal system
|
||||
* operation: called from the event hook (work_order.released_for_billing, report.approved), from
|
||||
* milestone confirmation and from scripts/billing-backfill.ts — it never checks user permissions
|
||||
* and only reads/writes through the tenant client of `ctx`.
|
||||
*
|
||||
* Sources and periods:
|
||||
* - order_completion: work order `released_for_billing` → from the end of the last billed section
|
||||
* (or order creation) until now; contains everything of the order not billed yet.
|
||||
* - milestone: confirmed milestone without record → from the end of the last billed section until
|
||||
* the confirmation time; contains every unbilled position recorded before the confirmation.
|
||||
* - daily_report: approved daily report of an order WITHOUT defined milestones → calendar day of
|
||||
* the report (tenant time zone). A new report version replaces the report of a still open record.
|
||||
*
|
||||
* Duplicates are impossible: partial unique indexes (one non-voided record per source) + INSERT … ON
|
||||
* CONFLICT DO NOTHING, which also keeps a surrounding transaction intact.
|
||||
*/
|
||||
export async function syncBillingCandidates(ctx: ServiceCtx, input: { workOrderId: string }, now = new Date()): Promise<{ created: number; updated: number }> {
|
||||
const wo = await ctx.db.workOrder.findFirst({ where: { id: input.workOrderId, deletedAt: null }, select: { id: true, status: true, createdAt: true } });
|
||||
if (!wo) return { created: 0, updated: 0 };
|
||||
|
||||
const [existing, milestones] = await Promise.all([
|
||||
ctx.db.billingRecord.findMany({
|
||||
where: { workOrderId: wo.id, status: { not: "voided" } },
|
||||
select: { id: true, kind: true, status: true, milestoneId: true, reportId: true, periodTo: true },
|
||||
}),
|
||||
ctx.db.workOrderMilestone.findMany({ where: { workOrderId: wo.id, deletedAt: null }, select: { id: true, status: true, confirmedAt: true, billingRecordId: true } }),
|
||||
]);
|
||||
|
||||
const billedTo = existing.filter((r) => r.status === "billed").map((r) => r.periodTo.getTime());
|
||||
// nothing billed yet → from the first documented activity (work may be recorded before the order row was created, e.g. imports/Notdienst)
|
||||
const periodFrom = billedTo.length ? new Date(Math.max(...billedTo)) : await firstActivity(ctx, wo.id, wo.createdAt);
|
||||
const data: Prisma.BillingRecordCreateManyInput[] = [];
|
||||
let updated = 0;
|
||||
|
||||
if (wo.status === "released_for_billing" && !existing.some((r) => r.kind === "order_completion")) {
|
||||
data.push({ tenantId: ctx.tenantId, workOrderId: wo.id, kind: "order_completion", periodFrom, periodTo: now > periodFrom ? now : periodFrom });
|
||||
}
|
||||
|
||||
for (const m of milestones) {
|
||||
if (m.status !== "confirmed" || m.billingRecordId) continue;
|
||||
if (existing.some((r) => r.kind === "milestone" && r.milestoneId === m.id)) continue;
|
||||
const to = m.confirmedAt ?? now;
|
||||
data.push({ tenantId: ctx.tenantId, workOrderId: wo.id, kind: "milestone", milestoneId: m.id, periodFrom: periodFrom < to ? periodFrom : to, periodTo: to });
|
||||
}
|
||||
|
||||
if (milestones.length === 0) {
|
||||
const reports = await ctx.db.report.findMany({
|
||||
where: { workOrderId: wo.id, type: "daily", status: { in: ["approved", "superseded"] } },
|
||||
select: { id: true, lineageId: true, status: true, reportDate: true },
|
||||
});
|
||||
const approved = reports.filter((r) => r.status === "approved");
|
||||
if (approved.length) {
|
||||
const tz = await tenantTimezone(ctx);
|
||||
const lineageOf = new Map(reports.map((r) => [r.id, r.lineageId]));
|
||||
for (const r of approved) {
|
||||
const sameLineage = existing.filter((e) => e.kind === "daily_report" && e.reportId && lineageOf.get(e.reportId) === r.lineageId);
|
||||
if (sameLineage.length) {
|
||||
for (const e of sameLineage) {
|
||||
if (e.status !== "open" || e.reportId === r.id) continue;
|
||||
updated += (await ctx.db.billingRecord.updateMany({ where: { id: e.id, status: "open" }, data: { reportId: r.id } })).count;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const w = dayWindow(dbDateToKey(r.reportDate), tz);
|
||||
data.push({ tenantId: ctx.tenantId, workOrderId: wo.id, kind: "daily_report", reportId: r.id, periodFrom: w.start, periodTo: w.end });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.length) return { created: 0, updated };
|
||||
const res = await ctx.db.billingRecord.createMany({ data, skipDuplicates: true });
|
||||
return { created: res.count, updated };
|
||||
}
|
||||
|
||||
/** Earliest of: order creation, first time entry, first material entry, first approved report day. */
|
||||
async function firstActivity(ctx: ServiceCtx, workOrderId: string, createdAt: Date): Promise<Date> {
|
||||
const [entry, usage, report] = await Promise.all([
|
||||
ctx.db.timeEntry.findFirst({ where: { workSession: { workOrderId } }, orderBy: { startedAt: "asc" }, select: { startedAt: true } }),
|
||||
ctx.db.materialUsage.findFirst({ where: { workOrderId }, orderBy: { createdAt: "asc" }, select: { createdAt: true } }),
|
||||
ctx.db.report.findFirst({ where: { workOrderId, status: "approved" }, orderBy: { reportDate: "asc" }, select: { reportDate: true } }),
|
||||
]);
|
||||
const times = [createdAt, entry?.startedAt, usage?.createdAt, report?.reportDate].filter((d): d is Date => !!d).map((d) => d.getTime());
|
||||
return new Date(Math.min(...times));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/** Shared helpers of the billing overview (L14). */
|
||||
|
||||
export async function auditBilling(
|
||||
ctx: ServiceCtx,
|
||||
entity: "billing_record" | "work_order_milestone",
|
||||
action: "create" | "update" | "delete",
|
||||
entityId: string,
|
||||
before?: unknown,
|
||||
after?: unknown,
|
||||
): Promise<void> {
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
||||
}
|
||||
|
||||
export function customerDisplay(c: { companyName: string | null; firstName: string | null; lastName: string | null; customerNumber?: string | null }): string {
|
||||
return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || c.customerNumber || "—";
|
||||
}
|
||||
|
||||
export function addressOf(x: { street?: string | null; houseNumber?: string | null; postalCode?: string | null; city?: string | null }) {
|
||||
return {
|
||||
line1: [x.street, x.houseNumber].filter(Boolean).join(" ") || null,
|
||||
line2: [x.postalCode, x.city].filter(Boolean).join(" ") || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON copy for audit before/after and snapshots (Dates → ISO strings). */
|
||||
export function plain<T>(value: T): unknown {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DomainEvent } from "@/lib/events";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { syncBillingCandidates } from "./candidates";
|
||||
|
||||
/**
|
||||
* Event hook (called from src/server/events.ts#emitEvent): creates billing candidates after the
|
||||
* existing billing release and daily report approval — without touching those services.
|
||||
*/
|
||||
export async function onBillingEvent(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
|
||||
if (event.type === "work_order.released_for_billing" && event.entityType === "work_order") {
|
||||
await syncBillingCandidates(ctx, { workOrderId: event.entityId });
|
||||
} else if (event.type === "report.approved" && event.entityType === "report") {
|
||||
const report = await ctx.db.report.findFirst({ where: { id: event.entityId }, select: { workOrderId: true, type: true } });
|
||||
if (report?.type === "daily") await syncBillingCandidates(ctx, { workOrderId: report.workOrderId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { WorkOrderMilestone } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createMilestoneSchema,
|
||||
milestoneReachPayload,
|
||||
moveMilestoneSchema,
|
||||
rejectMilestoneSchema,
|
||||
updateMilestoneSchema,
|
||||
type CreateMilestoneInput,
|
||||
} from "@/lib/billing/schemas";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { FINAL_STATUSES, loadVisibleWorkOrder, parseInput } from "@/server/services/work-orders/_shared";
|
||||
import { syncBillingCandidates } from "./candidates";
|
||||
import { auditBilling, plain } from "./common";
|
||||
|
||||
/**
|
||||
* Work order milestones (L14 §3.4): back office defines sections (`work_order:write`, only while
|
||||
* `open`), the field reports "reached" (`field:execute` + order in scope, or back office), back office
|
||||
* confirms (→ billing record) or rejects with a reason (`billing:write`).
|
||||
*/
|
||||
|
||||
async function requireMilestone(ctx: ServiceCtx, milestoneId: string): Promise<WorkOrderMilestone> {
|
||||
const m = await ctx.db.workOrderMilestone.findFirst({ where: { id: milestoneId, deletedAt: null } });
|
||||
if (!m) throw new ServiceError("not_found", "milestone_not_found");
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Milestones of a visible work order (field roles: scope; back office: all). */
|
||||
export async function listMilestones(ctx: ServiceCtx, workOrderId: string): Promise<WorkOrderMilestone[]> {
|
||||
await loadVisibleWorkOrder(ctx, workOrderId);
|
||||
return ctx.db.workOrderMilestone.findMany({ where: { workOrderId, deletedAt: null }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
|
||||
}
|
||||
|
||||
export async function createMilestone(ctx: ServiceCtx, raw: CreateMilestoneInput): Promise<WorkOrderMilestone> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const input = parseInput(createMilestoneSchema, raw);
|
||||
const wo = await loadVisibleWorkOrder(ctx, input.workOrderId);
|
||||
if (FINAL_STATUSES.includes(wo.status)) throw new ServiceError("invalid", "not_editable", { status: wo.status });
|
||||
const last = await ctx.db.workOrderMilestone.findFirst({ where: { workOrderId: wo.id, deletedAt: null }, orderBy: { sortOrder: "desc" }, select: { sortOrder: true } });
|
||||
const m = await ctx.db.workOrderMilestone.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, title: input.title, description: input.description, sortOrder: (last?.sortOrder ?? 0) + 10, createdById: ctx.userId },
|
||||
});
|
||||
await auditBilling(ctx, "work_order_milestone", "create", m.id, undefined, plain(m));
|
||||
return m;
|
||||
}
|
||||
|
||||
async function requireEditable(ctx: ServiceCtx, milestoneId: string, { openOnly }: { openOnly: boolean }) {
|
||||
const m = await requireMilestone(ctx, milestoneId);
|
||||
const wo = await loadVisibleWorkOrder(ctx, m.workOrderId);
|
||||
if (FINAL_STATUSES.includes(wo.status)) throw new ServiceError("invalid", "not_editable", { status: wo.status });
|
||||
if (openOnly && m.status !== "open") throw new ServiceError("invalid", "milestone_not_open", { status: m.status });
|
||||
return { m, wo };
|
||||
}
|
||||
|
||||
export async function updateMilestone(ctx: ServiceCtx, raw: z.input<typeof updateMilestoneSchema>): Promise<WorkOrderMilestone> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const input = parseInput(updateMilestoneSchema, raw);
|
||||
const { m } = await requireEditable(ctx, input.milestoneId, { openOnly: true });
|
||||
const data = { ...(input.title !== undefined ? { title: input.title } : {}), ...(input.description !== undefined ? { description: input.description } : {}) };
|
||||
if (!Object.keys(data).length) return m;
|
||||
const res = await ctx.db.workOrderMilestone.updateMany({ where: { id: m.id, status: "open", deletedAt: null }, data });
|
||||
if (res.count !== 1) throw new ServiceError("invalid", "milestone_not_open");
|
||||
const after = await requireMilestone(ctx, m.id);
|
||||
await auditBilling(ctx, "work_order_milestone", "update", m.id, plain(m), plain(after));
|
||||
return after;
|
||||
}
|
||||
|
||||
/** Soft delete (only `open`). */
|
||||
export async function deleteMilestone(ctx: ServiceCtx, milestoneId: string): Promise<void> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const { m } = await requireEditable(ctx, z.string().min(1).max(64).parse(milestoneId), { openOnly: true });
|
||||
const res = await ctx.db.workOrderMilestone.updateMany({ where: { id: m.id, status: "open", deletedAt: null }, data: { deletedAt: new Date() } });
|
||||
if (res.count !== 1) throw new ServiceError("invalid", "milestone_not_open");
|
||||
await auditBilling(ctx, "work_order_milestone", "delete", m.id, plain(m), { deletedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/** Move one step up/down; sort orders are renumbered (10, 20, …) in one transaction. */
|
||||
export async function moveMilestone(ctx: ServiceCtx, raw: z.input<typeof moveMilestoneSchema>): Promise<void> {
|
||||
assertCan(ctx, "work_order:write");
|
||||
const input = parseInput(moveMilestoneSchema, raw);
|
||||
const { m } = await requireEditable(ctx, input.milestoneId, { openOnly: false });
|
||||
await inTransaction(ctx, async (tx) => {
|
||||
const list = await tx.db.workOrderMilestone.findMany({ where: { workOrderId: m.workOrderId, deletedAt: null }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], select: { id: true, sortOrder: true } });
|
||||
const i = list.findIndex((x) => x.id === m.id);
|
||||
const j = input.direction === "up" ? i - 1 : i + 1;
|
||||
if (i < 0 || j < 0 || j >= list.length) return;
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
for (const [idx, x] of list.entries()) {
|
||||
if (x.sortOrder !== (idx + 1) * 10) await tx.db.workOrderMilestone.updateMany({ where: { id: x.id }, data: { sortOrder: (idx + 1) * 10 } });
|
||||
}
|
||||
await auditBilling(tx, "work_order_milestone", "update", m.id, { sortOrder: m.sortOrder }, { direction: input.direction, order: list.map((x) => x.id) });
|
||||
});
|
||||
}
|
||||
|
||||
const reachSchema = milestoneReachPayload.extend({ workOrderId: z.string().trim().min(1).max(64).optional() });
|
||||
|
||||
/**
|
||||
* "Erreicht melden" (field or back office). Idempotent: a milestone that is no longer `open`
|
||||
* stays unchanged (`changed: false`), so a replayed offline op never fails.
|
||||
*/
|
||||
export async function markMilestoneReached(ctx: ServiceCtx, raw: z.input<typeof reachSchema>): Promise<{ milestone: WorkOrderMilestone; changed: boolean }> {
|
||||
if (!can(ctx, "field:execute") && !can(ctx, "work_order:write")) throw new ServiceError("forbidden", "missing permission field:execute");
|
||||
const input = parseInput(reachSchema, raw);
|
||||
const m = await requireMilestone(ctx, input.milestoneId);
|
||||
if (input.workOrderId && input.workOrderId !== m.workOrderId) throw new ServiceError("not_found", "milestone_not_found");
|
||||
const wo = await loadVisibleWorkOrder(ctx, m.workOrderId);
|
||||
if (FINAL_STATUSES.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status });
|
||||
if (m.status !== "open") return { milestone: m, changed: false };
|
||||
|
||||
const res = await ctx.db.workOrderMilestone.updateMany({
|
||||
where: { id: m.id, status: "open", deletedAt: null },
|
||||
data: { status: "reached", reachedById: ctx.userId, reachedAt: new Date(), reachedNote: input.note },
|
||||
});
|
||||
const after = await requireMilestone(ctx, m.id);
|
||||
if (res.count !== 1) return { milestone: after, changed: false };
|
||||
await auditBilling(ctx, "work_order_milestone", "update", m.id, { status: m.status }, { status: after.status, reachedNote: after.reachedNote, op: "reach" });
|
||||
await emitEvent(ctx, { type: "milestone.reached", entityType: "milestone", entityId: m.id, data: { number: wo.number, milestone: m.title, workOrderId: wo.id } });
|
||||
return { milestone: after, changed: true };
|
||||
}
|
||||
|
||||
/** Confirm a reported (or still open) milestone → billing record (same transaction). */
|
||||
export async function confirmMilestone(ctx: ServiceCtx, milestoneId: string): Promise<WorkOrderMilestone> {
|
||||
assertCan(ctx, "billing:write");
|
||||
const id = z.string().trim().min(1).max(64).parse(milestoneId);
|
||||
const { before, after, number } = await inTransaction(ctx, async (tx) => {
|
||||
const m = await requireMilestone(tx, id);
|
||||
const wo = await loadVisibleWorkOrder(tx, m.workOrderId);
|
||||
if (m.status !== "open" && m.status !== "reached") throw new ServiceError("conflict", "milestone_not_confirmable", { status: m.status });
|
||||
const now = new Date();
|
||||
const res = await tx.db.workOrderMilestone.updateMany({
|
||||
where: { id: m.id, status: { in: ["open", "reached"] }, deletedAt: null },
|
||||
data: { status: "confirmed", confirmedById: tx.userId, confirmedAt: now, ...(m.reachedAt ? {} : { reachedAt: now, reachedById: tx.userId }) },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "milestone_not_confirmable");
|
||||
await syncBillingCandidates(tx, { workOrderId: m.workOrderId }, now);
|
||||
const updated = await requireMilestone(tx, m.id);
|
||||
await auditBilling(tx, "work_order_milestone", "update", m.id, { status: m.status }, { status: updated.status, op: "confirm" });
|
||||
return { before: m, after: updated, number: wo.number };
|
||||
});
|
||||
await emitEvent(ctx, {
|
||||
type: "milestone.confirmed",
|
||||
entityType: "milestone",
|
||||
entityId: after.id,
|
||||
data: { number, milestone: after.title, workOrderId: after.workOrderId, reporterId: before.reachedById, occurrenceId: `${after.id}:confirmed` },
|
||||
});
|
||||
return after;
|
||||
}
|
||||
|
||||
/** Reject a reported milestone (reason mandatory) → back to `open`, the reporter is informed. */
|
||||
export async function rejectMilestone(ctx: ServiceCtx, raw: z.input<typeof rejectMilestoneSchema>): Promise<WorkOrderMilestone> {
|
||||
assertCan(ctx, "billing:write");
|
||||
const input = parseInput(rejectMilestoneSchema, raw);
|
||||
const m = await requireMilestone(ctx, input.milestoneId);
|
||||
const wo = await loadVisibleWorkOrder(ctx, m.workOrderId);
|
||||
if (m.status !== "reached") throw new ServiceError("conflict", "milestone_not_reached", { status: m.status });
|
||||
const now = new Date();
|
||||
const res = await ctx.db.workOrderMilestone.updateMany({
|
||||
where: { id: m.id, status: "reached", deletedAt: null },
|
||||
data: { status: "open", rejectedById: ctx.userId, rejectedAt: now, rejectionReason: input.reason, reachedById: null, reachedAt: null, reachedNote: null },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "milestone_not_reached");
|
||||
const after = await requireMilestone(ctx, m.id);
|
||||
await auditBilling(ctx, "work_order_milestone", "update", m.id, { status: m.status, reachedById: m.reachedById }, { status: after.status, rejectionReason: input.reason, op: "reject" });
|
||||
await emitEvent(ctx, {
|
||||
type: "milestone.rejected",
|
||||
entityType: "milestone",
|
||||
entityId: m.id,
|
||||
data: { number: wo.number, milestone: m.title, workOrderId: wo.id, reporterId: m.reachedById, reason: input.reason, occurrenceId: `${m.id}:rejected:${now.getTime()}` },
|
||||
});
|
||||
return after;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTranslator } from "next-intl";
|
||||
import type { BillingStatement } from "@/lib/billing/statement";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { auditBilling } from "./common";
|
||||
|
||||
async function loadBillingMessages(locale: string): Promise<Record<string, unknown>> {
|
||||
const file = join(process.cwd(), "messages", locale, "billing.json");
|
||||
const fallback = join(process.cwd(), "messages", "de", "billing.json");
|
||||
return JSON.parse(await readFile(existsSync(file) ? file : fallback, "utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the PDF billing sheet of a BILLED record from its frozen snapshot and file it as Document
|
||||
* (category `other`, visibility `backoffice_only`, title "Abrechnungsblatt …"). Immutable: an existing
|
||||
* PDF is never replaced. Runs in the worker (jobs/processors/billing-pdf.ts).
|
||||
*/
|
||||
export async function generateBillingPdf(ctx: ServiceCtx, recordId: string): Promise<{ documentId: string; checksum: string; skipped: boolean }> {
|
||||
const record = await ctx.db.billingRecord.findFirst({ where: { id: recordId } });
|
||||
if (!record) throw new ServiceError("not_found", "billing_record_not_found");
|
||||
if (record.status !== "billed" || !record.snapshot) throw new ServiceError("invalid", "billing_record_not_billed");
|
||||
if (record.pdfDocumentId) return { documentId: record.pdfDocumentId, checksum: record.pdfChecksum ?? "", skipped: true };
|
||||
|
||||
const snap = record.snapshot as unknown as BillingStatement;
|
||||
const statement: BillingStatement = { ...snap, record: { ...snap.record, status: "billed", invoiceNumber: record.invoiceNumber } };
|
||||
const settings = await ctx.db.tenantSettings.findFirst({ select: { locale: true } });
|
||||
const locale = settings?.locale === "en" ? "en" : "de";
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const t = createTranslator({ locale, messages: await loadBillingMessages(locale) }) as unknown as (key: string, values?: Record<string, string | number>) => string;
|
||||
const fontPath = join(process.cwd(), "src", "app", "fonts", "inter-variable.ttf");
|
||||
const fontDataUri = existsSync(fontPath) ? `data:font/ttf;base64,${(await readFile(fontPath)).toString("base64")}` : null;
|
||||
|
||||
const [{ renderBillingHtml }, { renderHtmlToPdf }] = await Promise.all([import("@/server/pdf/templates/billing"), import("@/server/pdf/render")]);
|
||||
const { html, headerHtml, footerHtml } = renderBillingHtml({ statement, t, locale, timeZone, fontDataUri });
|
||||
const pdf = await renderHtmlToPdf(html, { headerHtml, footerHtml });
|
||||
|
||||
const doc = await storeFile(ctx, {
|
||||
bytes: pdf,
|
||||
fileName: `Abrechnungsblatt-${statement.workOrder.number}-${record.id.slice(-8)}.pdf`,
|
||||
declaredMime: "application/pdf",
|
||||
category: "other",
|
||||
visibility: "backoffice_only",
|
||||
links: { customerId: statement.customer.id, siteId: statement.site?.id ?? null, workOrderId: record.workOrderId },
|
||||
title: t("pdf.documentTitle", { number: statement.workOrder.number, kind: t(`kind.${statement.record.kind}`) }),
|
||||
approvalStatus: "approved",
|
||||
});
|
||||
|
||||
const res = await ctx.db.billingRecord.updateMany({ where: { id: record.id, pdfDocumentId: null }, data: { pdfDocumentId: doc.id, pdfChecksum: doc.checksum } });
|
||||
if (res.count !== 1) {
|
||||
await ctx.db.document.update({ where: { id: doc.id }, data: { deletedAt: new Date() } });
|
||||
const current = await ctx.db.billingRecord.findFirstOrThrow({ where: { id: record.id }, select: { pdfDocumentId: true, pdfChecksum: true } });
|
||||
return { documentId: current.pdfDocumentId ?? doc.id, checksum: current.pdfChecksum ?? "", skipped: true };
|
||||
}
|
||||
await auditBilling(ctx, "billing_record", "update", record.id, { pdfDocumentId: null }, { pdfDocumentId: doc.id, pdfChecksum: doc.checksum });
|
||||
return { documentId: doc.id, checksum: doc.checksum, skipped: false };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { BillingRecord, Prisma } from "@prisma/client";
|
||||
import { billingListFilterSchema, type BillingListFilter } from "@/lib/billing/schemas";
|
||||
import { countTrips, type BillingStatement } from "@/lib/billing/statement";
|
||||
import { dayWindow } from "@/lib/reports/dates";
|
||||
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { customerDisplay } from "./common";
|
||||
import { positionWindow, statementOf } from "./statement";
|
||||
|
||||
/** Read models of the billing overview (L14 §3.5) — all require `billing:read`. */
|
||||
|
||||
export type BillingSummary = { workMinutes: number; travelMinutes: number; trips: number; materialCount: number; pendingMinutes: number };
|
||||
|
||||
function inWindow(d: Date, w: { gte?: Date; lt?: Date } | undefined): boolean {
|
||||
if (!w) return true;
|
||||
return (!w.gte || d >= w.gte) && (!w.lt || d < w.lt);
|
||||
}
|
||||
|
||||
/** Summaries for a page of records: frozen snapshot for billed/voided, two batched queries for open records. */
|
||||
async function summarize(ctx: ServiceCtx, rows: BillingRecord[]): Promise<Map<string, BillingSummary>> {
|
||||
const out = new Map<string, BillingSummary>();
|
||||
for (const r of rows) {
|
||||
if (r.status === "open" || !r.snapshot) continue;
|
||||
const s = r.snapshot as unknown as BillingStatement;
|
||||
out.set(r.id, { workMinutes: s.time.totals.work, travelMinutes: s.time.totals.travel, trips: s.trips.count, materialCount: s.materials.used.length + s.materials.additional.length, pendingMinutes: s.time.pendingMinutes });
|
||||
}
|
||||
const open = rows.filter((r) => !out.has(r.id));
|
||||
if (!open.length) return out;
|
||||
const tz = await tenantTimezone(ctx);
|
||||
const woIds = [...new Set(open.map((r) => r.workOrderId))];
|
||||
const [entries, usages] = await Promise.all([
|
||||
ctx.db.timeEntry.findMany({
|
||||
where: { workSession: { workOrderId: { in: woIds } }, billingRecordId: null, approvalStatus: { in: ["approved", "pending"] } },
|
||||
select: { type: true, startedAt: true, endedAt: true, approvalStatus: true, workSession: { select: { workOrderId: true } } },
|
||||
}),
|
||||
ctx.db.materialUsage.findMany({ where: { workOrderId: { in: woIds }, billingRecordId: null }, select: { workOrderId: true, createdAt: true, usageStatus: true } }),
|
||||
]);
|
||||
for (const r of open) {
|
||||
const w = positionWindow(r);
|
||||
const sum: BillingSummary = { workMinutes: 0, travelMinutes: 0, trips: 0, materialCount: 0, pendingMinutes: 0 };
|
||||
const travel: Array<{ startedAt: Date; endedAt: Date | null }> = [];
|
||||
for (const e of entries) {
|
||||
if (e.workSession.workOrderId !== r.workOrderId || !inWindow(e.startedAt, w)) continue;
|
||||
const minutes = e.endedAt ? Math.max(0, Math.round((e.endedAt.getTime() - e.startedAt.getTime()) / 60_000)) : 0;
|
||||
if (e.approvalStatus === "pending") {
|
||||
if (e.type !== "break" && e.type !== "interruption") sum.pendingMinutes += minutes;
|
||||
continue;
|
||||
}
|
||||
if (!e.endedAt) continue;
|
||||
if (e.type === "work") sum.workMinutes += minutes;
|
||||
if (e.type === "travel" || e.type === "return_travel") sum.travelMinutes += minutes;
|
||||
if (e.type === "travel") travel.push(e);
|
||||
}
|
||||
sum.trips = countTrips(travel, tz).count;
|
||||
sum.materialCount = usages.filter((u) => u.workOrderId === r.workOrderId && u.usageStatus !== "not_used" && inWindow(u.createdAt, w)).length;
|
||||
out.set(r.id, sum);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listBillingRecords(ctx: ServiceCtx, raw: BillingListFilter = {}) {
|
||||
assertCan(ctx, "billing:read");
|
||||
const f = billingListFilterSchema.parse(raw);
|
||||
const tz = await tenantTimezone(ctx);
|
||||
|
||||
const workOrder: Prisma.WorkOrderWhereInput = { deletedAt: null };
|
||||
if (f.customerId) workOrder.customerId = f.customerId;
|
||||
if (f.teamId) workOrder.assignedTeamId = f.teamId;
|
||||
if (f.q) {
|
||||
const contains = { contains: f.q, mode: "insensitive" as const };
|
||||
workOrder.OR = [
|
||||
{ number: contains },
|
||||
{ externalOrderNumber: contains },
|
||||
{ offerNumber: contains },
|
||||
{ title: contains },
|
||||
{ customer: { OR: [{ companyName: contains }, { lastName: contains }, { firstName: contains }, { customerNumber: contains }] } },
|
||||
];
|
||||
}
|
||||
const where: Prisma.BillingRecordWhereInput = { status: f.status, workOrder, ...(f.kind ? { kind: f.kind } : {}) };
|
||||
if (f.from) where.periodTo = { gt: dayWindow(f.from, tz).start };
|
||||
if (f.to) where.periodFrom = { lt: dayWindow(f.to, tz).end };
|
||||
|
||||
const orderBy: Prisma.BillingRecordOrderByWithRelationInput[] =
|
||||
f.status === "open" ? [{ createdAt: "asc" }] : f.status === "billed" ? [{ billedAt: "desc" }] : [{ voidedAt: "desc" }];
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
ctx.db.billingRecord.count({ where }),
|
||||
ctx.db.billingRecord.findMany({
|
||||
where,
|
||||
orderBy: [...orderBy, { id: "asc" }],
|
||||
skip: (f.page - 1) * f.pageSize,
|
||||
take: f.pageSize,
|
||||
include: {
|
||||
workOrder: {
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
title: true,
|
||||
customer: { select: { id: true, companyName: true, firstName: true, lastName: true, customerNumber: true } },
|
||||
site: { select: { id: true, name: true } },
|
||||
team: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const milestoneIds = rows.map((r) => r.milestoneId).filter((x): x is string => !!x);
|
||||
const reportIds = rows.map((r) => r.reportId).filter((x): x is string => !!x);
|
||||
const [summaries, milestones, reports] = await Promise.all([
|
||||
summarize(ctx, rows),
|
||||
milestoneIds.length ? ctx.db.workOrderMilestone.findMany({ where: { id: { in: milestoneIds } }, select: { id: true, title: true } }) : Promise.resolve([]),
|
||||
reportIds.length ? ctx.db.report.findMany({ where: { id: { in: reportIds } }, select: { id: true, reportDate: true } }) : Promise.resolve([]),
|
||||
]);
|
||||
const titleOf = new Map(milestones.map((m) => [m.id, m.title]));
|
||||
const dateOf = new Map(reports.map((r) => [r.id, r.reportDate.toISOString().slice(0, 10)]));
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
periodFrom: r.periodFrom,
|
||||
periodTo: r.periodTo,
|
||||
readySince: r.createdAt,
|
||||
billedAt: r.billedAt,
|
||||
voidedAt: r.voidedAt,
|
||||
voidReason: r.voidReason,
|
||||
invoiceNumber: r.invoiceNumber,
|
||||
hasPdf: !!r.pdfDocumentId,
|
||||
milestoneTitle: r.milestoneId ? (titleOf.get(r.milestoneId) ?? null) : null,
|
||||
reportDate: r.reportId ? (dateOf.get(r.reportId) ?? null) : null,
|
||||
workOrder: { id: r.workOrder.id, number: r.workOrder.number, title: r.workOrder.title },
|
||||
customer: { id: r.workOrder.customer.id, name: customerDisplay(r.workOrder.customer) },
|
||||
site: r.workOrder.site,
|
||||
team: r.workOrder.team,
|
||||
summary: summaries.get(r.id)!,
|
||||
}));
|
||||
return { items, total, page: f.page, pageSize: f.pageSize };
|
||||
}
|
||||
|
||||
export type BillingListItem = Awaited<ReturnType<typeof listBillingRecords>>["items"][number];
|
||||
|
||||
export async function getBillingRecordDetail(ctx: ServiceCtx, recordId: string) {
|
||||
assertCan(ctx, "billing:read");
|
||||
const record = await ctx.db.billingRecord.findFirst({ where: { id: recordId, workOrder: { deletedAt: null } } });
|
||||
if (!record) throw new ServiceError("not_found", "billing_record_not_found");
|
||||
const [statement, voidedBy] = await Promise.all([
|
||||
statementOf(ctx, record),
|
||||
record.voidedById ? ctx.db.user.findFirst({ where: { id: record.voidedById }, select: { name: true } }) : Promise.resolve(null),
|
||||
]);
|
||||
return {
|
||||
record: {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
status: record.status,
|
||||
workOrderId: record.workOrderId,
|
||||
invoiceNumber: record.invoiceNumber,
|
||||
pdfDocumentId: record.pdfDocumentId,
|
||||
pdfChecksum: record.pdfChecksum,
|
||||
billedAt: record.billedAt,
|
||||
voidedAt: record.voidedAt,
|
||||
voidReason: record.voidReason,
|
||||
voidedByName: voidedBy?.name ?? null,
|
||||
createdAt: record.createdAt,
|
||||
},
|
||||
statement,
|
||||
};
|
||||
}
|
||||
|
||||
/** Records of one work order (detail tab). */
|
||||
export async function listWorkOrderBillingRecords(ctx: ServiceCtx, workOrderId: string) {
|
||||
assertCan(ctx, "billing:read");
|
||||
return ctx.db.billingRecord.findMany({
|
||||
where: { workOrderId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, kind: true, status: true, periodFrom: true, periodTo: true, invoiceNumber: true, billedAt: true, createdAt: true, milestoneId: true, reportId: true, pdfDocumentId: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Dashboard tile "Bereit zur Abrechnung" (0 without billing:read). */
|
||||
export async function countOpenBillingRecords(ctx: ServiceCtx): Promise<number> {
|
||||
if (!can(ctx, "billing:read")) return 0;
|
||||
return ctx.db.billingRecord.count({ where: { status: "open", workOrder: { deletedAt: null } } });
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { BillingRecord, Prisma } from "@prisma/client";
|
||||
import { markBilledSchema, updateInvoiceNumberSchema, voidBillingSchema, type MarkBilledInput, type VoidBillingInput } from "@/lib/billing/schemas";
|
||||
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { loadVisibleWorkOrder, parseInput } from "@/server/services/work-orders/_shared";
|
||||
import { applyTransition } from "@/server/services/work-orders/transition";
|
||||
import { auditBilling } from "./common";
|
||||
import { computeStatement } from "./statement";
|
||||
|
||||
/**
|
||||
* Billing actions (L14 §3.4). Handing over to the external accounting = "Abgerechnet" with an
|
||||
* optional invoice number + PDF sheet. Nothing here creates invoices, amounts or payments.
|
||||
*/
|
||||
|
||||
export type BillingDeps = {
|
||||
/** queue the PDF sheet rendering (worker); injectable for tests */
|
||||
dispatchPdf: (ctx: ServiceCtx, recordId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const defaultBillingDeps: BillingDeps = {
|
||||
async dispatchPdf(ctx, recordId) {
|
||||
try {
|
||||
const [{ dispatchJob }, { JOB_QUEUES }] = await Promise.all([import("@/server/jobs/dispatch"), import("@/server/jobs/queues")]);
|
||||
await dispatchJob(JOB_QUEUES.billingPdf, { tenantId: ctx.tenantId, entityId: recordId, actorId: ctx.userId });
|
||||
} catch (err) {
|
||||
// billed stays valid; the PDF can be generated again from the detail page
|
||||
console.error(`[billing] pdf job for ${recordId} failed:`, (err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const view = (r: Pick<BillingRecord, "id" | "kind" | "status" | "workOrderId" | "invoiceNumber" | "milestoneId" | "reportId">) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
workOrderId: r.workOrderId,
|
||||
invoiceNumber: r.invoiceNumber,
|
||||
milestoneId: r.milestoneId,
|
||||
reportId: r.reportId,
|
||||
});
|
||||
|
||||
async function requireRecord(ctx: ServiceCtx, recordId: string): Promise<BillingRecord> {
|
||||
const record = await ctx.db.billingRecord.findFirst({ where: { id: recordId } });
|
||||
if (!record) throw new ServiceError("not_found", "billing_record_not_found");
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an open record as billed: freeze the statement as snapshot, assign its time/material positions
|
||||
* (each position belongs to at most one billed section), mark the milestone billed, move the order
|
||||
* to `billed` for the order completion, audit, then queue the PDF sheet.
|
||||
* Pending (not approved) time entries in the period → `blocked pending_time_entries` unless
|
||||
* `confirmPendingExcluded` (they are then NOT part of the section).
|
||||
*/
|
||||
export async function markBilled(ctx: ServiceCtx, raw: MarkBilledInput, deps: BillingDeps = defaultBillingDeps): Promise<BillingRecord> {
|
||||
assertCan(ctx, "billing:write");
|
||||
const input = parseInput(markBilledSchema, raw);
|
||||
const updated = await inTransaction(ctx, async (tx) => {
|
||||
const record = await requireRecord(tx, input.recordId);
|
||||
if (record.status !== "open") throw new ServiceError("conflict", "billing_record_not_open", { status: record.status });
|
||||
|
||||
const statement = await computeStatement(tx, record);
|
||||
if (statement.time.pendingMinutes > 0 && !input.confirmPendingExcluded) {
|
||||
throw new ServiceError("blocked", "pending_time_entries", [{ kind: "missing_field", field: "pending_time_entries" }]);
|
||||
}
|
||||
|
||||
const billedAt = new Date();
|
||||
const billedBy = await tx.db.user.findFirst({ where: { id: tx.userId }, select: { name: true } });
|
||||
const frozen = {
|
||||
...statement,
|
||||
generatedAt: billedAt.toISOString(),
|
||||
record: { ...statement.record, status: "billed" as const, invoiceNumber: input.invoiceNumber, billedAt: billedAt.toISOString(), billedByName: billedBy?.name ?? null },
|
||||
};
|
||||
const res = await tx.db.billingRecord.updateMany({
|
||||
where: { id: record.id, status: "open" },
|
||||
data: { status: "billed", snapshot: frozen as unknown as Prisma.InputJsonValue, invoiceNumber: input.invoiceNumber, billedById: tx.userId, billedAt },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "billing_record_not_open");
|
||||
|
||||
const { timeEntryIds, materialUsageIds } = statement.positions;
|
||||
if (timeEntryIds.length) {
|
||||
const r = await tx.db.timeEntry.updateMany({ where: { id: { in: timeEntryIds }, billingRecordId: null }, data: { billingRecordId: record.id } });
|
||||
if (r.count !== timeEntryIds.length) throw new ServiceError("conflict", "positions_already_billed");
|
||||
}
|
||||
if (materialUsageIds.length) {
|
||||
const r = await tx.db.materialUsage.updateMany({ where: { id: { in: materialUsageIds }, billingRecordId: null }, data: { billingRecordId: record.id } });
|
||||
if (r.count !== materialUsageIds.length) throw new ServiceError("conflict", "positions_already_billed");
|
||||
}
|
||||
if (record.kind === "milestone" && record.milestoneId) {
|
||||
await tx.db.workOrderMilestone.updateMany({ where: { id: record.milestoneId, status: "confirmed" }, data: { status: "billed", billingRecordId: record.id } });
|
||||
}
|
||||
if (record.kind === "order_completion") {
|
||||
const wo = await loadVisibleWorkOrder(tx, record.workOrderId);
|
||||
if (wo.status === "released_for_billing") await applyTransition(tx, wo, "billed");
|
||||
}
|
||||
|
||||
const after = await tx.db.billingRecord.findFirstOrThrow({ where: { id: record.id } });
|
||||
await auditBilling(tx, "billing_record", "update", record.id, view(record), {
|
||||
...view(after),
|
||||
op: "mark_billed",
|
||||
timeEntries: timeEntryIds.length,
|
||||
materialUsages: materialUsageIds.length,
|
||||
pendingExcluded: statement.time.pendingCount,
|
||||
});
|
||||
return after;
|
||||
});
|
||||
await deps.dispatchPdf(ctx, updated.id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a billed record (reason mandatory): positions become free again, the milestone goes back to
|
||||
* `confirmed`, a NEW open record for the same source is created and — for the order completion —
|
||||
* the order returns from `billed` to `released_for_billing` (only allowed through this path).
|
||||
*/
|
||||
export async function voidBilling(ctx: ServiceCtx, raw: VoidBillingInput): Promise<{ voided: BillingRecord; reopened: BillingRecord | null }> {
|
||||
assertCan(ctx, "billing:write");
|
||||
const input = parseInput(voidBillingSchema, raw);
|
||||
return inTransaction(ctx, async (tx) => {
|
||||
const record = await requireRecord(tx, input.recordId);
|
||||
if (record.status !== "billed") throw new ServiceError("conflict", "billing_record_not_billed", { status: record.status });
|
||||
|
||||
const res = await tx.db.billingRecord.updateMany({
|
||||
where: { id: record.id, status: "billed" },
|
||||
data: { status: "voided", voidedById: tx.userId, voidedAt: new Date(), voidReason: input.reason },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "billing_record_not_billed");
|
||||
const freedTime = await tx.db.timeEntry.updateMany({ where: { billingRecordId: record.id }, data: { billingRecordId: null } });
|
||||
const freedMaterial = await tx.db.materialUsage.updateMany({ where: { billingRecordId: record.id }, data: { billingRecordId: null } });
|
||||
if (record.kind === "milestone" && record.milestoneId) {
|
||||
await tx.db.workOrderMilestone.updateMany({ where: { id: record.milestoneId, billingRecordId: record.id }, data: { status: "confirmed", billingRecordId: null } });
|
||||
}
|
||||
if (record.kind === "order_completion") {
|
||||
const wo = await loadVisibleWorkOrder(tx, record.workOrderId);
|
||||
if (wo.status === "billed") await applyTransition(tx, wo, "released_for_billing", { reason: input.reason, billingVoid: true });
|
||||
}
|
||||
|
||||
// new open record for the same source (the event hook of the status change may already have created it)
|
||||
await tx.db.billingRecord.createMany({
|
||||
data: [{ tenantId: tx.tenantId, workOrderId: record.workOrderId, kind: record.kind, milestoneId: record.milestoneId, reportId: record.reportId, periodFrom: record.periodFrom, periodTo: record.periodTo }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const sourceWhere: Prisma.BillingRecordWhereInput =
|
||||
record.kind === "order_completion" ? { workOrderId: record.workOrderId } : record.kind === "milestone" ? { milestoneId: record.milestoneId } : { reportId: record.reportId };
|
||||
const reopened = await tx.db.billingRecord.findFirst({ where: { ...sourceWhere, kind: record.kind, status: "open" } });
|
||||
const voided = await tx.db.billingRecord.findFirstOrThrow({ where: { id: record.id } });
|
||||
|
||||
await auditBilling(tx, "billing_record", "update", record.id, view(record), {
|
||||
...view(voided),
|
||||
op: "void",
|
||||
reason: input.reason,
|
||||
freedTimeEntries: freedTime.count,
|
||||
freedMaterialUsages: freedMaterial.count,
|
||||
reopenedId: reopened?.id ?? null,
|
||||
});
|
||||
if (reopened) await auditBilling(tx, "billing_record", "create", reopened.id, undefined, { ...view(reopened), reopenedFrom: record.id });
|
||||
return { voided, reopened };
|
||||
});
|
||||
}
|
||||
|
||||
/** Set / change the invoice number of the accounting afterwards (billed records only). */
|
||||
export async function updateInvoiceNumber(ctx: ServiceCtx, raw: { recordId: string; invoiceNumber?: string | null }): Promise<BillingRecord> {
|
||||
assertCan(ctx, "billing:write");
|
||||
const input = parseInput(updateInvoiceNumberSchema, raw);
|
||||
const record = await requireRecord(ctx, input.recordId);
|
||||
if (record.status !== "billed") throw new ServiceError("conflict", "billing_record_not_billed", { status: record.status });
|
||||
const res = await ctx.db.billingRecord.updateMany({ where: { id: record.id, status: "billed" }, data: { invoiceNumber: input.invoiceNumber } });
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "billing_record_not_billed");
|
||||
const after = await ctx.db.billingRecord.findFirstOrThrow({ where: { id: record.id } });
|
||||
await auditBilling(ctx, "billing_record", "update", record.id, { invoiceNumber: record.invoiceNumber }, { invoiceNumber: after.invoiceNumber, op: "invoice_number" });
|
||||
return after;
|
||||
}
|
||||
|
||||
/** Queue the PDF sheet again (billed record without PDF, e.g. worker was down). */
|
||||
export async function requestBillingPdf(ctx: ServiceCtx, recordId: string, deps: BillingDeps = defaultBillingDeps): Promise<void> {
|
||||
assertCan(ctx, "billing:write");
|
||||
const record = await requireRecord(ctx, recordId);
|
||||
if (record.status !== "billed") throw new ServiceError("conflict", "billing_record_not_billed", { status: record.status });
|
||||
if (record.pdfDocumentId) return;
|
||||
await deps.dispatchPdf(ctx, record.id);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { BillingRecord, Prisma } from "@prisma/client";
|
||||
import { dateKeyToDbDate } from "@/lib/reports/dates";
|
||||
import { countTrips, dateKeyIn, type BillingStatement, type StatementMaterialLine, type StatementPersonTime, type StatementTextBlock } from "@/lib/billing/statement";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { addressOf, customerDisplay } from "./common";
|
||||
|
||||
/**
|
||||
* Billing statement ("Abrechnungsblatt", L14 §3.3) — no prices. For `open` records the statement is
|
||||
* computed live from the positions that are not billed yet; for billed/voided records the frozen
|
||||
* `snapshot` is returned (only status and invoice number are overlaid from the row).
|
||||
*
|
||||
* Positions of a record (time entries and material usages with `billingRecordId = null`):
|
||||
* - daily_report: recorded on the report day [periodFrom, periodTo)
|
||||
* - milestone: recorded before the confirmation time (periodTo)
|
||||
* - order_completion: everything of the order that is not billed yet
|
||||
* Only approved, finished time entries count; pending entries are reported (`pendingMinutes`), rejected
|
||||
* entries and running entries are never included.
|
||||
*/
|
||||
|
||||
type Window = { gte?: Date; lt?: Date } | undefined;
|
||||
|
||||
export function positionWindow(record: Pick<BillingRecord, "kind" | "periodFrom" | "periodTo">): Window {
|
||||
if (record.kind === "daily_report") return { gte: record.periodFrom, lt: record.periodTo };
|
||||
if (record.kind === "milestone") return { lt: record.periodTo };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emptyTime = (): Omit<StatementPersonTime, "userId" | "name"> => ({ work: 0, travel: 0, materialProcurement: 0, breaks: 0, interruption: 0, total: 0, manualMinutes: 0 });
|
||||
|
||||
function minutesBetween(a: Date, b: Date): number {
|
||||
return Math.max(0, Math.round((b.getTime() - a.getTime()) / 60_000));
|
||||
}
|
||||
|
||||
type ReportContentLike = { reportNumber?: unknown; texts?: Record<string, unknown> };
|
||||
|
||||
/** Compute the statement of a record from the database (no permission check — callers check). */
|
||||
export async function computeStatement(ctx: ServiceCtx, record: BillingRecord, now = new Date()): Promise<BillingStatement> {
|
||||
const tz = await tenantTimezone(ctx);
|
||||
const window = positionWindow(record);
|
||||
|
||||
const [wo, settings, tenantUser, milestone, billedBy] = await Promise.all([
|
||||
ctx.db.workOrder.findFirst({
|
||||
where: { id: record.workOrderId },
|
||||
include: { customer: true, site: true, orderType: { select: { name: true } }, team: { select: { name: true } } },
|
||||
}),
|
||||
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
|
||||
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
|
||||
record.milestoneId ? ctx.db.workOrderMilestone.findFirst({ where: { id: record.milestoneId }, select: { title: true, description: true } }) : Promise.resolve(null),
|
||||
record.billedById ? ctx.db.user.findFirst({ where: { id: record.billedById }, select: { name: true } }) : Promise.resolve(null),
|
||||
]);
|
||||
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
|
||||
|
||||
const unbilled = record.status === "open" ? { billingRecordId: null } : { billingRecordId: record.id };
|
||||
const timeWhere: Prisma.TimeEntryWhereInput = {
|
||||
workSession: { workOrderId: wo.id },
|
||||
approvalStatus: { in: ["approved", "pending"] },
|
||||
...unbilled,
|
||||
...(window ? { startedAt: window } : {}),
|
||||
};
|
||||
const materialWhere: Prisma.MaterialUsageWhereInput = { workOrderId: wo.id, ...unbilled, ...(window ? { createdAt: window } : {}) };
|
||||
|
||||
const reportWhere: Prisma.ReportWhereInput = { workOrderId: wo.id, status: "approved" };
|
||||
if (record.kind === "daily_report") reportWhere.id = record.reportId ?? "__none__";
|
||||
else if (record.kind === "milestone") reportWhere.reportDate = { gte: dateKeyToDbDate(dateKeyIn(record.periodFrom, tz)), lte: dateKeyToDbDate(dateKeyIn(record.periodTo, tz)) };
|
||||
else reportWhere.reportDate = { gte: dateKeyToDbDate(dateKeyIn(record.periodFrom, tz)) };
|
||||
|
||||
const [entries, usages, plans, allPlanUsages, reports, dailyReport] = await Promise.all([
|
||||
ctx.db.timeEntry.findMany({
|
||||
where: timeWhere,
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { id: true, userId: true, type: true, startedAt: true, endedAt: true, approvalStatus: true, source: true },
|
||||
}),
|
||||
ctx.db.materialUsage.findMany({ where: materialWhere, orderBy: { createdAt: "asc" }, include: { materialPlan: true } }),
|
||||
record.kind === "order_completion" ? ctx.db.materialPlan.findMany({ where: { workOrderId: wo.id }, orderBy: { sortOrder: "asc" } }) : Promise.resolve([]),
|
||||
record.kind === "order_completion"
|
||||
? ctx.db.materialUsage.findMany({ where: { workOrderId: wo.id, materialPlanId: { not: null } }, select: { materialPlanId: true } })
|
||||
: Promise.resolve([]),
|
||||
ctx.db.report.findMany({
|
||||
where: reportWhere,
|
||||
orderBy: [{ reportDate: "asc" }, { version: "asc" }],
|
||||
select: { id: true, type: true, version: true, reportDate: true, approvedAt: true, content: true, signature: { select: { outcome: true, signerName: true, signedAt: true } } },
|
||||
}),
|
||||
record.kind === "daily_report" && record.reportId ? ctx.db.report.findFirst({ where: { id: record.reportId }, select: { reportDate: true } }) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// ---- time ----
|
||||
const persons = new Map<string, StatementPersonTime>();
|
||||
const timeEntryIds: string[] = [];
|
||||
const travelSegments: Array<{ startedAt: Date; endedAt: Date | null }> = [];
|
||||
const workDates = new Set<string>();
|
||||
let pendingMinutes = 0;
|
||||
let pendingCount = 0;
|
||||
let hasRunningEntries = false;
|
||||
for (const e of entries) {
|
||||
if (e.approvalStatus === "pending") {
|
||||
pendingCount++;
|
||||
if (e.type !== "break" && e.type !== "interruption") pendingMinutes += minutesBetween(e.startedAt, e.endedAt ?? now);
|
||||
continue;
|
||||
}
|
||||
if (!e.endedAt) {
|
||||
hasRunningEntries = true;
|
||||
continue;
|
||||
}
|
||||
timeEntryIds.push(e.id);
|
||||
const minutes = minutesBetween(e.startedAt, e.endedAt);
|
||||
const p = persons.get(e.userId) ?? { userId: e.userId, name: "—", ...emptyTime() };
|
||||
switch (e.type) {
|
||||
case "work":
|
||||
p.work += minutes;
|
||||
break;
|
||||
case "travel":
|
||||
p.travel += minutes;
|
||||
travelSegments.push({ startedAt: e.startedAt, endedAt: e.endedAt });
|
||||
break;
|
||||
case "return_travel":
|
||||
p.travel += minutes;
|
||||
break;
|
||||
case "material_procurement":
|
||||
p.materialProcurement += minutes;
|
||||
break;
|
||||
case "break":
|
||||
p.breaks += minutes;
|
||||
break;
|
||||
case "interruption":
|
||||
p.interruption += minutes;
|
||||
break;
|
||||
}
|
||||
if (e.type !== "break" && e.type !== "interruption") {
|
||||
workDates.add(dateKeyIn(e.startedAt, tz));
|
||||
if (e.source === "manual") p.manualMinutes += minutes;
|
||||
}
|
||||
persons.set(e.userId, p);
|
||||
}
|
||||
const names = persons.size ? await ctx.db.user.findMany({ where: { id: { in: [...persons.keys()] } }, select: { id: true, name: true } }) : [];
|
||||
const nameOf = new Map(names.map((u) => [u.id, u.name]));
|
||||
const totals = emptyTime();
|
||||
const personList = [...persons.values()]
|
||||
.map((p) => {
|
||||
const total = p.work + p.travel + p.materialProcurement;
|
||||
const withTotal = { ...p, name: nameOf.get(p.userId) ?? "—", total };
|
||||
for (const k of Object.keys(totals) as Array<keyof typeof totals>) totals[k] += withTotal[k];
|
||||
return withTotal;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de"));
|
||||
|
||||
// ---- material ----
|
||||
const used: StatementMaterialLine[] = [];
|
||||
const additional: StatementMaterialLine[] = [];
|
||||
const notUsed: StatementMaterialLine[] = [];
|
||||
const materialUsageIds: string[] = [];
|
||||
for (const u of usages) {
|
||||
materialUsageIds.push(u.id);
|
||||
const line: StatementMaterialLine = {
|
||||
usageId: u.id,
|
||||
planId: u.materialPlanId,
|
||||
name: u.name,
|
||||
articleNumber: u.articleNumber,
|
||||
quantity: u.actualQuantity.toString(),
|
||||
plannedQuantity: u.materialPlan ? u.materialPlan.plannedQuantity.toString() : null,
|
||||
unit: u.unit,
|
||||
status: u.usageStatus,
|
||||
deviationReason: u.deviationReason,
|
||||
};
|
||||
if (u.usageStatus === "not_used") notUsed.push(line);
|
||||
else if (u.usageStatus === "additional" || !u.materialPlanId) additional.push(line);
|
||||
else used.push(line);
|
||||
}
|
||||
const plansWithUsage = new Set(allPlanUsages.map((u) => u.materialPlanId));
|
||||
for (const p of plans) {
|
||||
if (plansWithUsage.has(p.id)) continue;
|
||||
notUsed.push({ usageId: null, planId: p.id, name: p.name, articleNumber: p.articleNumber, quantity: null, plannedQuantity: p.plannedQuantity.toString(), unit: p.unit, status: null, deviationReason: null });
|
||||
}
|
||||
|
||||
// ---- reports: references + texts ----
|
||||
const texts: BillingStatement["texts"] = { additionalWork: [], deviations: [], openItems: [] };
|
||||
const reportRefs: BillingStatement["reports"] = reports.map((r) => {
|
||||
const c = (r.content ?? {}) as ReportContentLike;
|
||||
const number = typeof c.reportNumber === "string" ? c.reportNumber : null;
|
||||
for (const key of ["additionalWork", "deviations", "openItems"] as const) {
|
||||
const v = c.texts?.[key];
|
||||
if (typeof v === "string" && v.trim()) (texts[key] as StatementTextBlock[]).push({ reportNumber: number, text: v.trim() });
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
number,
|
||||
type: r.type,
|
||||
version: r.version,
|
||||
reportDate: r.reportDate.toISOString().slice(0, 10),
|
||||
approvedAt: r.approvedAt?.toISOString() ?? null,
|
||||
signature: r.signature ? { outcome: r.signature.outcome, signerName: r.signature.signerName, signedAt: r.signature.signedAt.toISOString() } : null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: now.toISOString(),
|
||||
record: {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
status: record.status,
|
||||
periodFrom: record.periodFrom.toISOString(),
|
||||
periodTo: record.periodTo.toISOString(),
|
||||
readySince: record.createdAt.toISOString(),
|
||||
milestoneTitle: milestone?.title ?? null,
|
||||
milestoneDescription: milestone?.description ?? null,
|
||||
reportDate: dailyReport ? dailyReport.reportDate.toISOString().slice(0, 10) : null,
|
||||
invoiceNumber: record.invoiceNumber,
|
||||
billedAt: record.billedAt?.toISOString() ?? null,
|
||||
billedByName: billedBy?.name ?? null,
|
||||
},
|
||||
tenant: {
|
||||
name: settings?.orgName || tenantUser?.tenant.name || "—",
|
||||
address: settings?.address ?? null,
|
||||
phone: settings?.phone ?? null,
|
||||
email: settings?.email ?? null,
|
||||
logoDocumentId: null, // like reports: TenantSettings.logoKey is not a Document yet
|
||||
},
|
||||
customer: {
|
||||
id: wo.customer.id,
|
||||
number: wo.customer.customerNumber,
|
||||
name: customerDisplay(wo.customer),
|
||||
billingAddress: addressOf(wo.customer),
|
||||
billingNotes: wo.customer.billingNotes,
|
||||
},
|
||||
site: wo.site ? { id: wo.site.id, name: wo.site.name, address: addressOf(wo.site) } : null,
|
||||
workOrder: {
|
||||
id: wo.id,
|
||||
number: wo.number,
|
||||
externalOrderNumber: wo.externalOrderNumber,
|
||||
offerNumber: wo.offerNumber,
|
||||
title: wo.title,
|
||||
orderType: wo.orderType?.name ?? null,
|
||||
billingType: wo.billingType,
|
||||
team: wo.team?.name ?? null,
|
||||
},
|
||||
reports: reportRefs,
|
||||
workDates: [...workDates].sort(),
|
||||
time: { persons: personList, totals, pendingMinutes, pendingCount, hasRunningEntries },
|
||||
trips: countTrips(travelSegments, tz),
|
||||
materials: { used, additional, notUsed },
|
||||
texts,
|
||||
positions: { timeEntryIds, materialUsageIds },
|
||||
};
|
||||
}
|
||||
|
||||
/** Statement of a record for the UI/API (`billing:read`). Billed/voided → frozen snapshot. */
|
||||
export async function buildBillingStatement(ctx: ServiceCtx, recordId: string): Promise<BillingStatement> {
|
||||
assertCan(ctx, "billing:read");
|
||||
const record = await ctx.db.billingRecord.findFirst({ where: { id: recordId } });
|
||||
if (!record) throw new ServiceError("not_found", "billing_record_not_found");
|
||||
return statementOf(ctx, record);
|
||||
}
|
||||
|
||||
export async function statementOf(ctx: ServiceCtx, record: BillingRecord): Promise<BillingStatement> {
|
||||
if (record.status !== "open" && record.snapshot) {
|
||||
const s = record.snapshot as unknown as BillingStatement;
|
||||
return { ...s, record: { ...s.record, status: record.status, invoiceNumber: record.invoiceNumber } };
|
||||
}
|
||||
return computeStatement(ctx, record);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { milestoneReachPayload } from "@/lib/billing/schemas";
|
||||
import type { SyncOperationInput } from "@/lib/sync/envelope";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import type { ExternalOpResult } from "@/server/services/sync/external-ops";
|
||||
import { markMilestoneReached } from "./milestones";
|
||||
|
||||
/**
|
||||
* Sync op `milestone.reach` (registered in services/sync/external-ops.ts). Idempotent twice over:
|
||||
* the SyncOperation clientOpId and the service itself (an already reached milestone is unchanged).
|
||||
*/
|
||||
export async function applySyncOp(ctx: ServiceCtx, op: SyncOperationInput): Promise<ExternalOpResult> {
|
||||
if (op.opType !== "milestone.reach") throw new ServiceError("invalid", "unsupported_op");
|
||||
const parsed = milestoneReachPayload.safeParse(op.payload);
|
||||
if (!parsed.success) throw new ServiceError("invalid", "sync_payload_invalid");
|
||||
await markMilestoneReached(ctx, parsed.data);
|
||||
return {};
|
||||
}
|
||||
@@ -161,6 +161,10 @@ export function linkFor(event: DomainEvent, f: EventFacts, permissions: Readonly
|
||||
// L12: approvers open their approval list, the technician „Meine Zeiten"
|
||||
if (event.type === "time.approval_requested") return backoffice && permissions.has("time:approve") ? "/work-orders/time-approvals" : "/m/approvals";
|
||||
return "/m/time";
|
||||
case "milestone":
|
||||
// L14: back office → billing tab of the order, field → mobile order detail
|
||||
if (!f.workOrderId) return null;
|
||||
return backoffice ? `/work-orders/${f.workOrderId}?tab=billing` : `/m/orders/${f.workOrderId}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -321,6 +321,20 @@ export async function resolveRecipients(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "milestone.reached":
|
||||
case "milestone.confirmed":
|
||||
case "milestone.rejected": {
|
||||
// L14: reported → billing staff (in-app); confirmed/rejected → the reporting person (in-app)
|
||||
const milestone = await ctx.db.workOrderMilestone.findFirst({ where: { id: event.entityId }, select: { workOrderId: true } });
|
||||
if (!milestone) return null;
|
||||
const wo = await loadWorkOrder(ctx, milestone.workOrderId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
if (typeof event.data?.reason === "string") facts.rejectionReason = event.data.reason;
|
||||
if (event.type === "milestone.reached") rule = { users: [hasPermissionWhere("billing:write")], userIds: [], mailToUsers: false };
|
||||
else rule = { users: [], userIds: typeof event.data?.reporterId === "string" ? [event.data.reporterId] : [], mailToUsers: false };
|
||||
break;
|
||||
}
|
||||
case "sync.failed": {
|
||||
const op = await ctx.db.syncOperation.findFirst({
|
||||
where: { id: event.entityId },
|
||||
|
||||
@@ -20,6 +20,7 @@ export const EXTERNAL_OP_OWNERS: Partial<Record<SyncOpType, string>> = {
|
||||
"report.submit": "reports (L5)",
|
||||
"signature.capture": "reports (L5)",
|
||||
"emergency.create": "emergency (L8)",
|
||||
"milestone.reach": "billing (L14)",
|
||||
};
|
||||
|
||||
export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHandler>>> = {
|
||||
@@ -27,4 +28,5 @@ export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHa
|
||||
"report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // L10b
|
||||
// not registered: "signature.capture" — needs a signature image upload kind in /api/v1/uploads first (see docs/craftvia/lanes/betrieb.md)
|
||||
"emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp),
|
||||
"milestone.reach": () => import("@/server/services/billing/sync-ops").then((m) => m.applySyncOp), // L14
|
||||
};
|
||||
|
||||
@@ -3,8 +3,9 @@ import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
||||
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||
import { countOpenBillingRecords } from "@/server/services/billing/queries";
|
||||
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number; timeApprovals: number };
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number; timeApprovals: number; billingReady: number };
|
||||
|
||||
/**
|
||||
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
||||
@@ -18,14 +19,15 @@ export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter
|
||||
const counts = await Promise.all(
|
||||
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
||||
);
|
||||
const [reportsToReview, syncConflicts, timeApprovals] = await Promise.all([
|
||||
const [reportsToReview, syncConflicts, timeApprovals, billingReady] = await Promise.all([
|
||||
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
||||
can(ctx, "work_order:write")
|
||||
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
||||
: Promise.resolve(0),
|
||||
countPendingTimeEntries(ctx), // L12 „Zeiten zur Freigabe" (0 without time:approve)
|
||||
countOpenBillingRecords(ctx), // L14 „Bereit zur Abrechnung" (0 without billing:read)
|
||||
]);
|
||||
|
||||
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
||||
return { ...tiles, reportsToReview, syncConflicts, timeApprovals };
|
||||
return { ...tiles, reportsToReview, syncConflicts, timeApprovals, billingReady };
|
||||
}
|
||||
|
||||
@@ -76,13 +76,16 @@ export async function applyTransition(
|
||||
baseVersion?: number;
|
||||
extra?: Record<string, unknown>;
|
||||
eventData?: Record<string, string | number | boolean | null>;
|
||||
/** L14: billed → released_for_billing is ONLY allowed when a billing record is voided (services/billing/records.ts) */
|
||||
billingVoid?: boolean;
|
||||
} = {},
|
||||
): Promise<TransitionResult> {
|
||||
const from = wo.status;
|
||||
if (!canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to });
|
||||
if (!mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to });
|
||||
const voidRevert = opts.billingVoid === true && from === "billed" && to === "released_for_billing";
|
||||
if (!voidRevert && !canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to });
|
||||
if (voidRevert ? !can(ctx, "work_order:release_billing") : !mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to });
|
||||
assertBaseVersion(wo, opts.baseVersion);
|
||||
if (reasonRequired(from, to) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to });
|
||||
if ((voidRevert || reasonRequired(from, to)) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to });
|
||||
|
||||
const blockers = await transitionBlockers(ctx, wo, to);
|
||||
if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers);
|
||||
|
||||
Reference in New Issue
Block a user