Eviworx
Docs

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).

📊
Funktionen
✓ Standard-Reports (Kosten, Budget, Invest)
✓ Custom Report Builder (7 Datenquellen)
✓ SLA-Spalten an Tickets (14 Felder)
✓ Asynchroner report-generator (READ-ONLY DB)
✓ Export als CSV, XLSX und PDF
✓ Exportsprache je Report (de/en/fr/es/it)
✓ ODER-Filtergruppen (bis zu 5)
✓ Relative Datumsfilter (last_30_days)
✓ Zeitplan per Cron-Ausdruck
✓ Rechte-gefilterte Quellen, Felder und Zeilen

🔐 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-overviewJahresübersicht der Kostenreports.viewReports
GET/api/reports/category-breakdownAufschlüsselung nach Kategorienreports.viewReports
GET/api/reports/renewal-calendarVerlängerungskalender (Verträge/Lizenzen)reports.viewReports
GET/api/reports/budget-planBudget-Planungreports.viewReports + reports.viewBudget
GET/api/reports/cost-center-budgetBudget vs. Ist je Kostenstellereports.viewReports + reports.viewBudget
GET/api/reports/invest-planMehrjahres-Investitionsplan (Assets)reports.viewReports + reports.viewInvestPlan
GET/api/reports/exportStandard-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
yearalle außer renewal-calendar und invest-planBezugsjahr, Ganzzahl im Fenster ±20 Jahre um das laufende Jahr (Default: aktuelles Jahr)
monthsrenewal-calendar, exportVorschau-Fenster in Monaten, 1–24 (Default: 12)
yearsinvest-plan, exportAnzahl Jahre des Mehrjahresplans, 1–10 (Default: 5)
sourcesalleKomma-Liste der Kostenquellen: contracts, licenses, assets. Ohne Angabe gelten alle drei; ein unbekanntes Token ist 400.
assetCostModealle außer renewal-calendardepreciation (Default: der Preis verteilt sich über die Nutzungsdauer) | capex (voller Kaufpreis im Anschaffungsmonat)
typeexportyearly | category | renewal | budget | invest | costcenterbudget
formatexportxlsx | 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-reportsEigene + geteilte Reports auflisten — Antwort { data, pagination }; per Default 50, Maximum 100, Sortierung fest nach letzter ÄnderungviewOwnviewSharedviewAll
GET/api/custom-reports/schemaVerfügbare Entitäten & Felder (dynamisch nach Recht + Sichtbarkeit)createeditOwneditAll
GET/api/custom-reports/share-targetsTeilbare Rollen mit abgeleiteten Flags (für den Teilen-Dialog). reportId ist Pflicht; Antwort { data }share
GET/api/custom-reports/:id/recipient-candidatesEmpfä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/:idEinzelnen Report abrufen — ohne Ausführungs-Historie, die liefert /:id/executionsview (siehe oben)
POST/api/custom-reportsNeuen Report erstellen (201)create (+ share)
PUT/api/custom-reports/:idReport aktualisiereneditOwneditAll (+ share)
POST/api/custom-reports/:id/duplicateReport kopieren, optional { name } (201)create (+ view auf die Quelle)
DELETE/api/custom-reports/:idReport löschen (204, ohne Body)deleteOwndeleteAll

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:

FeldWirkung
isSharedSchaltet das Teilen überhaupt ein.
sharedWithRolesListe der Rollen-Namen, die den Report über viewShared sehen. Namen sind stabil (nach Anlage unveränderlich).
sharedWithAllRolesExplizites „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/previewLive-Preview ohne gespeicherten Report (202)createeditOwneditAll
POST/api/custom-reports/:id/executeReport ausführen, Daten-Ergebnis (202)view (Lesen)
POST/api/custom-reports/:id/exportExport in einem Format, Body { format } (202)export
POST/api/custom-reports/:id/generateIn 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/scheduleScheduling setzen { isScheduled, cronExpression, exportFormats, recipients }schedule
GET/api/custom-reports/:id/executionsAusfü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/statusExecution-Status abfragen (Polling)view
GET/api/custom-reports/executions/:execId/downloadGenerierte Datei herunterladen (als infiziert erkannte Dateien werden blockiert)export
DELETE/api/custom-reports/executions/:execIdExecution + 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
ticketstickets.reporting
problemsproblems.reporting
changeschanges.reporting
incidentsincidents.reporting
assetsassets.reporting + (viewAllHandoversviewOwnHandovers)
contractscontracts.reporting
licenseslicenses.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ötigWarum
tickets.participantEmailstickets.viewAllVerkettet 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, affectedDataSubjectsincidents.viewPIRDer 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.

FeldBedeutung
totalCountEchter Datenbank-Count der Haupt-Entität für die Filter — die Gesamtmenge, nicht nur die gelieferten Zeilen.
rowCountAnzahl tatsächlich gelieferter Ergebniszeilen.
countIsExactfalse, 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
slaStatusenumOK · WARNING · BREACH · CRITICAL · CANCELLED
slaResponseMet / slaResolutionMetbooleanResponse- bzw. Lösungs-Ziel erfüllt
slaResponseDeadline / slaResolutionDeadlinedateFristen (business-hours-berechnet)
slaResponseAt / slaResolvedAtdateZeitpunkt der Reaktion bzw. Lösung
slaBreachAtdateZeitpunkt der Verletzung
slaIsPaused / slaPausedMinutesboolean / numberUhr steht gerade / kumulierte Pausenzeit
slaEscalationLevelnumberErreichte Eskalationsstufe
slaPercentUsednumberVerbrauchte Zielzeit in Prozent
slaExcludedFromReportingbooleanAus Compliance-Auswertungen ausgenommen
slaPolicyNamestringAngewandte 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.ticketNumberrelationNummer des Elterntickets — Spalte „Elternticket". Filter- und sortierbar.
childTicketCountcomputedAnzahl 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

OperatorAnwendung
eq · neqGleich / ungleich
gt · gte · lt · lte · betweenZahlen- und Datumsvergleiche
contains · startsWithTextsuche
in · notInWerte-Liste (Array, kein Komma-String)
isNull · isNotNullFeld gesetzt / nicht gesetzt. Auf einem Relationsfeld (z.B. Agent, Kategorie, Elternticket) fragen beide Operatoren die Verknüpfung selbst ab: vorhanden / nicht vorhanden.
relativeNUR 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"] }
] } ] }
RegelWert
Gruppen1–5, jede mit mindestens einer Bedingung
Bedingungenmax. 20 GESAMT über alle Gruppen
groupLogic / conditionLogicOR|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).

BereichVerhalten
Spalten-ÜberschriftenStandard-Labels werden in die Exportsprache übersetzt; ein vom Nutzer selbst geschriebenes Label bleibt unverändert — Nutzertext gewinnt.
Zellwertebleiben in ALLEN Formaten roh (z.B. IN_PROGRESS) — bewusste Entscheidung, damit Exporte maschinell weiterverarbeitbar bleiben.
Previewnicht betroffen — die Voransicht rendert im Frontend und folgt der UI-Sprache.
Dateinamebleibt 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üfungVerhalten
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
PENDINGIn der Queue, wartet auf report-generator
RUNNINGWird gerade ausgeführt
COMPLETEDErfolgreich, Ergebnis/Download verfügbar
FAILEDFehlgeschlagen (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

errorCodeHTTPBedeutung
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:

CodeBedeutung
UNKNOWN_SOURCE · UNKNOWN_FIELDDatenquelle bzw. Feld existiert nicht
ENTITY_NOT_ALLOWEDDer Aufrufer hat das reporting-Recht der Datenquelle nicht — oder darf sie nicht sehen.
FIELD_NOT_ALLOWEDEin Feld verlangt eine Zusatz-Berechtigung, die der Aufrufer nicht hat (siehe „Felder mit Zusatz-Berechtigung" oben).
INVALID_NUMBER_VALUEEin Zahlenfilter hat keinen brauchbaren Wert (leer oder Text). Numerische Strings werden als Zahl gelesen — „5" gilt wie 5.
DUPLICATE_AGGREGATE_FIELDZweimal 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_FIELDEin Feld gleichzeitig gruppieren und aggregieren — dieselbe Schlüssel-Kollision, nur zwischen Gruppierung und Aggregat.
COMPUTED_FILTER_ON_JOINED_SOURCEBerechnetes 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.viewOwnEigene Reports sehen
customReports.viewSharedGeteilte Reports sehen (nur lesend; gewährt nie Bearbeiten oder Löschen)
customReports.viewAllAlle Reports sehen (Admin)
customReports.createReports erstellen, Preview, Schema abrufen
customReports.editOwnEigene Reports bearbeiten
customReports.editAllAlle Reports bearbeiten (Admin)
customReports.deleteOwnEigene Reports löschen
customReports.deleteAllFremde Reports löschen (kritisch; Prüfung direkt in der Datenbank)
customReports.exportExportieren/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.scheduleScheduling konfigurieren
customReports.shareReports 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
Containerreport-generator
Datenbank-ZugriffREAD-ONLY
QueueBullMQ: report-execution (attempts 3, exponential backoff)
Schedulingnicht in diesem Container — den Takt gibt die Job-Action report_schedule_check (jede Minute)
CSV DelimiterCSV_DELIMITER: ; (Standard), , oder Tab
ScalingMulti-Instance via BullMQ (automatische Job-Verteilung)
TracingtraceId/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).

Verwandte Seiten
Notifications →

REPORT_READY / REPORT_FAILED / REPORT_SCHEDULED_COMPLETE

Container Architecture →

report-generator Container im Detail

RBAC →

reports.* und customReports.* Rechte-Matrix

Cost Centers API →

Kostenstellen für cost-center-budget

Reopen & Lifecycle →

Reopen-Analytics (/api/analytics/reopen) + Lifecycle-Dashboard

SLA Management API →

SLA-Felder an der tickets-Entität + /sla/report als Gegenprobe