Compare commits
13
Commits
224baba326
...
ffd7647c5f
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ffd7647c5f | ||
|
|
b28b0e69c0 | ||
|
|
b1e007ed15 | ||
|
|
2a5cd141f2 | ||
|
|
4458300f43 | ||
|
|
42b410446d | ||
|
|
979e1c0edc | ||
|
|
32d78fdbe1 | ||
|
|
8b637f91b3 | ||
|
|
57ab6dae4a | ||
|
|
1c452b48de | ||
|
|
d3c752e2b9 | ||
|
|
dd6110b53f |
+42
-1
@@ -37,7 +37,8 @@ flowchart TD
|
||||
S_balances["balances.py<br/>account_balance(at), total_balance"]
|
||||
S_categorize["categorize.py<br/>apply_rules"]
|
||||
S_suggestions["suggestions.py<br/>suggest_recurring"]
|
||||
S_projection["projection_service.py<br/>run_projection"]
|
||||
S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"]
|
||||
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
|
||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
||||
end
|
||||
|
||||
@@ -75,6 +76,8 @@ flowchart TD
|
||||
R_gui --> E_loans
|
||||
R_gui --> E_recurrence
|
||||
R_gui --> S_balances
|
||||
R_gui --> S_projection
|
||||
R_gui --> S_planning_chart
|
||||
R_admin --> R_gui
|
||||
|
||||
R_accounts --> S_balances
|
||||
@@ -96,6 +99,9 @@ flowchart TD
|
||||
S_projection --> E_projection
|
||||
S_projection --> E_scenario
|
||||
S_projection --> S_balances
|
||||
S_planning_chart --> E_loans
|
||||
S_planning_chart --> E_recurrence
|
||||
S_planning_chart --> M_tables
|
||||
S_admin --> S_categorize
|
||||
|
||||
E_scenario --> E_loans
|
||||
@@ -300,6 +306,41 @@ ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`,
|
||||
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte
|
||||
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
|
||||
|
||||
**`v_projection` (Ausbaustufe 11) — System-Ist + Szenarien ab heute.**
|
||||
`models/views.py::VIEWS["v_projection"]` liefert für den Grafana-Szenario-
|
||||
Vergleich drei Teile in einer Tabelle: die Ist-Gesamtsaldo-Serie unter dem
|
||||
Namen `RESERVED_SCENARIO_NAME` (`"System-Ist"`, aus `v_balance_total`, endet
|
||||
heute), je bereits durchgerechnetem Szenario einen Startpunkt exakt heute
|
||||
(Ist-Saldo, damit die Kurve nahtlos ansetzt) sowie dessen gespeicherte
|
||||
Projektionspunkte NACH heute (`projection_points`, `day > CURRENT_DATE`) —
|
||||
Punkte <= heute aus einer älteren Berechnung erscheinen so nie.
|
||||
`RESERVED_SCENARIO_NAME` ist für Szenario-Namen gesperrt:
|
||||
`routers/scenarios.py::_check_reserved_name` lehnt `POST`/`PATCH` mit diesem
|
||||
Namen (case-insensitiv) mit 422 ab.
|
||||
|
||||
**Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände
|
||||
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
|
||||
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
|
||||
rollt zurück, loggt und lässt die Seite mit dem alten Stand rendern):
|
||||
`services/projection_service.py::refresh_stale_projections` rechnet jedes
|
||||
bereits durchgerechnete, aber veraltete Szenario mit dem Standard-Horizont
|
||||
neu (Advisory-Lock `STALE_PROJECTION_LOCK = 110_001`), aufgerufen von
|
||||
`index` und `szenarien_page`. `services/planning_chart.py::refresh_planning_chart`
|
||||
schreibt die Tabelle `planning_monthly` (`models/tables.py::PlanningMonthly`,
|
||||
Migration `d5e8b3c7a410`) komplett neu — Monatssummen je Kategorie aus
|
||||
Planung (wiederkehrende Posten, Einmalposten, Kreditraten aller Kredite
|
||||
unter Kategorie „Kreditraten") für das Grafana-Panel „Planung nach
|
||||
Kategorie" (`Advisory-Lock PLANNING_CHART_LOCK = 110_002`), aufgerufen von
|
||||
`index` und `planung_page`. Beide Tabellen sind jederzeit reproduzierbar
|
||||
(kein Backup-Sonderfall).
|
||||
|
||||
**Tabellensortierung (Ausbaustufe 11).** `static/sort.js` sortiert generische
|
||||
Tabellen client-seitig per Spaltenkopf-Klick (⇅/▲/▼, deutsche Zahlen-/
|
||||
Datumsformate, `htmx.onLoad`-Reinit für nachgeladene Fragmente) — Ausnahme
|
||||
`table[data-sort-mode="server"]`. Die Buchungsliste (`/buchungen`) ist so
|
||||
markiert und sortiert stattdessen serverseitig über alle Seiten
|
||||
(`routers/transactions.py::SORT_COLUMNS`, `normalize_sort`, `_apply_sort`).
|
||||
|
||||
**`VERSION`-Datei als Single Source.** `finance/VERSION` (ein Einzeiler, z.B.
|
||||
`0.4.0`) ist die einzige Versionsquelle: `app/version.py::get_version()`
|
||||
liest sie (Fallback `"0.0.0-dev"`), gespeist in den Jinja2-Footer
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
# 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
|
||||
„System-Ist"** (bewusst systemeigen benannt, Name für Szenarien
|
||||
reserviert), 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 'System-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.
|
||||
|
||||
Reservierter Name: Die Serienbezeichnung `System-Ist` ist als Modul-Konstante
|
||||
(`RESERVED_SCENARIO_NAME` in `models/views.py`, dort auch in der View-SQL
|
||||
verwendet) hinterlegt. `routers/scenarios.py` lehnt `POST` und `PATCH` mit
|
||||
diesem Namen (Vergleich nach `strip().casefold()`) mit **422** und der
|
||||
Meldung „Der Name »System-Ist« ist für die Ist-Kurve reserviert" ab, damit
|
||||
kein Szenario mit der Ist-Serie verschmelzen kann. Bestandsszenarien mit
|
||||
diesem Namen existieren nicht (live geprüft: 3 Szenarien, andere Namen).
|
||||
Tests: POST/PATCH mit `System-Ist`, `system-ist`, ` System-Ist ` → 422;
|
||||
normale Namen unverändert 201/200.
|
||||
|
||||
### 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.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.10.0
|
||||
0.11.0
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""planning_monthly (Grafana-Panel Planung nach Kategorie)
|
||||
|
||||
Revision ID: d5e8b3c7a410
|
||||
Revises: c4d7e2a91b53
|
||||
Create Date: 2026-09-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'd5e8b3c7a410'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c4d7e2a91b53'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'planning_monthly',
|
||||
sa.Column('month', sa.Date(), primary_key=True),
|
||||
sa.Column('category', sa.String(length=100), primary_key=True),
|
||||
sa.Column('total', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('planning_monthly')
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -21,3 +21,11 @@ def get_engine():
|
||||
def get_session():
|
||||
with Session(get_engine()) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def advisory_xact_lock(session: Session, key: int) -> None:
|
||||
"""Serialisiert konkurrierende Schreiber abgeleiteter Daten (Projektionen,
|
||||
Planungs-Diagramm) über einen Postgres-Transaktions-Advisory-Lock. Der
|
||||
Lock endet mit dem nächsten commit/rollback. Auf SQLite (Tests) No-Op."""
|
||||
if session.get_bind().dialect.name == "postgresql":
|
||||
session.execute(text("SELECT pg_advisory_xact_lock(:k)"), {"k": key})
|
||||
|
||||
@@ -156,3 +156,14 @@ class ProjectionResult(Base):
|
||||
low_point_balance: Mapped[Decimal] = mapped_column(MONEY)
|
||||
below_zero_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
below_threshold_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
|
||||
class PlanningMonthly(Base):
|
||||
"""Abgeleitete Daten für das Grafana-Panel „Planung nach Kategorie"
|
||||
(Ausbaustufe 11): Monatssummen je Kategorie aus der Planung. Wird von
|
||||
services/planning_chart.refresh_planning_chart komplett neu geschrieben;
|
||||
jederzeit reproduzierbar, kein Backup-Sonderfall."""
|
||||
__tablename__ = "planning_monthly"
|
||||
month: Mapped[date] = mapped_column(Date, primary_key=True)
|
||||
category: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
total: Mapped[Decimal] = mapped_column(MONEY)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
||||
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein
|
||||
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11).
|
||||
RESERVED_SCENARIO_NAME = "System-Ist"
|
||||
|
||||
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
|
||||
# same math as app.services.balances.account_balance, expressed as a single
|
||||
# constant "base" per account so it can be added to a plain (anchor-agnostic)
|
||||
@@ -109,9 +114,25 @@ VIEWS: dict[str, str] = {
|
||||
SUM(t.amount) AS total
|
||||
FROM transactions t LEFT JOIN categories c ON c.id = t.category_id
|
||||
WHERE t.status = 'confirmed' GROUP BY 1, 2""",
|
||||
"v_projection": """
|
||||
SELECT s.name AS scenario, p.day, p.balance
|
||||
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""",
|
||||
# Szenario-Vergleich (Ausbaustufe 11): Ist-Gesamtsaldo bis heute als eigene
|
||||
# Serie RESERVED_SCENARIO_NAME; jede Szenario-Kurve beginnt heute am
|
||||
# Ist-Saldo und zeigt danach nur Projektionspunkte NACH heute. Punkte
|
||||
# <= heute einer älteren Berechnung (Momentaufnahme des letzten
|
||||
# „Durchrechnen") erscheinen damit nie - auch wenn ein Szenario noch nicht
|
||||
# neu gerechnet wurde. Muss in VIEWS NACH v_balance_total stehen.
|
||||
"v_projection": f"""
|
||||
SELECT '{RESERVED_SCENARIO_NAME}'::text AS scenario, t.day, t.balance
|
||||
FROM v_balance_total t
|
||||
UNION ALL
|
||||
SELECT s.name::text, t.day, t.balance
|
||||
FROM scenarios s
|
||||
JOIN v_balance_total t ON t.day = CURRENT_DATE
|
||||
WHERE EXISTS (SELECT 1 FROM projection_points p0
|
||||
WHERE p0.scenario_id = s.id)
|
||||
UNION ALL
|
||||
SELECT s.name::text, p.day, p.balance
|
||||
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id
|
||||
WHERE p.day > CURRENT_DATE""",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -23,8 +25,10 @@ from app.routers.planning import list_planned as _list_planned
|
||||
from app.routers.planning import list_recurring as _list_recurring
|
||||
from app.routers.planning import recurring_suggestions as _recurring_suggestions
|
||||
from app.routers.scenarios import list_scenarios as _list_scenarios
|
||||
from app.routers.transactions import count_transactions, list_transactions
|
||||
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
||||
from app.services.balances import account_balance, total_balance
|
||||
from app.services.planning_chart import refresh_planning_chart
|
||||
from app.services.projection_service import refresh_stale_projections
|
||||
from app.version import get_version
|
||||
|
||||
PAGE_SIZE = 50
|
||||
@@ -51,6 +55,21 @@ templates.env.filters["eur"] = eur
|
||||
templates.env.filters["de_label"] = de_label
|
||||
router = APIRouter()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_refresh(session: Session, refresh: Callable[[Session, date], object],
|
||||
today: date) -> None:
|
||||
"""Frischt abgeleitete Daten (Projektionen, Planungs-Diagramm) vor dem
|
||||
Rendern auf. Ein Fehler darf die Seite nie brechen: rollback, Warnung ins
|
||||
Log, die Seite rendert mit dem vorhandenen Stand."""
|
||||
try:
|
||||
refresh(session, today)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
log.warning("Auffrischen fehlgeschlagen: %s",
|
||||
getattr(refresh, "__name__", refresh), exc_info=True)
|
||||
|
||||
|
||||
def gui_session(request: Request):
|
||||
if not session_valid(request.cookies.get(COOKIE)):
|
||||
@@ -127,6 +146,8 @@ def _month_starts_desc(session: Session, today: date) -> list[date]:
|
||||
|
||||
@router.get("/", dependencies=[Depends(gui_session)])
|
||||
def index(request: Request, session: Session = Depends(get_session)):
|
||||
_safe_refresh(session, refresh_stale_projections, date.today())
|
||||
_safe_refresh(session, refresh_planning_chart, date.today())
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
balances = [(a, account_balance(session, a)) for a in accounts]
|
||||
return templates.TemplateResponse(request, "index.html", {
|
||||
@@ -147,6 +168,8 @@ def buchungen(
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
page: int = 1,
|
||||
sort: str = "datum",
|
||||
dir: str = "asc",
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
# HTML-Filterformular sendet leere Strings fuer nicht ausgefuellte Felder -
|
||||
@@ -158,6 +181,7 @@ def buchungen(
|
||||
date_to = _opt_date(date_to)
|
||||
category_id = _opt_int(category_id)
|
||||
page = max(1, page)
|
||||
sort, dir = normalize_sort(sort, dir)
|
||||
total = count_transactions(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
@@ -176,6 +200,8 @@ def buchungen(
|
||||
category_id=category_id,
|
||||
q=q,
|
||||
status=status,
|
||||
sort=sort,
|
||||
dir=dir,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
session=session,
|
||||
@@ -196,6 +222,8 @@ def buchungen(
|
||||
},
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"sort": sort,
|
||||
"dir": dir,
|
||||
})
|
||||
|
||||
|
||||
@@ -304,6 +332,7 @@ def hilfe_page(request: Request):
|
||||
|
||||
@router.get("/planung", dependencies=[Depends(gui_session)])
|
||||
def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
_safe_refresh(session, refresh_planning_chart, date.today())
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
planned = _list_planned(session=session)
|
||||
@@ -323,6 +352,7 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
|
||||
@router.get("/szenarien", dependencies=[Depends(gui_session)])
|
||||
def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
||||
_safe_refresh(session, refresh_stale_projections, date.today())
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.models.views import RESERVED_SCENARIO_NAME
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
@@ -85,6 +86,14 @@ def _get_scenario(session: Session, scenario_id: int) -> Scenario:
|
||||
return scenario
|
||||
|
||||
|
||||
def _check_reserved_name(name: str | None) -> None:
|
||||
"""Der Serienname der Ist-Kurve (v_projection) ist für Szenarien tabu."""
|
||||
if name is not None and (name.strip().casefold()
|
||||
== RESERVED_SCENARIO_NAME.casefold()):
|
||||
raise HTTPException(
|
||||
422, f"Der Name »{RESERVED_SCENARIO_NAME}« ist für die Ist-Kurve reserviert")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScenarioOut])
|
||||
def list_scenarios(session: Session = Depends(get_session)):
|
||||
return [ScenarioOut.model_validate(s)
|
||||
@@ -93,6 +102,7 @@ def list_scenarios(session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("", response_model=ScenarioOut, status_code=201)
|
||||
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
|
||||
_check_reserved_name(data.name)
|
||||
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
scenario = Scenario(**data.model_dump())
|
||||
@@ -107,6 +117,7 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
_check_reserved_name(fields.get("name"))
|
||||
if "name" in fields and fields["name"] != scenario.name:
|
||||
clash = session.execute(
|
||||
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
||||
|
||||
@@ -75,6 +75,38 @@ def _apply_transaction_filters(
|
||||
return stmt
|
||||
|
||||
|
||||
# Serverseitige Sortierung der Buchungsliste (Ausbaustufe 11): Whitelist
|
||||
# URL-Schlüssel -> Sortierausdruck. Textspalten über lower(), damit SQLite
|
||||
# (Tests) und Postgres gleich (case-insensitiv) sortieren.
|
||||
SORT_COLUMNS = {
|
||||
"datum": Transaction.booking_date,
|
||||
"konto": func.lower(Account.name),
|
||||
"zweck": func.lower(Transaction.purpose),
|
||||
"empfaenger": func.lower(Transaction.counterparty),
|
||||
"betrag": Transaction.amount,
|
||||
"kategorie": func.lower(Category.name),
|
||||
}
|
||||
|
||||
|
||||
def normalize_sort(sort: str | None, dir: str | None) -> tuple[str, str]:
|
||||
"""Unbekannte Werte fallen still auf den Standard (datum/asc) zurück -
|
||||
kein 422 für manipulierte oder veraltete Links."""
|
||||
return (sort if sort in SORT_COLUMNS else "datum",
|
||||
dir if dir in ("asc", "desc") else "asc")
|
||||
|
||||
|
||||
def _apply_sort(stmt, sort: str | None, dir: str | None):
|
||||
sort, dir = normalize_sort(sort, dir)
|
||||
if sort == "konto":
|
||||
stmt = stmt.outerjoin(Account, Account.id == Transaction.account_id)
|
||||
elif sort == "kategorie":
|
||||
stmt = stmt.outerjoin(Category, Category.id == Transaction.category_id)
|
||||
col = SORT_COLUMNS[sort]
|
||||
if dir == "desc":
|
||||
return stmt.order_by(col.desc().nulls_last(), Transaction.id.desc())
|
||||
return stmt.order_by(col.asc().nulls_last(), Transaction.id.asc())
|
||||
|
||||
|
||||
@router.get("", response_model=list[TransactionOut])
|
||||
def list_transactions(
|
||||
account_id: int | None = None,
|
||||
@@ -83,6 +115,8 @@ def list_transactions(
|
||||
category_id: int | None = None,
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
sort: str = "datum",
|
||||
dir: str = "asc",
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session),
|
||||
@@ -91,7 +125,7 @@ def list_transactions(
|
||||
select(Transaction), account_id=account_id, date_from=date_from,
|
||||
date_to=date_to, category_id=category_id, q=q, status=status,
|
||||
)
|
||||
stmt = stmt.order_by(Transaction.booking_date, Transaction.id).offset(offset).limit(limit)
|
||||
stmt = _apply_sort(stmt, sort, dir).offset(offset).limit(limit)
|
||||
return [TransactionOut.model_validate(t) for t in session.execute(stmt).scalars()]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Planungs-Diagramm (Grafana-Panel „Planung nach Kategorie", Ausbaustufe 11).
|
||||
|
||||
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
|
||||
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
|
||||
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen,
|
||||
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel
|
||||
(engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import advisory_xact_lock
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||
RecurringItem)
|
||||
|
||||
UNKATEGORISIERT = "unkategorisiert"
|
||||
KREDITRATEN = "Kreditraten"
|
||||
PLANNING_CHART_LOCK = 110_002
|
||||
|
||||
|
||||
def planning_monthly_totals(session: Session, today: date,
|
||||
horizon_days: int) -> dict[tuple[date, str], Decimal]:
|
||||
"""Fenster: 1. des laufenden Monats bis today + horizon_days. Schlüssel
|
||||
(Monatserster, Kategoriename); Summen 0 entfallen."""
|
||||
window_start = today.replace(day=1)
|
||||
window_end = today + timedelta(days=horizon_days)
|
||||
names = {c.id: c.name for c in session.execute(select(Category)).scalars()}
|
||||
totals: dict[tuple[date, str], Decimal] = defaultdict(lambda: Decimal("0"))
|
||||
|
||||
def category(category_id: int | None) -> str:
|
||||
return names.get(category_id, UNKATEGORISIERT) if category_id else UNKATEGORISIERT
|
||||
|
||||
for r in session.execute(select(RecurringItem)).scalars():
|
||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||
r.start_date, r.end_date):
|
||||
totals[(d.replace(day=1), category(r.category_id))] += Decimal(r.amount)
|
||||
for p in session.execute(select(PlannedItem)).scalars():
|
||||
if window_start <= p.due <= window_end:
|
||||
totals[(p.due.replace(day=1), category(p.category_id))] += Decimal(p.amount)
|
||||
for loan in session.execute(select(Loan)).scalars():
|
||||
for inst in loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||||
loan.term_months, loan.payout_date,
|
||||
loan.repayment_type):
|
||||
if window_start <= inst.due <= window_end:
|
||||
# payment ist bereits negativ (Abfluss), wie in build_cashflows.
|
||||
totals[(inst.due.replace(day=1), KREDITRATEN)] += inst.payment
|
||||
return {k: v for k, v in totals.items() if v != 0}
|
||||
|
||||
|
||||
def refresh_planning_chart(session: Session, today: date) -> int:
|
||||
"""Schreibt planning_monthly in EINER Transaktion komplett neu (Lock
|
||||
gegen parallele Seitenaufrufe). Rückgabe: Anzahl geschriebener Zeilen."""
|
||||
advisory_xact_lock(session, PLANNING_CHART_LOCK)
|
||||
totals = planning_monthly_totals(session, today, get_settings().horizon_days)
|
||||
session.execute(delete(PlanningMonthly))
|
||||
session.add_all(PlanningMonthly(month=m, category=c, total=v)
|
||||
for (m, c), v in sorted(totals.items()))
|
||||
session.commit()
|
||||
return len(totals)
|
||||
@@ -2,10 +2,11 @@ from datetime import date, timedelta
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import advisory_xact_lock
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.projection import project
|
||||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||||
@@ -63,3 +64,32 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
||||
session.add(result)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
# Advisory-Lock-Schlüssel (beliebig, aber projektweit eindeutig).
|
||||
STALE_PROJECTION_LOCK = 110_001
|
||||
|
||||
|
||||
def refresh_stale_projections(session: Session, today: date) -> int:
|
||||
"""Rechnet jedes bereits durchgerechnete Szenario neu, dessen Projektion
|
||||
vor `today` beginnt (erster gespeicherter Punkt <= today, d.h. Rechen-Start
|
||||
< today). Maßstab ist der Projektionsbeginn, nicht computed_at (UTC-
|
||||
Zeitstempel, würde um Mitternacht falsch schneiden). Nie gerechnete
|
||||
Szenarien bleiben unberührt. Je Szenario: Lock nehmen, Staleness NEU
|
||||
prüfen (ein paralleler Seitenaufruf kann schon gerechnet haben), rechnen;
|
||||
run_projection committet und gibt damit den Lock frei.
|
||||
Rückgabe: Anzahl neu gerechneter Szenarien."""
|
||||
horizon = get_settings().horizon_days
|
||||
ids = list(session.execute(select(ProjectionResult.scenario_id)).scalars())
|
||||
refreshed = 0
|
||||
for sid in ids:
|
||||
advisory_xact_lock(session, STALE_PROJECTION_LOCK)
|
||||
first = session.execute(select(func.min(ProjectionPoint.day))
|
||||
.where(ProjectionPoint.scenario_id == sid)).scalar()
|
||||
scenario = session.get(Scenario, sid)
|
||||
if scenario is None or first is None or first > today:
|
||||
session.commit() # Lock freigeben
|
||||
continue
|
||||
run_projection(session, scenario, horizon, today)
|
||||
refreshed += 1
|
||||
return refreshed
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Tabellensortierung per Spaltenkopf (Ausbaustufe 11).
|
||||
*
|
||||
* Jede Tabelle mit <thead> bekommt je Spaltenkopf mit Text einen Button
|
||||
* (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend). Ausnahmen: th[data-nosort],
|
||||
* table[data-sort-mode="server"] (z.B. /buchungen, sortiert serverseitig).
|
||||
* Sortierwert: td[data-sort] (Zahl Punkt-dezimal / ISO-Datum) oder der
|
||||
* angezeigte Text (Datum TT.MM.JJJJ, deutscher Betrag 1.234,56, sonst Text).
|
||||
* Leere Werte und „–" stehen in beiden Richtungen am Ende. Zeilen mit
|
||||
* data-sort-follow (Bearbeiten-/Tilgungsplan-Zeilen) wandern mit ihrer
|
||||
* Vorzeile. Nur <tbody> wird sortiert, <tfoot> bleibt unberührt.
|
||||
* Initialisierung über htmx.onLoad - greift auch bei nachgeladenen Fragmenten.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DE_DATE = /^(\d{2})\.(\d{2})\.(\d{4})$/;
|
||||
var DE_NUMBER = /^-?(\d{1,3}(\.\d{3})+|\d+)(,\d+)?$/;
|
||||
var PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
|
||||
var ICON = { none: '⇅', asc: '▲', desc: '▼' };
|
||||
var collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
|
||||
|
||||
function sortKey(raw, fromDataSort) {
|
||||
var text = String(raw == null ? '' : raw).trim();
|
||||
if (text === '' || text === '–' || text === '-') { return null; }
|
||||
if (fromDataSort && PLAIN_NUMBER.test(text)) {
|
||||
return { kind: 'number', value: parseFloat(text), text: text };
|
||||
}
|
||||
var m = DE_DATE.exec(text);
|
||||
if (m) { return { kind: 'date', value: parseInt(m[3] + m[2] + m[1], 10), text: text }; }
|
||||
var num = text.replace(/\s*[€%]$/, '').replace(/\s/g, '');
|
||||
if (DE_NUMBER.test(num)) {
|
||||
return { kind: 'number', value: parseFloat(num.replace(/\./g, '').replace(',', '.')), text: text };
|
||||
}
|
||||
return { kind: 'text', value: text, text: text };
|
||||
}
|
||||
|
||||
function columnKind(keys) {
|
||||
var kind = null;
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (!keys[i]) { continue; }
|
||||
if (kind === null) { kind = keys[i].kind; } else if (kind !== keys[i].kind) { return 'text'; }
|
||||
}
|
||||
return kind || 'text';
|
||||
}
|
||||
|
||||
function sortItems(items, dir) {
|
||||
var kind = columnKind(items.map(function (it) { return it.key; }));
|
||||
var sign = dir === 'desc' ? -1 : 1;
|
||||
return items.slice().sort(function (a, b) {
|
||||
if (!a.key || !b.key) {
|
||||
if (a.key) { return -1; }
|
||||
if (b.key) { return 1; }
|
||||
return a.index - b.index;
|
||||
}
|
||||
var c = kind === 'text' ? collator.compare(a.key.text, b.key.text)
|
||||
: a.key.value - b.key.value;
|
||||
return c !== 0 ? sign * c : a.index - b.index;
|
||||
});
|
||||
}
|
||||
|
||||
function groupRows(rows, isFollow) {
|
||||
var groups = [];
|
||||
rows.forEach(function (row) {
|
||||
if (isFollow(row) && groups.length) { groups[groups.length - 1].push(row); }
|
||||
else { groups.push([row]); }
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function sortTable(table, col, dir) {
|
||||
var tbody = table.tBodies[0];
|
||||
if (!tbody) { return; }
|
||||
var groups = groupRows(Array.prototype.slice.call(tbody.rows), function (r) {
|
||||
return r.hasAttribute('data-sort-follow');
|
||||
});
|
||||
var items = groups.map(function (g, i) {
|
||||
var cell = g[0].cells[col];
|
||||
var key = null;
|
||||
if (cell && cell.colSpan === 1) {
|
||||
key = cell.hasAttribute('data-sort')
|
||||
? sortKey(cell.getAttribute('data-sort'), true)
|
||||
: sortKey(cell.textContent, false);
|
||||
}
|
||||
return { key: key, index: i, rows: g };
|
||||
});
|
||||
sortItems(items, dir).forEach(function (it) {
|
||||
it.rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
}
|
||||
|
||||
function initTable(table) {
|
||||
if (table.hasAttribute('data-sort-init') || !table.tHead || !table.tHead.rows.length
|
||||
|| table.getAttribute('data-sort-mode') === 'server') { return; }
|
||||
table.setAttribute('data-sort-init', '');
|
||||
var doc = table.ownerDocument;
|
||||
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th) {
|
||||
var label = th.textContent.trim();
|
||||
if (!label || th.hasAttribute('data-nosort')) { return; }
|
||||
var btn = doc.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sort-btn';
|
||||
btn.textContent = ICON.none;
|
||||
btn.setAttribute('aria-label', 'Nach ' + label + ' sortieren');
|
||||
btn.addEventListener('click', function () {
|
||||
var dir = btn.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
Array.prototype.forEach.call(table.tHead.querySelectorAll('.sort-btn'), function (b) {
|
||||
b.removeAttribute('data-dir');
|
||||
b.classList.remove('active');
|
||||
b.textContent = ICON.none;
|
||||
});
|
||||
btn.setAttribute('data-dir', dir);
|
||||
btn.classList.add('active');
|
||||
btn.textContent = ICON[dir];
|
||||
sortTable(table, th.cellIndex, dir);
|
||||
});
|
||||
th.appendChild(doc.createTextNode(' '));
|
||||
th.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
if (!root || !root.querySelectorAll) { return; }
|
||||
if (root.tagName === 'TABLE') { initTable(root); }
|
||||
Array.prototype.forEach.call(root.querySelectorAll('table'), initTable);
|
||||
}
|
||||
|
||||
var api = { sortKey: sortKey, columnKind: columnKind, sortItems: sortItems,
|
||||
groupRows: groupRows, sortTable: sortTable, initTable: initTable, init: init };
|
||||
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
|
||||
if (typeof window !== 'undefined') {
|
||||
window.FBSort = api;
|
||||
if (window.htmx) { window.htmx.onLoad(init); }
|
||||
}
|
||||
})();
|
||||
@@ -210,3 +210,19 @@ label:has(input:disabled), label:has(select:disabled) {
|
||||
.project-block {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Sortier-Buttons in Spaltenköpfen (Ausbaustufe 11, static/sort.js). */
|
||||
.sort-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 0.2rem;
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sort-btn.active {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<tr>
|
||||
<td>{{ s.filename }}</td>
|
||||
<td>{{ s.bank }}</td>
|
||||
<td>
|
||||
<td data-sort="{{ s.period_start.isoformat() if s.period_start else '' }}">
|
||||
{% if s.period_start and s.period_end %}
|
||||
{{ s.period_start.strftime('%d.%m.%Y') }} – {{ s.period_end.strftime('%d.%m.%Y') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<td>{{ c.name }}</td>
|
||||
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||
</tr>
|
||||
<tr id="cat-edit-{{ c.id }}" hidden>
|
||||
<tr id="cat-edit-{{ c.id }}" hidden data-sort-follow>
|
||||
<td colspan="2">
|
||||
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/sort.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -76,16 +76,36 @@
|
||||
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||
änderbar.
|
||||
änderbar. Ein Szenario lässt sich über „Löschen“ mit allen Einträgen
|
||||
wieder entfernen. Bereits durchgerechnete Szenarien werden automatisch
|
||||
mit dem Standard-Horizont neu gerechnet, sobald ihre Projektion nicht
|
||||
mehr bei heute beginnt (z. B. nach ein paar Tagen Inaktivität) – das
|
||||
passiert beim nächsten Aufruf der Übersicht oder der Szenarien-Seite.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||
Kategorie und der Szenario-Vergleich als Kurven (einmal mit demselben
|
||||
Kategorie, der Szenario-Vergleich als Kurven (darin die Ist-Kurve
|
||||
„System-Ist“ bis heute, ab heute je Szenario weiter) und das Panel
|
||||
„Planung nach Kategorie“ (Monatssummen aus der reinen Planung inkl.
|
||||
Kreditraten, ohne Szenario-Modifikatoren) – einmal mit demselben
|
||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||
Übersicht).
|
||||
Übersicht.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<strong>Tabellen sortieren:</strong> ein Klick auf einen Spaltenkopf
|
||||
sortiert die Tabelle danach (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend);
|
||||
bei der Buchungsliste wirkt das über alle Seiten, nicht nur auf die
|
||||
aktuell angezeigte.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Planungs-Diagramm und automatisches Neurechnen werden beim Öffnen von
|
||||
Übersicht, Planung oder Szenarien aktualisiert – Änderungen über die API
|
||||
erscheinen also erst nach dem nächsten Seitenaufruf.
|
||||
</p>
|
||||
|
||||
<h2>Empfohlener Arbeitsablauf</h2>
|
||||
<ol>
|
||||
<li>Monatlich: neue Auszüge importieren, Vorschau prüfen, übernehmen.</li>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
|
||||
<th>Umbenennen</th><th>Anker pflegen</th>
|
||||
<th data-nosort>Umbenennen</th><th data-nosort>Anker pflegen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -26,8 +26,8 @@
|
||||
<tr>
|
||||
<td class="account">{{ account.name }}</td>
|
||||
<td>{{ account.bank }}</td>
|
||||
<td class="{{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
|
||||
<td>
|
||||
<td class="{{ 'neg' if balance < 0 else '' }}" data-sort="{{ balance }}">{{ balance|eur }} €</td>
|
||||
<td data-sort="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}">
|
||||
{% if account.anchor_date is not none and account.anchor_balance is not none %}
|
||||
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
|
||||
{% else %}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{% for r in recurring %}
|
||||
<tr id="rec-row-{{ r.id }}">
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
|
||||
<td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
|
||||
<td>{{ r.rhythm|de_label }}</td>
|
||||
<td>{{ r.due_day }}</td>
|
||||
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '–' }}</td>
|
||||
@@ -28,7 +28,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="rec-edit-{{ r.id }}" hidden>
|
||||
<tr id="rec-edit-{{ r.id }}" hidden data-sort-follow>
|
||||
<td colspan="8">
|
||||
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
@@ -94,7 +94,7 @@
|
||||
{% for s in suggestions %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">
|
||||
<td class="{{ 'neg' if s.amount < 0 else '' }}" data-sort="{{ s.amount }}">
|
||||
{{ s.amount|eur }} €
|
||||
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
|
||||
</td>
|
||||
@@ -134,7 +134,7 @@
|
||||
{% for p in planned %}
|
||||
<tr id="pln-row-{{ p.id }}">
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
|
||||
<td class="{{ 'neg' if p.amount < 0 else '' }}" data-sort="{{ p.amount }}">{{ p.amount|eur }} €</td>
|
||||
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ category_names.get(p.category_id, '–') }}</td>
|
||||
<td>
|
||||
@@ -146,7 +146,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pln-edit-{{ p.id }}" hidden>
|
||||
<tr id="pln-edit-{{ p.id }}" hidden data-sort-follow>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
@@ -198,9 +198,9 @@
|
||||
{% for l in loans %}
|
||||
<tr id="loan-row-{{ l.id }}">
|
||||
<td>{{ l.name }}</td>
|
||||
<td>{{ l.principal|eur }} €</td>
|
||||
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
|
||||
<td>{{ l.annual_rate_pct|eur }} %</td>
|
||||
<td>{{ l.term_months }} Monate</td>
|
||||
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
|
||||
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ l.repayment_type|de_label }}</td>
|
||||
<td>
|
||||
@@ -214,7 +214,7 @@
|
||||
</tr>
|
||||
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
|
||||
muessen betroffene Szenarien neu durchgerechnet werden. -->
|
||||
<tr id="loan-edit-{{ l.id }}" hidden>
|
||||
<tr id="loan-edit-{{ l.id }}" hidden data-sort-follow>
|
||||
<td colspan="7">
|
||||
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
@@ -233,7 +233,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr id="loan-plan-{{ l.id }}" data-sort-follow>
|
||||
<td colspan="7">
|
||||
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
|
||||
<summary>Tilgungsplan</summary>
|
||||
|
||||
@@ -22,11 +22,13 @@
|
||||
{% else %}
|
||||
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td><strong>Gesamt</strong></td>
|
||||
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h2>Monatsanfangs-Übersicht</h2>
|
||||
@@ -46,7 +48,7 @@
|
||||
<tbody>
|
||||
{% for row in months %}
|
||||
<tr data-month="{{ row['month'].isoformat() }}">
|
||||
<td>{{ row['month'].strftime('%m.%Y') }}</td>
|
||||
<td data-sort="{{ row['month'].isoformat() }}">{{ row['month'].strftime('%m.%Y') }}</td>
|
||||
{% for account, value in row['values'] %}
|
||||
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
|
||||
{% endfor %}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
|
||||
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
|
||||
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
|
||||
<button type="button" hx-delete="/api/scenarios/{{ sc.id }}" hx-swap="none"
|
||||
hx-confirm="Szenario „{{ sc.name }}“ mit allen Einträgen wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">Löschen</button>
|
||||
</p>
|
||||
<div id="sc-edit-{{ sc.id }}" hidden>
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
|
||||
@@ -62,7 +65,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="mod-edit-{{ m.id }}" hidden>
|
||||
<tr id="mod-edit-{{ m.id }}" hidden data-sort-follow>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
@@ -114,7 +117,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="spi-edit-{{ sp.id }}" hidden>
|
||||
<tr id="spi-edit-{{ sp.id }}" hidden data-sort-follow>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
|
||||
@@ -39,11 +39,25 @@
|
||||
<button type="submit">Filtern</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
{% set filter_params = {
|
||||
'account_id': filters.account_id,
|
||||
'date_from': filters.date_from.isoformat() if filters.date_from else None,
|
||||
'date_to': filters.date_to.isoformat() if filters.date_to else None,
|
||||
'category_id': filters.category_id,
|
||||
'q': filters.q,
|
||||
'status': filters.status,
|
||||
} %}
|
||||
{% macro sort_th(key, label, params, cur_sort, cur_dir) -%}
|
||||
{%- set active = cur_sort == key -%}
|
||||
<th>{{ label }} <a class="sort-btn{{ ' active' if active else '' }}" aria-label="Nach {{ label }} sortieren"
|
||||
href="/buchungen?{% for k, v in params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ key }}&dir={{ 'desc' if active and cur_dir == 'asc' else 'asc' }}">{{ ('▲' if cur_dir == 'asc' else '▼') if active else '⇅' }}</a></th>
|
||||
{%- endmacro %}
|
||||
<table data-sort-mode="server">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
|
||||
<th>Betrag (€)</th><th>Kategorie</th><th></th>
|
||||
{{ sort_th('datum', 'Datum', filter_params, sort, dir) }}{{ sort_th('konto', 'Konto', filter_params, sort, dir) }}
|
||||
{{ sort_th('zweck', 'Verwendungszweck', filter_params, sort, dir) }}{{ sort_th('empfaenger', 'Empfänger/Zahler', filter_params, sort, dir) }}
|
||||
{{ sort_th('betrag', 'Betrag (€)', filter_params, sort, dir) }}{{ sort_th('kategorie', 'Kategorie', filter_params, sort, dir) }}<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -88,20 +102,12 @@
|
||||
</table>
|
||||
|
||||
<p class="pager">
|
||||
{% set pager_params = {
|
||||
'account_id': filters.account_id,
|
||||
'date_from': filters.date_from.isoformat() if filters.date_from else None,
|
||||
'date_to': filters.date_to.isoformat() if filters.date_to else None,
|
||||
'category_id': filters.category_id,
|
||||
'q': filters.q,
|
||||
'status': filters.status,
|
||||
} %}
|
||||
{% if page > 1 %}
|
||||
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page - 1 }}">Zurück</a>
|
||||
<a href="/buchungen?{% for k, v in filter_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ sort }}&dir={{ dir }}&page={{ page - 1 }}">Zurück</a>
|
||||
{% endif %}
|
||||
Seite {{ page }} von {{ total_pages }}
|
||||
{% if page < total_pages %}
|
||||
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page + 1 }}">Weiter</a>
|
||||
<a href="/buchungen?{% for k, v in filter_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ sort }}&dir={{ dir }}&page={{ page + 1 }}">Weiter</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -168,6 +168,28 @@
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "barchart",
|
||||
"title": "Planung nach Kategorie",
|
||||
"datasource": { "type": "postgres", "uid": "FinanzDB" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||
"fieldConfig": { "defaults": { "unit": "currencyEUR" }, "overrides": [] },
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"stacking": "normal",
|
||||
"xTickLabelRotation": 0
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "postgres", "uid": "FinanzDB" },
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT month AS time, category AS metric, total AS value FROM planning_monthly ORDER BY month",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
let JSDOM = null;
|
||||
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/sort.js'), 'utf8');
|
||||
|
||||
function load(html) {
|
||||
const dom = new JSDOM(`<!doctype html><body>${html}</body>`, { runScripts: 'outside-only' });
|
||||
dom.window.eval(SRC);
|
||||
dom.window.FBSort.init(dom.window.document.body);
|
||||
return dom.window.document;
|
||||
}
|
||||
|
||||
const TABLE = `
|
||||
<table id="t"><thead><tr><th>Name</th><th>Betrag</th><th>Aktion</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr id="r1"><td>B</td><td>10,00 €</td><td>x</td><td></td></tr>
|
||||
<tr id="r1-edit" hidden data-sort-follow><td colspan="4">edit1</td></tr>
|
||||
<tr id="r2"><td>A</td><td>-5,00 €</td><td>x</td><td></td></tr>
|
||||
<tr id="r2-edit" hidden data-sort-follow><td colspan="4">edit2</td></tr>
|
||||
<tr id="r3"><td>C</td><td>–</td><td>x</td><td></td></tr>
|
||||
</tbody>
|
||||
<tfoot><tr id="sum"><td>Gesamt</td><td>5,00 €</td><td></td><td></td></tr></tfoot>
|
||||
</table>
|
||||
<table data-sort-mode="server" id="s"><thead><tr><th>Datum</th></tr></thead><tbody></tbody></table>`;
|
||||
|
||||
const ids = (doc) => [...doc.querySelectorAll('#t tbody tr')].map((r) => r.id);
|
||||
|
||||
test('Buttons nur an Spalten mit Text, nicht an server-Tabellen', { skip: !JSDOM }, () => {
|
||||
const doc = load(TABLE.replace('<th>Aktion</th>', '<th data-nosort>Aktion</th>'));
|
||||
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 2);
|
||||
assert.equal(doc.querySelectorAll('#s .sort-btn').length, 0);
|
||||
assert.equal(doc.querySelector('#t .sort-btn').textContent, '⇅');
|
||||
});
|
||||
|
||||
test('Klick sortiert auf/ab, Folgezeilen wandern mit, Leerwert am Ende, tfoot bleibt', { skip: !JSDOM }, () => {
|
||||
const doc = load(TABLE);
|
||||
const btn = doc.querySelectorAll('#t .sort-btn')[1]; // Betrag
|
||||
btn.click();
|
||||
assert.equal(btn.textContent, '▲');
|
||||
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
|
||||
btn.click();
|
||||
assert.equal(btn.textContent, '▼');
|
||||
assert.deepEqual(ids(doc), ['r1', 'r1-edit', 'r2', 'r2-edit', 'r3']);
|
||||
assert.equal(doc.querySelector('#t tfoot tr').id, 'sum');
|
||||
const nameBtn = doc.querySelectorAll('#t .sort-btn')[0];
|
||||
nameBtn.click();
|
||||
assert.equal(btn.textContent, '⇅');
|
||||
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
|
||||
});
|
||||
|
||||
test('init zweimal erzeugt keine doppelten Buttons', { skip: !JSDOM }, () => {
|
||||
const doc = load(TABLE);
|
||||
doc.defaultView.FBSort.init(doc.body);
|
||||
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 3);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const S = require('../../app/static/sort.js');
|
||||
|
||||
const items = (vals, ds) => vals.map((v, i) => ({ key: S.sortKey(v, !!ds), index: i, label: v }));
|
||||
const labels = (arr) => arr.map((i) => i.label);
|
||||
|
||||
test('Datum TT.MM.JJJJ', () => {
|
||||
const k = S.sortKey('05.03.2026', false);
|
||||
assert.equal(k.kind, 'date');
|
||||
assert.equal(k.value, 20260305);
|
||||
});
|
||||
|
||||
test('deutsche Beträge und Zahlen', () => {
|
||||
assert.equal(S.sortKey('-1.234,56 €', false).value, -1234.56);
|
||||
assert.equal(S.sortKey('5,00 %', false).value, 5);
|
||||
assert.equal(S.sortKey('15', false).kind, 'number');
|
||||
assert.equal(S.sortKey('1.234', false).value, 1234);
|
||||
});
|
||||
|
||||
test('data-sort: Punkt-Dezimal und ISO-Datum', () => {
|
||||
assert.equal(S.sortKey('-1234.56', true).value, -1234.56);
|
||||
assert.equal(S.sortKey('2026-06-30', true).kind, 'text');
|
||||
});
|
||||
|
||||
test('Leerwerte sind null', () => {
|
||||
assert.equal(S.sortKey('', false), null);
|
||||
assert.equal(S.sortKey(' ', false), null);
|
||||
assert.equal(S.sortKey('–', false), null);
|
||||
assert.equal(S.sortKey(undefined, false), null);
|
||||
});
|
||||
|
||||
test('Zahlen numerisch statt alphabetisch', () => {
|
||||
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'asc')),
|
||||
['-100,00', '9,00', '10,00']);
|
||||
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'desc')),
|
||||
['10,00', '9,00', '-100,00']);
|
||||
});
|
||||
|
||||
test('Datum chronologisch', () => {
|
||||
assert.deepEqual(labels(S.sortItems(items(['01.02.2026', '15.01.2026', '31.12.2025']), 'asc')),
|
||||
['31.12.2025', '15.01.2026', '01.02.2026']);
|
||||
});
|
||||
|
||||
test('Leerwerte immer am Ende, beide Richtungen', () => {
|
||||
const v = ['01.02.2026', '–', '15.01.2026', ''];
|
||||
assert.deepEqual(labels(S.sortItems(items(v), 'asc')), ['15.01.2026', '01.02.2026', '–', '']);
|
||||
assert.deepEqual(labels(S.sortItems(items(v), 'desc')), ['01.02.2026', '15.01.2026', '–', '']);
|
||||
});
|
||||
|
||||
test('gemischte Typen -> Text', () => {
|
||||
assert.equal(S.columnKind([S.sortKey('10,00'), S.sortKey('abc'), null]), 'text');
|
||||
assert.equal(S.columnKind([S.sortKey('10,00'), null]), 'number');
|
||||
});
|
||||
|
||||
test('Text deutsch (Umlaute, Groß/klein)', () => {
|
||||
assert.deepEqual(labels(S.sortItems(items(['Zahnarzt', 'Ärztin', 'miete']), 'asc')),
|
||||
['Ärztin', 'miete', 'Zahnarzt']);
|
||||
});
|
||||
|
||||
test('stabil bei gleichen Werten', () => {
|
||||
const it = [{ key: S.sortKey('5'), index: 0, label: 'a' }, { key: S.sortKey('5'), index: 1, label: 'b' }];
|
||||
assert.deepEqual(labels(S.sortItems(it, 'desc')), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('groupRows: Folgezeilen hängen an der Vorzeile', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'a-edit', f: true }, { id: 'a-plan', f: true },
|
||||
{ id: 'b' }, { id: 'b-edit', f: true }];
|
||||
assert.deepEqual(S.groupRows(rows, (r) => !!r.f).map((g) => g.map((r) => r.id)),
|
||||
[['a', 'a-edit', 'a-plan'], ['b', 'b-edit']]);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.tables import Account, Transaction
|
||||
from app.services.balances import account_balance
|
||||
|
||||
@@ -294,3 +296,25 @@ def test_scenario_planned_patch(client, db):
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["System-Ist", "system-ist", " System-Ist ", "SYSTEM-IST"])
|
||||
def test_szenario_name_system_ist_ist_reserviert_post(client, name):
|
||||
r = client.post("/api/scenarios", json={"name": name}, headers=H)
|
||||
assert r.status_code == 422
|
||||
assert "reserviert" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_szenario_name_system_ist_ist_reserviert_patch(client):
|
||||
sid = client.post("/api/scenarios", json={"name": "Normal"}, headers=H).json()["id"]
|
||||
r = client.patch(f"/api/scenarios/{sid}", json={"name": "System-Ist"}, headers=H)
|
||||
assert r.status_code == 422
|
||||
assert "reserviert" in r.json()["detail"]
|
||||
# Andere Felder ohne Namensänderung bleiben erlaubt.
|
||||
r = client.patch(f"/api/scenarios/{sid}", json={"description": "x"}, headers=H)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_szenario_normale_namen_weiter_erlaubt(client):
|
||||
assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"},
|
||||
headers=H).status_code == 201
|
||||
|
||||
@@ -49,3 +49,23 @@ def test_time_window_covers_projection_horizon():
|
||||
f"Projektionshorizont von {horizon} Tagen nicht ab -> Projektionskurve "
|
||||
f"wird abgeschnitten"
|
||||
)
|
||||
|
||||
|
||||
def test_panel_planung_nach_kategorie():
|
||||
panels = {p["title"]: p for p in _load()["panels"]}
|
||||
p = panels["Planung nach Kategorie"]
|
||||
assert p["type"] == "barchart"
|
||||
assert "planning_monthly" in p["targets"][0]["rawSql"]
|
||||
assert p["options"]["stacking"] == "normal"
|
||||
|
||||
|
||||
def test_panels_ueberlappen_nicht_und_ids_eindeutig():
|
||||
panels = _load()["panels"]
|
||||
assert len({p["id"] for p in panels}) == len(panels)
|
||||
cells = set()
|
||||
for p in panels:
|
||||
g = p["gridPos"]
|
||||
for x in range(g["x"], g["x"] + g["w"]):
|
||||
for y in range(g["y"], g["y"] + g["h"]):
|
||||
assert (x, y) not in cells, p["title"]
|
||||
cells.add((x, y))
|
||||
|
||||
+106
-1
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -55,7 +56,10 @@ def test_gui_zeigt_deutsche_betragsformate(client, db):
|
||||
db.commit()
|
||||
r = client.get("/")
|
||||
assert "12.345,60" in r.text
|
||||
assert "12345.60" not in r.text
|
||||
# Punkt-Dezimal darf nur im maschinenlesbaren data-sort-Attribut
|
||||
# (Ausbaustufe 11, static/sort.js) stehen, nicht im sichtbaren Text.
|
||||
visible = re.sub(r' data-sort="[^"]*"', '', r.text)
|
||||
assert "12345.60" not in visible
|
||||
|
||||
|
||||
def test_planung_zeigt_deutsche_labels(client, db):
|
||||
@@ -483,3 +487,104 @@ def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
|
||||
assert "fb.example.de" not in r.text
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_szenarien_seite_hat_loeschen_button(client, db):
|
||||
from html.parser import HTMLParser
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Wegwerf", description="", include_recurring=True,
|
||||
include_planned=True)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
r = client.get("/szenarien")
|
||||
|
||||
# Verify delete button is present
|
||||
assert f'hx-delete="/api/scenarios/{sc.id}"' in r.text
|
||||
# Verify the full confirmation text in the page (brief requirement)
|
||||
assert 'Szenario „Wegwerf“ mit allen Einträgen wirklich löschen?' in r.text
|
||||
|
||||
# Parse HTML to verify the hx-confirm attribute has correct value with proper quotes
|
||||
class ButtonParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.hx_confirm_value = None
|
||||
self.found_delete_button = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "button":
|
||||
attrs_dict = dict(attrs)
|
||||
if attrs_dict.get("hx-delete") == f"/api/scenarios/{sc.id}":
|
||||
self.found_delete_button = True
|
||||
self.hx_confirm_value = attrs_dict.get("hx-confirm")
|
||||
|
||||
parser = ButtonParser()
|
||||
parser.feed(r.text)
|
||||
assert parser.found_delete_button, "Delete button not found"
|
||||
# Verify the attribute value uses correct typographic quotes
|
||||
expected_confirm = 'Szenario „Wegwerf“ mit allen Einträgen wirklich löschen?'
|
||||
assert parser.hx_confirm_value == expected_confirm, \
|
||||
f"hx-confirm attribute mismatch: got {repr(parser.hx_confirm_value)}, expected {repr(expected_confirm)}"
|
||||
|
||||
|
||||
def test_szenario_loeschen_roundtrip_gui(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Wegwerf-Roundtrip", description="", include_recurring=True,
|
||||
include_planned=True)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
assert client.delete(f"/api/scenarios/{sc.id}", headers=H).status_code == 204
|
||||
assert "Wegwerf-Roundtrip" not in client.get("/szenarien").text
|
||||
|
||||
|
||||
def test_szenarien_seite_rechnet_veraltete_projektion_neu(client, db):
|
||||
from sqlalchemy import func, select
|
||||
from app.models.tables import ProjectionPoint
|
||||
from app.services.projection_service import run_projection
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Veraltet", description="", include_recurring=False,
|
||||
include_planned=False)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
run_projection(db, sc, 10, date.today() - timedelta(days=3))
|
||||
assert client.get("/szenarien").status_code == 200
|
||||
first = db.execute(select(func.min(ProjectionPoint.day))
|
||||
.where(ProjectionPoint.scenario_id == sc.id)).scalar()
|
||||
assert first == date.today() + timedelta(days=1)
|
||||
|
||||
|
||||
def test_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
|
||||
def kaputt(session, today):
|
||||
raise RuntimeError("absichtlich kaputt")
|
||||
monkeypatch.setattr("app.routers.gui.refresh_stale_projections", kaputt)
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/szenarien"):
|
||||
assert client.get(path).status_code == 200, path
|
||||
|
||||
|
||||
def test_v_projection_zeigt_ist_serie_und_keine_alten_punkte():
|
||||
from app.models.views import RESERVED_SCENARIO_NAME, VIEWS
|
||||
body = VIEWS["v_projection"]
|
||||
assert f"'{RESERVED_SCENARIO_NAME}'" in body
|
||||
assert "p.day > CURRENT_DATE" in body
|
||||
names = list(VIEWS)
|
||||
assert names.index("v_balance_total") < names.index("v_projection")
|
||||
|
||||
|
||||
def test_planung_seite_befuellt_planungs_diagramm(client, db):
|
||||
from app.models.tables import PlanningMonthly
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(PlannedItem(name="Kauf", amount=Decimal("-10.00"),
|
||||
due=date.today() + timedelta(days=3)))
|
||||
db.commit()
|
||||
assert client.get("/planung").status_code == 200
|
||||
assert db.query(PlanningMonthly).count() == 1
|
||||
|
||||
|
||||
def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
|
||||
def kaputt(session, today):
|
||||
raise RuntimeError("absichtlich kaputt")
|
||||
monkeypatch.setattr("app.routers.gui.refresh_planning_chart", kaputt)
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/planung"):
|
||||
assert client.get(path).status_code == 200, path
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||
RecurringItem)
|
||||
from app.services.planning_chart import (KREDITRATEN, UNKATEGORISIERT,
|
||||
planning_monthly_totals,
|
||||
refresh_planning_chart)
|
||||
|
||||
TODAY = date(2026, 9, 28)
|
||||
M = lambda y, m: date(y, m, 1) # noqa: E731
|
||||
|
||||
|
||||
def _setup(db):
|
||||
wohnen = Category(name="Wohnen")
|
||||
vers = Category(name="Versicherung")
|
||||
eink = Category(name="Einkommen")
|
||||
db.add_all([wohnen, vers, eink])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
# monatlich, ohne Start -> 15.09., 15.10., 15.11., 15.12.
|
||||
RecurringItem(name="Miete", amount=Decimal("-50.00"), rhythm="monthly",
|
||||
due_day=15, category_id=wohnen.id),
|
||||
# quartalsweise, Phase ab 01.08. -> nur 01.11. im Fenster
|
||||
RecurringItem(name="Police", amount=Decimal("-300.00"), rhythm="quarterly",
|
||||
due_day=1, start_date=date(2026, 8, 1), category_id=vers.id),
|
||||
# jährlich ohne Kategorie, Phase Dezember -> 10.12.2026
|
||||
RecurringItem(name="Beitrag", amount=Decimal("-120.00"), rhythm="yearly",
|
||||
due_day=10, start_date=date(2025, 12, 10)),
|
||||
# endet 31.10. -> nur 01.09. und 01.10.
|
||||
RecurringItem(name="Gehalt", amount=Decimal("1000.00"), rhythm="monthly",
|
||||
due_day=1, end_date=date(2026, 10, 31), category_id=eink.id),
|
||||
# Einmalposten: innerhalb (Okt), vor dem Fenster, nach dem Fenster
|
||||
PlannedItem(name="Reparatur", amount=Decimal("-200.00"),
|
||||
due=date(2026, 10, 5), category_id=wohnen.id),
|
||||
PlannedItem(name="Vorher", amount=Decimal("-999.00"), due=date(2026, 8, 31)),
|
||||
PlannedItem(name="Nachher", amount=Decimal("-999.00"), due=date(2027, 2, 1)),
|
||||
# Kredit 1.200 zu 0 % auf 12 Monate, Auszahlung 10.09. -> Raten je -100
|
||||
# am 10.10., 10.11., 10.12. (10.01.2027 liegt hinter dem Fenster).
|
||||
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
|
||||
term_months=12, payout_date=date(2026, 9, 10), repayment_type="annuity"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_planung_nach_kategorie_handrechnung(db):
|
||||
_setup(db)
|
||||
assert planning_monthly_totals(db, TODAY, 100) == {
|
||||
(M(2026, 9), "Wohnen"): Decimal("-50.00"),
|
||||
(M(2026, 10), "Wohnen"): Decimal("-250.00"),
|
||||
(M(2026, 11), "Wohnen"): Decimal("-50.00"),
|
||||
(M(2026, 12), "Wohnen"): Decimal("-50.00"),
|
||||
(M(2026, 11), "Versicherung"): Decimal("-300.00"),
|
||||
(M(2026, 12), UNKATEGORISIERT): Decimal("-120.00"),
|
||||
(M(2026, 9), "Einkommen"): Decimal("1000.00"),
|
||||
(M(2026, 10), "Einkommen"): Decimal("1000.00"),
|
||||
(M(2026, 10), KREDITRATEN): Decimal("-100.00"),
|
||||
(M(2026, 11), KREDITRATEN): Decimal("-100.00"),
|
||||
(M(2026, 12), KREDITRATEN): Decimal("-100.00"),
|
||||
}
|
||||
|
||||
|
||||
def test_kredit_auszahlung_zaehlt_nicht(db):
|
||||
_setup(db)
|
||||
totals = planning_monthly_totals(db, TODAY, 100)
|
||||
assert all(v < 0 for (m, c), v in totals.items() if c == KREDITRATEN)
|
||||
assert (M(2026, 9), KREDITRATEN) not in totals
|
||||
|
||||
|
||||
def test_nullsummen_entfallen(db):
|
||||
c = Category(name="Durchlauf")
|
||||
db.add(c)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlannedItem(name="rein", amount=Decimal("10.00"), due=date(2026, 10, 1), category_id=c.id),
|
||||
PlannedItem(name="raus", amount=Decimal("-10.00"), due=date(2026, 10, 2), category_id=c.id),
|
||||
])
|
||||
db.commit()
|
||||
assert planning_monthly_totals(db, TODAY, 100) == {}
|
||||
|
||||
|
||||
def test_refresh_schreibt_tabelle_idempotent(db):
|
||||
_setup(db)
|
||||
n1 = refresh_planning_chart(db, TODAY)
|
||||
n2 = refresh_planning_chart(db, TODAY)
|
||||
assert n1 == n2 > 0
|
||||
assert db.execute(select(func.count()).select_from(PlanningMonthly)).scalar() == n2
|
||||
row = db.get(PlanningMonthly, (M(2026, 11), "Versicherung"))
|
||||
assert row.total == Decimal("-300.00")
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.tables import ProjectionPoint, ProjectionResult, Scenario
|
||||
from app.services.projection_service import refresh_stale_projections, run_projection
|
||||
|
||||
TODAY = date(2026, 9, 28)
|
||||
|
||||
|
||||
def _scenario(db, name):
|
||||
sc = Scenario(name=name, description="", include_recurring=False,
|
||||
include_planned=False)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
return sc
|
||||
|
||||
|
||||
def _first_day(db, sid):
|
||||
return db.execute(select(func.min(ProjectionPoint.day))
|
||||
.where(ProjectionPoint.scenario_id == sid)).scalar()
|
||||
|
||||
|
||||
def _count(db, sid):
|
||||
return db.execute(select(func.count()).select_from(ProjectionPoint)
|
||||
.where(ProjectionPoint.scenario_id == sid)).scalar()
|
||||
|
||||
|
||||
def test_veraltetes_szenario_wird_ab_heute_neu_gerechnet(db):
|
||||
sc = _scenario(db, "Alt")
|
||||
run_projection(db, sc, 10, TODAY - timedelta(days=5))
|
||||
assert _first_day(db, sc.id) == TODAY - timedelta(days=4)
|
||||
assert refresh_stale_projections(db, TODAY) == 1
|
||||
assert _first_day(db, sc.id) == TODAY + timedelta(days=1)
|
||||
assert _count(db, sc.id) == get_settings().horizon_days
|
||||
|
||||
|
||||
def test_gestern_gerechnet_gilt_als_veraltet(db):
|
||||
sc = _scenario(db, "Gestern")
|
||||
run_projection(db, sc, 10, TODAY - timedelta(days=1))
|
||||
assert refresh_stale_projections(db, TODAY) == 1
|
||||
|
||||
|
||||
def test_heute_gerechnet_bleibt_unveraendert(db):
|
||||
sc = _scenario(db, "Heute")
|
||||
run_projection(db, sc, 10, TODAY)
|
||||
before = db.get(ProjectionResult, sc.id).computed_at
|
||||
assert refresh_stale_projections(db, TODAY) == 0
|
||||
assert _count(db, sc.id) == 10
|
||||
assert db.get(ProjectionResult, sc.id).computed_at == before
|
||||
|
||||
|
||||
def test_nie_gerechnetes_szenario_bleibt_ohne_ergebnis(db):
|
||||
sc = _scenario(db, "Nie")
|
||||
assert refresh_stale_projections(db, TODAY) == 0
|
||||
assert db.get(ProjectionResult, sc.id) is None
|
||||
assert _count(db, sc.id) == 0
|
||||
|
||||
|
||||
def test_refresh_ist_idempotent(db):
|
||||
sc = _scenario(db, "Zweimal")
|
||||
run_projection(db, sc, 10, TODAY - timedelta(days=3))
|
||||
assert refresh_stale_projections(db, TODAY) == 1
|
||||
assert refresh_stale_projections(db, TODAY) == 0
|
||||
assert _count(db, sc.id) == get_settings().horizon_days
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Führt die JS-Tests von static/sort.js aus, falls `node` installiert ist.
|
||||
Auf dem Host fehlt node derzeit - dann skip; das Fable-Gate führt die Tests
|
||||
per Podman aus (siehe Plan A11 Task 4, Fable-Gate).
|
||||
|
||||
Die Testdateien werden explizit aufgelistet statt das Verzeichnis
|
||||
tests/js zu übergeben: `node --test <verzeichnis>` scheitert unter
|
||||
Node 22 mit "Cannot find module" (MODULE_NOT_FOUND), weil Node 22 einen
|
||||
Verzeichnis-Pfad nicht als Test-Root auflöst wie erwartet (Fable-Fund,
|
||||
A11-T4 Fix-Runde 1)."""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node nicht installiert")
|
||||
def test_sort_js():
|
||||
files = sorted(str(p) for p in (ROOT / "tests" / "js").glob("*.test.js"))
|
||||
r = subprocess.run([NODE, "--test", *files],
|
||||
capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
@@ -0,0 +1,72 @@
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import (Account, Category, Loan, PlannedItem,
|
||||
RecurringItem, Scenario, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
|
||||
|
||||
def _login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
|
||||
|
||||
def _follow_ids(html):
|
||||
return re.findall(r'<tr id="([a-z]+-(?:edit|plan)-\d+)"[^>]*data-sort-follow', html)
|
||||
|
||||
|
||||
def test_base_bindet_sort_js_ein(client):
|
||||
_login(client)
|
||||
assert '<script src="/static/sort.js"></script>' in client.get("/").text
|
||||
|
||||
|
||||
def test_planung_folgezeilen_markiert(client, db):
|
||||
_login(client)
|
||||
c = Category(name="K")
|
||||
db.add(c)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1, category_id=c.id),
|
||||
PlannedItem(name="P", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
|
||||
Loan(name="L", principal=Decimal("100.00"), annual_rate_pct=Decimal("1"),
|
||||
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity"),
|
||||
])
|
||||
db.commit()
|
||||
kinds = sorted(i.split("-")[0] + "-" + i.split("-")[1] for i in _follow_ids(client.get("/planung").text))
|
||||
assert kinds == ["loan-edit", "loan-plan", "pln-edit", "rec-edit"]
|
||||
|
||||
|
||||
def test_szenarien_und_admin_folgezeilen_markiert(client, db):
|
||||
_login(client)
|
||||
c = Category(name="K")
|
||||
sc = Scenario(name="S", description="")
|
||||
db.add_all([c, sc])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=c.id,
|
||||
kind="percent", value=Decimal("10")),
|
||||
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
|
||||
])
|
||||
db.commit()
|
||||
assert sorted(i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/szenarien").text)) == ["mod-edit", "spi-edit"]
|
||||
assert [i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/admin").text)] == ["cat-edit"]
|
||||
|
||||
|
||||
def test_salden_gesamtzeile_im_tfoot(client, db):
|
||||
_login(client)
|
||||
db.add(Account(bank="t", iban="DE-S-1", name="Konto", type="giro"))
|
||||
db.commit()
|
||||
html = client.get("/salden").text
|
||||
assert re.search(r'<tfoot>\s*<tr class="total-row">', html)
|
||||
|
||||
|
||||
def test_index_aktionsspalten_ohne_sortierung(client):
|
||||
_login(client)
|
||||
html = client.get("/").text
|
||||
assert "<th data-nosort>Umbenennen</th>" in html
|
||||
assert "<th data-nosort>Anker pflegen</th>" in html
|
||||
|
||||
|
||||
def test_buchungen_tabelle_ist_serverseitig(client):
|
||||
_login(client)
|
||||
assert 'data-sort-mode="server"' in client.get("/buchungen").text
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.tables import Account, Category, Transaction
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def _tx(db, acc, day, amount, purpose="Zweck", counterparty="X", category=None, n=[0]):
|
||||
n[0] += 1
|
||||
db.add(Transaction(account_id=acc.id, booking_date=day, amount=Decimal(amount),
|
||||
purpose=purpose, counterparty=counterparty, status="confirmed",
|
||||
category_id=category.id if category else None,
|
||||
dedup_hash=f"sort-{n[0]}"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def small(db):
|
||||
zeta = Account(bank="t", iban="DE-SO-1", name="Zeta", type="giro")
|
||||
alpha = Account(bank="t", iban="DE-SO-2", name="alpha", type="giro")
|
||||
kat = Category(name="Miete")
|
||||
db.add_all([zeta, alpha, kat])
|
||||
db.flush()
|
||||
_tx(db, zeta, date(2026, 1, 3), "-5.00", "b-Zweck", "Bert", kat)
|
||||
_tx(db, alpha, date(2026, 1, 1), "20.00", "A-Zweck", "anna", None)
|
||||
_tx(db, zeta, date(2026, 1, 2), "-30.00", "c-Zweck", "Carl", kat)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _amounts(client, qs):
|
||||
r = client.get(f"/api/transactions?{qs}", headers=H)
|
||||
assert r.status_code == 200
|
||||
return [Decimal(t["amount"]) for t in r.json()]
|
||||
|
||||
|
||||
def test_standard_bleibt_datum_aufsteigend(client, small):
|
||||
assert _amounts(client, "") == [Decimal("20.00"), Decimal("-30.00"), Decimal("-5.00")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort,dir,expected", [
|
||||
("betrag", "asc", ["-30.00", "-5.00", "20.00"]),
|
||||
("betrag", "desc", ["20.00", "-5.00", "-30.00"]),
|
||||
("datum", "desc", ["-5.00", "-30.00", "20.00"]),
|
||||
("konto", "asc", ["20.00", "-5.00", "-30.00"]), # alpha vor Zeta (case-insensitiv), Gleichstand -> id
|
||||
("zweck", "asc", ["20.00", "-5.00", "-30.00"]), # A-, b-, c-Zweck
|
||||
("empfaenger", "desc", ["-30.00", "-5.00", "20.00"]), # Carl, Bert, anna
|
||||
("kategorie", "asc", ["-5.00", "-30.00", "20.00"]), # Miete (id-Reihenfolge), unkategorisiert zuletzt
|
||||
("kategorie", "desc", ["-30.00", "-5.00", "20.00"]), # unkategorisiert auch absteigend zuletzt
|
||||
])
|
||||
def test_sortierung_je_spalte(client, small, sort, dir, expected):
|
||||
assert _amounts(client, f"sort={sort}&dir={dir}") == [Decimal(e) for e in expected]
|
||||
|
||||
|
||||
def test_ungueltige_werte_fallen_auf_standard(client, small):
|
||||
assert _amounts(client, "sort=foo;drop&dir=hoch") == _amounts(client, "")
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
assert client.get("/buchungen?sort=foo&dir=bar").status_code == 200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def many(db):
|
||||
acc = Account(bank="t", iban="DE-SO-3", name="Viel", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
for i in range(1, 61): # Beträge i,37; Datum verwürfelt
|
||||
_tx(db, acc, date(2026, 1, 1) + timedelta(days=(i * 7) % 60), f"{i}.37", f"Miete {i}")
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_sortierung_gilt_ueber_alle_seiten(client, many):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
p1 = client.get("/buchungen?sort=betrag&dir=desc").text
|
||||
p2 = client.get("/buchungen?sort=betrag&dir=desc&page=2").text
|
||||
assert ">60,37</td>" in p1 and ">1,37</td>" not in p1
|
||||
assert ">1,37</td>" in p2 and ">60,37</td>" not in p2
|
||||
|
||||
|
||||
def test_links_erhalten_filter_und_sortierung(client, many):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
html = client.get("/buchungen?q=Miete&sort=betrag&dir=asc").text
|
||||
# Aktiver Spaltenkopf kehrt die Richtung um, übernimmt den Filter, ohne page.
|
||||
assert 'href="/buchungen?q=Miete&status=confirmed&sort=betrag&dir=desc"' in html
|
||||
# Inaktive Spalte startet aufsteigend.
|
||||
assert 'href="/buchungen?q=Miete&status=confirmed&sort=datum&dir=asc"' in html
|
||||
# Pager behält Filter + Sortierung.
|
||||
assert "q=Miete&status=confirmed&sort=betrag&dir=asc&page=2" in html
|
||||
assert "▲" in html
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Postgres-only-Prüfung der Grafana-Views (Ausbaustufe 11).
|
||||
|
||||
Läuft nur mit FB_TEST_PG_URL (Wegwerf-Postgres, DB-Name muss auf '_test'
|
||||
enden - Schutz gegen versehentliche Ausführung gegen die Produktiv-DB).
|
||||
Beispiel:
|
||||
podman run -d --rm --name a11-pg -e POSTGRES_PASSWORD=wegwerf \
|
||||
-e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10
|
||||
FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \
|
||||
.venv/bin/python -m pytest -q tests/test_views_pg.py
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base
|
||||
import app.models # noqa: F401
|
||||
from app.models.tables import (Account, ProjectionPoint, ProjectionResult,
|
||||
Scenario, Transaction)
|
||||
from app.models.views import RESERVED_SCENARIO_NAME, VIEWS, create_views
|
||||
|
||||
PG_URL = os.environ.get("FB_TEST_PG_URL")
|
||||
pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt")
|
||||
|
||||
|
||||
def _reset(engine):
|
||||
with engine.begin() as conn:
|
||||
for name in VIEWS:
|
||||
conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg():
|
||||
assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!"
|
||||
engine = create_engine(PG_URL)
|
||||
_reset(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
create_views(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
_reset(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_v_projection_ist_serie_und_szenario_ab_heute(pg):
|
||||
today = pg.execute(text("SELECT CURRENT_DATE")).scalar()
|
||||
acc = Account(bank="t", iban="DE-PG-1", name="Testkonto", type="giro",
|
||||
anchor_date=today - timedelta(days=10),
|
||||
anchor_balance=Decimal("1000.00"))
|
||||
pg.add(acc)
|
||||
pg.flush()
|
||||
pg.add(Transaction(account_id=acc.id, booking_date=today - timedelta(days=2),
|
||||
amount=Decimal("-100.00"), purpose="t", counterparty="t",
|
||||
status="confirmed", dedup_hash="pg-1"))
|
||||
stale = Scenario(name="Veraltet", description="")
|
||||
empty = Scenario(name="Ohne Punkte", description="")
|
||||
pg.add_all([stale, empty])
|
||||
pg.flush()
|
||||
# Veraltete Momentaufnahme: Punkte von heute-5 bis heute+5.
|
||||
for off in range(-5, 6):
|
||||
pg.add(ProjectionPoint(scenario_id=stale.id, day=today + timedelta(days=off),
|
||||
balance=Decimal("5000.00")))
|
||||
pg.add(ProjectionResult(scenario_id=stale.id, computed_at=today,
|
||||
low_point_date=today, low_point_balance=Decimal("0")))
|
||||
pg.commit()
|
||||
|
||||
rows = pg.execute(text(
|
||||
"SELECT scenario, day, balance FROM v_projection ORDER BY scenario, day")).all()
|
||||
ist = [r for r in rows if r.scenario == RESERVED_SCENARIO_NAME]
|
||||
sc = [r for r in rows if r.scenario == "Veraltet"]
|
||||
assert max(r.day for r in ist) == today
|
||||
assert [r.balance for r in ist if r.day == today] == [Decimal("900.00")]
|
||||
# Szenario startet heute am Ist-Saldo, keine alten Punkte davor.
|
||||
assert sc[0].day == today and sc[0].balance == Decimal("900.00")
|
||||
assert all(r.day >= today for r in sc)
|
||||
assert [r.balance for r in sc[1:]] == [Decimal("5000.00")] * 5
|
||||
assert not any(r.scenario == "Ohne Punkte" for r in rows)
|
||||
|
||||
|
||||
def test_planning_chart_refresh_auf_postgres(pg):
|
||||
from app.models.tables import PlannedItem, PlanningMonthly
|
||||
from app.services.planning_chart import refresh_planning_chart
|
||||
today = pg.execute(text("SELECT CURRENT_DATE")).scalar()
|
||||
pg.add(PlannedItem(name="t", amount=Decimal("-5.00"), due=today + timedelta(days=2)))
|
||||
pg.commit()
|
||||
assert refresh_planning_chart(pg, today) == 1
|
||||
assert refresh_planning_chart(pg, today) == 1
|
||||
assert pg.query(PlanningMonthly).count() == 1
|
||||
Reference in New Issue
Block a user