docs: Spec Ausbaustufe 11 (Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
|||||||
|
# Design — Ausbaustufe 11: Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung (v0.11.0)
|
||||||
|
|
||||||
|
> Status: Design im Chat vom Nutzer freigegeben (2026-09-28), Spec zur
|
||||||
|
> Durchsicht. Anlass: vier Nutzerwünsche — (1) Szenarien lassen sich nicht
|
||||||
|
> löschen, (2) „Szenario-Vergleich" zeigt vor heute falsche Werte,
|
||||||
|
> (3) Planungs-Gegenstück zu „Monat nach Kategorie" fehlt, (4) Tabellen
|
||||||
|
> sollen per Spaltenkopf sortierbar sein.
|
||||||
|
|
||||||
|
## Nutzerentscheidungen (2026-09-28)
|
||||||
|
|
||||||
|
- Szenario-Vergleich vor heute: **Ist-Gesamtsaldo als eigene Serie „Ist"**,
|
||||||
|
Szenario-Kurven erst ab heute.
|
||||||
|
- Veraltete Szenarien: **automatisch neu rechnen** beim Seitenaufruf.
|
||||||
|
- Planungs-Diagramm: **Einnahmen + Ausgaben mit Vorzeichen** (Semantik wie
|
||||||
|
„Monat nach Kategorie").
|
||||||
|
- Kreditraten **aller** Kredite im Planungs-Diagramm als eigene Kategorie
|
||||||
|
**„Kreditraten"**, auch wenn keinem Szenario zugeordnet.
|
||||||
|
- Sortierung: **alle** Tabellen (Aufwand moderat, keine Einschränkung nötig).
|
||||||
|
|
||||||
|
## Befund (Ist-Zustand)
|
||||||
|
|
||||||
|
- `DELETE /api/scenarios/{id}` existiert bereits inkl. expliziter
|
||||||
|
Abräumung von `ProjectionPoint`, `ProjectionResult`, `ScenarioLoan`,
|
||||||
|
`ScenarioModifier`, `ScenarioPlannedItem` (`routers/scenarios.py`). Es
|
||||||
|
fehlt nur das GUI-Element.
|
||||||
|
- `projection_points` sind Momentaufnahmen des letzten „Durchrechnen"
|
||||||
|
(Serie ab `start_date + 1` bis `start_date + horizon_days`,
|
||||||
|
`engine/projection.py::project`). Wurde ein Szenario vor Wochen
|
||||||
|
gerechnet, zeigt `v_projection` für die Zeit bis heute eine alte Prognose
|
||||||
|
statt Ist-Werten; auch der Zukunftsteil startet vom damaligen Saldo.
|
||||||
|
- „Monat nach Kategorie" (Panel 3) liest `v_monthly_by_category` =
|
||||||
|
bestätigte Buchungen, also nur Vergangenheit.
|
||||||
|
- Tabellen: 14 statische `<table>` in 8 Templates (inkl. htmx-Fragmente
|
||||||
|
`_import_list.html`, `_preview_table.html`), plus der per JS erzeugte
|
||||||
|
Tilgungsplan. `/buchungen` ist serverseitig paginiert (50/Seite,
|
||||||
|
Standard-Reihenfolge `booking_date, id` aufsteigend).
|
||||||
|
|
||||||
|
## 1. Szenario löschen
|
||||||
|
|
||||||
|
- `szenarien.html`: im Szenario-Kopf (`<p id="sc-row-…">`) neben
|
||||||
|
„Bearbeiten" ein Formular
|
||||||
|
`hx-delete="/api/scenarios/{{ sc.id }}" hx-swap="none"`,
|
||||||
|
`hx-confirm="Szenario „{{ sc.name }}“ mit allen Einträgen wirklich löschen?"`,
|
||||||
|
bei Erfolg `window.location.reload()` — Muster wie die bestehenden
|
||||||
|
Einmalzahlungs-Löschbuttons.
|
||||||
|
- Keine API-Änderung.
|
||||||
|
- Tests: GUI-Seite enthält den Button mit korrektem `hx-delete`-Ziel;
|
||||||
|
(vorhandene API-Löschtests bleiben gültig, ggf. um GUI-Roundtrip
|
||||||
|
ergänzen: nach DELETE ist das Szenario nicht mehr auf `/szenarien`).
|
||||||
|
|
||||||
|
## 2. Szenario-Vergleich korrigieren
|
||||||
|
|
||||||
|
### 2a. View `v_projection` (`models/views.py`)
|
||||||
|
|
||||||
|
Neue Definition, Spalten unverändert (`scenario, day, balance`):
|
||||||
|
|
||||||
|
1. **Ist-Serie:** `SELECT 'Ist', day, balance FROM v_balance_total`
|
||||||
|
(reicht bis `CURRENT_DATE`).
|
||||||
|
2. **Szenario-Startpunkt:** je Szenario mit mindestens einem
|
||||||
|
Projektionspunkt eine Zeile `(s.name, CURRENT_DATE, <Ist-Saldo heute>)`
|
||||||
|
— Ist-Saldo aus `v_balance_total` am `CURRENT_DATE`. Dadurch beginnt
|
||||||
|
jede Szenario-Kurve lückenlos am Ist-Endpunkt.
|
||||||
|
3. **Projektion:** `projection_points` mit `day > CURRENT_DATE`.
|
||||||
|
Veraltete Punkte ≤ heute werden damit nie angezeigt — auch dann nicht,
|
||||||
|
wenn ein Szenario (noch) nicht neu gerechnet wurde.
|
||||||
|
|
||||||
|
Kombination per `UNION ALL`. `create_views` behandelt die geänderte
|
||||||
|
Definition bereits (CREATE OR REPLACE, Fallback DROP+CREATE). Da `v_projection`
|
||||||
|
auf `v_balance_total` aufbaut, muss es in `VIEWS` nach `v_balance_total`
|
||||||
|
stehen (ist bereits so; Reihenfolge beibehalten). Achtung: DROP … CASCADE
|
||||||
|
von `v_balance_total` im Fallback-Pfad würde `v_projection` mitreißen — da
|
||||||
|
`v_projection` danach ohnehin neu angelegt wird, ist das unkritisch.
|
||||||
|
|
||||||
|
Bekannte Grenze: ein Szenario namens „Ist" würde mit der Ist-Serie
|
||||||
|
verschmelzen. Wird hingenommen (keine Namensvalidierung eingeführt).
|
||||||
|
|
||||||
|
### 2b. Automatisches Neurechnen (`services/projection_service.py`)
|
||||||
|
|
||||||
|
Neue Funktion `refresh_stale_projections(session, today: date) -> int`
|
||||||
|
(Rückgabe: Anzahl neu gerechneter Szenarien):
|
||||||
|
|
||||||
|
- Kandidaten: Szenarien mit vorhandenem `ProjectionResult` **und**
|
||||||
|
`min(ProjectionPoint.day) <= today` (d.h. Rechen-Start lag vor heute).
|
||||||
|
Maßstab ist der gespeicherte Projektionsbeginn, nicht `computed_at`
|
||||||
|
(UTC-Zeitstempel, würde um Mitternacht falsch schneiden).
|
||||||
|
- Nie gerechnete Szenarien bleiben unberührt („Noch nicht durchgerechnet").
|
||||||
|
- Neu gerechnet mit `run_projection(session, scenario,
|
||||||
|
settings.horizon_days, today)` — identisch zum Button „Durchrechnen".
|
||||||
|
- Serialisierung: auf Postgres zu Beginn `SELECT pg_advisory_xact_lock(<konstante>)`,
|
||||||
|
danach Staleness neu prüfen (zwei parallele Seitenaufrufe dürfen nicht
|
||||||
|
doppelt Punkte schreiben). Auf SQLite (Tests) entfällt der Lock.
|
||||||
|
- Aufruf in `routers/gui.py` beim GET von `/` (Startseite, bettet Grafana
|
||||||
|
ein) und `/szenarien`, **vor** dem Rendern. Fehler im Refresh dürfen die
|
||||||
|
Seite nicht brechen: `try/except Exception` → `session.rollback()`,
|
||||||
|
Log-Warnung, Seite rendert mit dem vorhandenen Stand.
|
||||||
|
- „Durchrechnen" bleibt nötig nach Änderungen am selben Tag (ein heute
|
||||||
|
gerechnetes Szenario gilt nicht als veraltet).
|
||||||
|
|
||||||
|
Tests (SQLite, `run_projection` real):
|
||||||
|
- Szenario mit Start gestern → wird neu gerechnet, erster Punkt = heute+1.
|
||||||
|
- Szenario mit Start heute → unverändert (Zähler 0, `computed_at` gleich).
|
||||||
|
- Nie gerechnetes Szenario → bleibt ohne Result.
|
||||||
|
- GUI: GET `/szenarien` löst den Refresh aus; ein erzwungener Fehler im
|
||||||
|
Refresh ergibt trotzdem 200.
|
||||||
|
- View-Semantik (Postgres-only) wird im Fable-Gate in einer Wegwerf-DB
|
||||||
|
geprüft: Ist-Serie endet heute, Szenario-Serien beginnen heute mit dem
|
||||||
|
Ist-Saldo, keine Szenario-Punkte vor heute.
|
||||||
|
|
||||||
|
## 3. Neues Diagramm „Planung nach Kategorie"
|
||||||
|
|
||||||
|
### 3a. Tabelle `planning_monthly` (`models/tables.py` + Alembic-Migration)
|
||||||
|
|
||||||
|
| Spalte | Typ |
|
||||||
|
|---|---|
|
||||||
|
| `month` | `Date` (Monatserster), PK-Teil |
|
||||||
|
| `category` | `String(100)`, PK-Teil |
|
||||||
|
| `total` | `MONEY` (`Numeric(12,2)`) |
|
||||||
|
|
||||||
|
Composite Primary Key `(month, category)`. Migration mit up + down.
|
||||||
|
Abgeleitete Daten (jederzeit neu erzeugbar) — kein Backup-Sonderfall.
|
||||||
|
|
||||||
|
### 3b. Befüllung (`services/planning_chart.py`)
|
||||||
|
|
||||||
|
`refresh_planning_chart(session, today: date) -> None`:
|
||||||
|
|
||||||
|
- Fenster: `window_start` = 1. des laufenden Monats,
|
||||||
|
`window_end` = `today + settings.horizon_days`.
|
||||||
|
- **Wiederkehrende Posten** (`RecurringItem`): Termine über
|
||||||
|
`engine/recurrence.py::occurrences(rhythm, due_day, window_start,
|
||||||
|
window_end, start_date, end_date)` — dieselbe Logik wie Projektion und
|
||||||
|
„Nächste anstehende Posten". Betrag je Termin = `amount`.
|
||||||
|
- **Einmalposten** (`PlannedItem`): `window_start <= due <= window_end`.
|
||||||
|
- Kategorie: `Category.name` bzw. `"unkategorisiert"` bei
|
||||||
|
`category_id IS NULL` (wie `v_monthly_by_category`).
|
||||||
|
- **Kreditraten:** für **alle** `Loan` der Tilgungsplan
|
||||||
|
`engine/loans.py::loan_schedule(...)`; jede Rate mit
|
||||||
|
`window_start <= due <= window_end` geht mit `Installment.payment`
|
||||||
|
**unverändert** in Kategorie `"Kreditraten"` ein — `payment` ist bereits
|
||||||
|
negativ (`-(interest + principal_part)`), identisch zur Verwendung in
|
||||||
|
`build_cashflows`. Die Auszahlung (`payout_date`, `principal`) zählt
|
||||||
|
**nicht** (keine Rate).
|
||||||
|
- Aggregation in Python mit `Decimal` je (Monatserster, Kategorie);
|
||||||
|
Zeilen mit Summe 0 entfallen.
|
||||||
|
- Schreiben: gesamte Tabelle in **einer** Transaktion löschen und neu
|
||||||
|
befüllen; Serialisierung wie 2b (`pg_advisory_xact_lock`, eigene
|
||||||
|
Konstante).
|
||||||
|
- Aufruf in `routers/gui.py` beim GET von `/` und `/planung` (jede
|
||||||
|
Planung-Änderung lädt die Seite neu → Diagramm stets aktuell). Fehler
|
||||||
|
→ rollback + Log, Seite rendert trotzdem (wie 2b).
|
||||||
|
- Szenario-Modifikatoren fließen **nicht** ein (reine Planung).
|
||||||
|
|
||||||
|
Tests (Handrechnung, künstliche Werte — keine Echtdaten):
|
||||||
|
- monatlicher Posten mit Kategorie über Monatsgrenzen, quartalsweiser und
|
||||||
|
jährlicher Posten mit `start_date` (Phase), Posten mit `end_date`
|
||||||
|
mitten im Fenster, Posten ohne Kategorie → „unkategorisiert".
|
||||||
|
- Einmalposten innerhalb/außerhalb des Fensters.
|
||||||
|
- Kredit (annuity): Raten negativ unter „Kreditraten", Auszahlung fehlt.
|
||||||
|
- Zwei Aufrufe hintereinander → keine Duplikate (Idempotenz).
|
||||||
|
- GUI: GET `/planung` befüllt die Tabelle; Refresh-Fehler → trotzdem 200.
|
||||||
|
|
||||||
|
### 3c. Grafana-Panel (`grafana/dashboards/finanzen.json`)
|
||||||
|
|
||||||
|
- Neues Panel `id: 5`, `type: barchart`, Titel **„Planung nach Kategorie"**,
|
||||||
|
Optionen/`fieldConfig` wie Panel 3 (gestapelt, `currencyEUR`).
|
||||||
|
- Query: `SELECT month AS time, category AS metric, total AS value FROM
|
||||||
|
planning_monthly ORDER BY month`.
|
||||||
|
- Platzierung: direkt unter „Monat nach Kategorie": `gridPos
|
||||||
|
{h: 8, w: 12, x: 0, y: 16}`.
|
||||||
|
- Dashboard-Zeitfenster (`now-1y` … `now+19M`) deckt den Horizont bereits
|
||||||
|
ab (Commit 224baba); keine Änderung.
|
||||||
|
- Test: JSON-Strukturtest (Panel vorhanden, Query referenziert
|
||||||
|
`planning_monthly`, keine gridPos-Überlappung).
|
||||||
|
|
||||||
|
## 4. Tabellen sortieren
|
||||||
|
|
||||||
|
### 4a. Clientseitig: `app/static/sort.js`
|
||||||
|
|
||||||
|
Eingebunden in `base.html` (nach `htmx.min.js`). Initialisierung über
|
||||||
|
`htmx.onLoad(el => …)` — greift dadurch sowohl beim Seitenaufbau als auch
|
||||||
|
bei per htmx nachgeladenen Fragmenten (`_import_list.html`,
|
||||||
|
`_preview_table.html`).
|
||||||
|
|
||||||
|
- Erfasst werden alle `table` mit `thead`, außer `table[data-sort-mode="server"]`
|
||||||
|
und bereits initialisierte Tabellen (Markierung per Attribut).
|
||||||
|
- Jeder `th` mit nicht-leerem Text bekommt einen
|
||||||
|
`<button type="button" class="sort-btn">` hinter dem Text; `th` ohne Text
|
||||||
|
(Aktionsspalten) oder mit `data-nosort` bekommen keinen.
|
||||||
|
`aria-label="Nach <Spalte> sortieren"`.
|
||||||
|
- Symbol: `⇅` unsortiert, `▲` aufsteigend, `▼` absteigend. Klick auf eine
|
||||||
|
neue Spalte → aufsteigend; erneuter Klick → Richtung umkehren. Andere
|
||||||
|
Buttons der Tabelle springen auf `⇅` zurück. Buttons sind immer sichtbar
|
||||||
|
(UX-Regel).
|
||||||
|
- Sortierwert je Zelle: `td[data-sort]` falls vorhanden, sonst
|
||||||
|
`textContent.trim()`. Typerkennung in dieser Reihenfolge:
|
||||||
|
1. Datum `TT.MM.JJJJ` → `JJJJMMTT`,
|
||||||
|
2. deutscher Betrag/Zahl (`-1.234,56`, optional ` €`/` %`) → Zahl,
|
||||||
|
3. sonst Text via `localeCompare(…, 'de')`.
|
||||||
|
Leere Werte und `–` stehen unabhängig von der Richtung immer am Ende.
|
||||||
|
Stabile Sortierung (gleiche Werte behalten ihre Reihenfolge).
|
||||||
|
- **Zeilengruppen:** eine Zeile mit `data-sort-follow` hängt an der
|
||||||
|
vorhergehenden Zeile und wandert mit ihr (Bearbeiten-Zeilen
|
||||||
|
`*-edit-*` in `planning.html`, `szenarien.html` und ggf. weiteren
|
||||||
|
Templates erhalten dieses Attribut). Platzhalterzeilen („Keine
|
||||||
|
Einträge." mit `colspan`) werden nicht sortiert (Tabelle mit nur einer
|
||||||
|
Gruppe bleibt unverändert).
|
||||||
|
- Die Sortierung wird nicht gespeichert (nach Reload Standardreihenfolge).
|
||||||
|
|
||||||
|
Pflicht bei der Umsetzung: jede Spalte jeder Tabelle durchgehen und dort
|
||||||
|
`data-sort` setzen, wo die Anzeige nicht korrekt sortiert (z.B. Monatsspalte
|
||||||
|
`MM.JJJJ` in `salden.html` → `data-sort` ISO, übersetzte Labels, Zellen mit Formularen/Inputs, „Betrag
|
||||||
|
€" mit Zusatztext); Aktionsspalten mit Überschriftstext (z.B.
|
||||||
|
„Umbenennen", „Anker pflegen" in `index.html`) erhalten `data-nosort`.
|
||||||
|
|
||||||
|
Nicht erfasst: der per JS erzeugte Tilgungsplan in `planning.html`
|
||||||
|
(chronologisch per Definition; wird nach dem Erzeugen nicht initialisiert,
|
||||||
|
weil er ohne htmx eingefügt wird — so belassen).
|
||||||
|
|
||||||
|
Styling (`style.css`): `.sort-btn` dezent (kein Rahmen, kleine Schrift,
|
||||||
|
`cursor: pointer`), aktive Richtung hervorgehoben.
|
||||||
|
|
||||||
|
Tests: pytest prüft, dass `base.html` `sort.js` einbindet und die
|
||||||
|
Edit-Zeilen `data-sort-follow` tragen. Das JS-Verhalten (Typerkennung,
|
||||||
|
Gruppen, Leerwerte, Richtungswechsel) wird im Fable-Gate per Node
|
||||||
|
(`node` + minimaler DOM-Stub oder jsdom, falls vorhanden) bzw. im Browser
|
||||||
|
geprüft; wenn dafür eine reine Vergleichsfunktion aus `sort.js`
|
||||||
|
exportierbar ist, dort ansetzen.
|
||||||
|
|
||||||
|
### 4b. Serverseitig: `/buchungen`
|
||||||
|
|
||||||
|
- `table` in `transactions.html` erhält `data-sort-mode="server"`.
|
||||||
|
- `routers/transactions.py::list_transactions` bekommt optionale Parameter
|
||||||
|
`sort: str = "datum"`, `dir: str = "asc"`. Erlaubte Schlüssel (Whitelist,
|
||||||
|
unbekannte Werte → Standard, kein 422):
|
||||||
|
`datum` → `booking_date`, `konto` → `Account.name`, `zweck` → `purpose`,
|
||||||
|
`empfaenger` → `counterparty`, `betrag` → `amount`,
|
||||||
|
`kategorie` → `Category.name` (Outer-Join, unkategorisiert ans Ende).
|
||||||
|
Tiebreaker immer `Transaction.id` (gleiche Richtung). Standard
|
||||||
|
(`datum`/`asc`) ergibt exakt die bisherige Reihenfolge.
|
||||||
|
- `routers/gui.py::buchungen` reicht `sort`/`dir` durch und gibt sie ans
|
||||||
|
Template. Spaltenköpfe (Datum, Konto, Verwendungszweck,
|
||||||
|
Empfänger/Zahler, Betrag, Kategorie) werden Links mit demselben Symbol
|
||||||
|
wie 4a (`⇅/▲/▼`), die alle aktiven Filter übernehmen, `page` weglassen
|
||||||
|
(→ Seite 1) und bei aktiver Spalte die Richtung umkehren.
|
||||||
|
- Pager-Links übernehmen `sort`/`dir`.
|
||||||
|
- Tests: jede Sortierspalte auf/ab (inkl. Seitengrenze: Sortierung gilt
|
||||||
|
über alle Seiten), ungültige Werte → Standard ohne Fehler, Filter +
|
||||||
|
Sortierung kombiniert, Pager-Links enthalten `sort`/`dir`.
|
||||||
|
|
||||||
|
## 5. Release
|
||||||
|
|
||||||
|
- `finance/VERSION` 0.10.0 → 0.11.0, Redeploy via `./create_pod_finance.sh`
|
||||||
|
(Migration läuft über `entrypoint.sh` → `alembic upgrade head`).
|
||||||
|
- Live-Smoke: Szenario anlegen + löschen (Wegwerf-Szenario), veraltete
|
||||||
|
Live-Szenarien werden beim Aufruf von `/` automatisch neu gerechnet,
|
||||||
|
Grafana-Panels 4 und 5 zeigen plausible Daten, Sortierung auf
|
||||||
|
`/buchungen` und `/planung`, Bestandsdaten unverändert.
|
||||||
|
- `docs/ARCHITEKTUR.md` um `planning_monthly`, `refresh_planning_chart`,
|
||||||
|
`refresh_stale_projections` und `sort.js` ergänzen; `hilfe.html` kurz um
|
||||||
|
Sortierung/Löschen ergänzen, falls dort die Seiten beschrieben sind.
|
||||||
|
|
||||||
|
## Abgrenzung (nicht Teil dieser Stufe)
|
||||||
|
|
||||||
|
- Keine Speicherung der Sortierung, keine Mehrspalten-Sortierung.
|
||||||
|
- Kein Neurechnen bei jeder Datenänderung (nur Staleness-basiert + Button).
|
||||||
|
- Keine Kategorie-Farbabstimmung zwischen Panel 3 und 5 (Grafana-Default).
|
||||||
|
- Szenario-Modifikatoren nicht im Planungs-Diagramm.
|
||||||
|
|
||||||
|
## Rahmenregeln
|
||||||
|
|
||||||
|
- DATENSCHUTZ: Tests nur mit künstlichen Werten; keine Live-Beträge/Namen
|
||||||
|
in Commits, Ledger oder Chat.
|
||||||
|
- Decimal statt float für alle Beträge (auch in `planning_chart.py`).
|
||||||
|
- Deutsch für GUI-Texte, Commits, Fehlermeldungen; Datum TT.MM.JJJJ.
|
||||||
|
- Fable-Testagent-Gate je Plan-Task.
|
||||||
Reference in New Issue
Block a user