15 KiB
Statistik-Endpunkte: Clean-Cut auf dateFrom/dateTo + bucketSize
Beschreibung und Kontext
Folge-Arbeit zum PeriodPicker. Die
Komponente liefert semantisch sauber { preset, fromDate, toDate }. Drei
Statistik-Endpunkte im Gateway erwarten heute aber Legacy-Parameter, die
Date-Range mit anderen Konzepten vermischen:
| Endpunkt | Heute | Problem |
|---|---|---|
GET /api/audit/stats |
?timeRange={days} (relative Tagesanzahl ab heute) |
Kein echter Bereich; im Frontend muss PeriodValue per Adapter _periodToDays auf eine Tagesanzahl reduziert werden, dadurch wird "01.04 - 03.04" als "letzte 3 Tage ab heute" ausgewertet → falsche KPIs. |
GET /api/billing/statistics/{period} |
period: 'day' | 'month' | 'year', year, month? |
period mischt zwei Verantwortungen: Date-Range-Berechnung und Bucket-Granularitaet; aerbitraere Ranges (z.B. "letzte 12 Monate", Custom) sind nicht ausdrueckbar. |
GET /api/billing/view/statistics |
period: 'day' | 'month', year, month? |
Dito; zusaetzlich gibt period an getTransactionStatisticsAggregated(..., period=) die Bucket-Groesse weiter. |
Konsequenz heute: der PeriodPicker-Rollout in BillingDashboard und
BillingDataView ist blockiert; in ComplianceAuditPage arbeiten wir mit
einem dokumentierten Hack (_periodToDays).
Diese Arbeit ist ein Clean-Cut: die Legacy-Parameter werden entfernt, die
Endpunkte akzeptieren ausschliesslich dateFrom/dateTo (+ bucketSize,
wo Time-Series ausgegeben werden), und alle Frontend-Caller werden in derselben
Aenderung migriert. Keine Backwards-Compat-Aliase, kein "altes UND neues",
kein toleranter Fallback im Backend.
Fokus und kritische Details
- Eine Aenderung = eine PR: Backend-Refactor + alle Frontend-Caller zusammen, da ohne Aliase ein gemischter Zustand nicht laeuft.
/api/billing/view/statisticsmischt zwei Konzepte: (a) Date-Range (startTs/endTs) und (b) Bucket-Granularitaet fuer Time-Series (getTransactionStatisticsAggregated(..., period=)->dayvsmonth). Diese muessen getrennt werden: Date-Range =dateFrom/dateTo, Bucket-Groesse = neuer expliziter ParambucketSize: 'day' | 'month' | 'year'. Default-Heuristik nur im Frontend, nicht im Backend.- Timezones: alle Audit-Logger-Records sind UTC-Epoch-Sekunden; PeriodPicker
liefert lokale ISO-Daten (
YYYY-MM-DD) ohne Timezone. Konversion eindeutig und an genau einer Stelle (im Endpunkt selbst, vor der DB-Abfrage):dateFrom->00:00:00 UTCdesselben Tages,dateTo->23:59:59.999 UTCdesselben Tages (inklusive). Sonst fehlt der letzte Tag in der Aggregation. → Ein gemeinsamer Helper_isoDateRangeToUtcEpoch(dateFrom, dateTo)inplatform-core/modules/shared/dateRange.py. - Validierung im Backend:
dateFrom <= dateTo, beide Pflicht (kein optional/None),bucketSizeaus geschlossenem Enum. Bei Verletzung HTTP 400 mit klarer Message. Keine impliziten Defaults. - Aufrufende Stellen vollstaendig erfassen (geprueft per ripgrep, siehe unten - keine externen Konsumenten ausserhalb des Frontends).
Ziel und Nicht-Ziele
- Ziel:
/api/audit/statsakzeptiert ausschliesslichdateFrom/dateTo(ISOYYYY-MM-DD, Pflicht). Antwort enthaeltdateFrom/dateTo/daysstatttimeRangeDays./api/billing/statistics(Endpunkt-Pfad-Param{period}entfaellt, neuer PfadGET /api/billing/statistics) akzeptiert ausschliesslichdateFrom/dateTo(Pflicht) + optionalbucketSize./api/billing/view/statisticsanalog.aiAuditLogger.getAiAuditStats(mandateId, *, fromTs, toTs, groupBy)- Pflicht-Range.getTransactionStatisticsAggregated(..., bucketSize=)- Paramperiodumbenannt zubucketSize.- Frontend:
BillingDashboard,BillingDataView,ComplianceAuditPagenutzen<PeriodPicker>und schickendateFrom/dateTo. Adapter_periodToDaysersatzlos entfernt.useBilling.loadStatistics-Signatur nur noch({ dateFrom, dateTo, bucketSize? }).
- Explizit NICHT:
- Aliase
timeRange/period/year/monthim Backend behalten. - Stripe-
current_period_*umbenennen oder beruehren - Stripe-API. - Andere Stat-Endpunkte (
/api/admin/database-health/stats, etc.) anfassen - eigener Scope, sobald Bedarf. - DB-Schema aendern.
- Aliase
Betroffene Module
- Gateway:
platform-core/modules/routes/routeAudit.py(getAuditStats, Z. 303-317)platform-core/modules/shared/aiAuditLogger.py(getAiAuditStats, Z. 195-249)platform-core/modules/routes/routeBilling.py(getStatisticsZ. 526-604,getUserViewStatisticsZ. 1619 ff.,UsageReportResponseZ. 260)platform-core/modules/interfaces/interfaceBilling.py(oder konkrete Impl):calculateStatisticsFromTransactions(akzeptiert bereitsstartDate/endDate→ keine Aenderung),getTransactionStatisticsAggregated(period→bucketSize).- Neu:
platform-core/modules/shared/dateRange.py- kleiner HelperparseIsoDateRange(dateFrom, dateTo) -> (date, date)mit Validierung,isoDateRangeToUtcEpoch(dateFrom, dateTo) -> (float, float).
- Frontend:
ui-nyla/src/api/billingApi.ts(fetchStatistics,fetchViewStatistics)ui-nyla/src/hooks/useBilling.ts(loadStatisticsSignatur aendern)ui-nyla/src/pages/billing/BillingDashboard.tsx(Selects raus, PeriodPicker rein, optional separaterbucketSize-Toggle)ui-nyla/src/pages/billing/BillingDataView.tsx(FormGeneratorReport:periodSelectorraus,dateRangeSelectorrein)ui-nyla/src/pages/ComplianceAuditPage.tsx:_periodToDaysentfernen,_loadStats({ dateFrom, dateTo }).ui-nyla/src/api/auditApi.ts(oder direkt in der Page, je nach aktueller Struktur).
- DB-Migration: nein.
- Tests: Vorhandene Tests auf den 3 Endpunkten mitziehen (siehe Aufwandsschaetzung).
Externe Konsumenten - Pruefung
Vor Clean-Cut explizit verifizieren, dass keine externen Aufrufer (Webhooks, Skripte, Excel-Reports, Teams-Bot, service-llm-private) die Endpunkte direkt nutzen:
rg "billing/statistics" platform-core/ service-llm-private/ teams-bot/rg "audit/stats" platform-core/ service-llm-private/ teams-bot/rg "/api/billing/statistics|/api/audit/stats" ui-nyla/
Wenn Treffer ausserhalb des Frontends auftauchen, werden sie im selben PR mitgezogen. Wenn nicht, kann der Clean-Cut erfolgen.
Entscheidungen
| Datum | Entscheidung | Begründung |
|---|---|---|
| 2026-04-20 | Clean-Cut, keine Backwards-Compat-Aliase | Vermischte Param-Konzepte sind die Wurzel der UX-Probleme; sie zu konservieren reproduziert genau das Problem, das wir loesen. Es gibt keine externen Konsumenten ausserhalb unseres Frontends (zu verifizieren, siehe oben). |
| 2026-04-20 | dateFrom/dateTo als ISO YYYY-MM-DD String, nicht Epoch |
Konsistent mit PeriodValue, lesbar in Logs/Browsernetzwerk, eindeutig (Tagesgrenze, kein Sub-Tag). Audit-Logger arbeitet zwar intern mit Epoch, aber die Konversion macht der Endpunkt zentral. |
| 2026-04-20 | dateFrom/dateTo Pflicht, kein impliziter Default |
Verhindert "vergessene Filter" (z.B. globale Aggregation ueber alle Zeit, schlecht fuer Performance). Caller muss bewusst einen Bereich waehlen. Defaults gehoeren ins Frontend. |
| 2026-04-20 | bucketSize separater Param, nicht in dateFrom/dateTo impliziert |
Eine Verantwortung pro Param. Erlaubt "letzte 30 Tage in Tagen" UND "letzte 30 Tage in Wochen" ohne Backend-Aenderung. |
| 2026-04-20 | bucketSize ohne Default |
Fuer Endpunkte mit Time-Series Antwort: Caller muss explizit waehlen, sonst HTTP 400. Frontend kapselt sinnvolle Heuristik. |
| 2026-04-20 | Pfad GET /api/billing/statistics ohne {period} |
period war Pfad-Param, mit Wegfall hat es im Pfad nichts mehr verloren. Saubere Resource-URL. |
Umsetzungs-Checkliste
A. Vorbereitung (~30 min)
- Konsumenten-Pruefung wie oben (drei
rg-Aufrufe). platform-core/modules/shared/dateRange.pyanlegen mitparseIsoDateRange(dateFrom: str, dateTo: str) -> tuple[date, date](HTTP 400 bei ungueltig oderfrom > to) undisoDateRangeToUtcEpoch(dateFrom: str, dateTo: str) -> tuple[float, float]. Unit-Tests dazu.
B. Backend Audit-Stats (~1 h)
aiAuditLogger.getAiAuditStats(mandateId, *, fromTs: float, toTs: float, groupBy: str = "model"): Cutoff-Berechnung raus, ersetzt durch Range-FilterfromTs <= ts <= toTs.timeRangeDays-Feld in der Antwort durchdateFrom,dateTo,days((toTs - fromTs) / 86400, gerundet) ersetzen.routeAudit.getAuditStats:python dateFrom: str = Query(..., description="ISO YYYY-MM-DD") dateTo: str = Query(..., description="ISO YYYY-MM-DD") groupBy: str = Query("model", regex="^(model|user|feature|day)$")→ ueberisoDateRangeToUtcEpochan Logger weitergeben.timeRangeParam weg.
C. Backend Billing-Stats (~2 h)
getTransactionStatisticsAggregated: Paramperiodumbenennen zubucketSize(Enum-Validierung im Endpunkt, hier nur Type-Hint Literal). Alle Aufrufe im Modul mitziehen.routeBilling.getStatistics: - Pfad-Param{period}raus, neuer PfadGET /api/billing/statistics. - Neue Query-ParamsdateFrom: date = Query(...),dateTo: date = Query(...),bucketSize: Literal['day','month','year'] = Query(...). - Range-Berechnung Z. 570-582 ersetzen durchparseIsoDateRange. -UsageReportResponse.period: str→ entfernen oder umbenennen zubucketSize. Anderen Felder bleiben.routeBilling.getUserViewStatistics: - Query-Params analog.periodundmonth/yearraus. -bucketSizeangetTransactionStatisticsAggregatedweitergeben.
D. Frontend (~2 h)
billingApi.fetchStatisticsneu signiert:fetchStatistics(request, { dateFrom, dateTo, bucketSize }). Alte Signatur weg.billingApi.fetchViewStatisticsanalog.useBilling.loadStatistics({ dateFrom, dateTo, bucketSize })- bestehende Aufrufer auf neue Signatur umstellen.BillingDashboard.tsx: -selectedPeriod,selectedYear,selectedMonthState raus. -<PeriodPicker direction="past" defaultPreset={{ kind: 'thisMonth' }} enabledPresets={['thisMonth','lastMonth','thisQuarter','lastQuarter','ytd','lastYear','last12Months','lastN','custom']} />. - Separater kleiner<select>bucketSize(Tag/Monat/Jahr) mit Frontend-Heuristik als Default (z.B. range <= 60 Tage → 'day', <= 24 Monate → 'month', sonst 'year'); user kann uebersteuern.BillingDataView.tsx:periodSelectorConfigraus,dateRangeSelector={{ enabled: true, direction: 'past', defaultPresetKind: 'thisMonth' }}rein. Bucket-Size-Toggle separat im Toolbar (eigeneFormGeneratorReport-Erweiterung oder ausserhalb).ComplianceAuditPage.tsx:_periodToDaysund das_DEFAULT_STATS_PRESETraus,_loadStats({ dateFrom, dateTo }). PeriodPicker-onChange uebergibt direktnext.fromDate/next.toDate.
E. Tests (~1 h)
platform-core/tests/test_routeAudit.pyaktualisieren: altetimeRange-Tests ersetzen durchdateFrom/dateTo-Tests (positiv + 400 bei from>to + 400 bei missing).platform-core/tests/test_routeBilling.pyaktualisieren: alteperiod+year+month-Tests ersetzen.- Unit-Tests fuer
dateRange.py-Helper (Parsing, Timezone, Inklusivitaet des letzten Tages).
Akzeptanzkriterien
| # | Kriterium (Given-When-Then) | Prio |
|---|---|---|
| 1 | Given ein User auf BillingDashboard when er im PeriodPicker "01.03.2026 - 15.04.2026" waehlt then zeigen die Charts genau diesen Bereich, mit korrektem bucketSize-Toggle daneben. |
must |
| 2 | Given GET /api/billing/statistics?dateFrom=2026-04-01&dateTo=2026-04-15&bucketSize=day then liefert genau Transaktionen vom 01.04 00:00 bis 15.04 23:59:59 lokal, in Tages-Buckets. |
must |
| 3 | Given GET /api/billing/statistics?dateFrom=2026-04-15&dateTo=2026-04-01&bucketSize=day then Antwort HTTP 400 mit Message "dateFrom must be <= dateTo". |
must |
| 4 | Given GET /api/billing/statistics?dateFrom=2026-04-01&dateTo=2026-04-15 (kein bucketSize) then Antwort HTTP 400. |
must |
| 5 | Given GET /api/audit/stats?dateFrom=2026-04-01&dateTo=2026-04-03 then Antwort enthaelt dateFrom, dateTo, days=3, KPIs nur fuer diese 3 Tage. |
must |
| 6 | Given ein alter Aufruf GET /api/audit/stats?timeRange=7 ohne neue Params then Antwort HTTP 422 (unknown query param) - Legacy-Param ist explizit entfernt. |
must |
| 7 | Given ein User im ComplianceAuditPage waehlt "Custom: 01.04 - 03.04" then sieht er KPIs fuer genau 3 Tage; der Adapter _periodToDays existiert nicht mehr im Code. |
must |
| 8 | Given ein PeriodPicker-Range "letzte 24 Monate" when der User im BillingDashboard bucketSize=year waehlt then zeigt das Chart 3 Jahresbalken. |
should |
Testplan
| ID | AC | Art | Automatisiert | Repo-Pfad | Status |
|---|---|---|---|---|---|
| T1 | 2 | api | ja | platform-core/tests/test_routeBilling.py::test_getStatistics_dateRange_dayBucket |
pending |
| T2 | 3 | api | ja | platform-core/tests/test_routeBilling.py::test_getStatistics_invertedRange_400 |
pending |
| T3 | 4 | api | ja | platform-core/tests/test_routeBilling.py::test_getStatistics_missingBucketSize_400 |
pending |
| T4 | 5 | api | ja | platform-core/tests/test_routeAudit.py::test_getAuditStats_dateRange |
pending |
| T5 | 6 | api | ja | platform-core/tests/test_routeAudit.py::test_getAuditStats_legacyParam_rejected |
pending |
| T6 | - | unit | ja | platform-core/tests/test_dateRange.py::test_inclusive_endOfDay |
pending |
| T7 | 1, 8 | manuell | nein | BillingDashboard smoke |
pending |
| T8 | 7 | manuell | nein | ComplianceAuditPage smoke + grep _periodToDays muss leer sein |
pending |
Reihenfolge der Umsetzung
Der Clean-Cut zwingt zu einer atomaren PR (Backend + alle Caller in einem Commit). Empfohlene lokale Arbeits-Reihenfolge:
- A:
dateRange-Helper inkl. Tests. - B: Audit-Backend.
- C: Billing-Backend.
- D: Frontend (alle drei Pages parallel).
- E: Tests gruen, manueller Smoke aller drei Pages.
- PR.
Links
- Vorarbeit: PeriodPicker-Komponente + Schritt-1+2-Rollout (Changelog 2026-04-20)
- Code-Anker:
platform-core/modules/routes/routeAudit.py:303platform-core/modules/shared/aiAuditLogger.py:195platform-core/modules/routes/routeBilling.py:526platform-core/modules/routes/routeBilling.py:1619ui-nyla/src/pages/billing/BillingDashboard.tsx(selectedPeriod)ui-nyla/src/pages/billing/BillingDataView.tsx(_loadViewStatistics)ui-nyla/src/pages/ComplianceAuditPage.tsx(_periodToDays)
Abschluss
b-reference/platform-core/billing.mdStatistik-Section neu schreiben (dateFrom/dateTo/bucketSize).b-reference/platform/audit.mdStats-Section neu schreiben.- TOPICS.md - keine Aenderung noetig.
- Dieses Dokument →
z-archive/verschieben.