L14 Abrechnungsübersicht: Services für Einträge, Aufstellung, Abrechnen/Stornieren, Meilensteine und PDF

syncBillingCandidates (idempotent, Event-Hook für work_order.released_for_billing und report.approved), Aufstellung ohne Preise (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen informativ, Anfahrten-Zählung, Material), markBilled mit eingefrorenem Snapshot und Positionszuordnung, voidBilling mit Grund und neuem offenen Eintrag (billed → released_for_billing nur über applyTransition-Option billingVoid), Meilenstein-Flow mit Events, Job billing-pdf (Dokument other/backoffice_only), Sync-Op milestone.reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:35:38 +02:00
co-authored by Claude Opus 5
parent 6358d5762c
commit ac2d857e26
27 changed files with 2179 additions and 5 deletions
+250
View File
@@ -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."
}
}
+16 -1
View File
@@ -48,7 +48,10 @@
"sync_failed": "Synchronisationsfehler", "sync_failed": "Synchronisationsfehler",
"time_approval_requested": "Zeit zur Freigabe", "time_approval_requested": "Zeit zur Freigabe",
"time_approved": "Zeit freigegeben", "time_approved": "Zeit freigegeben",
"time_rejected": "Zeit abgelehnt" "time_rejected": "Zeit abgelehnt",
"milestone_reached": "Meilenstein erreicht",
"milestone_confirmed": "Meilenstein bestätigt",
"milestone_rejected": "Meilenstein abgelehnt"
}, },
"events": { "events": {
"work_order_assigned": { "work_order_assigned": {
@@ -130,6 +133,18 @@
"time_rejected": { "time_rejected": {
"title": "Zeit abgelehnt: {number}", "title": "Zeit abgelehnt: {number}",
"message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}" "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}"
},
"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": { "fallback": {
+250
View File
@@ -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."
}
}
+16 -1
View File
@@ -48,7 +48,10 @@
"sync_failed": "Sync failed", "sync_failed": "Sync failed",
"time_approval_requested": "Time to approve", "time_approval_requested": "Time to approve",
"time_approved": "Time approved", "time_approved": "Time approved",
"time_rejected": "Time rejected" "time_rejected": "Time rejected",
"milestone_reached": "Milestone reached",
"milestone_confirmed": "Milestone confirmed",
"milestone_rejected": "Milestone rejected"
}, },
"events": { "events": {
"work_order_assigned": { "work_order_assigned": {
@@ -130,6 +133,18 @@
"time_rejected": { "time_rejected": {
"title": "Time rejected: {number}", "title": "Time rejected: {number}",
"message": "{actor} rejected your time for {number}: {reason}" "message": "{actor} rejected your time for {number}: {reason}"
},
"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": { "fallback": {
@@ -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>
);
}
+77
View File
@@ -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>;
+148
View File
@@ -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 };
}
+2
View File
@@ -25,6 +25,8 @@ export const SYNC_OP_TYPES = [
"session.segment", "session.segment",
"time.add_manual", "time.add_manual",
"time.propose_correction", "time.propose_correction",
// L14 Abrechnungsübersicht (additive, conflict-free; registered in services/sync/external-ops.ts)
"milestone.reach",
] as const; ] as const;
export type SyncOpType = (typeof SYNC_OP_TYPES)[number]; export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
+1
View File
@@ -200,6 +200,7 @@ export const OP_PAYLOAD_SCHEMAS = {
"session.segment": sessionSegmentPayload, "session.segment": sessionSegmentPayload,
"time.add_manual": timeAddManualPayload, "time.add_manual": timeAddManualPayload,
"time.propose_correction": timeProposeCorrectionPayload, "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>; } satisfies Record<SyncOpType, z.ZodType>;
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
+9
View File
@@ -13,4 +13,13 @@ export async function emitEvent(ctx: ServiceCtx, event: DomainEvent): Promise<vo
} catch (err) { } catch (err) {
console.error(`[events] ${event.type} for ${event.entityType}:${event.entityId} failed:`, (err as Error).message); 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);
}
}
} }
+19
View File
@@ -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}`}`);
}
+1
View File
@@ -13,6 +13,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle "report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
"image-derivatives": () => import("./image-derivatives").then((m) => m.process), "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker) "ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
"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). */ /** Inline fallback when no Redis is available (dev/demo). */
+1
View File
@@ -13,6 +13,7 @@ export const JOB_QUEUES = {
reportPdf: "report-pdf", reportPdf: "report-pdf",
imageDerivatives: "image-derivatives", imageDerivatives: "image-derivatives",
aiRetention: "ai-retention", aiRetention: "ai-retention",
billingPdf: "billing-pdf", // L14 Abrechnungsblatt
} as const; } as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES]; export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
+46
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 };
}
+90
View File
@@ -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));
}
+31
View File
@@ -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));
}
+16
View File
@@ -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 });
}
}
+172
View File
@@ -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;
}
+60
View File
@@ -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 };
}
+183
View File
@@ -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 } } });
}
+180
View File
@@ -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);
}
+261
View File
@@ -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);
}
+17
View File
@@ -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 {};
}
@@ -156,6 +156,10 @@ export function linkFor(event: DomainEvent, f: EventFacts, permissions: Readonly
// L12: approvers open their approval list, the technician „Meine Zeiten" // 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"; if (event.type === "time.approval_requested") return backoffice && permissions.has("time:approve") ? "/work-orders/time-approvals" : "/m/approvals";
return "/m/time"; 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: default:
return null; return null;
} }
@@ -317,6 +317,20 @@ export async function resolveRecipients(
} }
break; 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": { case "sync.failed": {
const op = await ctx.db.syncOperation.findFirst({ const op = await ctx.db.syncOperation.findFirst({
where: { id: event.entityId }, where: { id: event.entityId },
+2
View File
@@ -20,6 +20,7 @@ export const EXTERNAL_OP_OWNERS: Partial<Record<SyncOpType, string>> = {
"report.submit": "reports (L5)", "report.submit": "reports (L5)",
"signature.capture": "reports (L5)", "signature.capture": "reports (L5)",
"emergency.create": "emergency (L8)", "emergency.create": "emergency (L8)",
"milestone.reach": "billing (L14)",
}; };
export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHandler>>> = { 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 "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) // 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), "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
}; };
@@ -76,13 +76,16 @@ export async function applyTransition(
baseVersion?: number; baseVersion?: number;
extra?: Record<string, unknown>; extra?: Record<string, unknown>;
eventData?: Record<string, string | number | boolean | null>; 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> { ): Promise<TransitionResult> {
const from = wo.status; const from = wo.status;
if (!canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to }); const voidRevert = opts.billingVoid === true && from === "billed" && to === "released_for_billing";
if (!mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to }); 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); 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); const blockers = await transitionBlockers(ctx, wo, to);
if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers); if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers);