Compare commits

..
13 Commits
Author SHA256 Message Date
rdmsandClaude Opus 5.5 ffd7647c5f docs: A11 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:44:35 +02:00
rdmsandClaude Opus 5.5 b28b0e69c0 chore: Release v0.11.0 (Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:34:18 +02:00
rdmsandClaude Opus 5.5 b1e007ed15 feat: Buchungen serverseitig nach Spalte sortierbar (A11-T5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:29:25 +02:00
rdmsandClaude Opus 5.5 2a5cd141f2 fix: node --test bekommt explizite Dateiliste statt Verzeichnis (A11-T4 Fix-Runde 1)
tests/test_sort_js.py übergab bisher tests/js als Verzeichnis an
`node --test`; unter Node 22 führt das zu MODULE_NOT_FOUND, weil Node
einen Verzeichnispfad hier nicht auflöst (Fable-Fund). Fix: Testdateien
per Glob (*.test.js, sortiert) einzeln übergeben. Verifiziert unter
Node 22 via Podman mit exakt dieser Argumentliste: 14/14 Tests grün,
0 geskippt. Gesamtsuite unverändert bei 248 passed, 3 skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:27:14 +02:00
rdmsandClaude Opus 5.5 4458300f43 feat: Tabellen per Spaltenkopf sortierbar (A11-T4)
Neues static/sort.js sortiert Tabellen clientseitig per Klick auf den
Spaltenkopf (⇅/▲/▼): Datum TT.MM.JJJJ, deutsche Zahlen/Beträge, sonst
Text (Intl.Collator 'de'). Sortierwert optional per td[data-sort]
(Punkt-Dezimal/ISO) überschreibbar, th[data-nosort] bekommt keinen
Button, table[data-sort-mode="server"] wird übersprungen (Buchungen
sortiert serverseitig, folgt in Task 5). Bearbeiten-/Tilgungsplan-
Zeilen (tr[data-sort-follow]) wandern mit ihrer Vorzeile mit.
Initialisierung über htmx.onLoad, greift auch bei nachgeladenen
Fragmenten.

Templates entsprechend annotiert (Planung, Szenarien, Admin, Index,
Salden, Import-Liste, Buchungen); Salden-Gesamtzeile dafür in ein
tfoot verschoben. test_gui.py::test_gui_zeigt_deutsche_betragsformate
angepasst, da das neue machinenlesbare data-sort-Attribut bewusst
Punkt-Dezimal führt (Interface-Konvention) - die Prüfung auf deutsches
Format im sichtbaren Text bleibt bestehen.

JS-Unit-/DOM-Tests unter tests/js (node --test, DOM-Teil optional via
jsdom), pytest-Wrapper skip ohne node (Fable-Gate führt sie per
Podman aus). Suite: 248 passed, 3 skipped (vorher 242/2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:22:34 +02:00
rdmsandClaude Opus 5.5 42b410446d feat: Grafana-Panel Planung nach Kategorie inkl. Kreditraten (A11-T3)
Neue Tabelle planning_monthly (Monatssummen je Kategorie aus wiederkehrenden
Posten, Einmalposten und allen Kreditraten), Service planning_chart mit
refresh_planning_chart (Advisory-Lock, komplette Neuschreibung), GUI-Hooks
in index/planung_page ueber _safe_refresh, neues Barchart-Panel im
Grafana-Dashboard (Panel 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:15:26 +02:00
rdmsandClaude Opus 5.5 979e1c0edc fix: Szenario-Vergleich zeigt vor heute Ist-Saldo, veraltete Szenarien werden neu gerechnet (A11-T2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:10:51 +02:00
rdmsandClaude Opus 5.5 32d78fdbe1 fix: German quote style U+201C instead of U+201D (A11-T1)
Corrected closing quotation mark in hx-confirm attribute and test assertions
from U+201D (RIGHT DOUBLE QUOTATION MARK) to U+201C (LEFT DOUBLE QUOTATION
MARK), matching German typographic convention („...") and house style (line 114).

Changes by code point:
- finance/app/templates/szenarien.html line 17: 1 × U+201D → U+201C
- finance/tests/test_gui.py lines 501, 521: 2 × U+201D → U+201C

Template lines 17 and 114 now have identical quote code points: U+201E...U+201C.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:07:31 +02:00
rdmsandClaude Opus 5.5 8b637f91b3 fix: HTML attribute quoting in hx-confirm, test assertion precision (A11-T1)
Critical quoting fix: the hx-confirm attribute now uses U+201D (") for the
closing German quote instead of ASCII " (0x22), which was terminating the
attribute early and losing the confirm message text. The template now
correctly contains: hx-confirm="Szenario „{{ sc.name }}" mit allen..."

Test enhancement: restored the complete assertion text from the brief and
added HTML parsing verification to ensure the attribute value matches exactly
with correct typographic quotes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:05:37 +02:00
rdmsandClaude Opus 5.5 57ab6dae4a feat: Szenario per GUI löschen, Name System-Ist reserviert (A11-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:01:21 +02:00
rdmsandClaude Opus 5.5 1c452b48de docs: Plan Ausbaustufe 11 (6 Tasks)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:43:02 +02:00
rdmsandClaude Opus 5.5 d3c752e2b9 docs: Spec A11 - Ist-Serie als reservierter Name System-Ist
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:33:59 +02:00
rdmsandClaude Opus 5.5 dd6110b53f docs: Spec Ausbaustufe 11 (Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:22:14 +02:00
36 changed files with 3124 additions and 44 deletions
+42 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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})
+11
View File
@@ -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)
+24 -3
View File
@@ -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""",
}
+31 -1
View File
@@ -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)
+11
View File
@@ -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()
+35 -1
View File
@@ -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()]
+66
View File
@@ -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)
+31 -1
View File
@@ -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
+134
View File
@@ -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); }
}
})();
+16
View File
@@ -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;
}
+1 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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()}">
+1
View File
@@ -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>
+23 -3
View File
@@ -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>
+3 -3
View File
@@ -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 %}
+9 -9
View File
@@ -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>
+4 -2
View File
@@ -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 %}
+5 -2
View File
@@ -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()}">
+19 -13
View File
@@ -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>
+22
View File
@@ -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
}
]
}
]
}
+60
View File
@@ -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);
});
+72
View File
@@ -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']]);
});
+24
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
+91
View File
@@ -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")
+66
View File
@@ -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
+25
View File
@@ -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
+72
View File
@@ -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
+89
View File
@@ -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
+93
View File
@@ -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