267 lines
15 KiB
Markdown
267 lines
15 KiB
Markdown
<!-- status: done -->
|
|
<!-- started: 2026-04-20 -->
|
|
<!-- finished: 2026-04-20 -->
|
|
<!-- component: gateway, ui-nyla -->
|
|
|
|
# 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 `<PeriodPicker>` 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.
|
|
- `<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`: `periodSelectorConfig` raus,
|
|
`dateRangeSelector={{ enabled: true, direction: 'past', defaultPresetKind: 'thisMonth' }}`
|
|
rein. Bucket-Size-Toggle separat im Toolbar (eigene
|
|
`FormGeneratorReport`-Erweiterung oder ausserhalb).
|
|
- [ ] `ComplianceAuditPage.tsx`: `_periodToDays` und das `_DEFAULT_STATS_PRESET`
|
|
raus, `_loadStats({ dateFrom, dateTo })`. PeriodPicker-onChange uebergibt
|
|
direkt `next.fromDate` / `next.toDate`.
|
|
|
|
### E. Tests (~1 h)
|
|
|
|
- [ ] `platform-core/tests/test_routeAudit.py` aktualisieren: alte `timeRange`-Tests
|
|
ersetzen durch `dateFrom`/`dateTo`-Tests (positiv + 400 bei from>to + 400
|
|
bei missing).
|
|
- [ ] `platform-core/tests/test_routeBilling.py` aktualisieren: alte `period+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:
|
|
|
|
1. A: `dateRange`-Helper inkl. Tests.
|
|
2. B: Audit-Backend.
|
|
3. C: Billing-Backend.
|
|
4. D: Frontend (alle drei Pages parallel).
|
|
5. E: Tests gruen, manueller Smoke aller drei Pages.
|
|
6. PR.
|
|
|
|
## Links
|
|
|
|
- Vorarbeit: PeriodPicker-Komponente + Schritt-1+2-Rollout (Changelog
|
|
2026-04-20)
|
|
- Code-Anker:
|
|
- `platform-core/modules/routes/routeAudit.py:303`
|
|
- `platform-core/modules/shared/aiAuditLogger.py:195`
|
|
- `platform-core/modules/routes/routeBilling.py:526`
|
|
- `platform-core/modules/routes/routeBilling.py:1619`
|
|
- `ui-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.md` Statistik-Section neu schreiben
|
|
(`dateFrom`/`dateTo`/`bucketSize`).
|
|
- [ ] `b-reference/platform/audit.md` Stats-Section neu schreiben.
|
|
- [ ] TOPICS.md - keine Aenderung noetig.
|
|
- [ ] Dieses Dokument → `z-archive/` verschieben.
|