Reports & Custom Reports API
Eviworx besitzt zwei getrennte Report-Systeme: (1) vordefinierte Standard-Reports (Kosten-/Budget-/Invest-Auswertungen, synchron im Backend) und (2) einen Custom Report Builder, der asynchron über den dedizierten report-generator Container mit READ-ONLY Datenbankzugriff ausgeführt wird (CSV/XLSX/PDF-Export, Scheduling, Live-Preview).
🔐 Auth: Alle Report-Endpunkte sind nur für angemeldete Benutzer: Sie akzeptieren eine Cookie-Session, aber keinen X-API-Key. Permissions werden über die Rollen-Matrix des Aufrufers geprüft. Siehe RBAC →.
Standard-Reports
Vordefinierte Kosten-/Budget-Auswertungen über Verträge, Lizenzen und Assets. Alle Endpunkte sind GET und liefern synchron JSON. Jede Kostenquelle wird nach dem Recht ihrer EIGENEN Entität gefiltert (viewAll / viewOwn / reporting auf contracts, licenses, assets), nach denselben Regeln wie Liste, Suche und Custom Reports: gesperrte Asset-Typen ohne Freigabe bleiben draußen, ein viewOwn-Manager sieht die Verträge der ihm unterstellten Mitarbeitenden, und viewOwn auf Lizenzen folgt der Zuweisung. Ein Bericht zeigt damit dieselben Kosten, die der Aufrufer auch in der jeweiligen Liste findet.
| Method | Endpoint | Beschreibung | Permission |
|---|---|---|---|
GET | /api/reports/yearly-overview | Jahresübersicht der Kosten | reports.viewReports |
GET | /api/reports/category-breakdown | Aufschlüsselung nach Kategorien | reports.viewReports |
GET | /api/reports/renewal-calendar | Verlängerungskalender (Verträge/Lizenzen) | reports.viewReports |
GET | /api/reports/budget-plan | Budget-Planung | reports.viewReports + reports.viewBudget |
GET | /api/reports/cost-center-budget | Budget vs. Ist je Kostenstelle | reports.viewReports + reports.viewBudget |
GET | /api/reports/invest-plan | Mehrjahres-Investitionsplan (Assets) | reports.viewReports + reports.viewInvestPlan |
GET | /api/reports/export | Standard-Report exportieren (XLSX/CSV/PDF) | reports.viewReports + reports.exportReports |
Query-Parameter
Jeder Parameter wird gegen ein Schema geprüft. Ein unbekannter oder unpassender Wert ist 400 VALIDATION_ERROR mit Feld-Pfad. Die Parameter-Mengen sind je Route verschieden: der Ablauf-Kalender kennt weder year noch assetCostMode, der Invest-Plan statt year den Parameter years.
| Parameter | Routen | Beschreibung |
|---|---|---|
year | alle außer renewal-calendar und invest-plan | Bezugsjahr, Ganzzahl im Fenster ±20 Jahre um das laufende Jahr (Default: aktuelles Jahr) |
months | renewal-calendar, export | Vorschau-Fenster in Monaten, 1–24 (Default: 12) |
years | invest-plan, export | Anzahl Jahre des Mehrjahresplans, 1–10 (Default: 5) |
sources | alle | Komma-Liste der Kostenquellen: contracts, licenses, assets. Ohne Angabe gelten alle drei; ein unbekanntes Token ist 400. |
assetCostMode | alle außer renewal-calendar | depreciation (Default: der Preis verteilt sich über die Nutzungsdauer) | capex (voller Kaufpreis im Anschaffungsmonat) |
type | export | yearly | category | renewal | budget | invest | costcenterbudget |
format | export | xlsx | csv | pdf |
Sprache der Exporte: Die Exporte folgen der Profilsprache des Aufrufers, ersatzweise der Systemsprache (general-settings.defaultLanguage), ersatzweise Englisch — dieselbe Kette wie bei Lizenz-, Asset- und Vertrags-Exporten. Die Antworten selbst tragen keine übersetzten Texte: Monate stehen als Schlüssel (2026-01), und nicht zugeordnete Positionen tragen name: null. Der Dateiname kommt aus dem Content-Disposition-Header der Antwort.
Aufbau der Kostenzahlen
- Drei benannte Quellen auf jeder Ebene: Monat, Quartal und Jahr tragen contracts, licenses und assets einzeln; die Summe der drei ist total.
- Invest-Plan: baseline (Bestand, = q1+q2+q3+q4), forecast (davon Prognose: die projizierten Verlängerungen des Jahres, 0 in Nicht-Prognosejahren) und total = baseline + forecast. Prognostiziert werden Vertrags- und Lizenz-Verlängerungen, keine Asset-Neuanschaffungen.
- Positionszähler: Monats- und Quartalsknoten tragen itemCount (Anzahl der Einzelpositionen). Ausführliche Zeilen liefern nur der Ablauf-Kalender und das Kostenstellen-Budget.
# Jahresübersicht nur für Verträge+Lizenzen, capex-Modus
GET /api/reports/yearly-overview?year=2026&sources=contracts,licenses&assetCostMode=capex
# Budget-Plan als XLSX exportieren
GET /api/reports/export?type=budget&format=xlsx&year=2026
Custom Report Builder
Frei definierbare Reports über eine oder mehrere Datenquellen mit Spalten, Filtern, Gruppierung, Sortierung und Visualisierung. Ausführen, Exportieren und Vorschau laufen ASYNCHRON: Das Backend legt eine Ausführung mit Status PENDING an und antwortet mit 202; der report-generator verarbeitet die Abfrage mit reinem Lesezugriff. Den Fortschritt fragt der Client über den Status-Endpunkt ab.
ASYNCHRONER EXECUTION-FLOW: 1. POST /api/custom-reports/:id/execute|export (oder /preview) 2. Backend erstellt ReportExecution (status: PENDING) → 202 3. Backend stellt BullMQ-Job ein → Queue "report-execution" (+ _trace) 4. report-generator übernimmt Job, führt Query READ-ONLY aus5. report-generator meldet den Status an das Backend (RUNNING/COMPLETED/FAILED) 6. Bei Export: Datei wird als Attachment gespeichert (virusScanStatus PENDING) 7. Frontend pollt GET .../executions/:execId/status → COMPLETED 8. Frontend GET .../executions/:execId/download
Report-Verwaltung
| Method | Endpoint | Beschreibung | Permission |
|---|---|---|---|
GET | /api/custom-reports | Eigene + geteilte Reports auflisten — Antwort { data, pagination }; per Default 50, Maximum 100, Sortierung fest nach letzter Änderung | viewOwn ‖ viewShared ‖ viewAll |
GET | /api/custom-reports/schema | Verfügbare Entitäten & Felder (dynamisch nach Recht + Sichtbarkeit) | create ‖ editOwn ‖ editAll |
GET | /api/custom-reports/share-targets | Teilbare Rollen mit abgeleiteten Flags (für den Teilen-Dialog). reportId ist Pflicht; Antwort { data } | share |
GET | /api/custom-reports/:id/recipient-candidates | Empfänger-Vorschläge für die Planung (nur report-sichtbare User). Suche über q oder Nachschlagen über ids (Komma-Liste, max. 50), limit 1–25; Antwort { data } | schedule + Edit auf den Report |
GET | /api/custom-reports/:id | Einzelnen Report abrufen — ohne Ausführungs-Historie, die liefert /:id/executions | view (siehe oben) |
POST | /api/custom-reports | Neuen Report erstellen (201) | create (+ share) |
PUT | /api/custom-reports/:id | Report aktualisieren | editOwn ‖ editAll (+ share) |
POST | /api/custom-reports/:id/duplicate | Report kopieren, optional { name } (201) | create (+ view auf die Quelle) |
DELETE | /api/custom-reports/:id | Report löschen (204, ohne Body) | deleteOwn ‖ deleteAll |
Die Kopie gehört immer dem Aufrufer und startet bewusst privat: createdById = Aufrufer, isShared=false, sharedWithRoles=[], isScheduled=false, recipients=[]. Ohne eigenen Namen im Body wird „(copy)" angehängt. So wird aus einem geteilten Report nie versehentlich ein zweiter geteilter oder geplanter Report.
isShared, sharedWithRoles und sharedWithAllRoles darf beim Erstellen oder Bearbeiten nur setzen oder ändern, wer customReports.share besitzt — damit gibt niemand ohne dieses Recht Daten weiter. customReports.deleteAll ist eine kritische Aktion: Beim Löschen eines FREMDEN Reports oder einer fremden Generierung wird das Recht direkt in der Datenbank geprüft, ein gerade entzogenes Recht wirkt also sofort. PUT ist ein echtes Teil-Update: nicht gesendete Felder bleiben unberührt, und description lässt sich mit null leeren.
Mit Rollen teilen
Ein Report wird an ROLLEN geteilt, nicht an einzelne Nutzer. Drei Felder steuern das:
| Feld | Wirkung |
|---|---|
isShared | Schaltet das Teilen überhaupt ein. |
sharedWithRoles | Liste der Rollen-Namen, die den Report über viewShared sehen. Namen sind stabil (nach Anlage unveränderlich). |
sharedWithAllRoles | Explizites „mit allen Rollen teilen". Nur mit diesem Flag ist der Report für jede viewShared-Rolle sichtbar. |
Leere Rollenliste: Eine leere sharedWithRoles-Liste bedeutet NIEMAND. „Mit allen Rollen teilen" verlangt das ausdrückliche Flag sharedWithAllRoles=true. So kann ein versehentlich leer gespeicherter Verteiler niemandem ungewollt Einblick geben.
Für die Rollenauswahl im Teilen-Dialog liefert GET /share-targets die aktiven Rollen mit abgeleiteten Hinweisen — canViewShared (hat die Rolle das viewShared-Recht?) und missingSources (welche Datenquellen des Reports darf die Rolle nicht auswerten?). Die Antwort enthält nur diese Flags, nicht die Rechte der Rollen selbst. Der Endpunkt verlangt customReports.share; damit können auch Nicht-Admins mit diesem Recht teilen.
Was „Teilen" bedeutet — bitte bewusst einsetzen: Geteilt wird nicht nur die Definition, sondern auch die ERGEBNIS-Sicht. Wer den Report sehen darf UND customReports.export besitzt, sieht die Ergebnisse und Downloads auch FREMDER Läufe — und zwar im Daten-Scope desjenigen, der den Lauf ausgelöst hat. Führt eine Administratorin den Report mit ihrer Vollsicht aus, sehen alle export-Berechtigten dieses Ergebnis, nicht ihr eigenes, engeres. Das export-Recht gilt einheitlich für alle drei Ergebnis-Wege: die inline-Daten (/status), den Datei-Download und den generischen Attachment-Weg. Wer den Report nur sehen darf, sieht Status und Zeilenzahl, aber keine Nutzdaten.
Das ist bewusst so und deckt sich mit den geplanten Reports, deren Mails ebenfalls den Scope des Erstellers an die berechtigten Empfänger liefern: Teilen IST die Datenweitergabe; maßgeblich ist, wer den Report sehen darf. Ergebnisse LÖSCHEN darf dagegen nur, wer den Lauf ausgelöst hat, dem Report gehört oder deleteAll besitzt — Sehen und Aufräumen sind zwei verschiedene Dinge.
Ausführung & Export (async)
| Method | Endpoint | Beschreibung | Permission |
|---|---|---|---|
POST | /api/custom-reports/preview | Live-Preview ohne gespeicherten Report (202) | create ‖ editOwn ‖ editAll |
POST | /api/custom-reports/:id/execute | Report ausführen, Daten-Ergebnis (202) | view (Lesen) |
POST | /api/custom-reports/:id/export | Export in einem Format, Body { format } (202) | export |
POST | /api/custom-reports/:id/generate | In ALLEN konfigurierten exportFormats generieren (202) | export |
generate erstellt eine Execution pro konfiguriertem Format und ist auf 25 gespeicherte Generierungen pro Report begrenzt → 409 EXECUTION_LIMIT_REACHED (zuerst alte Versionen löschen).
Scheduling & Execution-History
| Method | Endpoint | Beschreibung | Permission |
|---|---|---|---|
POST | /api/custom-reports/:id/schedule | Scheduling setzen { isScheduled, cronExpression, exportFormats, recipients } | schedule |
GET | /api/custom-reports/:id/executions | Ausführungs-Historie eines Reports — bewusst NUR Metadaten (Status, recordCount, Zeitpunkte). Die Ergebnis-Daten selbst liefert /executions/:execId/status, damit eine Historien-Liste nicht ungefragt Nutzdaten aller Läufe überträgt. Antwort { data }, höchstens 25 Zeilen; je Zeile id, executedById, status, format, recordCount, errorMessage, createdAt und attachment{originalName} oder null. | view |
GET | /api/custom-reports/executions/:execId/status | Execution-Status abfragen (Polling) | view |
GET | /api/custom-reports/executions/:execId/download | Generierte Datei herunterladen (als infiziert erkannte Dateien werden blockiert) | export |
DELETE | /api/custom-reports/executions/:execId | Execution + Attachment löschen (204, ohne Body) | Ersteller des Laufs ‖ Report-Eigentümer ‖ deleteAll |
Datenquellen & Report-Definition
GET /schema liefert die für den Aufrufer verfügbaren Datenquellen samt freigegebener Felder (Recht: customReports.create, editOwn ODER editAll — auch wer nur bearbeitet, braucht das Schema für Feld-Labels und Filter). Das Angebot ist DYNAMISCH und hängt an zwei Bedingungen zugleich: Der Aufrufer besitzt das reporting-Recht der Domäne (Assets zusätzlich viewAllHandovers ‖ viewOwnHandovers) UND sein Sichtbereich in dieser Domäne ist nicht von vornherein leer. Wer reporting auf changes hat, aber keine Changes sehen darf, bekommt changes also nicht angeboten — eine solche Quelle könnte beim Ausführen nur 0 Zeilen liefern. Felder und Report-Zeilen richten sich immer nach den Rechten des Aufrufers; ohne passende Berechtigung wird nichts angezeigt.
| Datenquelle | Benötigtes Recht |
|---|---|
tickets | tickets.reporting |
problems | problems.reporting |
changes | changes.reporting |
incidents | incidents.reporting |
assets | assets.reporting + (viewAllHandovers ‖ viewOwnHandovers) |
contracts | contracts.reporting |
licenses | licenses.reporting |
Sub-Entitäten erben das reporting-Recht ihrer Domäne: changes.reporting schaltet changes UND changeTasks frei, assets.reporting schaltet assets UND handovers frei (letzteres zusätzlich mit dem Handover-Sichtrecht). Ein eigenes reporting-Recht für changeTasks/handovers gibt es nicht.
Jedes Feld liefert neben dem Label auch einen Übersetzungs-Key (labelKey), enum-Felder zusätzlich ihre Enum-Gruppe (enumKey) — der Builder zeigt Felder, Werte und Joins damit in der UI-Sprache; das gespeicherte Spalten-Label bleibt der beim Hinzufügen gewählte Text.
Felder mit Zusatz-Berechtigung
Einzelne Felder verlangen über das reporting-Recht hinaus eine weitere Berechtigung der Domäne. Sie erscheinen dann gar nicht erst im Schema-Angebot; wer sie per API dennoch referenziert, bekommt 400 mit Issue-Code FIELD_NOT_ALLOWED, und beim Ausführen schlägt eine solche Definition ebenfalls fehl (Execution FAILED). Betroffen sind:
| Feld(er) | zusätzlich nötig | Warum |
|---|---|---|
tickets.participantEmails | tickets.viewAll | Verkettet die E-Mail-Adressen aller Beteiligten (inkl. externer CC) — eine Own-Scope-Rolle soll keine Adresslisten exportieren. participantCount (nur die Zahl) bleibt frei. |
incidents.isDataBreach, dsbNotifiedAt, dsbAcknowledgedAt, affectedDataSubjects | incidents.viewPIR | Der DSGVO-/Data-Breach-Block eines Incidents. |
Bewusste Asymmetrie: Damit ist der Report an dieser Stelle STRENGER als das Incident-Detail, das den DSGVO-Block jedem Incident-Sichtberechtigten zeigt. Das ist gewollt — ein Massenexport-Kanal (CSV/XLSX, Schedules, Mail) rechtfertigt eine höhere Hürde als die Einzelsicht. Rollen ohne incidents.viewPIR sehen die DSGVO-Spalten in ihren Reports nicht, bis ein Admin der Rolle das Recht gibt.
Aggregation & Zeilen-Zähler
Aggregate (COUNT/SUM/AVG/MIN/MAX) funktionieren mit UND ohne groupBy: ohne Gruppierung bilden alle Zeilen EINE Gesamtgruppe — das Ergebnis ist eine Zeile mit den Gesamtwerten (macht die KPI-Visualisierung erst sinnvoll). Aggregiert wird über die geladenen Zeilen, gedeckelt bei 10.000.
| Feld | Bedeutung |
|---|---|
totalCount | Echter Datenbank-Count der Haupt-Entität für die Filter — die Gesamtmenge, nicht nur die gelieferten Zeilen. |
rowCount | Anzahl tatsächlich gelieferter Ergebniszeilen. |
countIsExact | false, wenn totalCount wegen Zeilen-Deckel oder nachgelagerter Filterung (berechnete Felder) nur ein Schätzer ist — dann zählt rowCount. |
SLA-Felder (an der tickets-Entität)
SLA-Daten stehen als zusätzliche Spalten der Entität tickets zur Verfügung. Dadurch gelten für sie dieselben Sichtbarkeitsregeln und Verknüpfungen wie für das Ticket selbst.
| Feld | Typ | Bedeutung |
|---|---|---|
slaStatus | enum | OK · WARNING · BREACH · CRITICAL · CANCELLED |
slaResponseMet / slaResolutionMet | boolean | Response- bzw. Lösungs-Ziel erfüllt |
slaResponseDeadline / slaResolutionDeadline | date | Fristen (business-hours-berechnet) |
slaResponseAt / slaResolvedAt | date | Zeitpunkt der Reaktion bzw. Lösung |
slaBreachAt | date | Zeitpunkt der Verletzung |
slaIsPaused / slaPausedMinutes | boolean / number | Uhr steht gerade / kumulierte Pausenzeit |
slaEscalationLevel | number | Erreichte Eskalationsstufe |
slaPercentUsed | number | Verbrauchte Zielzeit in Prozent |
slaExcludedFromReporting | boolean | Aus Compliance-Auswertungen ausgenommen |
slaPolicyName | string | Angewandte SLA-Richtlinie |
Ein Report über Tickets zeigt ALLE Tickets — die SLA-Spalten sind eine Zusatzdimension. Für Compliance-Zahlen filtert man auf slaExcludedFromReporting = false und slaStatus ≠ CANCELLED; die mitgelieferte Vorlage „SLA-Compliance nach Priorität" macht genau das als sichtbaren Filter. Zwei Builder-Vorlagen sind an Bord: „SLA-Verstöße letzter Monat" und „SLA-Compliance nach Priorität". Gegenprobe für Zahlen: GET /api/sla/report.
Sub-Ticket-Felder (an der tickets-Entität)
| Feld | Typ | Bedeutung |
|---|---|---|
parentTicket.ticketNumber | relation | Nummer des Elterntickets — Spalte „Elternticket". Filter- und sortierbar. |
childTicketCount | computed | Anzahl der Sub-Tickets — Spalte „Anzahl Sub-Tickets". Gelöschte Sub-Tickets zählen nicht mit. Filter- und sortierbar. |
Die Eltern-Kind-Beziehung lässt sich damit direkt auswerten: parentTicket.ticketNumber mit isNull liefert alle Tickets ohne Elternticket, mit isNotNull nur die Sub-Tickets; childTicketCount > 0 liefert die Elterntickets. Als berechnetes Feld gilt für childTicketCount die Regel aller berechneten Felder — filterbar nur auf der Haupt-Entität, und die Filterung läuft nach der Abfrage (siehe countIsExact).
Definition (CustomReport):
dataSources (entity/alias/joinOn/joinType inner|left, max. 5),
columns (source/field/label, aggregate COUNT|SUM|AVG|MIN|MAX, max. 50),
filters (siehe unten, max. 20 Bedingungen),
sorting, groupBy (je max. 5),
visualization (table|bar|line|pie|kpi), chartConfig, pdfOptions,
exportLocale (de|en|fr|es|it),
isShared/sharedWithRoles/sharedWithAllRoles, exportFormats (csv|xlsx|pdf), recipients.
Filter: Operatoren & ODER-Gruppen
| Operator | Anwendung |
|---|---|
eq · neq | Gleich / ungleich |
gt · gte · lt · lte · between | Zahlen- und Datumsvergleiche |
contains · startsWith | Textsuche |
in · notIn | Werte-Liste (Array, kein Komma-String) |
isNull · isNotNull | Feld gesetzt / nicht gesetzt. Auf einem Relationsfeld (z.B. Agent, Kategorie, Elternticket) fragen beide Operatoren die Verknüpfung selbst ab: vorhanden / nicht vorhanden. |
relative | NUR Datumsfelder: benannter Zeitraum statt festem Datum (today, yesterday, this_week, last_week, last_7_days, last_30_days, last_90_days, this_month, last_month, this_quarter, last_quarter, this_year, last_year, next_7_days, next_30_days …). Wird zur AUSFÜHRUNGSZEIT aufgelöst — dadurch bleiben geplante Reports dauerhaft aktuell, statt auf ein festes Datum einzufrieren. |
Das Feld filters hat GENAU EINE Form: die Gruppen-Form mit "version": 2, auch bei einem Report mit einer einzigen UND-Gruppe. Jede andere Form im Request (etwa ein flaches Array) wird mit 400 VALIDATION_ERROR abgelehnt — so kann ein falsch geformter Filter nie unbemerkt zu einem Report ohne Einschränkung werden. Das Feld ist beim Erstellen Pflicht; „keine Filter" ist groups: [].
// (status=OPEN UND prio=CRITICAL) ODER (älter als 30 Tage)
"filters": {
"version": 2,
"groupLogic": "OR", // Verknüpfung ZWISCHEN Gruppen — Default OR
"conditionLogic": "AND", // Verknüpfung INNERHALB Gruppen — Default AND
"groups": [
{ "conditions": [
{ "source": "t", "field": "status", "operator": "eq", "value": "OPEN" },
{ "source": "t", "field": "priority", "operator": "eq", "value": "CRITICAL" }
] },
{ "conditions": [
{ "source": "t", "field": "createdAt", "operator": "relative", "value": "last_30_days" }
] }
]
}
// Ein einfacher Report = genau eine UND-Gruppe:
"filters": { "version": 2, "groups": [ { "conditions": [
{ "source": "t", "field": "status", "operator": "in", "value": ["OPEN", "IN_PROGRESS"] }
] } ] }
| Regel | Wert |
|---|---|
| Gruppen | 1–5, jede mit mindestens einer Bedingung |
| Bedingungen | max. 20 GESAMT über alle Gruppen |
groupLogic / conditionLogic | OR|AND bzw. AND|OR — beide frei wählbar, damit sich auch (A oder B) UND (C oder D) ohne doppelte Bedingungen ausdrücken lässt |
Sicherheit: Die Rechte-Einschränkung (gelöschte Datensätze, Eigentum, Sichtrechte) wird IMMER zusätzlich per UND angewendet, außerhalb der Gruppen — eine ODER-Gruppe kann die Sichtbarkeit also nicht erweitern. Zwei Bedingungen auf dasselbe Feld werden im UND-Modus zusammengeführt (z.B. zu einer Spanne), im ODER-Modus bleibt jede ein eigener Zweig; ein Umschalten von conditionLogic liefert deshalb das erwartete Ergebnis.
Eine Eigenheit des UND-Modus ist erwähnenswert: Zwei Bedingungen mit demselben Operator auf demselben Feld (Status = A UND Status = B) überschreiben sich — die letzte gewinnt, statt eine leere Menge zu ergeben. Wer „A oder B" meint, nutzt dafür den in-Operator oder eine ODER-Gruppe.
Bedingungen auf verknüpften Quellen: Zeilen vs. Einträge
Eine Bedingung auf einer verknüpften Quelle (z.B. „Asset-Status = IN_USE") wirkt normalerweise auf der Ebene der HAUPT-Entität: Sie entscheidet, ob ein Ticket überhaupt in den Report kommt („hat mindestens ein passendes Asset") — welche seiner Assets dann als Zeilen erscheinen, ist eine getrennte Frage. Kommt jedoch eine berechnete Bedingung ins Spiel (z.B. ein SLA-Feld), wertet die Engine das gesamte Prädikat pro AUFGEKLAPPTER Zeile aus. Die Menge der Tickets bleibt identisch, die Menge der Zeilen kann sich unterscheiden:
Filter: (assets.status = IN_USE) ODER (priority = CRITICAL) Ticket matcht nur über Gruppe 1 und trägt Assets [IN_USE, RETIRED] ohne berechnete Bedingung → 2 Zeilen (IN_USE + RETIRED)mit berechneter Bedingung → 1 Zeile (nur IN_USE)
Sonderfall: isNull auf einem Join-Feld erfasst bei der eintragsgenauen Auswertung auch Datensätze ganz ohne verknüpfte Einträge.
Verknüpfungen sind INNER-Joins: Ein Report „A + verknüpfte B" zeigt nur A-Zeilen mit mindestens EINEM für den Aufrufer sichtbaren B-Eintrag. Liegen alle verknüpften Einträge außerhalb seines Sichtbereichs (z.B. ausschließlich Assets gesperrter Typen ohne Freigabe), fällt die A-Zeile komplett aus dem Report. Das ist gewollt, aber nicht immer offensichtlich. Der Datenquellen-Picker weist im UI darauf hin.
API-Beispiele
Custom Report erstellen
POST /api/custom-reports
{
"name": "Open Tickets by Category",
"description": "Number of open tickets per category and priority",
"dataSources": [{ "entity": "tickets", "alias": "t" }],
"columns": [
{ "source": "t", "field": "category.name", "label": "Category" },
{ "source": "t", "field": "priority", "label": "Priority" },
{ "source": "t", "field": "ticketNumber", "label": "Count", "aggregate": "COUNT" }
],
"filters": {
"version": 2,
"groups": [ { "conditions": [
{ "source": "t", "field": "status", "operator": "in", "value": ["OPEN", "IN_PROGRESS"] }
] } ]
},
"groupBy": [
{ "source": "t", "field": "category.name" },
{ "source": "t", "field": "priority" }
],
"sorting": [{ "source": "t", "field": "priority", "direction": "desc" }],
"visualization": "bar",
"exportFormats": ["csv", "xlsx"],
"exportLocale": "en",
"isShared": true,
"sharedWithRoles": ["AGENT"]
}
Export-Sprache (exportLocale)
Ein Report hat EINE Exportsprache (de, en, fr, es, it — Default en, im Builder unter den Format-Kacheln wählbar; ein neuer Report übernimmt die UI-Sprache des Erstellers). Sie steuert alles, was der Worker in die Datei schreibt: Rahmentexte (Deckblatt, „Generiert am", Fußzeile, Diagramm-Legende), Ja/Nein-Werte sowie Zahlen- und Datumsformate (de-DE, en-GB, fr-FR, es-ES, it-IT — Englisch schreibt Datumswerte also als Tag/Monat/Jahr).
| Bereich | Verhalten |
|---|---|
| Spalten-Überschriften | Standard-Labels werden in die Exportsprache übersetzt; ein vom Nutzer selbst geschriebenes Label bleibt unverändert — Nutzertext gewinnt. |
| Zellwerte | bleiben in ALLEN Formaten roh (z.B. IN_PROGRESS) — bewusste Entscheidung, damit Exporte maschinell weiterverarbeitbar bleiben. |
| Preview | nicht betroffen — die Voransicht rendert im Frontend und folgt der UI-Sprache. |
| Dateiname | bleibt sprachneutral (Name + Datum). |
Fehlt einem Text die Übersetzung in der gewählten Sprache, greift Englisch (Kette: gewählte Sprache → Englisch → Schlüssel). Auch die Vorschau ohne gespeicherten Report läuft englisch. Exporte verwenden dieselben Übersetzungen wie die Oberfläche.
Report ausführen (async)
POST /api/custom-reports/:id/execute
// Response 202 Accepted — async!{
"executionId": "clx...",
"status": "PENDING"
}
Status abfragen (Polling)
GET /api/custom-reports/executions/:execId/status
// execute/preview nach COMPLETED — Daten inline:{
"executionId": "clx...",
"status": "COMPLETED",
"errorMessage": null,
"result": { "...": "resultData" }
}
// export nach COMPLETED — Datei über /executions/:execId/download holen:{
"executionId": "clx...",
"status": "COMPLETED",
"errorMessage": null
}
Der Status-Endpunkt ist bewusst schlank — er wird gepollt. Zeilenzahl, Format und Zeitpunkte stehen in der Historie (/:id/executions), die Datei-Metadaten liefert der Download über seine Header.
Export & Download
POST /api/custom-reports/:id/export
{ "format": "csv" } // csv | xlsx | pdf
// Response 202{ "executionId": "clx...", "status": "PENDING" }
# Nach COMPLETED:GET /api/custom-reports/executions/:execId/download
Scheduling konfigurieren
POST /api/custom-reports/:id/schedule
{
"isScheduled": true,
"cronExpression": "0 8 * * 1",
"exportFormats": ["csv", "pdf"],
"recipients": ["userId-1", "userId-2"]
}
// Response: gespeicherter Report + die nächsten 3 Termine (UTC){
"id": "clr...",
"isScheduled": true,
"cronExpression": "0 8 * * 1",
"nextRuns": ["2026-07-20T08:00:00.000Z", "2026-07-27T08:00:00.000Z", "2026-08-03T08:00:00.000Z"]
}
Den Takt gibt das Job-System: Die Action report_schedule_check läuft jede Minute, startet fällige Zeitpläne (je konfiguriertem Exportformat eine Ausführung) und schließt fertige Läufe mit der Abschluss-Mail ab. ⚠ Ohne diesen Job laufen geplante Reports NICHT; der report-generator führt nur aus. Manuelles Ausführen oder Exportieren verschiebt den Zeitplan nicht, und ein frisch eingeschalteter Zeitplan startet erst zum nächsten Cron-Zeitpunkt. Nach Abschluss erhalten die recipients EINE E-Mail mit allen Format-Anhängen und einem Hinweis auf Formate, die nicht erzeugt werden konnten. Empfänger sind einzelne User; die Vorschlagsliste im Dialog (GET /:id/recipient-candidates) enthält NUR User, die den Report sehen dürfen. Beim Versand wird das erneut geprüft (RECIPIENTS_NOT_ALLOWED).
| Prüfung | Verhalten |
|---|---|
| Cron-Ausdruck | Bei isScheduled=true ist cronExpression Pflicht und wird geparst — fehlend oder nicht parsebar ⇒ 400 VALIDATION_ERROR mit dem Feld-Pfad cronExpression. Ein kaputter Ausdruck wird also nicht gespeichert (sonst liefe der Report nie und niemand merkte es). |
| Empfänger | Jeder Empfänger muss den Report sehen dürfen (Eigentümer ∨ geteilt mit seiner Rolle ∨ customReports.viewAll) — sonst 403 RECIPIENTS_NOT_ALLOWED mit Namen. Beim Versand wird ERNEUT geprüft und es werden nur berechtigte Empfänger beliefert (der Report könnte nachträglich privat gestellt worden sein); aussortierte Empfänger landen im Audit-Log. |
Execution-Status
| Status | Beschreibung |
|---|---|
PENDING | In der Queue, wartet auf report-generator |
RUNNING | Wird gerade ausgeführt |
COMPLETED | Erfolgreich, Ergebnis/Download verfügbar |
FAILED | Fehlgeschlagen (errorMessage gesetzt) |
format = pdf | csv | xlsx | PREVIEW (null bei reinem execute); die Zeilenanzahl heißt recordCount. Beide stehen in der Historie, nicht in der Status-Antwort.
Aufbewahrung (Retention)
Die Report-Aufbewahrung ist Teil des gebündelten retention_purge-Jobs (job-worker, nächtlich 03:00) — er hält die Execution-Historie klein, sonst wüchse sie unbegrenzt:
- Hängengebliebene Läufe zuerst: Ein Lauf, der länger als 30 Minuten auf RUNNING steht, hat einen Worker-Absturz gesehen; einer, der länger als 24 Stunden PENDING ist, hat sein Queue-Ereignis verloren. Beide werden auf FAILED gesetzt — sonst blockierten sie dauerhaft das 25er-Limit, weil sie von keiner anderen Stelle je wieder angefasst würden.
- Preview-Executions (ohne Report) älter als 24 Stunden werden gelöscht.
- Gespeicherte Ergebnis-Daten abgeschlossener Läufe älter als 30 Tage werden geleert — Metadaten (Status, recordCount, Zeitpunkte) bleiben erhalten.
- FAILED-Executions ohne Datei älter als 30 Tage werden gelöscht, darunter auch die im Schritt davor auf FAILED gesetzten hängengebliebenen Läufe.
- Export-DATEIEN (die generierten Attachments) älter als 30 Tage werden soft-deleted — ein Export ist ein Abhol-Artefakt, kein Archiv. Das physische Löschen erledigt danach der attachment_cleanup-Job.
Fristen sind Parameter des retention_purge-Jobs; er läuft standardmäßig ENABLED.
Fehlercodes
| errorCode | HTTP | Bedeutung |
|---|---|---|
REPORT_DEFINITION_INVALID |
400 | Die Definition wird synchron geprüft — beim Speichern, beim Preview UND bei jeder Ausführung (execute/export/generate). details[] nennt jeden Fehler mit Pfad und Code (siehe Tabelle unten). Die Prüfung bei jeder Ausführung erkennt Reports, die durch spätere Schema-Änderungen (z.B. ein umbenanntes Feld) ungültig geworden sind, ohne dass jemand sie angefasst hat. |
COMPUTED_FILTER_ON_JOINED_SOURCE |
400 | Issue-Code in details[] von REPORT_DEFINITION_INVALID: Berechnete Felder (z.B. die SLA-Spalten) lassen sich nur auf der Haupt-Entität filtern, nicht auf einer verknüpften Quelle. |
RECIPIENTS_NOT_ALLOWED |
403 | Mindestens ein Empfänger darf den Report nicht sehen. |
EXECUTION_LIMIT_REACHED |
409 | Mehr als 25 gespeicherte Generierungen je Report — zuerst alte Versionen löschen. |
REPORT_ARCHIVED |
409 | Ein archivierter Report lässt sich weder ausführen noch planen. Das Entarchivieren bleibt selbstverständlich möglich — Archivieren ist kein Löschen. |
FILTERS_FORMAT_INVALID |
400 | Issue-Code in details[] von REPORT_DEFINITION_INVALID: Eine gespeicherte Definition hat nicht die Gruppen-Form (version 2). Sie wird abgelehnt, damit ein falsch geformter Filter nie zu einem Report ohne Einschränkung wird. Ein Request mit einem flachen Array scheitert schon an der Schema-Prüfung (400 VALIDATION_ERROR). |
REPORT_NOT_FOUND |
404 | Der Report existiert nicht — oder der Aufrufer darf ihn nicht sehen. Beide Fälle antworten gleich, damit sich nicht erkennen lässt, ob eine fremde Report-ID existiert; das gilt für alle Routen, die den Report laden. Die Sicht auf eine fremde GENERIERUNG (/status, /download) bleibt dagegen 403 — dort ist die Ausführungs-ID der Schlüssel, nicht die Report-ID. |
EXECUTION_NOT_FOUND |
404 | Die angefragte Ausführung existiert nicht. |
EXPORT_NOT_FOUND |
404 | Zur Ausführung gehört keine Export-Datei (z.B. ein reines execute ohne Format). |
EXPORT_FILE_UNAVAILABLE |
404 | Der Datensatz der Datei existiert, die Datei selbst liegt nicht mehr im Speicher (z.B. nach Ablauf der Aufbewahrung). |
ATTACHMENT_INFECTED |
403 | Der Virenscan hat die Export-Datei als infiziert markiert — der Download bleibt gesperrt. |
VALIDATION_ERROR |
400 | Schema-Verstoß (Limits, Enums, Pflichtfelder, Query-Parameter) mit Feld-Pfad in details[]. Darunter fallen ein unbrauchbarer between-Wert — der Operator verlangt GENAU zwei befüllte Werte — und der Zeitplan: fehlender oder nicht parsebarer cronExpression bei isScheduled=true. |
Issue-Codes der Definitions-Prüfung
Jeder Eintrag in details[] trägt den Pfad der schuldigen Stelle (z.B. columns[2].aggregate oder filters.groups[1].conditions[0].operator) und einen dieser Codes:
| Code | Bedeutung |
|---|---|
UNKNOWN_SOURCE · UNKNOWN_FIELD | Datenquelle bzw. Feld existiert nicht |
ENTITY_NOT_ALLOWED | Der Aufrufer hat das reporting-Recht der Datenquelle nicht — oder darf sie nicht sehen. |
FIELD_NOT_ALLOWED | Ein Feld verlangt eine Zusatz-Berechtigung, die der Aufrufer nicht hat (siehe „Felder mit Zusatz-Berechtigung" oben). |
INVALID_NUMBER_VALUE | Ein Zahlenfilter hat keinen brauchbaren Wert (leer oder Text). Numerische Strings werden als Zahl gelesen — „5" gilt wie 5. |
DUPLICATE_AGGREGATE_FIELD | Zweimal dasselbe Feld aggregiert (z.B. SUM und AVG auf Betrag): Beide Spalten trügen denselben Schlüssel — die zweite würde die erste still überschreiben. |
AGGREGATE_ON_GROUPBY_FIELD | Ein Feld gleichzeitig gruppieren und aggregieren — dieselbe Schlüssel-Kollision, nur zwischen Gruppierung und Aggregat. |
COMPUTED_FILTER_ON_JOINED_SOURCE | Berechnetes Feld (z.B. SLA-Spalte) auf einer verknüpften Quelle gefiltert — nur auf der Haupt-Entität möglich. |
Die beiden Aggregat-Regeln sperrt schon der Builder (Auswahl deaktiviert mit Begründung) — die Prüfung im Backend ist die zweite Verteidigungslinie für per API gebaute Definitionen.
Unwirksame Filterbedingungen brechen ab
Eine Filterbedingung, die sich nicht auflösen lässt (z.B. weil ihr Feld nach einer Schema-Änderung nicht mehr existiert), führt zu einem benannten Fehler — bei der Ausführung über die API als 400, bei geplanten Läufen als fehlgeschlagene Execution mit Nennung der betroffenen Bedingung.
Warum: Eine unbemerkt ausgefallene Bedingung lässt einen Report plötzlich zu viele Zeilen (die Gruppe wird wahr) oder zu wenige Zeilen liefern (der leere Zweig fällt aus der ODER-Verknüpfung). Beides sieht plausibel aus — besonders bei geplanten Reports, die niemand mehr prüft. Ein benannter Abbruch fällt auf.
Permissions (customReports)
| Permission | Beschreibung |
|---|---|
customReports.viewOwn | Eigene Reports sehen |
customReports.viewShared | Geteilte Reports sehen (nur lesend; gewährt nie Bearbeiten oder Löschen) |
customReports.viewAll | Alle Reports sehen (Admin) |
customReports.create | Reports erstellen, Preview, Schema abrufen |
customReports.editOwn | Eigene Reports bearbeiten |
customReports.editAll | Alle Reports bearbeiten (Admin) |
customReports.deleteOwn | Eigene Reports löschen |
customReports.deleteAll | Fremde Reports löschen (kritisch; Prüfung direkt in der Datenbank) |
customReports.export | Exportieren/generieren und herunterladen (auch über den generischen Attachment-Weg). Das LÖSCHEN einer Execution hängt NICHT an diesem Recht, sondern am Lauf: Ersteller ‖ Report-Eigentümer ‖ deleteAll. |
customReports.schedule | Scheduling konfigurieren |
customReports.share | Reports mit anderen Rollen teilen |
Standard-Reports nutzen ein eigenes Feature: reports.viewReports / reports.viewBudget / reports.viewInvestPlan / reports.exportReports. Die Report-ZEILEN werden zusätzlich nach den reporting/viewAll/viewOwn-Rechten der jeweiligen Datenquelle gefiltert.
🔔 Benachrichtigungen: Report-Läufe lösen REPORT_READY, REPORT_FAILED (User-getriggert) bzw. REPORT_SCHEDULED_COMPLETE (geplant, E-Mail mit Anhängen) aus. Kanäle/Vorlagen siehe Notifications →.
Report-Generator Container
Custom Reports werden nicht im Backend, sondern im dedizierten report-generator Container ausgeführt. Dieser hat READ-ONLY Datenbankzugriff und verarbeitet Jobs über die BullMQ-Queue report-execution.
| Eigenschaft | Wert |
|---|---|
| Container | report-generator |
| Datenbank-Zugriff | READ-ONLY |
| Queue | BullMQ: report-execution (attempts 3, exponential backoff) |
| Scheduling | nicht in diesem Container — den Takt gibt die Job-Action report_schedule_check (jede Minute) |
| CSV Delimiter | CSV_DELIMITER: ; (Standard), , oder Tab |
| Scaling | Multi-Instance via BullMQ (automatische Job-Verteilung) |
| Tracing | traceId/spanId/correlationId werden vom Backend an den report-generator weitergegeben |
📘 Details: Siehe Container Architecture → für report-generator Container-Details und Environment Variables → für Konfiguration (COMPANY_NAME, CSV_DELIMITER, BACKEND_URL).
REPORT_READY / REPORT_FAILED / REPORT_SCHEDULED_COMPLETE
report-generator Container im Detail
reports.* und customReports.* Rechte-Matrix
Kostenstellen für cost-center-budget
Reopen-Analytics (/api/analytics/reopen) + Lifecycle-Dashboard
SLA-Felder an der tickets-Entität + /sla/report als Gegenprobe