# Statistik-Endpunkte: Clean-Cut auf `dateFrom`/`dateTo` + `bucketSize` ## Beschreibung und Kontext Folge-Arbeit zum [`PeriodPicker`](../../local/notes/changelog.txt). 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/statistics` mischt zwei Konzepte**: (a) Date-Range (`startTs/endTs`) und (b) Bucket-Granularitaet fuer Time-Series (`getTransactionStatisticsAggregated(..., period=)` -> `day` vs `month`). Diese **muessen getrennt werden**: Date-Range = `dateFrom`/`dateTo`, Bucket-Groesse = neuer expliziter Param `bucketSize: '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 UTC` desselben Tages, `dateTo` -> `23:59:59.999 UTC` desselben Tages (inklusive). Sonst fehlt der letzte Tag in der Aggregation. → Ein gemeinsamer Helper `_isoDateRangeToUtcEpoch(dateFrom, dateTo)` in `platform-core/modules/shared/dateRange.py`. - **Validierung im Backend**: `dateFrom <= dateTo`, beide Pflicht (kein optional/None), `bucketSize` aus 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/stats` akzeptiert ausschliesslich `dateFrom`/`dateTo` (ISO `YYYY-MM-DD`, Pflicht). Antwort enthaelt `dateFrom`/`dateTo`/`days` statt `timeRangeDays`. - `/api/billing/statistics` (Endpunkt-Pfad-Param `{period}` entfaellt, neuer Pfad `GET /api/billing/statistics`) akzeptiert ausschliesslich `dateFrom`/`dateTo` (Pflicht) + optional `bucketSize`. - `/api/billing/view/statistics` analog. - `aiAuditLogger.getAiAuditStats(mandateId, *, fromTs, toTs, groupBy)` - Pflicht-Range. - `getTransactionStatisticsAggregated(..., bucketSize=)` - Param `period` umbenannt zu `bucketSize`. - Frontend: `BillingDashboard`, `BillingDataView`, `ComplianceAuditPage` nutzen `` und schicken `dateFrom`/`dateTo`. Adapter `_periodToDays` ersatzlos entfernt. `useBilling.loadStatistics`-Signatur nur noch `({ dateFrom, dateTo, bucketSize? })`. - **Explizit NICHT:** - Aliase `timeRange` / `period` / `year` / `month` im 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. ## 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` (`getStatistics` Z. 526-604, `getUserViewStatistics` Z. 1619 ff., `UsageReportResponse` Z. 260) - `platform-core/modules/interfaces/interfaceBilling.py` (oder konkrete Impl): `calculateStatisticsFromTransactions` (akzeptiert bereits `startDate`/`endDate` → keine Aenderung), `getTransactionStatisticsAggregated` (`period` → `bucketSize`). - **Neu**: `platform-core/modules/shared/dateRange.py` - kleiner Helper `parseIsoDateRange(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` (`loadStatistics` Signatur aendern) - `ui-nyla/src/pages/billing/BillingDashboard.tsx` (Selects raus, PeriodPicker rein, optional separater `bucketSize`-Toggle) - `ui-nyla/src/pages/billing/BillingDataView.tsx` (FormGeneratorReport: `periodSelector` raus, `dateRangeSelector` rein) - `ui-nyla/src/pages/ComplianceAuditPage.tsx`: `_periodToDays` entfernen, `_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.py` anlegen mit `parseIsoDateRange(dateFrom: str, dateTo: str) -> tuple[date, date]` (HTTP 400 bei ungueltig oder `from > to`) und `isoDateRangeToUtcEpoch(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-Filter `fromTs <= ts <= toTs`. `timeRangeDays`-Feld in der Antwort durch `dateFrom`, `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)$") ``` → ueber `isoDateRangeToUtcEpoch` an Logger weitergeben. `timeRange` Param weg. ### C. Backend Billing-Stats (~2 h) - [ ] `getTransactionStatisticsAggregated`: Param `period` umbenennen zu `bucketSize` (Enum-Validierung im Endpunkt, hier nur Type-Hint Literal). Alle Aufrufe im Modul mitziehen. - [ ] `routeBilling.getStatistics`: - Pfad-Param `{period}` raus, neuer Pfad `GET /api/billing/statistics`. - Neue Query-Params `dateFrom: date = Query(...)`, `dateTo: date = Query(...)`, `bucketSize: Literal['day','month','year'] = Query(...)`. - Range-Berechnung Z. 570-582 ersetzen durch `parseIsoDateRange`. - `UsageReportResponse.period: str` → entfernen oder umbenennen zu `bucketSize`. Anderen Felder bleiben. - [ ] `routeBilling.getUserViewStatistics`: - Query-Params analog. `period` und `month`/`year` raus. - `bucketSize` an `getTransactionStatisticsAggregated` weitergeben. ### D. Frontend (~2 h) - [ ] `billingApi.fetchStatistics` neu signiert: `fetchStatistics(request, { dateFrom, dateTo, bucketSize })`. Alte Signatur weg. - [ ] `billingApi.fetchViewStatistics` analog. - [ ] `useBilling.loadStatistics({ dateFrom, dateTo, bucketSize })` - bestehende Aufrufer auf neue Signatur umstellen. - [ ] `BillingDashboard.tsx`: - `selectedPeriod`, `selectedYear`, `selectedMonth` State raus. - ``. - Separater kleiner `