Compare commits
52
Commits
224baba326
..
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
4c3fa25cd0 | ||
|
|
d51287e8d8 | ||
|
|
eb68781a1d | ||
|
|
30330801cc | ||
|
|
dfb814955f | ||
|
|
94fd7ab96e | ||
|
|
80b8b369c7 | ||
|
|
cd7ba6aa6a | ||
|
|
8911623c15 | ||
|
|
2aed685d7e | ||
|
|
aaf05b9209 | ||
|
|
b0a1ba80a1 | ||
|
|
c065ade0d5 | ||
|
|
d8dc25f2a8 | ||
|
|
60cd3c4fea | ||
|
|
23557db0fb | ||
|
|
987e28847c | ||
|
|
b32f28fff5 | ||
|
|
3ef20034d3 | ||
|
|
4b6144d812 | ||
|
|
2a62bdca6c | ||
|
|
1cded22e90 | ||
|
|
9e804cd349 | ||
|
|
125d370497 | ||
|
|
7555d8e6a4 | ||
|
|
0f5bb45a55 | ||
|
|
acabc3d2a4 | ||
|
|
c015b52a70 | ||
|
|
a5f2f23a7e | ||
|
|
d2b21c8f8b | ||
|
|
b904709d0b | ||
|
|
f79e1ce26b | ||
|
|
b78990786f | ||
|
|
e665180b1f | ||
|
|
9e856ec655 | ||
|
|
42d3d95e48 | ||
|
|
0a5fa90ae8 | ||
|
|
5a8e05596f | ||
|
|
404c1a5648 | ||
|
|
ffd7647c5f | ||
|
|
b28b0e69c0 | ||
|
|
b1e007ed15 | ||
|
|
2a5cd141f2 | ||
|
|
4458300f43 | ||
|
|
42b410446d | ||
|
|
979e1c0edc | ||
|
|
32d78fdbe1 | ||
|
|
8b637f91b3 | ||
|
|
57ab6dae4a | ||
|
|
1c452b48de | ||
|
|
d3c752e2b9 | ||
|
|
dd6110b53f |
+147
-16
@@ -18,17 +18,17 @@ direkt aus `routers/gui.py`.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph TPL["templates (Jinja2, app/templates/)"]
|
||||
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html"]
|
||||
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html<br/>aufteilung.html"]
|
||||
end
|
||||
|
||||
subgraph ROUTERS["routers/"]
|
||||
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page"]
|
||||
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page,<br/>aufteilung_page, aufteilung_kategorie_fragment"]
|
||||
R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"]
|
||||
R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
|
||||
R_categories["categories.py<br/>list_categories, create_category_rule"]
|
||||
R_imports["imports.py<br/>upload, scan_inbox, preview,<br/>confirm, rollback"]
|
||||
R_planning["planning.py<br/>list_recurring, list_planned,<br/>list_loans, recurring_suggestions"]
|
||||
R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>project_scenario"]
|
||||
R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>add_scenario_recurring,<br/>project_scenario"]
|
||||
R_admin["admin.py<br/>admin_page, admin_change_password,<br/>apply_category_rules"]
|
||||
end
|
||||
|
||||
@@ -37,15 +37,22 @@ 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, load_scenario_inputs,<br/>refresh_stale_projections"]
|
||||
S_breakdown["breakdown.py<br/>compute_breakdown"]
|
||||
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
|
||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
||||
S_doublecount["double_count.py<br/>all_conflicts, conflicts_for_recurring,<br/>conflicts_for_loan"]
|
||||
end
|
||||
|
||||
N_reserved["app/reserved.py<br/>check_reserved_name"]
|
||||
|
||||
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
|
||||
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
|
||||
E_loans["loans.py<br/>loan_schedule, schedule_for,<br/>annuity_payment, add_months"]
|
||||
E_recurrence["recurrence.py<br/>occurrences"]
|
||||
E_projection["projection.py<br/>project"]
|
||||
E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"]
|
||||
E_scenario["scenario.py<br/>build_cashflows, modified_amount<br/>(Stichtag on, gilt_am)<br/>PlainRecurring/PlainPlanned/<br/>PlainModifier"]
|
||||
E_breakdown["breakdown.py<br/>breakdown_rows, summarize,<br/>category_rows, share"]
|
||||
E_pie["pie.py<br/>pie_slices"]
|
||||
end
|
||||
|
||||
subgraph PARSERS["parsers/"]
|
||||
@@ -60,9 +67,9 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph MODELS["models/ + alembic/"]
|
||||
M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ProjectionResult, ..."]
|
||||
M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ScenarioRecurringItem,<br/>ProjectionResult, ..."]
|
||||
M_views["views.py — create_views()<br/>v_balance_history, v_balance_total,<br/>v_monthly_by_category, v_projection"]
|
||||
M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>1ef6a356f028_konto_anker"]
|
||||
M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>1ef6a356f028_konto_anker<br/>e7f1a9c3b214_szenario_zeitraum"]
|
||||
end
|
||||
|
||||
R_gui --> T_pages
|
||||
@@ -75,6 +82,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
|
||||
@@ -83,7 +92,14 @@ flowchart TD
|
||||
R_imports --> S_importer
|
||||
R_planning --> E_loans
|
||||
R_planning --> S_suggestions
|
||||
R_planning --> S_doublecount
|
||||
R_scenarios --> S_projection
|
||||
R_scenarios --> N_reserved
|
||||
R_categories --> N_reserved
|
||||
R_gui --> S_doublecount
|
||||
R_gui --> S_breakdown
|
||||
R_gui --> E_breakdown
|
||||
R_gui --> E_pie
|
||||
R_admin --> S_admin
|
||||
|
||||
ROUTERS -.-> M_tables
|
||||
@@ -96,10 +112,20 @@ flowchart TD
|
||||
S_projection --> E_projection
|
||||
S_projection --> E_scenario
|
||||
S_projection --> S_balances
|
||||
S_breakdown --> S_projection
|
||||
S_breakdown --> E_breakdown
|
||||
S_breakdown --> E_loans
|
||||
S_breakdown --> S_doublecount
|
||||
S_planning_chart --> E_loans
|
||||
S_planning_chart --> E_recurrence
|
||||
S_planning_chart --> M_tables
|
||||
S_admin --> S_categorize
|
||||
|
||||
E_scenario --> E_loans
|
||||
E_scenario --> E_recurrence
|
||||
E_breakdown --> E_scenario
|
||||
E_breakdown --> E_recurrence
|
||||
E_breakdown --> E_loans
|
||||
|
||||
P_registry --> P_vr
|
||||
P_registry --> P_hvb
|
||||
@@ -153,7 +179,7 @@ sequenceDiagram
|
||||
IM->>IM: _find_or_create_account(bank, iban)
|
||||
IM->>DB: INSERT Statement(status="draft")
|
||||
|
||||
opt Saldo pruefbar (PDF immer; CSV nur VR: balance_checkable=True)
|
||||
opt Saldo pruefbar (PDF immer, CSV nur VR: balance_checkable=True)
|
||||
IM->>VA: balance_difference(parsed)
|
||||
Note right of IM: Differenz != 0 -> Statement.status="error", Abbruch
|
||||
end
|
||||
@@ -291,20 +317,125 @@ nur noch über `rollback` (löscht Statement + Transaktionen komplett) zu
|
||||
entfernen, nicht mehr über `DELETE /api/imports/{id}` (nur für Entwürfe und Fehl-Importe).
|
||||
|
||||
**Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`,
|
||||
`projection.py`, `scenario.py`) hat **keinen** Datenbank- oder
|
||||
Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von
|
||||
`loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/
|
||||
`projection.py`, `scenario.py`, `breakdown.py`, `pie.py`) hat **keinen**
|
||||
Datenbank- oder Request-Zugriff — `scenario.py` und `breakdown.py`
|
||||
importieren innerhalb der Engine (`loans.py`/`recurrence.py`/`scenario.py`),
|
||||
sonst keine Abhängigkeiten auf `app.*`. Ein-/
|
||||
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
|
||||
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
|
||||
ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`,
|
||||
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte
|
||||
ist die einzige Brücke: `load_scenario_inputs(session, scenario)` ist die
|
||||
gemeinsame Ladefunktion (`None` = System-Plan, ohne Szenario-Änderungen)
|
||||
und lädt ORM-Objekte (`Loan`, `RecurringItem`,
|
||||
`PlannedItem`, `Scenario`, `ScenarioModifier`, `ScenarioRecurringItem` — Letztere
|
||||
als `PlainRecurring` mit `id=None`), baut daraus die Plain-Objekte
|
||||
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
|
||||
|
||||
**Szenario-Einträge mit Zeitraum (Ausbaustufe 14).** `ScenarioModifier.start_date`/
|
||||
`end_date` sind „Gültig ab/bis" (inklusiv, leer = unbegrenzt; bei Art `ende`
|
||||
zählt nur `end_date` als Enddatum). Neue Art `set` („Neuer Betrag", nur für
|
||||
Posten) ersetzt den Betrag. `engine.scenario.modified_amount(..., on=)` wertet
|
||||
die Einträge mit einem Stichtag je Fälligkeit aus — `build_cashflows` nimmt
|
||||
jedes Fälligkeitsdatum, `engine.breakdown.breakdown_rows` über
|
||||
`engine.recurrence.due_in_month` das Fälligkeitsdatum im Monat; `gilt_am`
|
||||
prüft den Zeitraum, mehrere treffende Einträge wirken in Anlage-Reihenfolge.
|
||||
`ScenarioRecurringItem` (Tabelle `scenario_recurring_items`, API
|
||||
`/api/scenarios/{id}/recurring`) sind wiederkehrende Zahlungen nur in einem
|
||||
Szenario: sie zählen unabhängig von `include_recurring` und werden von
|
||||
Posten-Einträgen nie getroffen (`PlainRecurring.id=None`).
|
||||
`ScenarioPlannedItem.category_id` ordnet Szenario-Einmalzahlungen einer
|
||||
Kategorie zu. Die Szenarien-Seite (`szenarien.html`) nutzt dafür
|
||||
`static/szenarien.js` (`window.FBSzenario`). Migration:
|
||||
`e7f1a9c3b214_szenario_zeitraum`.
|
||||
|
||||
**`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:
|
||||
`app/reserved.py::check_reserved_name` lehnt `POST`/`PATCH` mit diesem
|
||||
Namen (case-insensitiv) mit 422 ab.
|
||||
|
||||
**Präfix `System-` reserviert (Ausbaustufe 12).** `app/reserved.py`
|
||||
(`SYSTEM_PREFIX = "System-"`, `is_reserved_name`, `check_reserved_name`)
|
||||
sperrt diesen Präfix für Kategorie- und Szenario-Namen (`routers/categories.py`,
|
||||
`routers/scenarios.py`, je `check_reserved_name` auf `POST`/`PATCH`, 422 bei
|
||||
Verstoß) — er gehört den vom Tool erzeugten Serien `RESERVED_SCENARIO_NAME`
|
||||
(`"System-Ist"`, `models/views.py`) und `services/planning_chart.py::KREDITRATEN`
|
||||
(`"System-Kreditraten"`).
|
||||
|
||||
**Doppelzählungs-Erkennung Kredit ↔ Fixposten (Ausbaustufe 12).**
|
||||
`app/services/double_count.py` erkennt, wenn ein monatlicher Ausgabe-Posten
|
||||
(`RecurringItem`, `rhythm == "monthly"`) betraglich vermutlich einer regulären
|
||||
Kreditrate (`regular_rate`, Annuität bzw. bei endfälligen Krediten die
|
||||
monatlichen Zinsen) entspricht — Toleranz `max(TOLERANZ_MIN=1.00,
|
||||
rate * TOLERANZ_PROZENT=3 / 100)` — und sich die Zeiträume überschneiden
|
||||
(`rate_period`). `routers/planning.py` ruft `conflicts_for_recurring` bzw.
|
||||
`conflicts_for_loan` bei `POST`/`PATCH` auf `/api/recurring` und
|
||||
`/api/loans` auf; ohne `doppelzaehlung_bestaetigt: true` im Request-Body
|
||||
antwortet es mit `409` (`detail.code == "doppelzaehlung_kredit"`, Konstante
|
||||
`CODE`). `static/doppelzaehlung.js` fängt genau diesen 409 im globalen
|
||||
`htmx:responseError`-Handler ab und zeigt das `<dialog id=
|
||||
"doppelzaehlung-dialog">` aus `base.html`; „Trotzdem speichern“ markiert das
|
||||
Formular (`data-doppelzaehlung-bestaetigt="1"`) und sendet es erneut, die
|
||||
`json-form`-Erweiterung ergänzt dabei `doppelzaehlung_bestaetigt: true`.
|
||||
`routers/gui.py::planung_page` ruft zusätzlich `all_conflicts` auf und zeigt
|
||||
einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`,
|
||||
solange der Konflikt fortbesteht.
|
||||
|
||||
**Aufteilung (Ausbaustufe 13).** `/aufteilung` (`routers/gui.py::aufteilung_page`,
|
||||
GET-Formular, die Auswahl steht in der URL) zeigt für einen Monat die Kosten
|
||||
je Kategorie aus reiner Planung, nie aus Ist-Buchungen.
|
||||
`services/breakdown.py::compute_breakdown` lädt die Planung über
|
||||
`load_scenario_inputs`; `PLAN_NAME` („System-Plan") entspricht
|
||||
`load_scenario_inputs(session, None)`, ein Szenario wird mit seinen
|
||||
Änderungen und Krediten gerechnet. Die Rechenregeln stehen in
|
||||
`engine/breakdown.py` (`breakdown_rows`, `summarize`, `category_rows`,
|
||||
`share`): Fixposten werden per `recurrence.STEP` auf den Monat normalisiert
|
||||
(vierteljährlich ÷ 3, jährlich ÷ 12), Einmalposten und Kreditraten zählen
|
||||
voll im Fälligkeitsmonat, eine Kredit-Auszahlung nie als Einkunft; `FREI`
|
||||
(„Frei verfügbar") ist der Rest der Einkünfte. Das Tortendiagramm entsteht
|
||||
serverseitig in `engine/pie.py::pie_slices` (`MIN_SHARE` 3 %, `MAX_NAMED` 8,
|
||||
Sammelsegment `UEBRIGE`). Drill-down: `GET /aufteilung/kategorie`
|
||||
(`aufteilung_kategorie_fragment`) liefert die Posten der Kategorie und
|
||||
tauscht die Haupttorte per `hx-swap-oob` aus (gewähltes Segment markiert);
|
||||
`sammel=1` wählt die Sammelgruppe „Übrige". Der Doppelzählungs-Hinweis nutzt
|
||||
`services/double_count.py::all_conflicts`. Keine Tabelle, keine View,
|
||||
Grafana unberührt.
|
||||
|
||||
**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 „System-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
|
||||
(`templates.env.globals["app_version"]` in `routers/gui.py`) und
|
||||
`GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image;
|
||||
(`templates.env.globals["app_version"]` in `routers/gui.py`), als
|
||||
Cache-Busting-Parameter an allen statischen Dateien (`/static/style.css?v=…`
|
||||
usw. in `base.html`/`login.html` — ohne ihn behalten Browser alte CSS/JS
|
||||
über ein Release hinaus) und `GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image;
|
||||
`create_pod_finance.sh` liest dieselbe Datei für den Image-Tag
|
||||
(`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`). Ein
|
||||
Release ist somit: `VERSION` hochzählen → Image bauen → Redeploy.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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.
|
||||
@@ -0,0 +1,249 @@
|
||||
# Design — Ausbaustufe 12: System-Präfix + Doppelzählungs-Warnung Kredit/Fixposten (v0.12.0)
|
||||
|
||||
> Status: Design im Chat vom Nutzer freigegeben (2026-09-28), Spec zur
|
||||
> Durchsicht. Anlass: (1) Eine echte Kategorie „Kreditraten" würde im
|
||||
> Grafana-Panel „Planung nach Kategorie" mit der synthetischen
|
||||
> Kreditraten-Serie verschmelzen. (2) Wird eine Kreditrate sowohl als
|
||||
> wiederkehrender Posten als auch als Kredit gepflegt, zählt sie in Planung
|
||||
> und Projektion doppelt — das passiert leicht versehentlich, ein Hilfetext
|
||||
> allein schützt nicht.
|
||||
|
||||
## Nutzerentscheidungen (2026-09-28)
|
||||
|
||||
- **Reservierter Präfix „System-"** für alle vom Tool erzeugten Serien.
|
||||
Kategorie- und Szenarionamen mit diesem Präfix werden abgelehnt.
|
||||
Kreditraten-Serie heißt künftig **„System-Kreditraten"**; „System-Ist"
|
||||
fällt unter dieselbe Regel.
|
||||
- **„unkategorisiert" bleibt** unverändert (Verschmelzen mit einer echten
|
||||
gleichnamigen Kategorie ist semantisch harmlos; Beschriftung bleibt
|
||||
gleich zum Ist-Diagramm „Monat nach Kategorie").
|
||||
- **Aktiver Doppelzählungs-Check** statt Hilfevermerk: Speichern wird bei
|
||||
Verdacht blockiert und ist nur nach **expliziter Bestätigung** in einem
|
||||
Warnfenster möglich.
|
||||
- **Toleranz** Betragsvergleich: **±3 %, mindestens 1,00 €**.
|
||||
- **Dauerhafter Hinweis** auf der Planung-Seite an betroffenen Posten und
|
||||
Krediten (nicht blockierend).
|
||||
- **Version v0.12.0** (neues API-Verhalten).
|
||||
|
||||
## Befund (Ist-Zustand, v0.11.0)
|
||||
|
||||
- `RESERVED_SCENARIO_NAME = "System-Ist"` in `models/views.py`;
|
||||
`routers/scenarios.py::_check_reserved_name` prüft nur diesen einen Namen.
|
||||
- `services/planning_chart.py`: `KREDITRATEN = "Kreditraten"`.
|
||||
- `routers/categories.py`: `create_category`/`patch_category` ohne
|
||||
Namensreservierung.
|
||||
- `routers/planning.py`: `create_recurring`/`patch_recurring`,
|
||||
`create_loan`/`patch_loan` ohne Querprüfung. Schemas: `RecurringIn`
|
||||
(Basis von `RecurringOut`), `RecurringPatch`, `LoanIn` (Basis von
|
||||
`LoanOut`), `LoanPatch`.
|
||||
- GUI-Formulare gegen diese Endpunkte (alle `hx-ext="json-form"`,
|
||||
`planning.html`): Fixposten anlegen (`hx-post /api/recurring`),
|
||||
bearbeiten (`hx-patch /api/recurring/{id}`), „Vorschlag übernehmen"
|
||||
(`hx-post /api/recurring`), Kredit anlegen (`hx-post /api/loans`),
|
||||
bearbeiten (`hx-patch /api/loans/{id}`).
|
||||
- Fehleranzeige global in `base.html`: `htmx:responseError` →
|
||||
`alert('Fehler: ' + responseText)`.
|
||||
- Live-Bestand geprüft (2026-09-28): keine Kategorie/kein Szenario beginnt
|
||||
mit „System-", keine Kategorie heißt „Kreditraten"/„unkategorisiert";
|
||||
0 Kredite.
|
||||
- Beratungsumgebung `/home/wlfb/fb`: Skill `finanz-api` dokumentiert
|
||||
`POST /api/recurring` und `POST /api/loans`, Skill `finanzberatung` legt
|
||||
Kredite per API an → vom neuen 409-Verhalten betroffen.
|
||||
|
||||
## 1. Reservierter Präfix „System-"
|
||||
|
||||
Neues Modul `app/reserved.py` (einzige Quelle):
|
||||
|
||||
```python
|
||||
SYSTEM_PREFIX = "System-"
|
||||
|
||||
def is_reserved_name(name: str) -> bool:
|
||||
"""True, wenn name (strip + casefold) mit SYSTEM_PREFIX beginnt."""
|
||||
|
||||
def check_reserved_name(name: str | None) -> None:
|
||||
"""None -> nichts; reserviert -> HTTPException(422, <Meldung>)."""
|
||||
```
|
||||
|
||||
Meldung (422): **„Namen, die mit »System-« beginnen, sind für vom Tool
|
||||
erzeugte Einträge reserviert"**.
|
||||
|
||||
- `models/views.py`: `RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"`
|
||||
(Wert unverändert „System-Ist", View-SQL unverändert).
|
||||
- `services/planning_chart.py`: `KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"`.
|
||||
- `routers/scenarios.py`: `_check_reserved_name` entfällt, stattdessen
|
||||
`check_reserved_name` in `create_scenario` und `patch_scenario` (gleiche
|
||||
Stellen wie bisher).
|
||||
- `routers/categories.py`: `check_reserved_name(data.name)` als erste
|
||||
Zeile in `create_category` und `patch_category`.
|
||||
- Grafana-Panel 5 zeigt ab dem nächsten Seitenaufruf „System-Kreditraten"
|
||||
(Tabelle `planning_monthly` wird ohnehin komplett neu geschrieben — keine
|
||||
Migration nötig).
|
||||
|
||||
Tests: Kategorien und Szenarien je POST/PATCH mit „System-X",
|
||||
„system-kreditraten", „ SYSTEM-Ist " → 422 mit Meldung; „Kreditraten",
|
||||
„Systemkosten", „Ist-System" → erlaubt (201/200). Planning-Chart-Tests
|
||||
nutzen die Konstante (Wert „System-Kreditraten").
|
||||
|
||||
## 2. Doppelzählungs-Erkennung (`app/services/double_count.py`)
|
||||
|
||||
Reine Logik ohne HTTP-Abhängigkeit.
|
||||
|
||||
```python
|
||||
TOLERANZ_PROZENT = Decimal("3")
|
||||
TOLERANZ_MIN = Decimal("1.00")
|
||||
CODE = "doppelzaehlung_kredit"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konflikt:
|
||||
posten_id: int | None # None = Posten noch nicht gespeichert
|
||||
posten_name: str
|
||||
posten_betrag: Decimal # negativ
|
||||
kredit_id: int | None # None = Kredit noch nicht gespeichert
|
||||
kredit_name: str
|
||||
kredit_rate: Decimal # positiv, reguläre Monatsrate
|
||||
|
||||
def regular_rate(principal, annual_rate_pct, term_months, repayment_type) -> Decimal | None
|
||||
def rate_period(payout_date, term_months) -> tuple[date, date]
|
||||
def is_conflict(posten, kredit) -> bool
|
||||
def conflicts_for_recurring(session, posten, exclude_id=None) -> list[Konflikt]
|
||||
def conflicts_for_loan(session, kredit, exclude_id=None) -> list[Konflikt]
|
||||
def all_conflicts(session) -> list[Konflikt]
|
||||
```
|
||||
|
||||
`posten`/`kredit` sind einfache Wertobjekte (Name, Betrag bzw. Kreditdaten,
|
||||
Rhythmus, Start/Ende) — so lassen sich auch noch nicht gespeicherte bzw.
|
||||
per PATCH veränderte Zustände prüfen.
|
||||
|
||||
**Reguläre Monatsrate** (`regular_rate`, positiv, auf Cent gerundet):
|
||||
- `annuity`: `engine/loans.py::annuity_payment(principal, rate, term)`.
|
||||
- `bullet`: monatliche Zinsen `principal × rate / 100 / 12` (ROUND_HALF_UP).
|
||||
Bei Zinssatz 0 → `None` (keine reguläre Rate → nie Konflikt).
|
||||
- Schlusszahlung endfälliger Kredite wird nicht betrachtet.
|
||||
|
||||
**Ratenzeitraum** (`rate_period`): erste Rate
|
||||
`add_months(payout_date, 1)`, letzte Rate `add_months(payout_date,
|
||||
term_months)` (identisch zu `loan_schedule`).
|
||||
|
||||
**Konflikt** (`is_conflict`), wenn ALLE gelten:
|
||||
1. Posten-Rhythmus `monthly` und Posten-Betrag `< 0`;
|
||||
2. Kredit hat eine reguläre Rate `r` (nicht `None`);
|
||||
3. `abs(abs(betrag) − r) ≤ max(TOLERANZ_MIN, r × TOLERANZ_PROZENT / 100)`;
|
||||
4. Zeiträume überschneiden sich: `(start is None or start ≤ letzte_rate)
|
||||
and (ende is None or ende ≥ erste_rate)`.
|
||||
|
||||
Kein Namensvergleich (unzuverlässig). Bekannte Grenze: weicht der Posten
|
||||
stärker als die Toleranz ab (z.B. Rate inkl. Versicherung), wird nichts
|
||||
erkannt. Alle Beträge `Decimal`.
|
||||
|
||||
Tests (Handrechnung, künstliche Werte): Annuität exakt / knapp innerhalb /
|
||||
knapp außerhalb der 3 %-Grenze; Mindesttoleranz 1,00 € bei kleiner Rate;
|
||||
endfällig (Zinsrate) inkl. Zinssatz 0 → kein Konflikt; Rhythmus
|
||||
quarterly → kein Konflikt; positiver Betrag → kein Konflikt; Zeiträume
|
||||
disjunkt (Posten endet vor erster Rate / startet nach letzter Rate) →
|
||||
kein Konflikt; offene Start/Ende → Konflikt; `exclude_id` schließt den
|
||||
bearbeiteten Datensatz selbst aus; `all_conflicts` findet mehrere Paare.
|
||||
|
||||
## 3. API-Verhalten (`routers/planning.py`)
|
||||
|
||||
- Neues Anfragefeld **`doppelzaehlung_bestaetigt: bool = False`** — nur in
|
||||
den Request-Modellen (`RecurringCreate(RecurringIn)`, `RecurringPatch`,
|
||||
`LoanCreate(LoanIn)`, `LoanPatch`), **nicht** in `RecurringOut`/`LoanOut`,
|
||||
nicht persistiert, beim Anlegen der ORM-Objekte aus `model_dump`
|
||||
ausgeschlossen.
|
||||
- **POST** `/api/recurring` bzw. `/api/loans`: vor dem Speichern
|
||||
`conflicts_for_recurring`/`conflicts_for_loan` für den neuen Datensatz;
|
||||
Treffer und Flag nicht gesetzt → **409** (nichts gespeichert).
|
||||
- **PATCH** `/api/recurring/{id}` bzw. `/api/loans/{id}`: Konflikte vor und
|
||||
nach Anwendung der Änderung ermitteln (bezogen auf diesen Datensatz);
|
||||
409 nur, wenn nach der Änderung ein Konflikt-Paar existiert, das vorher
|
||||
nicht existierte (ein bereits bestätigter Konflikt fragt bei reiner
|
||||
Namensänderung nicht erneut). Bei 409 `session.rollback()`.
|
||||
- **409-Antwort** (`HTTPException(409, detail={...})`):
|
||||
```json
|
||||
{"detail": {"code": "doppelzaehlung_kredit",
|
||||
"meldung": "<deutscher Erklärtext>",
|
||||
"konflikte": [{"posten": "...", "posten_betrag": "-312.50",
|
||||
"kredit": "...", "kredit_rate": "312.50"}]}}
|
||||
```
|
||||
Beträge als Strings mit Punkt-Dezimal (API-Konvention).
|
||||
- Flag `true` → Speichern trotz Konflikt (201/200 wie bisher).
|
||||
- Andere Endpunkte unverändert (Einmalposten, Szenario-Zuordnung).
|
||||
|
||||
Tests: je Endpunkt (POST/PATCH × Posten/Kredit) Konflikt → 409 mit
|
||||
`code`, `konflikte` und nichts gespeichert/geändert; mit Flag → 201/200 und
|
||||
gespeichert; ohne Konflikt → unverändert 201/200; PATCH einer reinen
|
||||
Namensänderung bei bestehendem Konflikt → 200 ohne Flag; `RecurringOut`/
|
||||
`LoanOut` enthalten kein `doppelzaehlung_bestaetigt`; bestehende Tests
|
||||
bleiben grün.
|
||||
|
||||
## 4. GUI: Warnfenster (`base.html`)
|
||||
|
||||
- Ein `<dialog id="doppelzaehlung-dialog">` im Basis-Layout mit Titel
|
||||
„Mögliche Doppelzählung", Erklärtext (`meldung`), Liste der Konflikte
|
||||
(„Posten „X" (−312,50 €) ↔ Kredit „Y" (Rate 312,50 €)", Beträge deutsch
|
||||
formatiert) und Warnsatz („Beide zusammen zählen die Rate doppelt —
|
||||
Planung und Szenarien wären zu pessimistisch."). Buttons:
|
||||
**„Abbrechen"** (`autofocus`, schließt ohne Speichern) und
|
||||
**„Trotzdem speichern"**.
|
||||
- `htmx:responseError`-Handler: Status 409 und JSON-`detail.code ===
|
||||
"doppelzaehlung_kredit"` → Dialog füllen + `showModal()`, **kein**
|
||||
`alert`. Alle anderen Fehler wie bisher per `alert`.
|
||||
- „Trotzdem speichern": setzt am auslösenden Formular
|
||||
`data-doppelzaehlung-bestaetigt="1"` und löst es erneut aus
|
||||
(`htmx.trigger(form, 'submit')`); die `json-form`-Extension ergänzt bei
|
||||
gesetztem Attribut `doppelzaehlung_bestaetigt: true`; nach dem Request
|
||||
(`htmx:afterRequest`) wird das Attribut wieder entfernt.
|
||||
- Gilt automatisch für alle fünf Formulare (Anlegen/Bearbeiten Posten,
|
||||
Vorschlag übernehmen, Anlegen/Bearbeiten Kredit).
|
||||
- UX-Regel: Formular bleibt sichtbar; Abbrechen lässt Eingaben stehen.
|
||||
|
||||
## 5. Dauerhafter Hinweis (Planung-Seite)
|
||||
|
||||
- `routers/gui.py::planung_page` ermittelt `all_conflicts(session)` und
|
||||
übergibt je Posten-ID bzw. Kredit-ID die Gegenseite(n).
|
||||
- `planning.html`: in der Namens-Zelle betroffener Posten/Kredite ein
|
||||
`<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „Y"</span>`
|
||||
bzw. „… mit Posten „X"". Die Namens-Zelle bekommt `data-sort="{{ name }}"`,
|
||||
damit der Hinweis die Sortierung nicht beeinflusst.
|
||||
- CSS `.warn-hint` dezent (kleine Schrift, Warnfarbe), nicht blockierend.
|
||||
|
||||
Tests: GUI `/planung` mit einem Konflikt-Paar zeigt den Hinweis an Posten
|
||||
und Kredit; ohne Konflikt kein Hinweis; `data-sort` der Namenszellen
|
||||
enthält nur den Namen.
|
||||
|
||||
## 6. Doku / Beratungsumgebung / Release
|
||||
|
||||
- `hilfe.html`: kurzer Absatz „Reserviert: Namen mit »System-«" und
|
||||
„Doppelzählungs-Warnung Kredit/Fixposten" (was geprüft wird, Toleranz,
|
||||
Bestätigung, Hinweis in der Planung).
|
||||
- `docs/ARCHITEKTUR.md`: `reserved.py`, `double_count.py` (Diagrammnamen
|
||||
müssen im Code existieren).
|
||||
- **`/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`** (eigenes Repo,
|
||||
eigener Commit): Abschnitte „Wiederkehrende Posten" und „Kredite" um die
|
||||
409-Antwort `doppelzaehlung_kredit` und das Feld
|
||||
`doppelzaehlung_bestaetigt` ergänzen, mit der Regel: **Flag nur nach
|
||||
ausdrücklicher Rückfrage beim Nutzer setzen**; Reservierung „System-" für
|
||||
Kategorien/Szenarien erwähnen.
|
||||
- `finance/VERSION` 0.11.0 → **0.12.0**; Backup, Redeploy, Live-Smoke
|
||||
(Wegwerf-Posten/-Kredit mit Konflikt → 409, mit Flag → 201, wieder
|
||||
löschen; Kategorie „System-Test" → 422; Panel 5 zeigt
|
||||
„System-Kreditraten" nur wenn Kredite existieren; Bestandszahlen
|
||||
unverändert).
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- Keine Prüfung von Einmalposten (z.B. Schlusszahlung endfällig).
|
||||
- Keine Prüfung bei Szenario-Kredit-Zuordnung.
|
||||
- Kein Namensabgleich Posten↔Kredit.
|
||||
- Keine Umbenennung von „unkategorisiert".
|
||||
- Keine Migration (keine Schemaänderung).
|
||||
|
||||
## Rahmenregeln
|
||||
|
||||
- DATENSCHUTZ: Tests nur mit künstlichen Werten; keine Live-Beträge/-Namen
|
||||
in Commits, Ledger, Chat.
|
||||
- Decimal statt float für alle Beträge (auch Toleranzrechnung).
|
||||
- Deutsch für GUI-Texte, Meldungen, Commits; Datum TT.MM.JJJJ.
|
||||
- UX-Regel: Bedienelemente sichtbar; Warnung per Dialog mit expliziter
|
||||
Bestätigung, sonst bestehender Fehler-Alert.
|
||||
- Fable-Testagent-Gate je Plan-Task.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Design — Ausbaustufe 13: Seite „Aufteilung" (Monatskosten je Kategorie) (v0.13.0)
|
||||
|
||||
> Status: Design im Chat vom Nutzer freigegeben (2026-09-29), Spec zur
|
||||
> Durchsicht. Anlass: In der Beratung soll für einen gewählten Monat
|
||||
> anschaulich werden, welche monatlichen Kosten je Kategorie anfallen und
|
||||
> welcher Anteil der Einkünfte danach frei verfügbar bleibt — für die reine
|
||||
> Planung ebenso wie für ein Szenario.
|
||||
|
||||
## Nutzerentscheidungen (2026-09-29)
|
||||
|
||||
- **Neuer Menüpunkt „Aufteilung"** (zwischen „Szenarien" und „Admin").
|
||||
- **Datenbasis immer Planung, nie Ist-Buchungen.** Auch vergangene Monate
|
||||
werden aus den damals gültigen Planungsdaten berechnet (Fixposten,
|
||||
Einmalposten, Kredite). Die Auswahl heißt deshalb **„System-Plan"**
|
||||
(nicht „System-Ist" — das bleibt echten Buchungen vorbehalten). Der Name
|
||||
fällt unter den reservierten Präfix `System-` (Ausbaustufe 12), kann also
|
||||
nicht mit einem echten Szenario kollidieren.
|
||||
- **Szenarien** sind nur für den laufenden und künftige Monate wählbar;
|
||||
„System-Plan" für jeden Monat.
|
||||
- **Fixposten normalisiert** auf den Monat: monatlich ×1, quartalsweise ×⅓,
|
||||
jährlich ×1/12.
|
||||
- **Einmalposten mit vollem Betrag**, nur im Monat ihrer Fälligkeit (keine
|
||||
Verteilung über 12 Monate).
|
||||
- **Filter** „Fixposten", „Einmalposten", „Kredite" wirken auf Einnahmen
|
||||
**und** Ausgaben gleichermaßen.
|
||||
- **Tabelle flach**: eine Zeile je Posten mit Kategorie-Spalte, per
|
||||
`sort.js` sortierbar.
|
||||
- **Tortendiagramm direkt auf der Seite** (serverseitiges SVG), **kein
|
||||
Grafana-Panel**.
|
||||
- **Torte = Einkünfte**: Kostensegmente je Kategorie + Segment „Frei
|
||||
verfügbar". Bei Unterdeckung wechselt die Basis auf die Gesamtkosten (mit
|
||||
deutlichem Hinweis); die Tabelle bleibt bei „% der Einkünfte".
|
||||
- **„Frei verfügbar" als Tabellenzeile** im `<tfoot>` (bei Unterdeckung
|
||||
„Unterdeckung", rot). Darunter „Gesamte Einkünfte".
|
||||
- **Kategorien < 3 %** der Tortenbasis werden nur in der Torte zu „Übrige"
|
||||
zusammengefasst. Zusätzlich (Nachtrag 2026-09-29): **höchstens 8 benannte
|
||||
Kategorien** + „Übrige" + „Frei verfügbar" = max. 10 Segmente
|
||||
(8-Farben-Referenzpalette, validiert).
|
||||
- **Endfällige Kredite**: Die Schlussrate enthält die volle Tilgung und
|
||||
erscheint im letzten Monat voll (konsequent zu „Einmalposten voll").
|
||||
- **Drill-down**: Klick auf ein Kostensegment (oder dessen Legendeneintrag)
|
||||
zeigt unter der Haupttorte eine zweite Torte mit allen Posten dieser
|
||||
Kategorie. Hinweistext „Für Kategorie-Aufteilung auf Segment klicken".
|
||||
- **Version v0.13.0** (neue Seite).
|
||||
|
||||
## Befund (Ist-Zustand, v0.12.0)
|
||||
|
||||
- `engine/scenario.py`: `build_cashflows` liefert nur `(Datum, Betrag)` —
|
||||
ohne Name/Kategorie/Art, daher für die Aufteilung nicht verwendbar.
|
||||
Modifikator-Logik steckt in den privaten Helfern `_modified` und
|
||||
`_effective_end`.
|
||||
- `services/projection_service.py::run_projection` lädt Fixposten
|
||||
(`include_recurring`), globale Einmalposten (`include_planned`),
|
||||
`ScenarioPlannedItem`s (immer, ohne Kategorie), Szenario-Kredite
|
||||
(`ScenarioLoan`) und Modifikatoren inline — keine wiederverwendbare
|
||||
Ladefunktion.
|
||||
- `services/planning_chart.py` rechnet die reine Planung (alle Kredite,
|
||||
keine Modifikatoren) mit Kategorie `KREDITRATEN = "System-Kreditraten"`
|
||||
und `UNKATEGORISIERT = "unkategorisiert"`.
|
||||
- `services/double_count.py::all_conflicts` liefert `Konflikt`
|
||||
(`posten_id`, `kredit_id`, Namen, Beträge) über **alle** Kredite.
|
||||
- `static/sort.js` sortiert nur `<tbody>`; `<tfoot>` bleibt stehen.
|
||||
- htmx ist in `base.html` eingebunden.
|
||||
|
||||
## Architektur
|
||||
|
||||
Kernachse bleibt `routers → services → engine`. Keine Schemaänderung, keine
|
||||
Migration, keine View, Grafana unverändert.
|
||||
|
||||
| Einheit | Aufgabe |
|
||||
|---|---|
|
||||
| `engine/scenario.py` | `_modified` → öffentlich `modified_amount`, `_effective_end` → `effective_end` (reine Umbenennung, `build_cashflows` nutzt die neuen Namen). |
|
||||
| `engine/breakdown.py` (neu, rein) | `BreakdownRow` (Dataclass: `name`, `category`, `kind`, `rhythm \| None`, `amount: Decimal`, vorzeichenbehaftet), `Filters` (Dataclass: `recurring`, `planned`, `loans: bool`), `breakdown_rows(...)`, `summarize(rows)`, `category_rows(rows, kategorie, small)`. |
|
||||
| `engine/pie.py` (neu, rein) | `pie_slices(values, min_share)` → Segmente mit Label, Betrag, Anteil, Start-/Endwinkel, SVG-Pfad `d`; fasst Anteile < `min_share` zu „Übrige" zusammen; Sonderfall ein Segment = Vollkreis. |
|
||||
| `services/projection_service.py` | Neu `load_scenario_inputs(session, scenario) → ScenarioInputs` (Fixposten, Einmalposten inkl. Szenario-Einmalposten, Kredite als `(Name, Tilgungsplan, Auszahlung)`, Modifikatoren). `run_projection` nutzt sie; Verhalten unverändert. |
|
||||
| `services/breakdown.py` (neu) | `compute_breakdown(session, month, scenario_id \| None, filters) → Breakdown`. `None` = System-Plan: alle `RecurringItem`, alle `PlannedItem`, alle `Loan`, keine Modifikatoren, keine Szenario-Einmalposten. Sonst `load_scenario_inputs`. Setzt Kategorie-IDs in Namen um. Ermittelt Doppelzählungs-Hinweise (siehe unten). |
|
||||
| `routers/gui.py` | `aufteilung_page` (`GET /aufteilung`) und `aufteilung_kategorie_fragment` (`GET /aufteilung/kategorie`). |
|
||||
| `templates/aufteilung.html`, `templates/_aufteilung_torte.html`, `templates/_aufteilung_kategorie.html` (neu) | Seite, Haupttorte (Partial) bzw. Fragment der zweiten Torte. |
|
||||
| `templates/base.html` | Menüpunkt „Aufteilung". |
|
||||
| `templates/hilfe.html` | Abschnitt „Aufteilung". |
|
||||
| `static/style.css` | Layout Tabelle/Torte (nebeneinander, schmal untereinander), Tortenfarben als Tokens (hell/dunkel). |
|
||||
|
||||
## Rechenregeln (`engine/breakdown.py`)
|
||||
|
||||
Monat M = erster bis letzter Kalendertag. Alle Beträge `Decimal`; jede Zeile
|
||||
wird auf Cent gerundet (`ROUND_HALF_UP`), Summen entstehen aus den
|
||||
gerundeten Zeilen.
|
||||
|
||||
**Fixposten** (nur wenn `filters.recurring`; bei Szenario zusätzlich nur mit
|
||||
`include_recurring` — das erledigt `load_scenario_inputs`):
|
||||
- Zählt, wenn `start_date` leer oder ≤ Monatsende **und** effektives Ende
|
||||
(`effective_end`: eigenes `end_date` bzw. früheres Ende aus
|
||||
`ende`-Modifikator) leer oder ≥ Monatsanfang.
|
||||
- Betrag = `modified_amount(amount, category_id, id, modifiers)`;
|
||||
`None` (Modifikator `remove`) ⇒ Posten entfällt.
|
||||
- Monatsbetrag = Betrag × Faktor (`monthly` 1, `quarterly` 1/3, `yearly`
|
||||
1/12), gerundet.
|
||||
- `kind = "recurring"`, `rhythm` gesetzt.
|
||||
|
||||
**Einmalposten** (nur wenn `filters.planned`):
|
||||
- Posten mit `due` in M, voller Betrag nach `modified_amount(amount,
|
||||
category_id, None, modifiers)`.
|
||||
- Bei Szenario: globale `PlannedItem`s nur mit `include_planned`,
|
||||
`ScenarioPlannedItem`s immer (Kategorie `None` ⇒ „unkategorisiert").
|
||||
- `kind = "planned"`.
|
||||
|
||||
**Kredite** (nur wenn `filters.loans`):
|
||||
- System-Plan: alle Kredite; Szenario: nur zugeordnete (`ScenarioLoan`).
|
||||
- Eine Zeile je Kredit: Name = Kreditname, Kategorie =
|
||||
`KREDITRATEN` („System-Kreditraten"), Betrag = Summe `payment` aller
|
||||
Raten mit `due` in M (bereits negativ). Keine Rate in M ⇒ keine Zeile.
|
||||
- Auszahlung zählt **nicht**. Endfällige Schlussrate zählt voll.
|
||||
- `kind = "loan"`.
|
||||
|
||||
**Allgemein:**
|
||||
- Zeilen mit Betrag 0 entfallen.
|
||||
- Kategorie-Name: `Category.name`, sonst „unkategorisiert"
|
||||
(`planning_chart.UNKATEGORISIERT`).
|
||||
|
||||
**`summarize(rows)` → `Summary`:**
|
||||
- `income` = Σ positive Beträge; `costs` = Σ |negative Beträge|;
|
||||
`free = income − costs` (negativ ⇒ Unterdeckung).
|
||||
- `share(row)` = |Betrag| ÷ `income` × 100, eine Nachkommastelle
|
||||
(`ROUND_HALF_UP`), nur für Kostenzeilen; `income == 0` ⇒ `None`
|
||||
(Anzeige „–").
|
||||
- `category_costs`: Kosten je Kategorie (positiv), absteigend.
|
||||
- `pie_basis`: `income`, wenn `income ≥ costs` und `income > 0`; sonst
|
||||
`costs` (Unterdeckung oder keine Einkünfte).
|
||||
- `pie_values`: Kategorienkosten, plus „Frei verfügbar" = `free`, wenn
|
||||
`pie_basis == income` und `free > 0`.
|
||||
|
||||
**`category_rows(rows, kategorie, small)`:** Kostenzeilen der Kategorie;
|
||||
für `kategorie == "Übrige"` die Kostenzeilen aller Kategorien in `small`
|
||||
(die in der Haupttorte zu „Übrige" zusammengefassten). Einnahmezeilen
|
||||
erscheinen nie in einer Torte.
|
||||
|
||||
## Torte (`engine/pie.py`)
|
||||
|
||||
- `pie_slices(values: list[tuple[str, Decimal]], min_share=Decimal("0.03"))`:
|
||||
Anteil = Wert ÷ Summe; Werte mit Anteil < `min_share` werden zu einem
|
||||
Segment „Übrige" zusammengefasst (nur wenn mindestens zwei betroffen
|
||||
sind — ein einzelnes kleines Segment bleibt, sonst hieße es sinnlos
|
||||
„Übrige"). „Frei verfügbar" wird nie zusammengefasst.
|
||||
- Rückgabe je Segment: `label`, `value`, `share`, `path` (SVG-`d` im
|
||||
Einheitskreis-Koordinatensystem, `large-arc`-Flag korrekt ab 180°),
|
||||
`members` (bei „Übrige": die zusammengefassten Labels).
|
||||
- Genau ein Segment ⇒ `path = None`, Template zeichnet `<circle>`.
|
||||
- Winkelrechnung darf `float` verwenden (reine Geometrie, keine Beträge);
|
||||
Beträge und Anteile bleiben `Decimal`.
|
||||
|
||||
## Seite `/aufteilung`
|
||||
|
||||
**Query-Parameter:** `monat=JJJJ-MM` (Default laufender Monat),
|
||||
`szenario=<id>` (leer = System-Plan), `fixposten=1`, `einmal=1`,
|
||||
`kredite=1`, `gesendet=1` (unterscheidet Erstaufruf — alle Filter an — von
|
||||
„alle Häkchen entfernt"), `kategorie=<Name>` (optional, Drill-down).
|
||||
|
||||
**Aufbau von oben nach unten:**
|
||||
1. **Formular** (GET, immer sichtbar): `<input type="month">`, `<select>`
|
||||
„System-Plan" + alle Szenarien (alphabetisch), drei Checkboxen,
|
||||
Button „Anzeigen". Gewählter Monat zusätzlich als MM.JJJJ angezeigt.
|
||||
2. **Kennzahlen**: Einkünfte · Kosten · Frei verfügbar / Unterdeckung (rot).
|
||||
3. **Hinweise** (inline):
|
||||
- Fehler: ungültiger Monat, unbekanntes Szenario, Szenario + Monat vor
|
||||
dem laufenden Monat („Szenarien sind erst ab dem laufenden Monat
|
||||
wählbar. Für vergangene Monate „System-Plan" wählen.") ⇒ keine
|
||||
Tabelle/Torte, Formular mit der Auswahl bleibt.
|
||||
- ⚠ Doppelzählung: je `Konflikt` aus `all_conflicts`, dessen Fixposten
|
||||
**und** Kredit beide als Zeile in der aktuellen Aufteilung stehen:
|
||||
„⚠ Fixposten „X" zählt evtl. doppelt mit Kredit „Y"".
|
||||
- Unterdeckung: „Kosten übersteigen die Einkünfte um X € — das
|
||||
Diagramm zeigt die Aufteilung der Gesamtkosten."
|
||||
- Keine Zeilen: „Für diese Auswahl gibt es keine Posten."
|
||||
4. **Tabelle** (`sort.js`, clientseitig): Posten · Kategorie · Art
|
||||
(„Fixposten (monatlich|quartalsweise|jährlich)", „Einmalposten",
|
||||
„Kredit") · Monatsbetrag (vorzeichenbehaftet, `neg`-Klasse wie in
|
||||
`planning.html`) · % der Einkünfte (leer bei Einnahmen, „–" bei
|
||||
Einkünften 0). Anfangssortierung: Monatsbetrag aufsteigend (größte
|
||||
Kosten oben). `<tfoot>`: Zeile „Frei verfügbar" bzw. „Unterdeckung"
|
||||
(rot) mit Betrag und Anteil. Direkt unter der Tabelle: „Gesamte
|
||||
Einkünfte: X €".
|
||||
5. **Haupttorte** (SVG) mit Legende (Label, Betrag, %). Jedes Segment hat
|
||||
`<title>` (Label, Betrag, %). „Frei verfügbar" in eigener, klar
|
||||
unterscheidbarer Farbe. Farbpalette per `dataviz`-Skill festgelegt,
|
||||
hell/dunkel lesbar. Darunter Text „Für Kategorie-Aufteilung auf Segment
|
||||
klicken".
|
||||
6. **Drill-down-Container** `#aufteilung-kategorie`: leer, oder bei
|
||||
gesetztem `kategorie` bereits die zweite Torte.
|
||||
|
||||
**Drill-down:**
|
||||
- Kostensegmente und ihre Legendeneinträge sind Links
|
||||
`href="/aufteilung?<aktuelle Auswahl>&kategorie=<Name>"` mit
|
||||
`hx-get="/aufteilung/kategorie?<…>"`, `hx-target="#aufteilung-kategorie"`,
|
||||
`hx-push-url="<href>"`. Ohne JS funktioniert der Link als Seitenaufruf.
|
||||
- „Frei verfügbar" ist kein Link.
|
||||
- Gewähltes Segment in der Haupttorte hervorgehoben (Klasse `gewaehlt`,
|
||||
Umriss). Die Haupttorte liegt in einem eigenen Partial
|
||||
`_aufteilung_torte.html` (Container `#aufteilung-torte`); das
|
||||
Fragment-Response von `/aufteilung/kategorie` enthält sie zusätzlich mit
|
||||
`hx-swap-oob="true"` und markiertem Segment. Kein eigenes JS.
|
||||
- Zweite Torte: Überschrift „<Kategorie>: X € / Monat"; Segmente = Posten
|
||||
(`category_rows`), Basis = Kategoriekosten, 3-%-Regel wie oben;
|
||||
Legende: Posten, Monatsbetrag, % der Kategorie, % der Einkünfte.
|
||||
- Unbekannte oder leere Kategorie (z. B. alte URL, Filter geändert):
|
||||
Hinweis „Kategorie „X" hat in dieser Auswahl keine Kosten." statt
|
||||
Fehler.
|
||||
- `GET /aufteilung/kategorie` nimmt dieselben Parameter, validiert
|
||||
identisch und liefert nur `_aufteilung_kategorie.html` (Fehler als
|
||||
Hinweis im Fragment, Status 200).
|
||||
|
||||
Beide Routen: `Depends(gui_session)`, kein `_safe_refresh` (nichts
|
||||
Abgeleitetes zu schreiben — reine Berechnung beim Aufruf).
|
||||
|
||||
## Tests
|
||||
|
||||
| Datei | Prüft |
|
||||
|---|---|
|
||||
| `tests/test_scenario_engine.py` | Bestehende Tests grün nach Umbenennung `modified_amount`/`effective_end`. |
|
||||
| `tests/test_breakdown.py` (neu, ohne DB) | Normalisierung monatlich/quartalsweise/jährlich inkl. Rundung; Laufzeitgrenzen (Start am Monatsletzten, Ende am Monatsersten, Ende im Vormonat); `ende`-Modifikator; `remove`/`percent`/`absolute`; Einmalposten in/außerhalb M; Annuitätenrate; endfällige Zins-/Schlussrate; Auszahlung zählt nicht; Filter wirken auf Einnahmen; 0-€-Zeilen entfallen; `summarize` (Anteile, Unterdeckung, Einkünfte 0, `pie_basis`, „Frei verfügbar" nur ohne Unterdeckung); `category_rows` inkl. „Übrige" und Kategorie ohne Kosten. |
|
||||
| `tests/test_pie.py` (neu) | Summe der Winkel = 360°; `large-arc`-Flag; „Übrige" < 3 % (nur ab zwei Segmenten); „Frei verfügbar" nie zusammengefasst; Einzelsegment ⇒ `path is None`; leere Eingabe ⇒ leere Liste. |
|
||||
| `tests/test_projection.py` / `test_projection_refresh.py` | Grün nach Refactoring `load_scenario_inputs` (Verhaltensgleichheit). |
|
||||
| `tests/test_breakdown_service.py` (neu, DB wie bestehende Service-Tests) | System-Plan: alle Kredite, keine Modifikatoren/Szenario-Einmalposten; Szenario: nur zugeordnete Kredite, `include_recurring`/`include_planned`, `ScenarioPlannedItem` als „unkategorisiert"; Kategorienamen; Doppelzählungs-Hinweis nur, wenn beide Zeilen enthalten. |
|
||||
| `tests/test_gui.py` | Menüpunkt; Default-Auswahl; `gesendet=1` ohne Filter ⇒ „keine Posten"; `<tfoot>`-Zeile; Unterdeckungs-Hinweis; Szenario + Vormonat, ungültiger Monat, unbekanntes Szenario ⇒ Hinweis inline, Formular vorhanden, Status 200; Drill-down-Fragment; Seite mit `kategorie` enthält zweite Torte; unbekannte Kategorie ⇒ Hinweis; „Frei verfügbar" ohne Link. |
|
||||
| `tests/test_sort_templates.py` | Tabelle der Aufteilung ist sortierbar, `<tfoot>` außerhalb `<tbody>`. |
|
||||
|
||||
Testdaten sind synthetisch (DATENSCHUTZ: keine Werte aus Fixtures oder
|
||||
Live-DB).
|
||||
|
||||
## Abschluss
|
||||
|
||||
- `docs/ARCHITEKTUR.md`: neue Module im Komponenten-Diagramm, Konzept-
|
||||
absatz „Aufteilung (Ausbaustufe 13)".
|
||||
- `hilfe.html`: Abschnitt „Aufteilung".
|
||||
- `finance/VERSION` → `0.13.0`, `./create_pod_finance.sh`, Live-Check.
|
||||
- Fable-Testagent-Gate je Task; letzter Task als Live-Test gegen den
|
||||
laufenden Pod (inkl. Klick-Drill-down im Browser).
|
||||
- Ledger-Einträge in `.superpowers/sdd/progress.md`.
|
||||
|
||||
## Nicht Teil dieser Ausbaustufe (YAGNI)
|
||||
|
||||
- Grafana-Panel / Snapshot-Tabelle.
|
||||
- Ist-Buchungen als Datenbasis.
|
||||
- Verteilung von Einmalposten über mehrere Monate.
|
||||
- Export (CSV/PDF) der Aufteilung.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Design — Ausbaustufe 14: Szenario-Einträge mit Zeitraum, „Neuer Betrag", szenario-eigene wiederkehrende Zahlungen (v0.14.0)
|
||||
|
||||
> Status: vom Nutzer im Chat freigegeben (2026-09-30), Abschnitte 1–3.
|
||||
> Umsetzungsplan folgt unter `docs/superpowers/plans/`. Datenschutz-Regel aus
|
||||
> `CLAUDE.md` gilt: konkrete Posten-Namen/Beträge aus der Live-Datenbank
|
||||
> stehen nur im Chat, nicht in diesem Dokument, nicht in Tests/Commits/Ledger.
|
||||
|
||||
## Anlass
|
||||
|
||||
Der Nutzer wollte in einem Szenario einen bestehenden monatlichen Fixposten
|
||||
ab einem Stichtag auf 50 % senken. Das ging nicht: Szenario-Einträge
|
||||
(Prozent/Absolut/Entfällt) wirken über die gesamte Laufzeit, „Absolut" ist
|
||||
eine Kürzung (nicht ein neuer Betrag), und ein Szenario kann keine eigenen
|
||||
wiederkehrenden Zahlungen haben — nur Einmalzahlungen. Die Lücke ist eine
|
||||
bewusste Auslassung aus A5 („Ende" als eigene Art statt Gültigkeitszeitraum
|
||||
aller Modifikatoren).
|
||||
|
||||
## Nutzerentscheidungen (Chat 2026-09-30)
|
||||
|
||||
- **A — Zeitraum:** Prozent, Kürzung (€), Neuer Betrag und Entfällt bekommen
|
||||
optionale Grenzen **Gültig ab** und **Gültig bis** (beide leer =
|
||||
unbegrenzt). „Ende" bleibt wie bisher.
|
||||
- **Neue Eintragsart „Neuer Betrag"** (ersetzt den Betrag eines Postens).
|
||||
„Absolut" heißt in der GUI künftig **„Kürzung (€)"**.
|
||||
- **B — szenario-eigene wiederkehrende Zahlungen** mit denselben Feldern wie
|
||||
Fixposten inkl. **optionaler Kategorie**; Kategorie-Einträge treffen sie.
|
||||
- **Formular geteilt** in „Neue Änderung an Bestehendem" und „Neue Zahlung
|
||||
nur in diesem Szenario" (Szenario-Einmalzahlungen bekommen dabei ebenfalls
|
||||
eine optionale Kategorie).
|
||||
- **A14-Kandidaten** aus dem Ledger werden mitgenommen, **außer**
|
||||
„figcaption nicht fett" — die Bildunterschrift der Torte bleibt fett
|
||||
(ausdrücklicher Nutzerwunsch).
|
||||
- Das Live-Szenario des Nutzers wird **nicht** vom Tool-Team angepasst; der
|
||||
Nutzer probiert die Neuerung selbst daran aus.
|
||||
|
||||
## Teil 1 — Datenmodell (eine Alembic-Revision, `down_revision = 'd5e8b3c7a410'`)
|
||||
|
||||
- `scenario_modifiers`: neue Spalte `start_date DATE NULL` („Gültig ab").
|
||||
Die vorhandene Spalte `end_date` bedeutet künftig bei **allen** Arten
|
||||
„Gültig bis" (inklusiv); bei `ende` bleibt sie das Enddatum des Postens.
|
||||
Bestandsdaten: bisherige `percent`/`absolute`/`remove`-Zeilen haben
|
||||
`end_date = NULL` (API hat es bisher genullt) → gelten unverändert
|
||||
unbegrenzt. **Keine Datenmigration nötig.**
|
||||
- Neue Art `set` (GUI-Label „Neuer Betrag"): `value` ist der neue Betrag
|
||||
**mit Vorzeichen** (wie Fixposten-Beträge, z. B. `-137.50`). Nur mit
|
||||
`target_type = "recurring"` zulässig.
|
||||
- Neue Tabelle `scenario_recurring_items`: `id`, `scenario_id` (FK
|
||||
`scenarios.id`, index), `name String(200)`, `amount MONEY`,
|
||||
`rhythm String(20)`, `due_day Integer`, `start_date Date NULL`,
|
||||
`end_date Date NULL`, `category_id` (FK `categories.id`, NULL). Modell
|
||||
`ScenarioRecurringItem` in `models/tables.py`.
|
||||
- `scenario_planned_items`: neue Spalte `category_id` (FK `categories.id`,
|
||||
NULL).
|
||||
- **Downgrade:** löscht `set`-Zeilen, setzt `end_date = NULL` für alle
|
||||
Nicht-`ende`-Zeilen (alte Semantik), droppt `start_date`,
|
||||
`scenario_planned_items.category_id` und `scenario_recurring_items`.
|
||||
|
||||
## Teil 2 — Rechenlogik (`engine/scenario.py`, `engine/breakdown.py`)
|
||||
|
||||
- `PlainModifier` bekommt `start_date: date | None = None`.
|
||||
- `modified_amount(amount, category_id, recurring_id, modifiers, on: date)`:
|
||||
ein Modifikator wirkt nur, wenn `(start_date is None or on >= start_date)
|
||||
and (end_date is None or on <= end_date)` (bei Art `ende` wird er hier wie
|
||||
bisher übersprungen — Ende wirkt nur über `effective_end`). Reihenfolge
|
||||
unverändert = Anlage-Reihenfolge (ID). `set` → `amount = value`; ein
|
||||
späterer `percent`/`absolute` rechnet auf dem neuen Betrag weiter.
|
||||
`remove` im Zeitraum → `None` (Fälligkeit entfällt), außerhalb → normal.
|
||||
- **Projektion** (`build_cashflows`): Betrag **je Fälligkeit** mit
|
||||
`on = Fälligkeitsdatum` bestimmen (bisher einmal je Posten).
|
||||
Einmalposten: `on = due`.
|
||||
- **Aufteilung** (`breakdown_rows`): Stichtag je Fixposten = sein
|
||||
Fälligkeitstag im gewählten Monat, auf die Monatslänge geklemmt (gleiche
|
||||
Klemmregel wie `recurrence.occurrences`); Einmalposten: `on = due`.
|
||||
Monatsausschluss über `start_date`/`effective_end` bleibt wie bisher.
|
||||
Beispiel: Posten fällig am 14., Eintrag „50 % ab 16.10." → Oktober voll,
|
||||
ab November halbiert.
|
||||
- **Szenario-eigene wiederkehrende Zahlungen** werden in
|
||||
`load_scenario_inputs` als `PlainRecurring` mit **`id = None`** geladen
|
||||
(Typ `id: int | None`), damit Posten-Einträge (`target_type="recurring"`)
|
||||
sie nie treffen — die IDs zweier Tabellen könnten kollidieren.
|
||||
Kategorie-Einträge treffen sie über `category_id`. Sie zählen **immer**,
|
||||
unabhängig von `include_recurring` (Regel wie Szenario-Einmalzahlungen).
|
||||
Für „System-Plan" (`scenario=None`) gibt es sie nicht.
|
||||
- Szenario-Einmalzahlungen werden mit ihrer `category_id` geladen (bisher
|
||||
fest `None`).
|
||||
- `BreakdownRow.recurring_id` bleibt für szenario-eigene Zahlungen `None` →
|
||||
der Doppelzählungs-Hinweis (Kredit vs. Fixposten) berücksichtigt sie
|
||||
nicht.
|
||||
- Unberührt: Grafana „Planung nach Kategorie", „System-Plan", v_projection.
|
||||
|
||||
## Teil 3 — API (`routers/scenarios.py`)
|
||||
|
||||
- `ModifierIn`: `kind: Literal["percent", "absolute", "set", "remove",
|
||||
"ende"]`, neu `start_date: date | None = None`. Validator (422, deutsch):
|
||||
- `ende` ohne `end_date` → „Art »Ende« erfordert ein Datum" (wie bisher);
|
||||
bei `ende` wird `start_date` genullt.
|
||||
- `set` mit `target_type != "recurring"` → „Art »Neuer Betrag« ist nur für
|
||||
Posten möglich".
|
||||
- `start_date > end_date` → „»Gültig ab« liegt nach »Gültig bis«".
|
||||
- `end_date` wird **nicht mehr** für Nicht-`ende`-Arten genullt.
|
||||
POST/PATCH bleiben Full-Body.
|
||||
- Neuer Endpunkt `/api/scenarios/{id}/recurring`: `GET` (Liste, sortiert
|
||||
nach Name), `POST` (201), `PATCH /{item_id}` (Full-Body), `DELETE
|
||||
/{item_id}` (204). Schema `ScenarioRecurringIn` mit den Feldern und
|
||||
Validierungen von `RecurringIn` (Rhythmus-Literal, `due_day` 1–31, Ende
|
||||
nicht vor Start); Kategorie-Existenz via `_check_category`-Muster (404
|
||||
„Kategorie nicht gefunden"); fremde/fehlende IDs → 404 „Wiederkehrende
|
||||
Zahlung nicht gefunden"; fehlendes Szenario → 404. Keine
|
||||
Doppelzählungs-Prüfung.
|
||||
- `ScenarioPlannedIn`: neu `category_id: int | None = None` mit
|
||||
Kategorie-Existenzprüfung.
|
||||
- `delete_scenario` löscht zusätzlich `ScenarioRecurringItem`-Zeilen.
|
||||
|
||||
## Teil 4 — GUI (`templates/szenarien.html`, `routers/gui.py`, `formats.py`)
|
||||
|
||||
- `de_label`: `absolute → "Kürzung (€)"`, neu `set → "Neuer Betrag"`.
|
||||
`modifier_kinds` = `percent, absolute, set, remove, ende`.
|
||||
- **Tabelle „Einträge"**, Spalten: Was | Art | Wert/Betrag | Zeitraum | (Aktionen).
|
||||
- Modifikator-Zeilen: Wert bei `percent`/`absolute`/`set`; Zeitraum
|
||||
„ab TT.MM.JJJJ", „bis TT.MM.JJJJ", „TT.MM.JJJJ – TT.MM.JJJJ" oder „–";
|
||||
bei `ende` das Enddatum.
|
||||
- Neue Zeilen für szenario-eigene wiederkehrende Zahlungen: Name (+
|
||||
Kategorie), Art „Wiederkehrend (monatlich, am 14.)", Betrag, Zeitraum
|
||||
Start–Ende; Inline-Bearbeiten/Löschen (Muster Einmalzahlung).
|
||||
- Einmalzahlungs-Zeilen zeigen ggf. die Kategorie; Edit-Zeile bekommt das
|
||||
Kategorie-Feld.
|
||||
- **Formular „Neue Änderung an Bestehendem"** (POST `/modifiers`): Art
|
||||
(Prozent | Kürzung (€) | Neuer Betrag | Entfällt | Ende), Ziel-Typ,
|
||||
Kategorie/Posten, Wert, Gültig ab, Gültig bis.
|
||||
- Wert-Label dynamisch: „Prozentsatz (%)" / „Kürzung (€)" / „Neuer
|
||||
Betrag (€)"; bei Entfällt/Ende gesperrt.
|
||||
- Neuer Betrag: Ziel-Typ fest auf Posten (Option Kategorie gesperrt).
|
||||
- Ende: „Gültig ab" gesperrt, „Gültig bis" heißt „Endet am".
|
||||
- **Formular „Neue Zahlung nur in diesem Szenario"**: Art (Einmalzahlung |
|
||||
Wiederkehrend), Name, Betrag, Kategorie (optional, „(keine)"), Fällig am
|
||||
(nur Einmal), Rhythmus + Fälligkeitstag + Start + Ende (nur
|
||||
Wiederkehrend). POST-Ziel wechselt `/planned` ↔ `/recurring` (Muster
|
||||
`data-planned-url`).
|
||||
- Modifikator-Edit-Zeilen bekommen „Gültig ab"/„Gültig bis" und dieselbe
|
||||
Umschaltlogik.
|
||||
- UX-Regel bleibt: nicht passende Felder sichtbar + `disabled` + Tooltip,
|
||||
Fehler inline über den bestehenden Response-Error-Handler.
|
||||
- `hilfe.html`: Szenario-Absatz um Zeitraum, Neuer Betrag, Kürzung (€) und
|
||||
wiederkehrende Zahlungen ergänzen, mit neutralem Beispiel („ein Posten
|
||||
wird ab 16.10. halbiert").
|
||||
|
||||
## Teil 5 — A14-Kandidaten aus dem Ledger
|
||||
|
||||
1. **Nav-Überlauf bei 390 px:** `nav` darf umbrechen (`flex-wrap: wrap`,
|
||||
Abstände), kein horizontales Scrollen der Seite.
|
||||
2. **Umriss des gewählten Tortensegments zuletzt zeichnen:** gewähltes
|
||||
Segment wird im SVG als letztes Element ausgegeben, damit sein Umriss
|
||||
nicht von Nachbarn überdeckt wird.
|
||||
3. **Tilgungsplan-Helfer:** die 4 Aufrufe `loan_schedule(Decimal(l.principal),
|
||||
Decimal(l.annual_rate_pct), l.term_months, l.payout_date,
|
||||
l.repayment_type)` (planning-Router, services/breakdown,
|
||||
projection_service, planning_chart) über einen Helfer
|
||||
`schedule_for(loan)` führen — verhaltensneutral.
|
||||
4. **`.legende-zusatz` ohne CSS:** eigene Regel (normales Gewicht,
|
||||
gedämpfte Farbe), damit der Zusatz bei gewählter Legendenzeile nicht mit
|
||||
fett wird.
|
||||
5. **`term_months`-Validierung** 1..1200 für Kredite (Anlegen + PATCH),
|
||||
422 inline.
|
||||
|
||||
Ausdrücklich **nicht**: figcaption der Torte entfetten.
|
||||
|
||||
## Teil 6 — Tests, Doku, Release
|
||||
|
||||
- **Engine:** Grenzen inklusiv (Tag vor/an/nach ab bzw. bis), `set` dann
|
||||
`percent`, `remove` mit Zeitraum, szenario-eigene Zahlung von
|
||||
Kategorie-Eintrag getroffen / von Posten-Eintrag mit gleicher ID nicht,
|
||||
zählt bei `include_recurring = false`; Aufteilungs-Stichtag (Okt voll /
|
||||
Nov halbiert, Klemmung am Monatsende) — nur erfundene Beträge.
|
||||
- **API:** alle 422/404-Fälle aus Teil 3, CRUD `/recurring`,
|
||||
Szenario-Löschen räumt `/recurring` mit ab, Planned mit Kategorie,
|
||||
`term_months`-Grenzen.
|
||||
- **Migration:** Upgrade/Downgrade in Wegwerf-Postgres; Bestandseinträge
|
||||
bleiben gleich.
|
||||
- **GUI/JS:** Umschaltmatrix beider Formulare und der Edit-Zeilen (Stil der
|
||||
bestehenden Template-/JS-Tests), Nav-CSS, Torten-Reihenfolge.
|
||||
- Suite bleibt grün (Basis: 377 passed / 3 skipped).
|
||||
- **Doku:** `docs/ARCHITEKTUR.md` (neue Tabelle, Endpunkt, Stichtag-Regel),
|
||||
`CLAUDE.md` falls betroffen, fb-Repo (finanz-api- und
|
||||
finanzberatung-Skill).
|
||||
- **Ablauf:** Plan `docs/superpowers/plans/2026-09-30-ausbaustufe-14.md`,
|
||||
direkt auf `main`; jeder Task mit Fable-Testagent-Gate und
|
||||
Ledger-Eintrag; Release **v0.14.0** (VERSION, Backup, Redeploy,
|
||||
Live-Smoke, Fable-Release-Gate).
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.10.0
|
||||
0.14.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')
|
||||
@@ -0,0 +1,56 @@
|
||||
"""szenario zeitraum, neuer betrag, szenario-eigene wiederkehrende zahlungen
|
||||
|
||||
Revision ID: e7f1a9c3b214
|
||||
Revises: d5e8b3c7a410
|
||||
Create Date: 2026-09-30
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'e7f1a9c3b214'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd5e8b3c7a410'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
FK_PLANNED_CATEGORY = 'fk_scenario_planned_items_category_id'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Bestandsdaten: percent/absolute/remove haben end_date NULL (API hat es
|
||||
# bisher genullt) und gelten damit unverändert unbegrenzt.
|
||||
op.add_column('scenario_modifiers', sa.Column('start_date', sa.Date(), nullable=True))
|
||||
# Batch-Modus: SQLite kennt kein ALTER von Constraints (Tabelle wird neu
|
||||
# angelegt); auf Postgres entspricht das den direkten ALTER-Befehlen.
|
||||
with op.batch_alter_table('scenario_planned_items') as batch:
|
||||
batch.add_column(sa.Column('category_id', sa.Integer(), nullable=True))
|
||||
batch.create_foreign_key(FK_PLANNED_CATEGORY, 'categories',
|
||||
['category_id'], ['id'])
|
||||
op.create_table(
|
||||
'scenario_recurring_items',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('rhythm', sa.String(length=20), nullable=False),
|
||||
sa.Column('due_day', sa.Integer(), nullable=False),
|
||||
sa.Column('start_date', sa.Date(), nullable=True),
|
||||
sa.Column('end_date', sa.Date(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), sa.ForeignKey('categories.id'), nullable=True),
|
||||
)
|
||||
op.create_index('ix_scenario_recurring_items_scenario_id',
|
||||
'scenario_recurring_items', ['scenario_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Alte Semantik: "set" gab es nicht, end_date nur bei "ende".
|
||||
op.execute("DELETE FROM scenario_modifiers WHERE kind = 'set'")
|
||||
op.execute("UPDATE scenario_modifiers SET end_date = NULL WHERE kind <> 'ende'")
|
||||
op.drop_index('ix_scenario_recurring_items_scenario_id', 'scenario_recurring_items')
|
||||
op.drop_table('scenario_recurring_items')
|
||||
with op.batch_alter_table('scenario_planned_items') as batch:
|
||||
batch.drop_column('category_id')
|
||||
op.drop_column('scenario_modifiers', 'start_date')
|
||||
+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})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Aufteilung (Ausbaustufe 13): Monatsbeträge je Posten für einen Monat.
|
||||
|
||||
Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert
|
||||
(quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll
|
||||
im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren
|
||||
wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein
|
||||
Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum.
|
||||
"""
|
||||
import calendar
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from app.engine.loans import Installment
|
||||
from app.engine.recurrence import STEP, due_in_month
|
||||
from app.engine.scenario import (CENT, PlainModifier, PlainPlanned,
|
||||
PlainRecurring, effective_end, modified_amount)
|
||||
|
||||
FREI = "Frei verfügbar"
|
||||
PCT = Decimal("0.1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Filters:
|
||||
recurring: bool = True
|
||||
planned: bool = True
|
||||
loans: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainLoan:
|
||||
id: int
|
||||
name: str
|
||||
schedule: list[Installment]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BreakdownRow:
|
||||
name: str
|
||||
category: str
|
||||
kind: str # "recurring" | "planned" | "loan"
|
||||
rhythm: str | None
|
||||
amount: Decimal # Monatsbetrag, Kosten negativ
|
||||
recurring_id: int | None = None
|
||||
loan_id: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Summary:
|
||||
income: Decimal
|
||||
costs: Decimal # positiv
|
||||
free: Decimal # income - costs
|
||||
deficit: bool # costs > income
|
||||
pie_on_income: bool # Tortenbasis = Einkünfte
|
||||
category_costs: list[tuple[str, Decimal]] # positiv, absteigend
|
||||
pie_rest: Decimal | None # „Frei verfügbar"-Segment
|
||||
|
||||
|
||||
def breakdown_rows(month: date, recurring: list[PlainRecurring],
|
||||
planned: list[PlainPlanned], loans: list[PlainLoan],
|
||||
modifiers: list[PlainModifier], filters: Filters, *,
|
||||
category_names: dict[int, str], loan_category: str,
|
||||
uncategorized: str) -> list[BreakdownRow]:
|
||||
first = month.replace(day=1)
|
||||
last = first.replace(day=calendar.monthrange(first.year, first.month)[1])
|
||||
|
||||
def cat(category_id: int | None) -> str:
|
||||
if category_id is None:
|
||||
return uncategorized
|
||||
return category_names.get(category_id, uncategorized)
|
||||
|
||||
out: list[BreakdownRow] = []
|
||||
if filters.recurring:
|
||||
for r in recurring:
|
||||
if r.start_date is not None and r.start_date > last:
|
||||
continue
|
||||
end = effective_end(r, modifiers)
|
||||
if end is not None and end < first:
|
||||
continue
|
||||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers,
|
||||
on=due_in_month(first, r.due_day))
|
||||
if amount is None:
|
||||
continue
|
||||
monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP)
|
||||
out.append(BreakdownRow(r.name, cat(r.category_id), "recurring",
|
||||
r.rhythm, monthly, recurring_id=r.id))
|
||||
if filters.planned:
|
||||
for p in planned:
|
||||
if not first <= p.due <= last:
|
||||
continue
|
||||
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
|
||||
if amount is None:
|
||||
continue
|
||||
out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None,
|
||||
amount.quantize(CENT, ROUND_HALF_UP)))
|
||||
if filters.loans:
|
||||
for loan in loans:
|
||||
total = sum((i.payment for i in loan.schedule if first <= i.due <= last),
|
||||
Decimal("0"))
|
||||
out.append(BreakdownRow(loan.name, loan_category, "loan", None,
|
||||
total, loan_id=loan.id))
|
||||
return sorted((r for r in out if r.amount != 0), key=lambda r: (r.amount, r.name))
|
||||
|
||||
|
||||
def share(amount: Decimal, basis: Decimal) -> Decimal | None:
|
||||
"""|amount| in Prozent von basis, eine Nachkommastelle; basis 0 -> None."""
|
||||
if basis == 0:
|
||||
return None
|
||||
return (abs(amount) * 100 / basis).quantize(PCT, ROUND_HALF_UP)
|
||||
|
||||
|
||||
def summarize(rows: list[BreakdownRow]) -> Summary:
|
||||
income = sum((r.amount for r in rows if r.amount > 0), Decimal("0"))
|
||||
costs = sum((-r.amount for r in rows if r.amount < 0), Decimal("0"))
|
||||
free = income - costs
|
||||
per_category: dict[str, Decimal] = defaultdict(Decimal)
|
||||
for r in rows:
|
||||
if r.amount < 0:
|
||||
per_category[r.category] += -r.amount
|
||||
category_costs = sorted(per_category.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
deficit = costs > income
|
||||
pie_on_income = income > 0 and not deficit
|
||||
pie_rest = free if pie_on_income and free > 0 else None
|
||||
return Summary(income, costs, free, deficit, pie_on_income, category_costs, pie_rest)
|
||||
|
||||
|
||||
def category_rows(rows: list[BreakdownRow], categories: set[str]) -> list[BreakdownRow]:
|
||||
"""Kostenzeilen der genannten Kategorien (Einnahmen erscheinen in keiner Torte)."""
|
||||
return [r for r in rows if r.amount < 0 and r.category in categories]
|
||||
@@ -54,3 +54,11 @@ def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int
|
||||
-(interest + principal_part), interest,
|
||||
principal_part, remaining.quantize(CENT)))
|
||||
return plan
|
||||
|
||||
|
||||
def schedule_for(loan) -> list[Installment]:
|
||||
"""Tilgungsplan eines Kredit-Objekts (ORM-Loan oder gleichartig mit
|
||||
principal, annual_rate_pct, term_months, payout_date, repayment_type).
|
||||
Ersetzt die wiederholte Feld-für-Feld-Übergabe an loan_schedule (A14)."""
|
||||
return loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||||
loan.term_months, loan.payout_date, loan.repayment_type)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tortendiagramm-Segmente (Ausbaustufe 13), rein.
|
||||
|
||||
Beträge und Anteile bleiben Decimal; nur die Geometrie (Winkel, SVG-
|
||||
Koordinaten im Einheitskreis) rechnet mit float. Start 12 Uhr, im
|
||||
Uhrzeigersinn (SVG-y zeigt nach unten). Große Segmente (> 50 %) werden als
|
||||
zwei Teilbögen durch Mittelpunkt gezogen, um Rundungsfehler zu vermeiden.
|
||||
"""
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
UEBRIGE = "Übrige"
|
||||
MIN_SHARE = Decimal("0.03")
|
||||
MAX_NAMED = 8
|
||||
PCT = Decimal("0.1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Slice:
|
||||
label: str
|
||||
value: Decimal
|
||||
share: Decimal # Prozent der Tortenbasis, eine Nachkommastelle
|
||||
path: str | None # SVG-d; None = Vollkreis (einziges Segment)
|
||||
color: str # "s1".."s8", "uebrige", "frei"
|
||||
merged: bool
|
||||
members: tuple[str, ...]
|
||||
start: float # Anteil 0..1
|
||||
end: float
|
||||
|
||||
|
||||
def _point(fraction: float) -> tuple[float, float]:
|
||||
angle = 2 * math.pi * fraction - math.pi / 2
|
||||
return math.cos(angle), math.sin(angle)
|
||||
|
||||
|
||||
def _arc(start: float, end: float) -> str:
|
||||
x0, y0 = _point(start)
|
||||
x1, y1 = _point(end)
|
||||
if end - start > 0.5:
|
||||
# Großes Segment: zwei Teilbögen durch Mittelpunkt, um Rundungsfehler zu vermeiden
|
||||
mid = (start + end) / 2
|
||||
xm, ym = _point(mid)
|
||||
return f"M 0 0 L {x0:.4f} {y0:.4f} A 1 1 0 0 1 {xm:.4f} {ym:.4f} A 1 1 0 0 1 {x1:.4f} {y1:.4f} Z"
|
||||
else:
|
||||
return f"M 0 0 L {x0:.4f} {y0:.4f} A 1 1 0 0 1 {x1:.4f} {y1:.4f} Z"
|
||||
|
||||
|
||||
def pie_slices(values: list[tuple[str, Decimal]], rest: Decimal | None = None,
|
||||
rest_label: str = "", min_share: Decimal = MIN_SHARE,
|
||||
max_named: int = MAX_NAMED) -> list[Slice]:
|
||||
items = sorted(((l, v) for l, v in values if v > 0), key=lambda lv: (-lv[1], lv[0]))
|
||||
rest = rest if rest is not None and rest > 0 else None
|
||||
total = sum((v for _, v in items), Decimal("0")) + (rest or Decimal("0"))
|
||||
if total <= 0:
|
||||
return []
|
||||
named: list[tuple[str, Decimal]] = []
|
||||
small: list[tuple[str, Decimal]] = []
|
||||
for label, value in items:
|
||||
if len(named) < max_named and value / total >= min_share:
|
||||
named.append((label, value))
|
||||
else:
|
||||
small.append((label, value))
|
||||
parts = [(l, v, f"s{i}", False, ()) for i, (l, v) in enumerate(named, 1)]
|
||||
if len(small) == 1:
|
||||
parts.append((small[0][0], small[0][1], "uebrige", False, ()))
|
||||
elif small:
|
||||
parts.append((UEBRIGE, sum((v for _, v in small), Decimal("0")), "uebrige",
|
||||
True, tuple(l for l, _ in small)))
|
||||
if rest is not None:
|
||||
parts.append((rest_label, rest, "frei", False, ()))
|
||||
slices: list[Slice] = []
|
||||
acc = Decimal("0")
|
||||
for label, value, color, merged, members in parts:
|
||||
start = float(acc / total)
|
||||
acc += value
|
||||
end = float(acc / total)
|
||||
slices.append(Slice(label, value,
|
||||
(value * 100 / total).quantize(PCT, ROUND_HALF_UP),
|
||||
None if len(parts) == 1 else _arc(start, end),
|
||||
color, merged, members, start, end))
|
||||
return slices
|
||||
@@ -8,6 +8,12 @@ def _clamp(y: int, m: int, day: int) -> date:
|
||||
return date(y, m, min(day, calendar.monthrange(y, m)[1]))
|
||||
|
||||
|
||||
def due_in_month(month: date, due_day: int) -> date:
|
||||
"""Fälligkeitstag `due_day` im Monat von `month`, auf die Monatslänge
|
||||
geklemmt (gleiche Regel wie occurrences)."""
|
||||
return _clamp(month.year, month.month, due_day)
|
||||
|
||||
|
||||
def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date,
|
||||
item_start: date | None = None, item_end: date | None = None) -> list[date]:
|
||||
step = STEP[rhythm]
|
||||
|
||||
@@ -10,7 +10,7 @@ CENT = Decimal("0.01")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainRecurring:
|
||||
id: int
|
||||
id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie)
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
@@ -34,19 +34,36 @@ class PlainModifier:
|
||||
target_id: int
|
||||
kind: str
|
||||
value: Decimal
|
||||
end_date: date | None = None
|
||||
end_date: date | None = None # ende: Enddatum; sonst „Gültig bis" (inklusiv)
|
||||
start_date: date | None = None # „Gültig ab" (inklusiv); bei ende immer None
|
||||
|
||||
|
||||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||
modifiers: list[PlainModifier]) -> Decimal | None:
|
||||
def _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool:
|
||||
return ((m.target_type == "category" and category_id is not None
|
||||
and category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and recurring_id is not None
|
||||
and recurring_id == m.target_id))
|
||||
|
||||
|
||||
def gilt_am(m: PlainModifier, on: date) -> bool:
|
||||
"""Liegt der Stichtag im Zeitraum des Eintrags (Grenzen inklusiv)?"""
|
||||
return ((m.start_date is None or on >= m.start_date)
|
||||
and (m.end_date is None or on <= m.end_date))
|
||||
|
||||
|
||||
def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||
modifiers: list[PlainModifier], *, on: date) -> Decimal | None:
|
||||
"""Betrag einer Fälligkeit am Stichtag `on` nach allen treffenden, dort
|
||||
gültigen Einträgen in Anlage-Reihenfolge. None = entfällt. Art "ende"
|
||||
wirkt nur über effective_end."""
|
||||
for m in modifiers:
|
||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and recurring_id == m.target_id))
|
||||
if not hit:
|
||||
if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on):
|
||||
continue
|
||||
if m.kind == "remove":
|
||||
return None
|
||||
if m.kind == "percent":
|
||||
if m.kind == "set":
|
||||
amount = m.value
|
||||
elif m.kind == "percent":
|
||||
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||||
elif m.kind == "absolute":
|
||||
if amount < 0:
|
||||
@@ -56,15 +73,12 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
|
||||
return amount
|
||||
|
||||
|
||||
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||
def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||
"""Frühestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||
end = r.end_date
|
||||
for m in modifiers:
|
||||
if m.kind != "ende" or m.end_date is None:
|
||||
if m.kind != "ende" or m.end_date is None or not _trifft(m, r.category_id, r.id):
|
||||
continue
|
||||
hit = ((m.target_type == "category" and r.category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and r.id == m.target_id))
|
||||
if hit:
|
||||
end = m.end_date if end is None else min(end, m.end_date)
|
||||
return end
|
||||
|
||||
@@ -76,15 +90,16 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
|
||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||
flows: list[tuple[date, Decimal]] = []
|
||||
for r in recurring:
|
||||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
||||
if amount is None:
|
||||
continue
|
||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||
r.start_date, _effective_end(r, modifiers)):
|
||||
r.start_date, effective_end(r, modifiers)):
|
||||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d)
|
||||
if amount is not None:
|
||||
flows.append((d, amount))
|
||||
for p in planned:
|
||||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
||||
if amount is not None and window_start <= p.due <= window_end:
|
||||
if not window_start <= p.due <= window_end:
|
||||
continue
|
||||
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
|
||||
if amount is not None:
|
||||
flows.append((p.due, amount))
|
||||
for d, payout in loan_payouts:
|
||||
if window_start <= d <= window_end:
|
||||
|
||||
+10
-1
@@ -13,7 +13,8 @@ DE_LABELS = {
|
||||
"annuity": "Annuität",
|
||||
"bullet": "endfällig",
|
||||
"percent": "Prozent",
|
||||
"absolute": "Absolut",
|
||||
"absolute": "Kürzung (€)",
|
||||
"set": "Neuer Betrag",
|
||||
"remove": "Entfällt",
|
||||
"ende": "Ende",
|
||||
}
|
||||
@@ -28,3 +29,11 @@ def eur(value) -> str:
|
||||
|
||||
def de_label(value: str) -> str:
|
||||
return DE_LABELS.get(value, value)
|
||||
|
||||
|
||||
def pct(value) -> str:
|
||||
"""Decimal("12.5") -> "12,5 %"; None -> "–" (z. B. keine Einkünfte)."""
|
||||
if value is None:
|
||||
return "–"
|
||||
d = Decimal(str(value)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
|
||||
return f"{d}".replace(".", ",") + " %"
|
||||
|
||||
@@ -123,9 +123,11 @@ class ScenarioModifier(Base):
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
kind: Mapped[str] = mapped_column(String(20))
|
||||
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
||||
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
|
||||
# dieses Szenarios endet (Ausbaustufe 5).
|
||||
# kind="ende": Datum, an dem der Ziel-Posten in diesem Szenario endet (A5).
|
||||
# Alle anderen Arten: „Gültig bis" (inklusiv, NULL = unbegrenzt, A14).
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# „Gültig ab" (inklusiv, NULL = unbegrenzt); bei kind="ende" immer NULL (A14).
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
|
||||
class ScenarioPlannedItem(Base):
|
||||
@@ -138,6 +140,24 @@ class ScenarioPlannedItem(Base):
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
due: Mapped[date] = mapped_column(Date)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
|
||||
class ScenarioRecurringItem(Base):
|
||||
"""Wiederkehrende Zahlung, die NUR in einem bestimmten Szenario zählt -
|
||||
unabhängig von Scenario.include_recurring. Posten-Einträge
|
||||
(target_type="recurring") treffen sie nie, Kategorie-Einträge schon.
|
||||
Ausbaustufe 14."""
|
||||
__tablename__ = "scenario_recurring_items"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
rhythm: Mapped[str] = mapped_column(String(20))
|
||||
due_day: Mapped[int] = mapped_column(Integer)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
|
||||
class ProjectionPoint(Base):
|
||||
@@ -156,3 +176,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,12 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from app.reserved import SYSTEM_PREFIX
|
||||
|
||||
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
||||
# Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py.
|
||||
RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}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 +115,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""",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Reservierte Namen (Ausbaustufe 12).
|
||||
|
||||
Alles, was mit SYSTEM_PREFIX beginnt, gehört dem Tool: synthetische Serien
|
||||
wie „System-Ist" (v_projection) oder „System-Kreditraten" (Planungs-Panel).
|
||||
Kategorien und Szenarien dürfen solche Namen nicht tragen, sonst verschmelzen
|
||||
sie in Grafana mit den Tool-Serien.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
SYSTEM_PREFIX = "System-"
|
||||
RESERVED_MESSAGE = (f"Namen, die mit »{SYSTEM_PREFIX}« beginnen, sind für "
|
||||
"vom Tool erzeugte Einträge reserviert")
|
||||
|
||||
|
||||
def is_reserved_name(name: str) -> bool:
|
||||
"""True, wenn name (ohne Randleerzeichen, Groß/klein egal) mit dem
|
||||
System-Präfix beginnt."""
|
||||
return name.strip().casefold().startswith(SYSTEM_PREFIX.casefold())
|
||||
|
||||
|
||||
def check_reserved_name(name: str | None) -> None:
|
||||
"""None (Feld nicht gesetzt) -> keine Prüfung; reserviert -> 422."""
|
||||
if name is not None and is_reserved_name(name):
|
||||
raise HTTPException(422, RESERVED_MESSAGE)
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Category, CategoryRule
|
||||
from app.reserved import check_reserved_name
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["categories"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
@@ -39,6 +40,7 @@ def list_categories(session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=201)
|
||||
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
||||
check_reserved_name(data.name)
|
||||
if session.execute(select(Category).where(Category.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Kategorie existiert bereits")
|
||||
cat = Category(**data.model_dump())
|
||||
@@ -51,6 +53,7 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
||||
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||
def patch_category(category_id: int, data: CategoryIn,
|
||||
session: Session = Depends(get_session)):
|
||||
check_reserved_name(data.name)
|
||||
cat = session.get(Category, category_id)
|
||||
if cat is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
+207
-5
@@ -1,5 +1,9 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Callable
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -11,11 +15,14 @@ from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.engine.loans import add_months
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.formats import de_label, eur
|
||||
from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share
|
||||
from app.engine.pie import UEBRIGE, pie_slices
|
||||
from app.formats import de_label, eur, pct
|
||||
from app.models.tables import (Account, Category, PlannedItem,
|
||||
ProjectionResult, RecurringItem,
|
||||
ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem, Transaction)
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem, ScenarioRecurringItem,
|
||||
Transaction)
|
||||
from app.routers.imports import list_imports as _list_imports
|
||||
from app.routers.imports import preview as _preview_import
|
||||
from app.routers.planning import list_loans as _list_loans
|
||||
@@ -23,8 +30,12 @@ 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.breakdown import PLAN_NAME, compute_breakdown
|
||||
from app.services.double_count import all_conflicts
|
||||
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
|
||||
@@ -49,8 +60,24 @@ def grafana_public_base(request: Request) -> str:
|
||||
templates.env.globals["grafana_public_base"] = grafana_public_base
|
||||
templates.env.filters["eur"] = eur
|
||||
templates.env.filters["de_label"] = de_label
|
||||
templates.env.filters["pct"] = pct
|
||||
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 +154,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 +176,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 +189,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 +208,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 +230,8 @@ def buchungen(
|
||||
},
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"sort": sort,
|
||||
"dir": dir,
|
||||
})
|
||||
|
||||
|
||||
@@ -287,11 +323,16 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
|
||||
select(ScenarioPlannedItem)
|
||||
.where(ScenarioPlannedItem.scenario_id == sc.id)
|
||||
.order_by(ScenarioPlannedItem.due)).scalars().all()
|
||||
recurring_items = session.execute(
|
||||
select(ScenarioRecurringItem)
|
||||
.where(ScenarioRecurringItem.scenario_id == sc.id)
|
||||
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars().all()
|
||||
rows.append({
|
||||
"scenario": sc,
|
||||
"assigned_loan_ids": assigned_loan_ids,
|
||||
"modifiers": modifiers,
|
||||
"planned_items": planned_items,
|
||||
"recurring_items": recurring_items,
|
||||
"result": session.get(ProjectionResult, sc.id),
|
||||
})
|
||||
return rows
|
||||
@@ -304,10 +345,16 @@ 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)
|
||||
loans = _list_loans(session=session)
|
||||
doppelt_posten: dict[int, list[str]] = {}
|
||||
doppelt_kredit: dict[int, list[str]] = {}
|
||||
for k in all_conflicts(session):
|
||||
doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name)
|
||||
doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name)
|
||||
return templates.TemplateResponse(request, "planning.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
@@ -318,11 +365,14 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
"loans": loans,
|
||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||
"repayment_types": ["annuity", "bullet"],
|
||||
"doppelt_posten": doppelt_posten,
|
||||
"doppelt_kredit": doppelt_kredit,
|
||||
})
|
||||
|
||||
|
||||
@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)
|
||||
@@ -334,5 +384,157 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
||||
"recurring_names": {r.id: r.name for r in recurring},
|
||||
"loans": loans,
|
||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||
"modifier_kinds": ["percent", "absolute", "set", "remove", "ende"],
|
||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||
})
|
||||
|
||||
|
||||
# --- Aufteilung (Ausbaustufe 13) ---
|
||||
|
||||
FEHLER_SZENARIO_VERGANGENHEIT = ("Szenarien sind erst ab dem laufenden Monat wählbar. "
|
||||
"Für vergangene Monate „System-Plan“ wählen.")
|
||||
|
||||
|
||||
def _parse_monat(value: str | None, today: date) -> date | None:
|
||||
"""„JJJJ-MM" -> Monatserster; leer -> laufender Monat; ungültig -> None."""
|
||||
if not value:
|
||||
return today.replace(day=1)
|
||||
m = re.fullmatch(r"(\d{4})-(\d{2})", value.strip())
|
||||
if not m or not 1 <= int(m.group(2)) <= 12 or int(m.group(1)) < 1:
|
||||
return None
|
||||
return date(int(m.group(1)), int(m.group(2)), 1)
|
||||
|
||||
|
||||
def _art(row: BreakdownRow) -> str:
|
||||
if row.kind == "recurring":
|
||||
return f"Fixposten ({de_label(row.rhythm)})"
|
||||
return "Einmalposten" if row.kind == "planned" else "Kredit"
|
||||
|
||||
|
||||
def _segmente(slices) -> list[dict]:
|
||||
return [{"s": s, "q": None, "gewaehlt": False, "zusatz": None} for s in slices]
|
||||
|
||||
|
||||
def _aufteilung_context(session: Session, today: date, monat: str | None,
|
||||
szenario: str | None, fixposten: str | None,
|
||||
einmal: str | None, kredite: str | None,
|
||||
gesendet: str | None) -> dict:
|
||||
# Erstaufruf (ohne gesendet): alle Filter an. Nach Absenden zählt nur,
|
||||
# was angehakt ist - der Browser sendet leere Checkboxen gar nicht.
|
||||
filters = (Filters(recurring=fixposten == "1", planned=einmal == "1",
|
||||
loans=kredite == "1") if gesendet else Filters())
|
||||
ctx: dict = {
|
||||
"scenarios": session.execute(select(Scenario).order_by(Scenario.name)).scalars().all(),
|
||||
"filters": filters, "plan_name": PLAN_NAME,
|
||||
"monat_wert": monat or today.strftime("%Y-%m"), "monat_anzeige": None,
|
||||
"szenario_id": None, "szenario_name": PLAN_NAME, "error": None, "b": None,
|
||||
}
|
||||
month = _parse_monat(monat, today)
|
||||
if month is None:
|
||||
ctx["error"] = f"Ungültiger Monat „{monat}“ – bitte im Format JJJJ-MM wählen."
|
||||
return ctx
|
||||
ctx["monat_wert"] = f"{month.year:04d}-{month.month:02d}"
|
||||
ctx["monat_anzeige"] = f"{month.month:02d}.{month.year:04d}"
|
||||
scenario = None
|
||||
if szenario:
|
||||
valid_id = re.fullmatch(r"[0-9]{1,9}", szenario) # ASCII, kein Integer-Überlauf
|
||||
scenario = session.get(Scenario, int(szenario)) if valid_id else None
|
||||
if scenario is None:
|
||||
ctx["error"] = "Unbekanntes Szenario – bitte neu wählen."
|
||||
return ctx
|
||||
ctx["szenario_id"], ctx["szenario_name"] = scenario.id, scenario.name
|
||||
if month < today.replace(day=1):
|
||||
ctx["error"] = FEHLER_SZENARIO_VERGANGENHEIT
|
||||
return ctx
|
||||
b = compute_breakdown(session, month, scenario, filters)
|
||||
s = b.summary
|
||||
ctx.update({
|
||||
"b": b, "s": s,
|
||||
"zeilen": [{"r": r, "art": _art(r), "einnahme": r.amount > 0,
|
||||
"anteil": share(r.amount, s.income) if r.amount < 0 else None}
|
||||
for r in b.rows],
|
||||
"frei_anteil": share(s.free, s.income),
|
||||
"haupt": _segmente(pie_slices(s.category_costs, rest=s.pie_rest, rest_label=FREI)),
|
||||
"haupt_titel": ("Anteile an den Einkünften" if s.pie_on_income
|
||||
else "Anteile an den Gesamtkosten"),
|
||||
})
|
||||
return ctx
|
||||
|
||||
|
||||
def _basis_query(ctx: dict) -> dict:
|
||||
q = {"monat": ctx["monat_wert"], "szenario": ctx["szenario_id"] or "", "gesendet": "1"}
|
||||
f = ctx["filters"]
|
||||
for key, on in (("fixposten", f.recurring), ("einmal", f.planned), ("kredite", f.loans)):
|
||||
if on:
|
||||
q[key] = "1"
|
||||
return q
|
||||
|
||||
|
||||
def _drilldown(ctx: dict, kategorie: str | None, sammel: str | None) -> None:
|
||||
"""Links der Haupttorte + ggf. zweite Torte. Die Sammelgruppe „Übrige"
|
||||
läuft über sammel=1, damit eine echte Kategorie „Übrige" eindeutig bleibt."""
|
||||
ctx["detail"] = None
|
||||
ctx["detail_hinweis"] = None
|
||||
if ctx["b"] is None:
|
||||
return
|
||||
sammel_gewaehlt = sammel == "1"
|
||||
basis = _basis_query(ctx)
|
||||
for seg in ctx["haupt"]:
|
||||
s = seg["s"]
|
||||
if s.color == "frei":
|
||||
continue
|
||||
extra = {"sammel": "1"} if s.merged else {"kategorie": s.label}
|
||||
seg["q"] = urlencode({**basis, **extra})
|
||||
seg["gewaehlt"] = s.merged if sammel_gewaehlt else (kategorie == s.label and not s.merged)
|
||||
if sammel_gewaehlt:
|
||||
merged = next((seg["s"] for seg in ctx["haupt"] if seg["s"].merged), None)
|
||||
titel = UEBRIGE
|
||||
enthaelt = list(merged.members) if merged else []
|
||||
elif kategorie:
|
||||
titel, enthaelt = kategorie, []
|
||||
else:
|
||||
return
|
||||
rows = category_rows(ctx["b"].rows, set(enthaelt) if sammel_gewaehlt else {kategorie})
|
||||
if not rows:
|
||||
ctx["detail_hinweis"] = f"Kategorie „{titel}“ hat in dieser Auswahl keine Kosten."
|
||||
return
|
||||
income = ctx["s"].income
|
||||
segmente = _segmente(pie_slices([(r.name, -r.amount) for r in rows]))
|
||||
for seg in segmente:
|
||||
anteil = share(seg["s"].value, income)
|
||||
seg["zusatz"] = f"{pct(anteil)} der Einkünfte" if anteil is not None else None
|
||||
ctx["detail"] = {"titel": titel, "enthaelt": enthaelt, "segmente": segmente,
|
||||
"summe": sum((-r.amount for r in rows), Decimal("0"))}
|
||||
|
||||
|
||||
@router.get("/aufteilung", dependencies=[Depends(gui_session)])
|
||||
def aufteilung_page(request: Request, monat: str | None = None,
|
||||
szenario: str | None = None, fixposten: str | None = None,
|
||||
einmal: str | None = None, kredite: str | None = None,
|
||||
gesendet: str | None = None, kategorie: str | None = None,
|
||||
sammel: str | None = None,
|
||||
session: Session = Depends(get_session)):
|
||||
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
|
||||
einmal, kredite, gesendet)
|
||||
_drilldown(ctx, kategorie, sammel)
|
||||
return templates.TemplateResponse(request, "aufteilung.html", ctx)
|
||||
|
||||
|
||||
@router.get("/aufteilung/kategorie", dependencies=[Depends(gui_session)])
|
||||
def aufteilung_kategorie_fragment(request: Request, monat: str | None = None,
|
||||
szenario: str | None = None,
|
||||
fixposten: str | None = None,
|
||||
einmal: str | None = None,
|
||||
kredite: str | None = None,
|
||||
gesendet: str | None = None,
|
||||
kategorie: str | None = None,
|
||||
sammel: str | None = None,
|
||||
session: Session = Depends(get_session)):
|
||||
"""htmx-Fragment: zweite Torte + Haupttorte out-of-band (Markierung)."""
|
||||
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
|
||||
einmal, kredite, gesendet)
|
||||
_drilldown(ctx, kategorie, sammel)
|
||||
if ctx["error"]:
|
||||
ctx["detail_hinweis"] = ctx["error"]
|
||||
ctx["oob"] = ctx["b"] is not None
|
||||
return templates.TemplateResponse(request, "_aufteilung_kategorie.html", ctx)
|
||||
|
||||
@@ -9,9 +9,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.loans import schedule_for
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
ScenarioLoan)
|
||||
from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt,
|
||||
KreditDaten, PostenDaten,
|
||||
conflicts_for_loan, conflicts_for_recurring,
|
||||
kredit_aus, posten_aus)
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["planning"],
|
||||
@@ -36,6 +40,11 @@ class RecurringIn(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class RecurringCreate(RecurringIn):
|
||||
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class RecurringOut(RecurringIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -49,6 +58,7 @@ class RecurringPatch(BaseModel):
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class SuggestionOut(BaseModel):
|
||||
@@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
_FLAG = "doppelzaehlung_bestaetigt"
|
||||
|
||||
|
||||
def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException:
|
||||
"""409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster."""
|
||||
return HTTPException(409, detail={
|
||||
"code": DOPPELZAEHLUNG_CODE,
|
||||
"meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits "
|
||||
"über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen "
|
||||
"zählen die Rate doppelt – Planung und Szenarien wären zu "
|
||||
"pessimistisch. Nur speichern, wenn das wirklich gewollt ist."),
|
||||
"konflikte": [{"posten": k.posten_name,
|
||||
"posten_betrag": str(k.posten_betrag.quantize(_CENT)),
|
||||
"kredit": k.kredit_name,
|
||||
"kredit_rate": str(k.kredit_rate.quantize(_CENT))}
|
||||
for k in konflikte],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/recurring", response_model=list[RecurringOut])
|
||||
def list_recurring(session: Session = Depends(get_session)):
|
||||
return [RecurringOut.model_validate(r)
|
||||
@@ -73,9 +103,16 @@ def list_recurring(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/recurring", response_model=RecurringOut, status_code=201)
|
||||
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
|
||||
def create_recurring(data: RecurringCreate, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = RecurringItem(**data.model_dump())
|
||||
fields = data.model_dump(exclude={_FLAG})
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
konflikte = conflicts_for_recurring(session, PostenDaten(
|
||||
None, fields["name"], fields["amount"], fields["rhythm"],
|
||||
fields["start_date"], fields["end_date"]))
|
||||
if konflikte:
|
||||
raise _doppelzaehlung(konflikte)
|
||||
item = RecurringItem(**fields)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
@@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))}
|
||||
fields = data.model_dump(exclude_unset=True, exclude={_FLAG})
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
@@ -103,6 +141,12 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
and item.end_date < item.start_date):
|
||||
session.rollback()
|
||||
raise HTTPException(422, "Ende darf nicht vor Start liegen")
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
neu = [k for k in conflicts_for_recurring(session, posten_aus(item))
|
||||
if k.paar not in vorher]
|
||||
if neu:
|
||||
session.rollback()
|
||||
raise _doppelzaehlung(neu)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
@@ -183,23 +227,35 @@ class LoanIn(BaseModel):
|
||||
name: str
|
||||
principal: Decimal
|
||||
annual_rate_pct: Decimal
|
||||
term_months: int
|
||||
term_months: int = Field(ge=1, le=1200)
|
||||
payout_date: date
|
||||
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||
|
||||
|
||||
class LoanOut(LoanIn):
|
||||
class LoanCreate(LoanIn):
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class LoanOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
principal: Decimal
|
||||
annual_rate_pct: Decimal
|
||||
# Ausgabe darf Altbestände nicht ablehnen: keine Grenzen auf term_months.
|
||||
term_months: int
|
||||
payout_date: date
|
||||
repayment_type: Literal["annuity", "bullet"]
|
||||
|
||||
|
||||
class LoanPatch(BaseModel):
|
||||
name: str | None = None
|
||||
principal: Decimal | None = None
|
||||
annual_rate_pct: Decimal | None = None
|
||||
term_months: int | None = None
|
||||
term_months: int | None = Field(default=None, ge=1, le=1200)
|
||||
payout_date: date | None = None
|
||||
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class InstallmentOut(BaseModel):
|
||||
@@ -217,8 +273,15 @@ def list_loans(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/loans", response_model=LoanOut, status_code=201)
|
||||
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
|
||||
loan = Loan(**data.model_dump())
|
||||
def create_loan(data: LoanCreate, session: Session = Depends(get_session)):
|
||||
fields = data.model_dump(exclude={_FLAG})
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
konflikte = conflicts_for_loan(session, KreditDaten(
|
||||
None, fields["name"], fields["principal"], fields["annual_rate_pct"],
|
||||
fields["term_months"], fields["payout_date"], fields["repayment_type"]))
|
||||
if konflikte:
|
||||
raise _doppelzaehlung(konflikte)
|
||||
loan = Loan(**fields)
|
||||
session.add(loan)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
@@ -230,8 +293,15 @@ def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_ses
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
vorher = {k.paar for k in conflicts_for_loan(session, kredit_aus(loan))}
|
||||
for key, value in data.model_dump(exclude_unset=True, exclude={_FLAG}).items():
|
||||
setattr(loan, key, value)
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
neu = [k for k in conflicts_for_loan(session, kredit_aus(loan))
|
||||
if k.paar not in vorher]
|
||||
if neu:
|
||||
session.rollback()
|
||||
raise _doppelzaehlung(neu)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
@@ -255,8 +325,7 @@ def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||||
loan.term_months, loan.payout_date, loan.repayment_type)
|
||||
plan = schedule_for(loan)
|
||||
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
|
||||
principal=i.principal, remaining=i.remaining)
|
||||
for i in plan]
|
||||
|
||||
@@ -12,7 +12,9 @@ from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||
from app.routers.planning import RecurringIn, _check_category
|
||||
from app.reserved import check_reserved_name
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
@@ -46,16 +48,23 @@ class ScenarioPatch(BaseModel):
|
||||
class ModifierIn(BaseModel):
|
||||
target_type: Literal["category", "recurring"]
|
||||
target_id: int
|
||||
kind: Literal["percent", "absolute", "remove", "ende"]
|
||||
kind: Literal["percent", "absolute", "set", "remove", "ende"]
|
||||
value: Decimal = Decimal("0")
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _ende_braucht_datum(self):
|
||||
if self.kind == "ende" and self.end_date is None:
|
||||
def _art_und_zeitraum(self):
|
||||
# end_date = Enddatum bei "ende", sonst „Gültig bis" (A14).
|
||||
if self.kind == "ende":
|
||||
if self.end_date is None:
|
||||
raise ValueError("Art »Ende« erfordert ein Datum")
|
||||
if self.kind != "ende":
|
||||
self.end_date = None
|
||||
self.start_date = None
|
||||
if self.kind == "set" and self.target_type != "recurring":
|
||||
raise ValueError("Art »Neuer Betrag« ist nur für Posten möglich")
|
||||
if (self.start_date is not None and self.end_date is not None
|
||||
and self.start_date > self.end_date):
|
||||
raise ValueError("»Gültig ab« liegt nach »Gültig bis«")
|
||||
return self
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -124,6 +135,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
||||
# foreign-key violations (would surface as a 500 on Postgres).
|
||||
session.execute(delete(ScenarioRecurringItem).where(
|
||||
ScenarioRecurringItem.scenario_id == scenario_id))
|
||||
session.execute(delete(ProjectionPoint).where(
|
||||
ProjectionPoint.scenario_id == scenario_id))
|
||||
session.execute(delete(ProjectionResult).where(
|
||||
@@ -200,6 +213,7 @@ class ScenarioPlannedIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class ScenarioPlannedOut(ScenarioPlannedIn):
|
||||
@@ -222,6 +236,7 @@ def list_scenario_planned(scenario_id: int, session: Session = Depends(get_sessi
|
||||
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
_check_category(session, data.category_id)
|
||||
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
@@ -245,6 +260,7 @@ def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlanned
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update, Muster patch_modifier.
|
||||
_get_scenario(session, scenario_id)
|
||||
_check_category(session, data.category_id)
|
||||
item = session.get(ScenarioPlannedItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||
@@ -275,3 +291,62 @@ def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
||||
below_threshold_date=result.below_threshold_date,
|
||||
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
|
||||
)
|
||||
|
||||
|
||||
class ScenarioRecurringOut(RecurringIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
scenario_id: int
|
||||
|
||||
|
||||
def _get_scenario_recurring(session: Session, scenario_id: int,
|
||||
item_id: int) -> ScenarioRecurringItem:
|
||||
item = session.get(ScenarioRecurringItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Wiederkehrende Zahlung nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{scenario_id}/recurring", response_model=list[ScenarioRecurringOut])
|
||||
def list_scenario_recurring(scenario_id: int, session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
return [ScenarioRecurringOut.model_validate(r) for r in session.execute(
|
||||
select(ScenarioRecurringItem)
|
||||
.where(ScenarioRecurringItem.scenario_id == scenario_id)
|
||||
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars()]
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/recurring", response_model=ScenarioRecurringOut,
|
||||
status_code=201)
|
||||
def add_scenario_recurring(scenario_id: int, data: RecurringIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Bewusst ohne Doppelzählungs-Prüfung (gilt nur für globale Fixposten).
|
||||
_get_scenario(session, scenario_id)
|
||||
_check_category(session, data.category_id)
|
||||
item = ScenarioRecurringItem(scenario_id=scenario_id, **data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioRecurringOut.model_validate(item)
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}/recurring/{item_id}", response_model=ScenarioRecurringOut)
|
||||
def patch_scenario_recurring(scenario_id: int, item_id: int, data: RecurringIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update, Muster patch_scenario_planned.
|
||||
_get_scenario(session, scenario_id)
|
||||
item = _get_scenario_recurring(session, scenario_id, item_id)
|
||||
_check_category(session, data.category_id)
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioRecurringOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}/recurring/{item_id}", status_code=204)
|
||||
def delete_scenario_recurring(scenario_id: int, item_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
session.delete(_get_scenario_recurring(session, scenario_id, item_id))
|
||||
session.commit()
|
||||
|
||||
@@ -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,45 @@
|
||||
"""Aufteilung (Ausbaustufe 13): Brücke DB -> engine/breakdown.py.
|
||||
|
||||
scenario=None ist „System-Plan" (reine Planung, alle Kredite, keine
|
||||
Modifikatoren) - dieselbe Datenbasis wie load_scenario_inputs(None).
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.engine.breakdown import (BreakdownRow, Filters, PlainLoan, Summary,
|
||||
breakdown_rows, summarize)
|
||||
from app.engine.loans import schedule_for
|
||||
from app.models.tables import Category, Scenario
|
||||
from app.reserved import SYSTEM_PREFIX
|
||||
from app.services.double_count import Konflikt, all_conflicts
|
||||
from app.services.planning_chart import KREDITRATEN, UNKATEGORISIERT
|
||||
from app.services.projection_service import load_scenario_inputs
|
||||
|
||||
PLAN_NAME = f"{SYSTEM_PREFIX}Plan"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Breakdown:
|
||||
month: date
|
||||
rows: list[BreakdownRow]
|
||||
summary: Summary
|
||||
conflicts: list[Konflikt]
|
||||
|
||||
|
||||
def compute_breakdown(session: Session, month: date, scenario: Scenario | None,
|
||||
filters: Filters) -> Breakdown:
|
||||
inputs = load_scenario_inputs(session, scenario)
|
||||
names = {c.id: c.name for c in session.execute(select(Category)).scalars()}
|
||||
loans = [PlainLoan(l.id, l.name, schedule_for(l)) for l in inputs.loans]
|
||||
rows = breakdown_rows(month, inputs.recurring, inputs.planned, loans,
|
||||
inputs.modifiers, filters, category_names=names,
|
||||
loan_category=KREDITRATEN, uncategorized=UNKATEGORISIERT)
|
||||
# Doppelzählungs-Hinweis nur, wenn Fixposten UND Kredit hier als Zeile stehen.
|
||||
posten = {r.recurring_id for r in rows if r.recurring_id is not None}
|
||||
kredite = {r.loan_id for r in rows if r.loan_id is not None}
|
||||
conflicts = [k for k in all_conflicts(session)
|
||||
if k.posten_id in posten and k.kredit_id in kredite]
|
||||
return Breakdown(month.replace(day=1), rows, summarize(rows), conflicts)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Doppelzählungs-Erkennung Kredit ↔ wiederkehrender Posten (Ausbaustufe 12).
|
||||
|
||||
Ein monatlicher Ausgabe-Posten gilt als vermutlich doppelt zu einem Kredit,
|
||||
wenn sein Betrag innerhalb der Toleranz der regulären Monatsrate liegt und
|
||||
sich die Zeiträume überschneiden. Bewusst kein Namensvergleich
|
||||
(„Autokredit" vs. „Rate VW Bank" ist nicht zuverlässig zuordenbar).
|
||||
Reine Logik auf Wertobjekten, damit auch noch nicht gespeicherte bzw. per
|
||||
PATCH veränderte Zustände geprüft werden können. Alle Beträge Decimal.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.engine.loans import add_months, annuity_payment
|
||||
from app.models.tables import Loan, RecurringItem
|
||||
|
||||
CODE = "doppelzaehlung_kredit"
|
||||
TOLERANZ_PROZENT = Decimal("3")
|
||||
TOLERANZ_MIN = Decimal("1.00")
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostenDaten:
|
||||
id: int | None
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreditDaten:
|
||||
id: int | None
|
||||
name: str
|
||||
principal: Decimal
|
||||
annual_rate_pct: Decimal
|
||||
term_months: int
|
||||
payout_date: date
|
||||
repayment_type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konflikt:
|
||||
posten_id: int | None
|
||||
posten_name: str
|
||||
posten_betrag: Decimal
|
||||
kredit_id: int | None
|
||||
kredit_name: str
|
||||
kredit_rate: Decimal
|
||||
|
||||
@property
|
||||
def paar(self) -> tuple[int | None, int | None]:
|
||||
return (self.posten_id, self.kredit_id)
|
||||
|
||||
|
||||
def posten_aus(item: RecurringItem) -> PostenDaten:
|
||||
return PostenDaten(item.id, item.name, Decimal(item.amount), item.rhythm,
|
||||
item.start_date, item.end_date)
|
||||
|
||||
|
||||
def kredit_aus(loan: Loan) -> KreditDaten:
|
||||
return KreditDaten(loan.id, loan.name, Decimal(loan.principal),
|
||||
Decimal(loan.annual_rate_pct), loan.term_months,
|
||||
loan.payout_date, loan.repayment_type)
|
||||
|
||||
|
||||
def regular_rate(k: KreditDaten) -> Decimal | None:
|
||||
"""Reguläre Monatsrate (positiv): Annuität bzw. bei endfälligen Krediten
|
||||
die monatlichen Zinsen. Ohne reguläre Rate (0 % endfällig, Laufzeit <= 0)
|
||||
-> None, dann nie Konflikt. Die Schlusszahlung wird nicht betrachtet."""
|
||||
if k.term_months <= 0:
|
||||
return None
|
||||
if k.repayment_type == "bullet":
|
||||
if k.annual_rate_pct == 0:
|
||||
return None
|
||||
return (k.principal * k.annual_rate_pct / Decimal(100) / Decimal(12)
|
||||
).quantize(CENT, ROUND_HALF_UP)
|
||||
return annuity_payment(k.principal, k.annual_rate_pct, k.term_months)
|
||||
|
||||
|
||||
def rate_period(k: KreditDaten) -> tuple[date, date]:
|
||||
"""Erste und letzte Rate, identisch zu engine/loans.loan_schedule."""
|
||||
return add_months(k.payout_date, 1), add_months(k.payout_date, k.term_months)
|
||||
|
||||
|
||||
def find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None:
|
||||
if p.rhythm != "monthly" or p.amount >= 0:
|
||||
return None
|
||||
rate = regular_rate(k)
|
||||
if rate is None or rate <= 0:
|
||||
return None
|
||||
toleranz = max(TOLERANZ_MIN, rate * TOLERANZ_PROZENT / Decimal(100))
|
||||
if abs(-p.amount - rate) > toleranz:
|
||||
return None
|
||||
erste, letzte = rate_period(k)
|
||||
if p.start_date is not None and p.start_date > letzte:
|
||||
return None
|
||||
if p.end_date is not None and p.end_date < erste:
|
||||
return None
|
||||
return Konflikt(p.id, p.name, p.amount, k.id, k.name, rate)
|
||||
|
||||
|
||||
def conflicts_for_recurring(session: Session, p: PostenDaten) -> list[Konflikt]:
|
||||
loans = session.execute(select(Loan).order_by(Loan.name, Loan.id)).scalars()
|
||||
return [c for c in (find_conflict(p, kredit_aus(l)) for l in loans) if c]
|
||||
|
||||
|
||||
def conflicts_for_loan(session: Session, k: KreditDaten) -> list[Konflikt]:
|
||||
items = session.execute(select(RecurringItem)
|
||||
.order_by(RecurringItem.name, RecurringItem.id)).scalars()
|
||||
return [c for c in (find_conflict(posten_aus(r), k) for r in items) if c]
|
||||
|
||||
|
||||
def all_conflicts(session: Session) -> list[Konflikt]:
|
||||
kredite = [kredit_aus(l) for l in session.execute(
|
||||
select(Loan).order_by(Loan.name, Loan.id)).scalars()]
|
||||
out: list[Konflikt] = []
|
||||
for r in session.execute(select(RecurringItem)
|
||||
.order_by(RecurringItem.name, RecurringItem.id)).scalars():
|
||||
p = posten_aus(r)
|
||||
out.extend(c for c in (find_conflict(p, k) for k in kredite) if c)
|
||||
return out
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
„System-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 schedule_for
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||
RecurringItem)
|
||||
from app.reserved import SYSTEM_PREFIX
|
||||
|
||||
UNKATEGORISIERT = "unkategorisiert"
|
||||
KREDITRATEN = f"{SYSTEM_PREFIX}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 schedule_for(loan):
|
||||
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)
|
||||
@@ -1,52 +1,85 @@
|
||||
from dataclasses import dataclass
|
||||
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.engine.loans import loan_schedule
|
||||
from app.db import advisory_xact_lock
|
||||
from app.engine.loans import schedule_for
|
||||
from app.engine.projection import project
|
||||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||||
build_cashflows)
|
||||
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
||||
ProjectionResult, RecurringItem, Scenario,
|
||||
ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||
from app.services.balances import total_balance
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioInputs:
|
||||
"""Eingaben einer Szenario-Rechnung (Projektion wie Aufteilung)."""
|
||||
recurring: list[PlainRecurring]
|
||||
planned: list[PlainPlanned]
|
||||
loans: list[Loan]
|
||||
modifiers: list[PlainModifier]
|
||||
|
||||
|
||||
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
|
||||
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine
|
||||
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
|
||||
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten und keine
|
||||
szenario-eigenen wiederkehrenden Zahlungen."""
|
||||
recurring = []
|
||||
if scenario is None or scenario.include_recurring:
|
||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||
r.due_day, r.start_date, r.end_date, r.category_id)
|
||||
for r in session.execute(
|
||||
select(RecurringItem).order_by(RecurringItem.id)).scalars()]
|
||||
planned = []
|
||||
if scenario is None or scenario.include_planned:
|
||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||
for p in session.execute(
|
||||
select(PlannedItem).order_by(PlannedItem.id)).scalars()]
|
||||
if scenario is None:
|
||||
loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars())
|
||||
return ScenarioInputs(recurring, planned, loans, [])
|
||||
# Szenario-eigene Zahlungen zählen IMMER für dieses Szenario -
|
||||
# include_recurring/include_planned steuern nur die globalen Posten.
|
||||
# id=None: Posten-Einträge (target_type="recurring") treffen sie nie,
|
||||
# die IDs zweier Tabellen könnten sonst kollidieren.
|
||||
recurring += [PlainRecurring(None, r.name, Decimal(r.amount), r.rhythm, r.due_day,
|
||||
r.start_date, r.end_date, r.category_id)
|
||||
for r in session.execute(select(ScenarioRecurringItem).where(
|
||||
ScenarioRecurringItem.scenario_id == scenario.id)
|
||||
.order_by(ScenarioRecurringItem.id)).scalars()]
|
||||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||
for p in session.execute(select(ScenarioPlannedItem).where(
|
||||
ScenarioPlannedItem.scenario_id == scenario.id)
|
||||
.order_by(ScenarioPlannedItem.id)).scalars()]
|
||||
loans = list(session.execute(
|
||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||
.where(ScenarioLoan.scenario_id == scenario.id)
|
||||
.order_by(Loan.id)).scalars())
|
||||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||||
Decimal(m.value), m.end_date, m.start_date)
|
||||
for m in session.execute(select(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario.id)
|
||||
.order_by(ScenarioModifier.id)).scalars()]
|
||||
return ScenarioInputs(recurring, planned, loans, modifiers)
|
||||
|
||||
|
||||
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
||||
start_date: date) -> ProjectionResult:
|
||||
end = start_date + timedelta(days=horizon_days)
|
||||
recurring = []
|
||||
if scenario.include_recurring:
|
||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||
r.due_day, r.start_date, r.end_date, r.category_id)
|
||||
for r in session.execute(select(RecurringItem)).scalars()]
|
||||
planned = []
|
||||
if scenario.include_planned:
|
||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||
for p in session.execute(select(PlannedItem)).scalars()]
|
||||
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
||||
# include_planned steuert nur die globalen PlannedItems.
|
||||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
||||
for p in session.execute(select(ScenarioPlannedItem).where(
|
||||
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
|
||||
loans = session.execute(
|
||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
|
||||
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||||
l.term_months, l.payout_date, l.repayment_type)
|
||||
for l in loans]
|
||||
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
|
||||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||||
Decimal(m.value), m.end_date)
|
||||
for m in session.execute(select(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
||||
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
|
||||
start_date, end)
|
||||
inputs = load_scenario_inputs(session, scenario)
|
||||
schedules = [schedule_for(l) for l in inputs.loans]
|
||||
payouts = [(l.payout_date, Decimal(l.principal)) for l in inputs.loans]
|
||||
flows = build_cashflows(inputs.recurring, inputs.planned, schedules, payouts,
|
||||
inputs.modifiers, start_date, end)
|
||||
proj = project(total_balance(session), start_date, flows, horizon_days,
|
||||
threshold=get_settings().warn_threshold)
|
||||
session.execute(delete(ProjectionPoint).where(
|
||||
@@ -63,3 +96,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,89 @@
|
||||
/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12).
|
||||
*
|
||||
* Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein
|
||||
* Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen
|
||||
* Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen
|
||||
* weiter über den alert) und zeigt den <dialog>. „Trotzdem speichern" markiert
|
||||
* das auslösende Formular (data-doppelzaehlung-bestaetigt="1") und sendet es
|
||||
* erneut; die json-form-Extension ergänzt dann doppelzaehlung_bestaetigt=true.
|
||||
* Nach jedem Request wird die Markierung entfernt, damit ein späteres
|
||||
* Speichern die Warnung nicht stillschweigend umgeht.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CODE = 'doppelzaehlung_kredit';
|
||||
var ATTR = 'data-doppelzaehlung-bestaetigt';
|
||||
var fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
function parseDetail(xhr) {
|
||||
if (!xhr || xhr.status !== 409) { return null; }
|
||||
var body;
|
||||
try { body = JSON.parse(xhr.responseText); } catch (e) { return null; }
|
||||
var d = body && body.detail;
|
||||
return d && d.code === CODE ? d : null;
|
||||
}
|
||||
|
||||
function eur(s) { return fmt.format(parseFloat(s)) + ' €'; }
|
||||
|
||||
function konfliktText(k) {
|
||||
return 'Posten „' + k.posten + '“ (' + eur(k.posten_betrag) + ') ↔ Kredit „'
|
||||
+ k.kredit + '“ (Rate ' + eur(k.kredit_rate) + ')';
|
||||
}
|
||||
|
||||
function handle(evt) {
|
||||
var d = parseDetail(evt && evt.detail && evt.detail.xhr);
|
||||
var elt = evt && evt.detail && evt.detail.elt;
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
if (!d || !form) { return false; }
|
||||
var doc = form.ownerDocument;
|
||||
var dlg = doc.getElementById('doppelzaehlung-dialog');
|
||||
if (!dlg) { return false; }
|
||||
dlg.querySelector('.dz-meldung').textContent = d.meldung;
|
||||
var list = dlg.querySelector('.dz-konflikte');
|
||||
list.textContent = '';
|
||||
(d.konflikte || []).forEach(function (k) {
|
||||
var li = doc.createElement('li');
|
||||
li.textContent = konfliktText(k);
|
||||
list.appendChild(li);
|
||||
});
|
||||
dlg.fbForm = form;
|
||||
dlg.showModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
function abbrechen(dlg) {
|
||||
dlg.fbForm = null;
|
||||
dlg.close();
|
||||
}
|
||||
|
||||
function bestaetigen(dlg) {
|
||||
var form = dlg.fbForm;
|
||||
dlg.fbForm = null;
|
||||
dlg.close();
|
||||
if (!form) { return; }
|
||||
form.setAttribute(ATTR, '1');
|
||||
form.ownerDocument.defaultView.htmx.trigger(form, 'submit');
|
||||
}
|
||||
|
||||
function init(doc) {
|
||||
var dlg = doc.getElementById('doppelzaehlung-dialog');
|
||||
if (!dlg) { return; }
|
||||
doc.getElementById('dz-abbrechen').addEventListener('click', function () { abbrechen(dlg); });
|
||||
doc.getElementById('dz-bestaetigen').addEventListener('click', function () { bestaetigen(dlg); });
|
||||
doc.body.addEventListener('htmx:afterRequest', function (e) {
|
||||
var elt = e.detail && e.detail.elt;
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
if (form && form.getAttribute(ATTR) === '1') {
|
||||
// Erst nach dem Request mit Flag entfernen: der 409-Request selbst
|
||||
// hat die Markierung nie gesetzt.
|
||||
form.removeAttribute(ATTR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var api = { parseDetail: parseDetail, konfliktText: konfliktText, handle: handle,
|
||||
abbrechen: abbrechen, bestaetigen: bestaetigen, init: init };
|
||||
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
|
||||
if (typeof window !== 'undefined') { window.FBDoppel = api; }
|
||||
})();
|
||||
@@ -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); }
|
||||
}
|
||||
})();
|
||||
@@ -12,6 +12,8 @@ body {
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.4rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 0.5rem;
|
||||
@@ -37,6 +39,12 @@ table {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Schmale Bildschirme: breite Tabellen scrollen in sich, nicht die Seite (A14). */
|
||||
@media (max-width: 600px) {
|
||||
table { display: block; overflow-x: auto; }
|
||||
fieldset { min-width: 0; }
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4rem 0.6rem;
|
||||
@@ -210,3 +218,73 @@ 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;
|
||||
}
|
||||
|
||||
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
|
||||
.warn-hint {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: #b45309;
|
||||
}
|
||||
#doppelzaehlung-dialog {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette,
|
||||
Slots 1-8 (Nachbarpaare inkl. Kreisschluss und Grautöne CVD-validiert auf
|
||||
weißem Grund); Übrige/Frei neutral. Max. 8 benannte + Übrige + Frei. */
|
||||
.aufteilung {
|
||||
--seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-4: #eda100;
|
||||
--seg-5: #e87ba4; --seg-6: #008300; --seg-7: #4a3aa7; --seg-8: #e34948;
|
||||
--seg-uebrige: #8a8984; --seg-frei: #d6d5cf;
|
||||
}
|
||||
.kennzahlen { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; }
|
||||
.kennzahlen div { display: flex; flex-direction: column; }
|
||||
.kennzahlen strong { font-size: 1.25rem; }
|
||||
.tabelle-scroll { overflow-x: auto; }
|
||||
/* Torte: schmal untereinander, breit SVG neben Legende */
|
||||
.torte { margin: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; align-items: start; }
|
||||
@media (min-width: 700px) {
|
||||
.torte { grid-template-columns: 280px minmax(0, 1fr); }
|
||||
.torte figcaption { grid-column: 1 / -1; }
|
||||
}
|
||||
.torte figcaption { font-weight: bold; margin-bottom: 0.5rem; }
|
||||
.torte-svg { width: 100%; max-width: 280px; display: block; margin: 0 auto; }
|
||||
.torte-svg .seg { stroke: #fff; stroke-width: 0.02; }
|
||||
.seg.s1 { fill: var(--seg-1); } .swatch.s1 { background: var(--seg-1); }
|
||||
.seg.s2 { fill: var(--seg-2); } .swatch.s2 { background: var(--seg-2); }
|
||||
.seg.s3 { fill: var(--seg-3); } .swatch.s3 { background: var(--seg-3); }
|
||||
.seg.s4 { fill: var(--seg-4); } .swatch.s4 { background: var(--seg-4); }
|
||||
.seg.s5 { fill: var(--seg-5); } .swatch.s5 { background: var(--seg-5); }
|
||||
.seg.s6 { fill: var(--seg-6); } .swatch.s6 { background: var(--seg-6); }
|
||||
.seg.s7 { fill: var(--seg-7); } .swatch.s7 { background: var(--seg-7); }
|
||||
.seg.s8 { fill: var(--seg-8); } .swatch.s8 { background: var(--seg-8); }
|
||||
.seg.uebrige { fill: var(--seg-uebrige); } .swatch.uebrige { background: var(--seg-uebrige); }
|
||||
.seg.frei { fill: var(--seg-frei); } .swatch.frei { background: var(--seg-frei); box-shadow: inset 0 0 0 1px #aaa; }
|
||||
.legende { list-style: none; padding: 0; margin: 0.75rem 0 0; font-size: 0.9rem; }
|
||||
.legende li { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
|
||||
.swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; }
|
||||
.legende-wert { margin-left: auto; color: #555; white-space: nowrap; }
|
||||
.hinweis-klein { font-size: 0.85rem; color: #555; }
|
||||
|
||||
.torte-svg .seg.klickbar { cursor: pointer; }
|
||||
.torte-svg .seg.klickbar:hover { opacity: 0.85; }
|
||||
.torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; }
|
||||
.legende li.gewaehlt { font-weight: bold; }
|
||||
.legende-zusatz { font-weight: normal; color: #666; }
|
||||
#aufteilung-kategorie h2 { font-size: 1.1rem; margin-top: 1.5rem; }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* Szenarien-Seite (Ausbaustufe 14): Umschaltlogik der Formulare
|
||||
* „Neue Änderung an Bestehendem" (.aenderung-form, auch in den Edit-Zeilen
|
||||
* der Einträge) und „Neue Zahlung nur in diesem Szenario" (.zahlung-form).
|
||||
* UX-Regel: Felder werden nur per disabled gesperrt, nie entfernt;
|
||||
* gesperrte Felder tragen den Tooltip TIP. Gesperrte Felder sendet htmx
|
||||
* nicht mit.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TIP = 'Für diese Eintragsart nicht relevant';
|
||||
var WERT_LABEL = { percent: 'Prozentsatz (%)', absolute: 'Kürzung (€)', set: 'Neuer Betrag (€)' };
|
||||
|
||||
function sperren(el, gesperrt) {
|
||||
if (!el) { return; }
|
||||
el.disabled = gesperrt;
|
||||
if (gesperrt) { el.title = TIP; } else { el.removeAttribute('title'); }
|
||||
}
|
||||
|
||||
function feld(form, name) { return form.querySelector('[name="' + name + '"]'); }
|
||||
|
||||
function beschriften(form, cls, text) {
|
||||
var el = form.querySelector('.' + cls);
|
||||
if (el) { el.textContent = text; }
|
||||
}
|
||||
|
||||
// Ziel-Typ: passende Auswahl (Kategorie oder Posten) zeigen und freigeben.
|
||||
function zielTyp(select) {
|
||||
var form = select.closest('form');
|
||||
var istKategorie = select.value === 'category';
|
||||
var kat = form.querySelector('.mod-target-category');
|
||||
var rec = form.querySelector('.mod-target-recurring');
|
||||
kat.style.display = istKategorie ? '' : 'none';
|
||||
rec.style.display = istKategorie ? 'none' : '';
|
||||
sperren(kat.querySelector('select'), !istKategorie);
|
||||
sperren(rec.querySelector('select'), istKategorie);
|
||||
}
|
||||
|
||||
function aenderungArt(select) {
|
||||
var form = select.closest('form');
|
||||
var art = select.value;
|
||||
var mitWert = Object.prototype.hasOwnProperty.call(WERT_LABEL, art);
|
||||
var wert = feld(form, 'value');
|
||||
sperren(wert, !mitWert);
|
||||
if (art === 'set') {
|
||||
// Vorzeichenfalle: Ausgaben sind negativ (wie Fixposten-Beträge).
|
||||
wert.placeholder = '-250,00';
|
||||
wert.title = 'Neuer Betrag mit Vorzeichen (Ausgabe negativ)';
|
||||
if (wert.value === '0') { wert.value = ''; }
|
||||
} else {
|
||||
wert.removeAttribute('placeholder');
|
||||
}
|
||||
beschriften(form, 'value-label', mitWert ? WERT_LABEL[art] : 'Wert');
|
||||
sperren(feld(form, 'start_date'), art === 'ende');
|
||||
beschriften(form, 'bis-label', art === 'ende' ? 'Endet am' : 'Gültig bis');
|
||||
var typ = feld(form, 'target_type');
|
||||
var kategorieOption = typ.querySelector('option[value="category"]');
|
||||
// „Neuer Betrag" gibt es nur für Posten.
|
||||
if (art === 'set') { typ.value = 'recurring'; }
|
||||
kategorieOption.disabled = art === 'set';
|
||||
zielTyp(typ);
|
||||
}
|
||||
|
||||
function zahlungArt(select) {
|
||||
var form = select.closest('form');
|
||||
var einmal = select.value === 'einmal';
|
||||
sperren(feld(form, 'due'), !einmal);
|
||||
['rhythm', 'due_day', 'start_date', 'end_date'].forEach(function (name) {
|
||||
sperren(feld(form, name), einmal);
|
||||
});
|
||||
form.setAttribute('hx-post', form.getAttribute(einmal ? 'data-planned-url' : 'data-recurring-url'));
|
||||
if (window.htmx) { window.htmx.process(form); }
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
root.querySelectorAll('.aenderung-form select[name="kind"]').forEach(aenderungArt);
|
||||
root.querySelectorAll('.zahlung-form select.zahlung-art').forEach(zahlungArt);
|
||||
}
|
||||
|
||||
window.FBSzenario = { zielTyp: zielTyp, aenderungArt: aenderungArt, zahlungArt: zahlungArt, init: init };
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
{% from "_torte_macros.html" import torte %}
|
||||
{% if detail_hinweis %}
|
||||
<div class="warning">{{ detail_hinweis }}</div>
|
||||
{% elif detail %}
|
||||
<h2>{{ detail.titel }}: {{ detail.summe|eur }} € / Monat</h2>
|
||||
{% if detail.enthaelt %}<p class="hinweis-klein">Enthält: {{ detail.enthaelt|join(', ') }}</p>{% endif %}
|
||||
{{ torte(detail.segmente, 'Posten: ' ~ detail.titel) }}
|
||||
{% endif %}
|
||||
{% if oob %}{% include "_aufteilung_torte.html" %}{% endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% from "_torte_macros.html" import torte %}
|
||||
<div id="aufteilung-torte"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||
{{ torte(haupt, haupt_titel) }}
|
||||
</div>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts
|
||||
{s: engine.pie.Slice, q: Query-String für den Drill-down oder None,
|
||||
gewaehlt: bool, zusatz: Zusatztext oder None}.
|
||||
Klickbar sind SVG-Segmente (hx-get direkt am <path>, kein SVG-<a>, weil
|
||||
htmx nur HTML-Links am Navigieren hindert) und die Legenden-Links (auch
|
||||
ohne JS und per Tastatur nutzbar). #}
|
||||
{% macro seg_attrs(seg) -%}
|
||||
class="seg {{ seg.s.color }}{% if seg.q %} klickbar{% endif %}{% if seg.gewaehlt %} gewaehlt{% endif %}"
|
||||
{%- if seg.q %} hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}"{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro torte(segmente, titel) %}
|
||||
<figure class="torte">
|
||||
<figcaption>{{ titel }}</figcaption>
|
||||
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
|
||||
{# Gewähltes Segment zuletzt zeichnen, damit sein Umriss nicht von
|
||||
Nachbarsegmenten überdeckt wird (A14). #}
|
||||
{% for seg in (segmente|rejectattr('gewaehlt')|list) + (segmente|selectattr('gewaehlt')|list) %}{% set s = seg.s %}
|
||||
{% if s.path %}<path d="{{ s.path }}" {{ seg_attrs(seg) }}><title>{{ s.label }}{% if s.merged %} ({{ s.members|join(', ') }}){% endif %}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path>
|
||||
{% else %}<circle r="1" {{ seg_attrs(seg) }}><title>{{ s.label }}{% if s.merged %} ({{ s.members|join(', ') }}){% endif %}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
<ul class="legende">
|
||||
{% for seg in segmente %}{% set s = seg.s %}
|
||||
<li{% if seg.gewaehlt %} class="gewaehlt"{% endif %}><span class="swatch {{ s.color }}"></span>
|
||||
{%- if seg.q %}<a href="/aufteilung?{{ seg.q }}" hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}">{{ s.label }}</a>
|
||||
{%- else %}<span>{{ s.label }}</span>{% endif %}
|
||||
{%- if s.merged %} <span class="legende-zusatz">({{ s.members|join(', ') }})</span>{% endif %}
|
||||
<span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</figure>
|
||||
{% endmacro %}
|
||||
@@ -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()}">
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Aufteilung – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<div class="aufteilung">
|
||||
<h1>Aufteilung</h1>
|
||||
|
||||
<form method="get" action="/aufteilung" class="filter-form">
|
||||
<input type="hidden" name="gesendet" value="1">
|
||||
<label>Monat <input type="month" name="monat" value="{{ monat_wert }}" required></label>
|
||||
<label>Szenario
|
||||
<select name="szenario">
|
||||
<option value="">{{ plan_name }}</option>
|
||||
{% for sc in scenarios %}<option value="{{ sc.id }}"{% if sc.id == szenario_id %} selected{% endif %}>{{ sc.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label><span><input type="checkbox" name="fixposten" value="1"{% if filters.recurring %} checked{% endif %}> Fixposten</span></label>
|
||||
<label><span><input type="checkbox" name="einmal" value="1"{% if filters.planned %} checked{% endif %}> Einmalposten</span></label>
|
||||
<label><span><input type="checkbox" name="kredite" value="1"{% if filters.loans %} checked{% endif %}> Kredite</span></label>
|
||||
<button type="submit">Anzeigen</button>
|
||||
</form>
|
||||
|
||||
{% if monat_anzeige %}<p class="hinweis-klein">{{ monat_anzeige }} · {{ szenario_name }}</p>{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="warning" role="alert">{{ error }}</div>
|
||||
{% elif b %}
|
||||
<div class="kennzahlen">
|
||||
<div><span>Einkünfte</span><strong>{{ s.income|eur }} €</strong></div>
|
||||
<div><span>Kosten</span><strong>{{ s.costs|eur }} €</strong></div>
|
||||
<div{% if s.deficit %} class="neg"{% endif %}><span>{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</span><strong>{{ s.free|eur }} €</strong></div>
|
||||
</div>
|
||||
{% for k in b.conflicts %}
|
||||
<div class="warning">⚠ Fixposten „{{ k.posten_name }}“ zählt evtl. doppelt mit Kredit „{{ k.kredit_name }}“.</div>
|
||||
{% endfor %}
|
||||
{% if not b.rows %}
|
||||
<p>Für diese Auswahl gibt es keine Posten.</p>
|
||||
{% else %}
|
||||
{% if s.deficit %}
|
||||
<div class="warning">Kosten übersteigen die Einkünfte um {{ (s.costs - s.income)|eur }} € — das Diagramm zeigt die Aufteilung der Gesamtkosten.</div>
|
||||
{% endif %}
|
||||
<div class="tabelle-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Posten</th><th>Kategorie</th><th>Art</th><th>Monatsbetrag</th><th>% der Einkünfte</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for z in zeilen %}
|
||||
<tr>
|
||||
<td data-sort="{{ z.r.name }}">{{ z.r.name }}</td>
|
||||
<td>{{ z.r.category }}</td>
|
||||
<td>{{ z.art }}</td>
|
||||
<td class="amount{% if z.r.amount < 0 %} neg{% endif %}" data-sort="{{ z.r.amount }}">{{ z.r.amount|eur }} €</td>
|
||||
<td class="amount" data-sort="{{ z.anteil if z.anteil is not none else '' }}">{% if not z.einnahme %}{{ z.anteil|pct }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr{% if s.deficit %} class="neg"{% endif %}>
|
||||
<th colspan="3">{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</th>
|
||||
<td class="amount">{{ s.free|eur }} €</td>
|
||||
<td class="amount">{{ frei_anteil|pct }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
|
||||
<div>
|
||||
{% include "_aufteilung_torte.html" %}
|
||||
<p class="hinweis-klein">Für Kategorie-Aufteilung auf Segment klicken</p>
|
||||
<div id="aufteilung-kategorie">{% include "_aufteilung_kategorie.html" %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,8 +4,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ app_version }}">
|
||||
<script src="/static/htmx.min.js?v={{ app_version }}"></script>
|
||||
<script src="/static/sort.js?v={{ app_version }}"></script>
|
||||
<script src="/static/doppelzaehlung.js?v={{ app_version }}"></script>
|
||||
<script src="/static/szenarien.js?v={{ app_version }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
@@ -15,6 +18,7 @@
|
||||
<a href="/salden">Salden</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="/szenarien">Szenarien</a>
|
||||
<a href="/aufteilung">Aufteilung</a>
|
||||
<a href="/admin">Admin</a>
|
||||
<a href="/hilfe">Hilfe</a>
|
||||
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
|
||||
@@ -25,8 +29,18 @@
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<dialog id="doppelzaehlung-dialog">
|
||||
<h2>Mögliche Doppelzählung</h2>
|
||||
<p class="dz-meldung"></p>
|
||||
<ul class="dz-konflikte"></ul>
|
||||
<p>Trotzdem speichern?</p>
|
||||
<button type="button" id="dz-abbrechen" autofocus>Abbrechen</button>
|
||||
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
|
||||
</dialog>
|
||||
<script>
|
||||
document.body.addEventListener('htmx:responseError', function (e) {
|
||||
// Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert.
|
||||
if (window.FBDoppel && window.FBDoppel.handle(e)) { return; }
|
||||
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||
});
|
||||
|
||||
@@ -71,6 +85,10 @@
|
||||
out[field.name] = field.checked;
|
||||
});
|
||||
}
|
||||
// Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"):
|
||||
if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') {
|
||||
out.doppelzaehlung_bestaetigt = true;
|
||||
}
|
||||
xhr.overrideMimeType('text/json');
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
@@ -81,6 +99,9 @@
|
||||
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
||||
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
||||
}
|
||||
|
||||
if (window.FBDoppel) { window.FBDoppel.init(document); }
|
||||
if (window.FBSzenario) { window.FBSzenario.init(document); }
|
||||
</script>
|
||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||
</body>
|
||||
|
||||
@@ -67,25 +67,94 @@
|
||||
<li>
|
||||
<strong>Szenarien:</strong> eine "Was-wäre-wenn"-Rechnung. Basis = alle
|
||||
wiederkehrenden Posten + Einmalposten. Varianten entstehen durch Zuordnen
|
||||
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
||||
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
||||
von Krediten und Einträgen (Kategorie oder Posten prozentual oder um einen Betrag
|
||||
kürzen, auf einen neuen Betrag setzen oder ganz streichen – auch nur für
|
||||
einen Zeitraum). "Durchrechnen" liefert: tiefster Kontostand
|
||||
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
||||
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||
ä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.
|
||||
Szenarios. Das Formular „Neue Änderung an Bestehendem“ ändert
|
||||
vorhandene Posten oder Kategorien: „Prozent“ und „Kürzung (€)“ kürzen
|
||||
Beträge, „Neuer Betrag“ ersetzt den Betrag eines Postens (mit
|
||||
Vorzeichen, z. B. -250,00), „Entfällt“ blendet ihn aus, „Ende“ lässt
|
||||
einen wiederkehrenden Posten zu einem Datum enden. Mit „Gültig ab“ und
|
||||
„Gültig bis“ wirken Prozent, Kürzung, Neuer Betrag und Entfällt nur in
|
||||
diesem Zeitraum (Grenzen einschließlich, leer = unbegrenzt). Beispiel:
|
||||
ein Posten wird mit „Prozent 50 ab 16.10.“ halbiert – ist er am 14.
|
||||
fällig, zählt er im Oktober noch voll, ab November zur Hälfte. Das
|
||||
Formular „Neue Zahlung nur in diesem Szenario“ ergänzt eine
|
||||
Einmalzahlung oder eine wiederkehrende Zahlung (Rhythmus,
|
||||
Fälligkeitstag, Start/Ende, optional mit Kategorie), die nur in diesem
|
||||
Szenario zählt – auch wenn „Wiederkehrende Posten einschließen“ bzw.
|
||||
„Einmalposten einschließen“ aus ist. Jeder Eintrag ist über
|
||||
„Bearbeiten“ direkt ä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.
|
||||
„System-Kreditraten“, ohne Szenario-Modifikatoren) – einmal mit demselben
|
||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||
Übersicht).
|
||||
Übersicht.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Aufteilung:</strong> zeigt für einen Monat, welche monatlichen
|
||||
Kosten je Kategorie anfallen und wie viel der Einkünfte frei verfügbar
|
||||
bleibt. Grundlage ist immer die Planung (Fixposten, Einmalposten,
|
||||
Kredite), nie die tatsächlichen Buchungen: „System-Plan“ rechnet mit
|
||||
allen Posten und Krediten ohne Szenario-Änderungen und ist für jeden
|
||||
Monat wählbar; ein Szenario (mit seinen Änderungen und Krediten) erst ab
|
||||
dem laufenden Monat. Fixposten werden auf den Monat umgerechnet
|
||||
(vierteljährlich ÷ 3, jährlich ÷ 12), Einmalposten und Kreditraten zählen
|
||||
voll im Monat ihrer Fälligkeit, eine Kredit-Auszahlung zählt nicht als
|
||||
Einkunft. Die Häkchen „Fixposten“, „Einmalposten“, „Kredite“ gelten für
|
||||
Einnahmen und Ausgaben. Das Tortendiagramm zeigt die Einkünfte (Kosten je
|
||||
Kategorie plus „Frei verfügbar“); übersteigen die Kosten die Einkünfte,
|
||||
zeigt es die Gesamtkosten. Kleine Kategorien (unter 3 %, bzw. ab der
|
||||
neunten) sind zu „Übrige“ zusammengefasst – höchstens 8 benannte
|
||||
Kategorien plus „Übrige“ und „Frei verfügbar“. Ein Klick auf ein Segment oder
|
||||
einen Legendeneintrag zeigt darunter die Posten dieser Kategorie.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<strong>Reservierte Namen:</strong> Kategorien und Szenarien dürfen nicht
|
||||
mit „System-“ beginnen – dieses Präfix ist für vom Tool erzeugte Einträge
|
||||
reserviert (z. B. die Ist-Serie „System-Ist“ und die Kreditraten-Serie
|
||||
„System-Kreditraten“ in Grafana). Ein Versuch, einen eigenen Namen mit
|
||||
diesem Präfix anzulegen, wird abgelehnt.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Doppelzählungs-Warnung:</strong> beim Anlegen oder Ändern eines
|
||||
wiederkehrenden Postens oder eines Kredits prüft das Tool, ob ein
|
||||
monatlicher Ausgabe-Posten ungefähr einer regulären Kreditrate entspricht
|
||||
(Betrag innerhalb ±3 %, mindestens 1 €, mit überlappendem Zeitraum). Trifft
|
||||
das zu, erscheint ein Warnfenster – erst ein Klick auf „Trotzdem
|
||||
speichern“ übernimmt den Eintrag. Solange der Konflikt bestehen bleibt,
|
||||
zeigt die Planungs-Seite dauerhaft einen ⚠-Hinweis bei den betroffenen
|
||||
Posten und Krediten an. Am saubersten lässt sich das vermeiden, wenn
|
||||
Kreditraten ausschließlich über „Kredite“ gepflegt werden statt zusätzlich
|
||||
als wiederkehrender Posten.
|
||||
</p>
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden – Finanzberatung</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ app_version }}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Finanzberatung</h1>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<tbody>
|
||||
{% 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 data-sort="{{ r.name }}">{{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“</span>{% endfor %}</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()}">
|
||||
@@ -197,10 +197,10 @@
|
||||
<tbody>
|
||||
{% for l in loans %}
|
||||
<tr id="loan-row-{{ l.id }}">
|
||||
<td>{{ l.name }}</td>
|
||||
<td>{{ l.principal|eur }} €</td>
|
||||
<td data-sort="{{ l.name }}">{{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Posten „{{ pn }}“</span>{% endfor %}</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,14 +214,14 @@
|
||||
</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()}">
|
||||
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
|
||||
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
|
||||
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" max="1200" value="{{ l.term_months }}" required></label>
|
||||
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
|
||||
<label>Tilgungsart
|
||||
<select name="repayment_type" data-type="str">
|
||||
@@ -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>
|
||||
@@ -254,7 +254,7 @@
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label>
|
||||
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" max="1200" required></label>
|
||||
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
|
||||
<label>Tilgungsart
|
||||
<select name="repayment_type" data-type="str">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Szenarien – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
{% macro zeitraum(von, bis) -%}
|
||||
{%- if von and bis -%}{{ von.strftime('%d.%m.%Y') }} – {{ bis.strftime('%d.%m.%Y') }}
|
||||
{%- elif von -%}ab {{ von.strftime('%d.%m.%Y') }}
|
||||
{%- elif bis -%}bis {{ bis.strftime('%d.%m.%Y') }}
|
||||
{%- else -%}–{%- endif -%}
|
||||
{%- endmacro %}
|
||||
{% set TIP = 'Für diese Eintragsart nicht relevant' %}
|
||||
|
||||
<h1>Szenarien</h1>
|
||||
|
||||
<section class="planning-section">
|
||||
@@ -13,6 +21,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"
|
||||
@@ -42,7 +53,7 @@
|
||||
<h3>Einträge</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
|
||||
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Zeitraum</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in row.modifiers %}
|
||||
@@ -52,8 +63,8 @@
|
||||
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||||
</td>
|
||||
<td>{{ m.kind|de_label }}</td>
|
||||
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||||
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td>
|
||||
<td>{% if m.kind in ('percent', 'absolute', 'set') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||||
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}{{ zeitraum(m.start_date, m.end_date) }}{% endif %}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
@@ -62,12 +73,17 @@
|
||||
</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"
|
||||
<form class="aenderung-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()}">
|
||||
<label>Art
|
||||
<select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
|
||||
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
|
||||
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
|
||||
</select>
|
||||
@@ -86,22 +102,59 @@
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label>Art
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
|
||||
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
|
||||
<label><span class="value-label">Wert</span> <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute', 'set') %}disabled title="{{ TIP }}"{% endif %}></label>
|
||||
<label>Gültig ab <input type="date" name="start_date" value="{{ m.start_date.isoformat() if m.start_date else '' }}" {% if m.kind == 'ende' %}disabled title="{{ TIP }}"{% endif %}></label>
|
||||
<label><span class="bis-label">{{ 'Endet am' if m.kind == 'ende' else 'Gültig bis' }}</span> <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}"></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for ri in row.recurring_items %}
|
||||
<tr id="sri-row-{{ ri.id }}">
|
||||
<td>{{ ri.name }}{% if ri.category_id %} <span class="muted">({{ category_names.get(ri.category_id, '') }})</span>{% endif %}</td>
|
||||
<td>Wiederkehrend ({{ ri.rhythm|de_label }}, am {{ ri.due_day }}.)</td>
|
||||
<td class="{{ 'neg' if ri.amount < 0 else '' }}">{{ ri.amount|eur }} €</td>
|
||||
<td>{{ zeitraum(ri.start_date, ri.end_date) }}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ ri.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="sri-edit-{{ ri.id }}" hidden data-sort-follow>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ ri.name }}" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ ri.amount|eur }}" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">(keine)</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == ri.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Rhythmus
|
||||
<select name="rhythm" data-type="str">
|
||||
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == ri.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ ri.due_day }}" required></label>
|
||||
<label>Start <input type="date" name="start_date" value="{{ ri.start_date.isoformat() if ri.start_date else '' }}"></label>
|
||||
<label>Ende <input type="date" name="end_date" value="{{ ri.end_date.isoformat() if ri.end_date else '' }}"></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for sp in row.planned_items %}
|
||||
<tr id="spi-row-{{ sp.id }}">
|
||||
<td>{{ sp.name }}</td>
|
||||
<td>{{ sp.name }}{% if sp.category_id %} <span class="muted">({{ category_names.get(sp.category_id, '') }})</span>{% endif %}</td>
|
||||
<td>Einmalzahlung</td>
|
||||
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||||
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||||
@@ -114,12 +167,18 @@
|
||||
</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()}">
|
||||
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">(keine)</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == sp.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
|
||||
@@ -127,28 +186,25 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not row.modifiers and not row.planned_items %}
|
||||
{% if not row.modifiers and not row.planned_items and not row.recurring_items %}
|
||||
<tr><td colspan="5">Keine Einträge.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Neuer Eintrag</h4>
|
||||
<form class="entry-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
|
||||
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||
<h4>Neue Änderung an Bestehendem</h4>
|
||||
<form class="entry-form aenderung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<div class="entry-art">
|
||||
<label>Eintragsart
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
<label>Art
|
||||
<select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||
<option value="einmal">Einmalzahlung</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||||
</div>
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
|
||||
<option value="category">Kategorie</option>
|
||||
<option value="recurring">Wiederkehrender Posten</option>
|
||||
</select>
|
||||
@@ -162,16 +218,48 @@
|
||||
</span>
|
||||
<span class="mod-target-recurring" style="display:none">
|
||||
<label>Posten
|
||||
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant">
|
||||
<select name="target_id" data-type="int" disabled title="{{ TIP }}">
|
||||
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
|
||||
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Gültig ab <input type="date" name="start_date"></label>
|
||||
<label><span class="bis-label">Gültig bis</span> <input type="date" name="end_date"></label>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
<h4>Neue Zahlung nur in diesem Szenario</h4>
|
||||
<form class="entry-form zahlung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
|
||||
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||
data-recurring-url="/api/scenarios/{{ sc.id }}/recurring"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<div class="entry-art">
|
||||
<label>Art
|
||||
<select class="zahlung-art" onchange="FBSzenario.zahlungArt(this)">
|
||||
<option value="einmal">Einmalzahlung</option>
|
||||
<option value="wiederkehrend">Wiederkehrend</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="muted">— zählt nur in diesem Szenario, auch ohne „Wiederkehrende Posten/Einmalposten einschließen“.</span>
|
||||
</div>
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-250,00" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">(keine)</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fällig am <input type="date" name="due" required></label>
|
||||
<label>Rhythmus
|
||||
<select name="rhythm" data-type="str" disabled title="{{ TIP }}">
|
||||
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required disabled title="{{ TIP }}"></label>
|
||||
<label>Start <input type="date" name="start_date" disabled title="{{ TIP }}"></label>
|
||||
<label>Ende <input type="date" name="end_date" disabled title="{{ TIP }}"></label>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
@@ -212,75 +300,6 @@
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function onModTargetTypeChange(select) {
|
||||
var form = select.closest('form');
|
||||
var catSpan = form.querySelector('.mod-target-category');
|
||||
var recSpan = form.querySelector('.mod-target-recurring');
|
||||
var catSelect = catSpan.querySelector('select');
|
||||
var recSelect = recSpan.querySelector('select');
|
||||
var isCategory = select.value === 'category';
|
||||
catSpan.style.display = isCategory ? '' : 'none';
|
||||
recSpan.style.display = isCategory ? 'none' : '';
|
||||
catSelect.disabled = !isCategory;
|
||||
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { catSelect.removeAttribute('title'); }
|
||||
recSelect.disabled = isCategory;
|
||||
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { recSelect.removeAttribute('title'); }
|
||||
}
|
||||
|
||||
function onEntryArtChange(select) {
|
||||
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
|
||||
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
|
||||
// kein name/amount/due), wird es uebersprungen.
|
||||
var form = select.closest('form');
|
||||
var art = select.value;
|
||||
var isMod = art !== 'einmal';
|
||||
function setDisabled(name, disabled) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
if (el) {
|
||||
el.disabled = disabled;
|
||||
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { el.removeAttribute('title'); }
|
||||
}
|
||||
}
|
||||
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||
var valueLabel = form.querySelector('.value-label');
|
||||
if (valueLabel) {
|
||||
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
|
||||
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
|
||||
}
|
||||
setDisabled('end_date', art !== 'ende');
|
||||
setDisabled('name', isMod);
|
||||
setDisabled('amount', isMod);
|
||||
setDisabled('due', isMod);
|
||||
var typeSelect = form.querySelector('[name="target_type"]');
|
||||
if (typeSelect) {
|
||||
typeSelect.disabled = !isMod;
|
||||
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { typeSelect.removeAttribute('title'); }
|
||||
var isCategory = typeSelect.value === 'category';
|
||||
var catSelect = form.querySelector('.mod-target-category select');
|
||||
var recSelect = form.querySelector('.mod-target-recurring select');
|
||||
if (catSelect) {
|
||||
catSelect.disabled = !isMod || !isCategory;
|
||||
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { catSelect.removeAttribute('title'); }
|
||||
}
|
||||
if (recSelect) {
|
||||
recSelect.disabled = !isMod || isCategory;
|
||||
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { recSelect.removeAttribute('title'); }
|
||||
}
|
||||
}
|
||||
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
||||
if (form.hasAttribute('data-planned-url')) {
|
||||
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
|
||||
: form.getAttribute('data-planned-url'));
|
||||
htmx.process(form);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
||||
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
||||
.then(function (resp) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -103,12 +103,14 @@
|
||||
"defaults": {
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
"overrides": [{ "matcher": { "id": "byType", "options": "time" }, "properties": [{ "id": "unit", "value": "time:MM.YYYY" }] }]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"stacking": "normal",
|
||||
"xTickLabelRotation": 0
|
||||
"xTickLabelRotation": 0,
|
||||
"showValue": "never",
|
||||
"tooltip": { "mode": "multi", "sort": "asc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
@@ -168,6 +170,30 @@
|
||||
"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": [{ "matcher": { "id": "byType", "options": "time" }, "properties": [{ "id": "unit", "value": "time:MM.YYYY" }] }] },
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"stacking": "normal",
|
||||
"xTickLabelRotation": 0,
|
||||
"showValue": "never",
|
||||
"tooltip": { "mode": "multi", "sort": "asc" }
|
||||
},
|
||||
"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,75 @@
|
||||
'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/doppelzaehlung.js'), 'utf8');
|
||||
|
||||
const HTML = `
|
||||
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
|
||||
<dialog id="doppelzaehlung-dialog">
|
||||
<p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
|
||||
<button type="button" id="dz-abbrechen">Abbrechen</button>
|
||||
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
|
||||
</dialog>`;
|
||||
const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung',
|
||||
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } });
|
||||
|
||||
function load() {
|
||||
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
|
||||
const w = dom.window;
|
||||
const calls = [];
|
||||
w.htmx = { trigger: (el, name) => calls.push([el.id, name]) };
|
||||
const dlg = w.document.getElementById('doppelzaehlung-dialog');
|
||||
dlg.showModal = function () { this.setAttribute('open', ''); };
|
||||
dlg.close = function () { this.removeAttribute('open'); };
|
||||
w.eval(SRC);
|
||||
w.FBDoppel.init(w.document);
|
||||
const form = w.document.getElementById('f');
|
||||
const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } };
|
||||
return { w, dlg, form, evt, calls };
|
||||
}
|
||||
|
||||
test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => {
|
||||
const { w, dlg, evt } = load();
|
||||
assert.equal(w.FBDoppel.handle(evt), true);
|
||||
assert.ok(dlg.hasAttribute('open'));
|
||||
assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung');
|
||||
assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1);
|
||||
});
|
||||
|
||||
test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => {
|
||||
const { w, form } = load();
|
||||
assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false);
|
||||
});
|
||||
|
||||
test('Abbrechen speichert nicht', { skip: !JSDOM }, () => {
|
||||
const { w, dlg, form, evt, calls } = load();
|
||||
w.FBDoppel.handle(evt);
|
||||
w.document.getElementById('dz-abbrechen').click();
|
||||
assert.ok(!dlg.hasAttribute('open'));
|
||||
assert.equal(calls.length, 0);
|
||||
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
|
||||
assert.equal(form.querySelector('input').value, 'Rate Auto');
|
||||
});
|
||||
|
||||
test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => {
|
||||
const { w, dlg, form, evt, calls } = load();
|
||||
w.FBDoppel.handle(evt);
|
||||
w.document.getElementById('dz-bestaetigen').click();
|
||||
assert.ok(!dlg.hasAttribute('open'));
|
||||
assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1');
|
||||
assert.deepEqual(calls, [['f', 'submit']]);
|
||||
});
|
||||
|
||||
test('Flag nach Request entfernt', { skip: !JSDOM }, () => {
|
||||
const { w, form, evt } = load();
|
||||
w.FBDoppel.handle(evt);
|
||||
w.document.getElementById('dz-bestaetigen').click();
|
||||
const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } });
|
||||
form.dispatchEvent(e);
|
||||
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const D = require('../../app/static/doppelzaehlung.js');
|
||||
|
||||
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
|
||||
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
|
||||
|
||||
test('parseDetail erkennt nur 409 mit Code', () => {
|
||||
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
|
||||
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
|
||||
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
|
||||
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
|
||||
assert.equal(D.parseDetail(null), null);
|
||||
});
|
||||
|
||||
test('konfliktText deutsch formatiert', () => {
|
||||
assert.equal(D.konfliktText(body.detail.konflikte[0]),
|
||||
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
|
||||
});
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
'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/szenarien.js'), 'utf8');
|
||||
const TIP = 'Für diese Eintragsart nicht relevant';
|
||||
|
||||
const HTML = `
|
||||
<form id="a" class="entry-form aenderung-form" hx-post="/api/scenarios/1/modifiers">
|
||||
<select name="kind"><option value="percent">Prozent</option><option value="absolute">Kürzung (€)</option>
|
||||
<option value="set">Neuer Betrag</option><option value="remove">Entfällt</option><option value="ende">Ende</option></select>
|
||||
<select name="target_type"><option value="category">Kategorie</option><option value="recurring">Wiederkehrender Posten</option></select>
|
||||
<span class="mod-target-category"><select name="target_id"><option value="1">K</option></select></span>
|
||||
<span class="mod-target-recurring" style="display:none"><select name="target_id" disabled><option value="2">P</option></select></span>
|
||||
<label><span class="value-label">Prozentsatz (%)</span><input name="value"></label>
|
||||
<input type="date" name="start_date">
|
||||
<label><span class="bis-label">Gültig bis</span><input type="date" name="end_date"></label>
|
||||
</form>
|
||||
<form id="z" class="entry-form zahlung-form" hx-post="/api/scenarios/1/planned"
|
||||
data-planned-url="/api/scenarios/1/planned" data-recurring-url="/api/scenarios/1/recurring">
|
||||
<select class="zahlung-art"><option value="einmal">Einmalzahlung</option><option value="wiederkehrend">Wiederkehrend</option></select>
|
||||
<input name="name"><input name="amount"><select name="category_id"><option value="">(keine)</option></select>
|
||||
<input type="date" name="due">
|
||||
<select name="rhythm"><option value="monthly">monatlich</option></select>
|
||||
<input name="due_day"><input type="date" name="start_date"><input type="date" name="end_date">
|
||||
</form>`;
|
||||
|
||||
function load() {
|
||||
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
|
||||
const w = dom.window;
|
||||
const processed = [];
|
||||
w.htmx = { process: (el) => processed.push(el.id) };
|
||||
w.eval(SRC);
|
||||
w.FBSzenario.init(w.document);
|
||||
const d = w.document;
|
||||
return { w, a: d.getElementById('a'), z: d.getElementById('z'), processed };
|
||||
}
|
||||
|
||||
function f(form, name) { return form.querySelector(`[name="${name}"]`); }
|
||||
function art(w, form, wert) {
|
||||
const sel = form.querySelector('[name="kind"]');
|
||||
sel.value = wert;
|
||||
w.FBSzenario.aenderungArt(sel);
|
||||
}
|
||||
|
||||
test('Prozent: Wert und Zeitraum aktiv', { skip: !JSDOM }, () => {
|
||||
const { a } = load();
|
||||
assert.equal(f(a, 'value').disabled, false);
|
||||
assert.equal(a.querySelector('.value-label').textContent, 'Prozentsatz (%)');
|
||||
assert.equal(f(a, 'start_date').disabled, false);
|
||||
assert.equal(f(a, 'end_date').disabled, false);
|
||||
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
|
||||
});
|
||||
|
||||
test('Kürzung: Label Kürzung (€)', { skip: !JSDOM }, () => {
|
||||
const { w, a } = load();
|
||||
art(w, a, 'absolute');
|
||||
assert.equal(a.querySelector('.value-label').textContent, 'Kürzung (€)');
|
||||
});
|
||||
|
||||
test('Neuer Betrag: Ziel-Typ fest auf Posten, Kategorie gesperrt', { skip: !JSDOM }, () => {
|
||||
const { w, a } = load();
|
||||
art(w, a, 'set');
|
||||
const typ = f(a, 'target_type');
|
||||
assert.equal(typ.value, 'recurring');
|
||||
assert.equal(typ.querySelector('option[value="category"]').disabled, true);
|
||||
assert.equal(a.querySelector('.mod-target-recurring select').disabled, false);
|
||||
assert.equal(a.querySelector('.mod-target-category select').disabled, true);
|
||||
assert.equal(a.querySelector('.value-label').textContent, 'Neuer Betrag (€)');
|
||||
art(w, a, 'percent');
|
||||
assert.equal(typ.querySelector('option[value="category"]').disabled, false);
|
||||
});
|
||||
|
||||
test('Neuer Betrag: Vorzeichen-Hinweis als Platzhalter und Tooltip', { skip: !JSDOM }, () => {
|
||||
const { w, a } = load();
|
||||
art(w, a, 'set');
|
||||
assert.equal(f(a, 'value').placeholder, '-250,00');
|
||||
assert.ok(f(a, 'value').title.includes('Vorzeichen'));
|
||||
art(w, a, 'percent');
|
||||
assert.equal(f(a, 'value').placeholder, '');
|
||||
assert.equal(f(a, 'value').hasAttribute('title'), false);
|
||||
});
|
||||
|
||||
test('Ende: Gültig ab und Wert gesperrt, Label Endet am', { skip: !JSDOM }, () => {
|
||||
const { w, a } = load();
|
||||
art(w, a, 'ende');
|
||||
assert.equal(f(a, 'start_date').disabled, true);
|
||||
assert.equal(f(a, 'start_date').title, TIP);
|
||||
assert.equal(f(a, 'value').disabled, true);
|
||||
assert.equal(f(a, 'end_date').disabled, false);
|
||||
assert.equal(a.querySelector('.bis-label').textContent, 'Endet am');
|
||||
});
|
||||
|
||||
test('Entfällt: Wert gesperrt, Zeitraum aktiv', { skip: !JSDOM }, () => {
|
||||
const { w, a } = load();
|
||||
art(w, a, 'remove');
|
||||
assert.equal(f(a, 'value').disabled, true);
|
||||
assert.equal(a.querySelector('.value-label').textContent, 'Wert');
|
||||
assert.equal(f(a, 'start_date').disabled, false);
|
||||
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
|
||||
});
|
||||
|
||||
test('Zahlung: Einmal → Wiederkehrend → Einmal', { skip: !JSDOM }, () => {
|
||||
const { w, z, processed } = load();
|
||||
const sel = z.querySelector('.zahlung-art');
|
||||
const wiederkehrend = ['rhythm', 'due_day', 'start_date', 'end_date'];
|
||||
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
|
||||
assert.equal(f(z, 'due').disabled, false);
|
||||
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
|
||||
sel.value = 'wiederkehrend';
|
||||
w.FBSzenario.zahlungArt(sel);
|
||||
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/recurring');
|
||||
assert.equal(f(z, 'due').disabled, true);
|
||||
assert.equal(f(z, 'due').title, TIP);
|
||||
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, false, n));
|
||||
sel.value = 'einmal';
|
||||
w.FBSzenario.zahlungArt(sel);
|
||||
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
|
||||
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
|
||||
['name', 'amount', 'category_id'].forEach((n) => assert.equal(f(z, n).disabled, false, n));
|
||||
assert.ok(processed.includes('z'));
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.engine.loans import add_months
|
||||
from app.models.tables import (Category, Loan, RecurringItem, Scenario,
|
||||
ScenarioModifier)
|
||||
|
||||
D = Decimal
|
||||
MONAT = "2030-03"
|
||||
|
||||
|
||||
def _login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
|
||||
|
||||
def _daten(db, gehalt="3000.00"):
|
||||
wohnen, eink, vers = Category(name="Wohnen"), Category(name="Einkommen"), Category(name="Versicherung")
|
||||
db.add_all([wohnen, eink, vers])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
RecurringItem(name="Gehalt", amount=D(gehalt), rhythm="monthly", due_day=1, category_id=eink.id),
|
||||
RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=wohnen.id),
|
||||
RecurringItem(name="Police", amount=D("-1200.00"), rhythm="yearly", due_day=1, category_id=vers.id),
|
||||
])
|
||||
db.commit()
|
||||
return wohnen
|
||||
|
||||
|
||||
def _get(client, **params):
|
||||
return client.get("/aufteilung", params=params)
|
||||
|
||||
|
||||
def test_menuepunkt(client):
|
||||
_login(client)
|
||||
assert '<a href="/aufteilung">Aufteilung</a>' in client.get("/").text
|
||||
|
||||
|
||||
def test_standardauswahl(client):
|
||||
_login(client)
|
||||
r = _get(client)
|
||||
assert r.status_code == 200
|
||||
assert f'value="{date.today().strftime("%Y-%m")}"' in r.text
|
||||
assert re.search(r'<option value="">System-Plan</option>', r.text)
|
||||
assert len(re.findall(r'type="checkbox"[^>]*checked', r.text)) == 3
|
||||
|
||||
|
||||
def test_tabelle_kennzahlen_torte(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert "03.2030" in t
|
||||
assert "Fixposten (jährlich)" in t
|
||||
assert "-100,00 €" in t # Police 1.200 / 12
|
||||
assert "33,3 %" in t # Miete 1.000 von 3.000
|
||||
assert "Gesamte Einkünfte: <strong>3.000,00 €</strong>" in t
|
||||
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
|
||||
assert "Frei verfügbar" in tfoot and "1.900,00 €" in tfoot and "63,3 %" in tfoot
|
||||
assert '<svg class="torte-svg"' in t
|
||||
# Präfix ohne schließendes Anführungszeichen: ab Task 6 folgt „ klickbar".
|
||||
assert 'class="seg s1' in t and 'class="seg frei"' in t
|
||||
assert "Anteile an den Einkünften" in t
|
||||
|
||||
|
||||
def test_einnahmezeile_ohne_prozent(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT).text
|
||||
zeile = re.search(r"<tr>\s*<td[^>]*>Gehalt</td>.*?</tr>", t, re.S).group(0)
|
||||
assert "%" not in zeile
|
||||
|
||||
|
||||
def test_alle_filter_aus_zeigt_keine_posten(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT, gesendet="1").text
|
||||
assert "Für diese Auswahl gibt es keine Posten." in t
|
||||
assert '<form method="get" action="/aufteilung"' in t
|
||||
assert not re.findall(r'type="checkbox"[^>]*checked', t)
|
||||
|
||||
|
||||
def test_unterdeckung(client, db):
|
||||
_login(client)
|
||||
_daten(db, gehalt="500.00")
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert "Kosten übersteigen die Einkünfte um 600,00 €" in t
|
||||
assert "Anteile an den Gesamtkosten" in t
|
||||
assert 'class="seg frei"' not in t
|
||||
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
|
||||
assert "Unterdeckung" in tfoot and "-600,00 €" in tfoot
|
||||
|
||||
|
||||
def test_szenario_mit_modifikator(client, db):
|
||||
_login(client)
|
||||
wohnen = _daten(db)
|
||||
sc = Scenario(name="Sparen", description="")
|
||||
db.add(sc)
|
||||
db.flush()
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=wohnen.id,
|
||||
kind="percent", value=D("50")))
|
||||
db.commit()
|
||||
t = _get(client, monat=MONAT, szenario=str(sc.id)).text
|
||||
assert "-500,00 €" in t and "Sparen" in t
|
||||
assert f'<option value="{sc.id}" selected>Sparen</option>' in t
|
||||
|
||||
|
||||
def test_szenario_vergangenheit_fehler_inline(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
sc = Scenario(name="S", description="")
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
vormonat = add_months(date.today().replace(day=1), -1).strftime("%Y-%m")
|
||||
r = _get(client, monat=vormonat, szenario=str(sc.id))
|
||||
assert r.status_code == 200
|
||||
assert "Szenarien sind erst ab dem laufenden Monat wählbar" in r.text
|
||||
assert '<form method="get" action="/aufteilung"' in r.text
|
||||
assert "<table" not in r.text
|
||||
|
||||
|
||||
def test_system_plan_in_vergangenheit_erlaubt(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
r = _get(client, monat="2020-01")
|
||||
assert r.status_code == 200 and "<table" in r.text
|
||||
|
||||
|
||||
def test_ungueltiger_monat_und_unbekanntes_szenario(client):
|
||||
_login(client)
|
||||
for params, text in (({"monat": "2030-13"}, "Ungültiger Monat"),
|
||||
({"monat": "abc"}, "Ungültiger Monat"),
|
||||
({"monat": "0000-01"}, "Ungültiger Monat"),
|
||||
({"monat": MONAT, "szenario": "²"}, "Unbekanntes Szenario"),
|
||||
({"monat": MONAT, "szenario": "999"}, "Unbekanntes Szenario"),
|
||||
({"monat": MONAT, "szenario": "x"}, "Unbekanntes Szenario"),
|
||||
({"monat": MONAT, "szenario": "9" * 30}, "Unbekanntes Szenario")):
|
||||
r = _get(client, **params)
|
||||
assert r.status_code == 200 and text in r.text
|
||||
assert '<form method="get" action="/aufteilung"' in r.text
|
||||
|
||||
|
||||
def test_doppelzaehlungs_hinweis(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
db.add_all([
|
||||
RecurringItem(name="Autorate", amount=D("-100.00"), rhythm="monthly", due_day=15),
|
||||
Loan(name="Autokredit", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2030, 1, 15), repayment_type="annuity"),
|
||||
])
|
||||
db.commit()
|
||||
assert "⚠ Fixposten „Autorate“ zählt evtl. doppelt mit Kredit „Autokredit“" in _get(client, monat=MONAT).text
|
||||
assert "⚠" not in _get(client, monat=MONAT, gesendet="1", fixposten="1", einmal="1").text
|
||||
|
||||
|
||||
def test_tabelle_scrollbar_torte_darunter(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert re.search(r'<div class="tabelle-scroll">\s*<table', t)
|
||||
assert t.index('id="aufteilung-torte"') > t.index("</table>")
|
||||
|
||||
|
||||
def _drill(client, pfad="/aufteilung/kategorie", **params):
|
||||
return client.get(pfad, params={"monat": MONAT, **params})
|
||||
|
||||
|
||||
def test_hinweistext_und_segment_links(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert "Für Kategorie-Aufteilung auf Segment klicken" in t
|
||||
assert 'hx-get="/aufteilung/kategorie?' in t
|
||||
assert "kategorie=Wohnen" in t
|
||||
assert 'hx-target="#aufteilung-kategorie"' in t
|
||||
assert '<div id="aufteilung-kategorie">' in t
|
||||
|
||||
|
||||
def test_frei_verfuegbar_ohne_link(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert "kategorie=Frei" not in t
|
||||
frei = re.search(r'<path[^>]*class="seg frei[^"]*"[^>]*>', t).group(0)
|
||||
assert "hx-get" not in frei
|
||||
|
||||
|
||||
def test_fragment_zweite_torte_und_oob(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
r = _drill(client, kategorie="Wohnen")
|
||||
assert r.status_code == 200
|
||||
assert "Wohnen: 1.000,00 € / Monat" in r.text
|
||||
assert "Miete" in r.text and "33,3 % der Einkünfte" in r.text
|
||||
assert '<div id="aufteilung-torte" hx-swap-oob="true">' in r.text
|
||||
assert "gewaehlt" in r.text
|
||||
assert "<html" not in r.text
|
||||
|
||||
|
||||
def test_seite_mit_kategorie_zeigt_zweite_torte(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
t = _get(client, monat=MONAT, kategorie="Versicherung").text
|
||||
assert "Versicherung: 100,00 € / Monat" in t
|
||||
assert 'hx-swap-oob' not in t
|
||||
|
||||
|
||||
def test_unbekannte_kategorie_hinweis(client, db):
|
||||
_login(client)
|
||||
_daten(db)
|
||||
for pfad in ("/aufteilung", "/aufteilung/kategorie"):
|
||||
r = _drill(client, pfad, kategorie="Gibtsnicht")
|
||||
assert r.status_code == 200
|
||||
assert "Kategorie „Gibtsnicht“ hat in dieser Auswahl keine Kosten." in r.text
|
||||
|
||||
|
||||
def test_fragment_bei_fehler_nur_hinweis(client):
|
||||
_login(client)
|
||||
r = client.get("/aufteilung/kategorie", params={"monat": "2030-13", "kategorie": "X"})
|
||||
assert r.status_code == 200
|
||||
assert "Ungültiger Monat" in r.text and "hx-swap-oob" not in r.text
|
||||
|
||||
|
||||
def _viele_kategorien(db):
|
||||
eink = Category(name="Einkommen")
|
||||
kleine = [Category(name="Klein A"), Category(name="Klein B")]
|
||||
gross = Category(name="Groß")
|
||||
db.add_all([eink, gross, *kleine])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
|
||||
RecurringItem(name="Posten G", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=gross.id),
|
||||
RecurringItem(name="Posten A", amount=D("-10.00"), rhythm="monthly", due_day=1, category_id=kleine[0].id),
|
||||
RecurringItem(name="Posten B", amount=D("-20.00"), rhythm="monthly", due_day=1, category_id=kleine[1].id),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_sammelgruppe_uebrige(client, db):
|
||||
_login(client)
|
||||
_viele_kategorien(db)
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert "sammel=1" in t
|
||||
r = _drill(client, sammel="1")
|
||||
assert "Übrige: 30,00 € / Monat" in r.text
|
||||
assert "Enthält: Klein B, Klein A" in r.text
|
||||
assert "Posten A" in r.text and "Posten B" in r.text
|
||||
|
||||
|
||||
def test_echte_kategorie_uebrige_nicht_verwechselt(client, db):
|
||||
_login(client)
|
||||
_viele_kategorien(db)
|
||||
echte = Category(name="Übrige")
|
||||
db.add(echte)
|
||||
db.flush()
|
||||
db.add(RecurringItem(name="Echt", amount=D("-500.00"), rhythm="monthly", due_day=1,
|
||||
category_id=echte.id))
|
||||
db.commit()
|
||||
r = _drill(client, kategorie="Übrige")
|
||||
assert "Übrige: 500,00 € / Monat" in r.text
|
||||
assert "Echt" in r.text and "Posten A" not in r.text
|
||||
r = _drill(client, sammel="1")
|
||||
assert "Übrige: 30,00 € / Monat" in r.text and "Echt" not in r.text
|
||||
|
||||
|
||||
def test_drilldown_sonderzeichen(client, db):
|
||||
_login(client)
|
||||
eink, haus = Category(name="Einkommen"), Category(name="Haus & Garten")
|
||||
db.add_all([eink, haus])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
|
||||
RecurringItem(name="Rasen", amount=D("-300.00"), rhythm="monthly", due_day=1, category_id=haus.id),
|
||||
])
|
||||
db.commit()
|
||||
t = _get(client, monat=MONAT).text
|
||||
assert "kategorie=Haus+%26+Garten" in t
|
||||
r = _drill(client, kategorie="Haus & Garten")
|
||||
assert "Haus & Garten: 300,00 € / Monat" in r.text and "Rasen" in r.text
|
||||
|
||||
|
||||
def test_monat_vor_jahr_1000_vierstellig(client, db):
|
||||
_login(client)
|
||||
eink, wohnen = Category(name="Einkommen"), Category(name="Wohnen")
|
||||
db.add_all([eink, wohnen])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
|
||||
RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=wohnen.id),
|
||||
])
|
||||
db.commit()
|
||||
r = _get(client, monat="0001-01")
|
||||
assert r.status_code == 200
|
||||
assert 'value="0001-01"' in r.text
|
||||
assert "01.0001" in r.text
|
||||
assert "monat=0001-01" in r.text
|
||||
|
||||
|
||||
def test_uebrige_mitglieder_in_legende_der_zweiten_torte(client, db):
|
||||
_login(client)
|
||||
eink, kat = Category(name="Einkommen"), Category(name="Vielfalt")
|
||||
db.add_all([eink, kat])
|
||||
db.flush()
|
||||
db.add(RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id))
|
||||
db.add(RecurringItem(name="Großposten", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=kat.id))
|
||||
for i in range(10):
|
||||
db.add(RecurringItem(name=f"Kleinposten {i}", amount=D("-5.00"), rhythm="monthly",
|
||||
due_day=1, category_id=kat.id))
|
||||
db.commit()
|
||||
t = _drill(client, kategorie="Vielfalt").text
|
||||
legende = re.search(r'<ul class="legende">(.*?)</ul>', t, re.S).group(1)
|
||||
assert 'class="legende-zusatz"' in legende
|
||||
assert "Kleinposten 3" in legende
|
||||
|
||||
|
||||
CSS = (Path(__file__).resolve().parent.parent / "app" / "static" / "style.css").read_text()
|
||||
|
||||
|
||||
def test_nav_bricht_um():
|
||||
nav = re.search(r"(?m)^nav\s*\{([^}]*)\}", CSS).group(1)
|
||||
assert re.search(r"flex-wrap:\s*wrap", nav)
|
||||
|
||||
|
||||
def test_legende_zusatz_nicht_fett_und_figcaption_bleibt_fett():
|
||||
assert re.search(r"\.legende-zusatz\s*\{[^}]*font-weight:\s*normal", CSS)
|
||||
# Nutzerwunsch A14: Bildunterschrift der Torte bleibt fett.
|
||||
assert re.search(r"\.torte figcaption\s*\{[^}]*font-weight:\s*bold", CSS)
|
||||
|
||||
|
||||
def test_gewaehltes_segment_zuletzt_gezeichnet():
|
||||
from app.routers.gui import templates
|
||||
|
||||
def seg(label, gewaehlt):
|
||||
s = SimpleNamespace(path=f"M0 0 L{label}", color="s1", label=label, merged=False, members=[],
|
||||
value=Decimal("1.00"), share=Decimal("50.0"))
|
||||
return {"s": s, "q": None, "gewaehlt": gewaehlt, "zusatz": None}
|
||||
|
||||
torte = templates.env.get_template("_torte_macros.html").module.torte
|
||||
html = str(torte([seg("Alpha", True), seg("Beta", False)], "Titel"))
|
||||
svg = html.split("<svg", 1)[1].split("</svg>", 1)[0]
|
||||
assert svg.index("M0 0 LBeta") < svg.index("M0 0 LAlpha")
|
||||
legende = html.split('<ul class="legende">', 1)[1]
|
||||
assert legende.index("Alpha") < legende.index("Beta") # Legende behält Reihenfolge
|
||||
|
||||
|
||||
def test_tabellen_scrollen_auf_schmalen_bildschirmen_in_sich():
|
||||
m = re.search(r"@media \(max-width: 600px\)[^@]*table\s*\{([^}]*)\}", CSS)
|
||||
assert m and re.search(r"overflow-x:\s*auto", m.group(1)) and re.search(r"display:\s*block", m.group(1))
|
||||
assert re.search(r"@media \(max-width: 600px\)[^@]*fieldset\s*\{[^}]*min-width:\s*0", CSS)
|
||||
@@ -0,0 +1,196 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.engine.breakdown import (FREI, BreakdownRow, Filters, PlainLoan,
|
||||
breakdown_rows, category_rows, share,
|
||||
summarize)
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.scenario import PlainModifier, PlainPlanned, PlainRecurring
|
||||
|
||||
M = date(2030, 3, 1)
|
||||
NAMES = {1: "Wohnen", 2: "Einkommen", 3: "Versicherung"}
|
||||
D = Decimal
|
||||
|
||||
|
||||
def rows(month=M, recurring=(), planned=(), loans=(), modifiers=(), filters=Filters()):
|
||||
return breakdown_rows(month, list(recurring), list(planned), list(loans),
|
||||
list(modifiers), filters, category_names=NAMES,
|
||||
loan_category="System-Kreditraten",
|
||||
uncategorized="unkategorisiert")
|
||||
|
||||
|
||||
def rec(id, amount, rhythm="monthly", start=None, end=None, cat=None):
|
||||
return PlainRecurring(id, f"R{id}", D(amount), rhythm, 1, start, end, cat)
|
||||
|
||||
|
||||
def by_name(rs):
|
||||
return {r.name: r.amount for r in rs}
|
||||
|
||||
|
||||
def test_fixposten_normalisiert_auf_monat():
|
||||
rs = rows(recurring=[rec(1, "-1200.00", "yearly"), rec(2, "-100.00", "quarterly"),
|
||||
rec(3, "-100.00", "yearly"), rec(4, "-50.00")])
|
||||
assert by_name(rs) == {"R1": D("-100.00"), "R2": D("-33.33"),
|
||||
"R3": D("-8.33"), "R4": D("-50.00")}
|
||||
r2 = next(r for r in rs if r.name == "R2")
|
||||
assert (r2.kind, r2.rhythm, r2.recurring_id, r2.loan_id) == ("recurring", "quarterly", 2, None)
|
||||
|
||||
|
||||
def test_fixposten_laufzeitgrenzen():
|
||||
rs = rows(recurring=[
|
||||
rec(1, "-1.00", start=date(2030, 3, 31)), # Start am Monatsletzten: zählt
|
||||
rec(2, "-1.00", start=date(2030, 4, 1)), # Start im Folgemonat: fehlt
|
||||
rec(3, "-1.00", end=date(2030, 3, 1)), # Ende am Monatsersten: zählt
|
||||
rec(4, "-1.00", end=date(2030, 2, 28)), # Ende im Vormonat: fehlt
|
||||
])
|
||||
assert sorted(by_name(rs)) == ["R1", "R3"]
|
||||
|
||||
|
||||
def test_ende_modifikator():
|
||||
mods_vor = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 28))]
|
||||
mods_in = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 3, 15))]
|
||||
assert rows(recurring=[rec(1, "-10.00")], modifiers=mods_vor) == []
|
||||
assert by_name(rows(recurring=[rec(1, "-10.00")], modifiers=mods_in)) == {"R1": D("-10.00")}
|
||||
|
||||
|
||||
def test_modifikatoren_remove_percent_absolute():
|
||||
mods = [PlainModifier("recurring", 1, "remove", D("0")),
|
||||
PlainModifier("category", 1, "percent", D("10")),
|
||||
PlainModifier("recurring", 3, "absolute", D("30"))]
|
||||
rs = rows(recurring=[rec(1, "-100.00"), rec(2, "-100.00", cat=1), rec(3, "-100.00")],
|
||||
modifiers=mods)
|
||||
assert by_name(rs) == {"R2": D("-90.00"), "R3": D("-70.00")}
|
||||
|
||||
|
||||
def test_null_zeilen_entfallen():
|
||||
mods = [PlainModifier("recurring", 1, "absolute", D("100"))]
|
||||
assert rows(recurring=[rec(1, "-50.00")], modifiers=mods) == []
|
||||
|
||||
|
||||
def test_einmalposten_voll_nur_im_monat():
|
||||
planned = [PlainPlanned("E", D("-500.00"), date(2030, 3, 31), 1),
|
||||
PlainPlanned("F", D("-500.00"), date(2030, 4, 1), 1),
|
||||
PlainPlanned("G", D("-80.00"), date(2030, 3, 1), None)]
|
||||
rs = rows(planned=planned, modifiers=[PlainModifier("category", 1, "percent", D("10"))])
|
||||
assert by_name(rs) == {"E": D("-450.00"), "G": D("-80.00")}
|
||||
e = next(r for r in rs if r.name == "E")
|
||||
assert (e.kind, e.category, e.rhythm) == ("planned", "Wohnen", None)
|
||||
assert next(r for r in rs if r.name == "G").category == "unkategorisiert"
|
||||
|
||||
|
||||
def test_kredit_annuitaet():
|
||||
k = PlainLoan(7, "Auto", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 1, 15), "annuity"))
|
||||
rs = rows(loans=[k])
|
||||
assert len(rs) == 1
|
||||
assert (rs[0].name, rs[0].category, rs[0].kind, rs[0].amount, rs[0].loan_id) == \
|
||||
("Auto", "System-Kreditraten", "loan", D("-100.00"), 7)
|
||||
|
||||
|
||||
def test_kredit_endfaellig_schlussrate_voll():
|
||||
sched = loan_schedule(D("1000.00"), D("12"), 3, date(2030, 1, 10), "bullet")
|
||||
k = PlainLoan(1, "Endf", sched)
|
||||
assert by_name(rows(month=date(2030, 3, 1), loans=[k])) == {"Endf": D("-10.00")}
|
||||
assert by_name(rows(month=date(2030, 4, 1), loans=[k])) == {"Endf": D("-1010.00")}
|
||||
|
||||
|
||||
def test_auszahlung_zaehlt_nicht():
|
||||
# Auszahlung im März, erste Rate im April: März hat keine Kreditzeile.
|
||||
k = PlainLoan(1, "K", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 3, 5), "annuity"))
|
||||
assert rows(loans=[k]) == []
|
||||
|
||||
|
||||
def test_kredit_rate_am_monatsende_geclampt():
|
||||
k = PlainLoan(1, "K", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 1, 31), "annuity"))
|
||||
assert by_name(rows(month=date(2030, 2, 1), loans=[k])) == {"K": D("-100.00")}
|
||||
|
||||
|
||||
def test_filter_wirken_auf_einnahmen_und_ausgaben():
|
||||
recurring = [rec(1, "3000.00", cat=2), rec(2, "-100.00")]
|
||||
planned = [PlainPlanned("Bonus", D("500.00"), date(2030, 3, 10), 2)]
|
||||
k = PlainLoan(1, "K", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 1, 15), "annuity"))
|
||||
assert sorted(by_name(rows(recurring=recurring, planned=planned, loans=[k],
|
||||
filters=Filters(recurring=False)))) == ["Bonus", "K"]
|
||||
assert sorted(by_name(rows(recurring=recurring, planned=planned, loans=[k],
|
||||
filters=Filters(planned=False)))) == ["K", "R1", "R2"]
|
||||
assert sorted(by_name(rows(recurring=recurring, planned=planned, loans=[k],
|
||||
filters=Filters(loans=False)))) == ["Bonus", "R1", "R2"]
|
||||
assert rows(recurring=recurring, planned=planned, loans=[k],
|
||||
filters=Filters(False, False, False)) == []
|
||||
|
||||
|
||||
def test_sortierung_groesste_kosten_zuerst():
|
||||
rs = rows(recurring=[rec(1, "3000.00"), rec(2, "-50.00"), rec(3, "-100.00")])
|
||||
assert [r.name for r in rs] == ["R3", "R2", "R1"]
|
||||
|
||||
|
||||
def test_unbekannte_kategorie_ist_unkategorisiert():
|
||||
rs = rows(recurring=[rec(1, "-1.00", cat=99), rec(2, "-1.00", cat=None)])
|
||||
assert {r.category for r in rs} == {"unkategorisiert"}
|
||||
|
||||
|
||||
def _r(name, amount, cat):
|
||||
return BreakdownRow(name, cat, "recurring", "monthly", D(amount))
|
||||
|
||||
|
||||
def test_summarize_normalfall():
|
||||
s = summarize([_r("Gehalt", "3000.00", "Einkommen"), _r("Miete", "-1000.00", "Wohnen"),
|
||||
_r("Strom", "-100.00", "Wohnen"), _r("Police", "-200.00", "Versicherung")])
|
||||
assert (s.income, s.costs, s.free) == (D("3000.00"), D("1300.00"), D("1700.00"))
|
||||
assert (s.deficit, s.pie_on_income, s.pie_rest) == (False, True, D("1700.00"))
|
||||
assert s.category_costs == [("Wohnen", D("1100.00")), ("Versicherung", D("200.00"))]
|
||||
|
||||
|
||||
def test_summarize_unterdeckung():
|
||||
s = summarize([_r("Gehalt", "1000.00", "Einkommen"), _r("Miete", "-1300.00", "Wohnen")])
|
||||
assert (s.free, s.deficit, s.pie_on_income, s.pie_rest) == (D("-300.00"), True, False, None)
|
||||
|
||||
|
||||
def test_summarize_ohne_einkuenfte():
|
||||
s = summarize([_r("Miete", "-100.00", "Wohnen")])
|
||||
assert (s.income, s.deficit, s.pie_on_income, s.pie_rest) == (D("0"), True, False, None)
|
||||
assert share(D("-100.00"), s.income) is None
|
||||
|
||||
|
||||
def test_summarize_nur_einkuenfte_und_ausgeglichen():
|
||||
s = summarize([_r("Gehalt", "500.00", "Einkommen")])
|
||||
assert (s.costs, s.category_costs, s.pie_rest) == (D("0"), [], D("500.00"))
|
||||
s = summarize([_r("Gehalt", "500.00", "Einkommen"), _r("Miete", "-500.00", "Wohnen")])
|
||||
assert (s.free, s.deficit, s.pie_on_income, s.pie_rest) == (D("0.00"), False, True, None)
|
||||
|
||||
|
||||
def test_share_rundung():
|
||||
assert share(D("-100.00"), D("3000.00")) == D("3.3")
|
||||
assert share(D("-1"), D("3")) == D("33.3")
|
||||
assert share(D("-1"), D("16")) == D("6.3") # 6.25 -> HALF_UP
|
||||
assert share(D("250"), D("1000")) == D("25.0")
|
||||
|
||||
|
||||
def test_category_rows_nur_kosten_der_kategorien():
|
||||
rs = [_r("Miete", "-1000.00", "Wohnen"), _r("Erstattung", "50.00", "Wohnen"),
|
||||
_r("Police", "-200.00", "Versicherung"), _r("Strom", "-100.00", "Wohnen")]
|
||||
assert [r.name for r in category_rows(rs, {"Wohnen"})] == ["Miete", "Strom"]
|
||||
assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"]
|
||||
assert category_rows(rs, set()) == []
|
||||
assert category_rows(rs, {FREI}) == []
|
||||
|
||||
|
||||
def test_stichtag_ist_faelligkeitstag_im_monat():
|
||||
r = PlainRecurring(1, "R1", D("-200.00"), "monthly", 14, None, None, None)
|
||||
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
|
||||
assert by_name(rows(month=date(2030, 10, 1), recurring=[r], modifiers=mods)) == {"R1": D("-200.00")}
|
||||
assert by_name(rows(month=date(2030, 11, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
|
||||
|
||||
|
||||
def test_stichtag_monatsende_geklemmt():
|
||||
# Fällig am 31.: im Februar ist der Stichtag der 28.02. (liegt im Zeitraum).
|
||||
r = PlainRecurring(1, "R1", D("-100.00"), "monthly", 31, None, None, None)
|
||||
mods = [PlainModifier("recurring", 1, "remove", D("0"), end_date=date(2030, 2, 28))]
|
||||
assert rows(month=date(2030, 2, 1), recurring=[r], modifiers=mods) == []
|
||||
assert by_name(rows(month=date(2030, 3, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
|
||||
|
||||
|
||||
def test_szenario_zahlung_ohne_recurring_id():
|
||||
r = PlainRecurring(None, "S", D("-30.00"), "monthly", 1, None, None, 1)
|
||||
rs = rows(recurring=[r])
|
||||
assert by_name(rs) == {"S": D("-30.00")}
|
||||
assert (rs[0].recurring_id, rs[0].category) == (None, "Wohnen")
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.engine.breakdown import Filters
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.services.breakdown import PLAN_NAME, compute_breakdown
|
||||
|
||||
M = date(2030, 3, 1)
|
||||
D = Decimal
|
||||
|
||||
|
||||
def _basis(db):
|
||||
wohnen = Category(name="Wohnen")
|
||||
db.add(wohnen)
|
||||
db.flush()
|
||||
miete = RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly",
|
||||
due_day=1, category_id=wohnen.id)
|
||||
gehalt = RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1)
|
||||
reise = PlannedItem(name="Reise", amount=D("-600.00"), due=date(2030, 3, 20))
|
||||
k1 = Loan(name="K1", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2030, 1, 15), repayment_type="annuity")
|
||||
k2 = Loan(name="K2", principal=D("2400.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2030, 1, 15), repayment_type="annuity")
|
||||
db.add_all([miete, gehalt, reise, k1, k2])
|
||||
db.commit()
|
||||
return wohnen, miete, k1, k2
|
||||
|
||||
|
||||
def test_plan_name():
|
||||
assert PLAN_NAME == "System-Plan"
|
||||
|
||||
|
||||
def test_system_plan_alle_kredite_ohne_modifikatoren(db):
|
||||
wohnen, miete, k1, k2 = _basis(db)
|
||||
b = compute_breakdown(db, date(2030, 3, 17), None, Filters())
|
||||
assert b.month == M
|
||||
assert {r.name: r.amount for r in b.rows} == {
|
||||
"Miete": D("-1000.00"), "Gehalt": D("3000.00"), "Reise": D("-600.00"),
|
||||
"K1": D("-100.00"), "K2": D("-200.00")}
|
||||
assert {r.name: r.category for r in b.rows}["Miete"] == "Wohnen"
|
||||
assert {r.name: r.category for r in b.rows}["Reise"] == "unkategorisiert"
|
||||
assert {r.name: r.category for r in b.rows}["K1"] == "System-Kreditraten"
|
||||
assert b.summary.income == D("3000.00") and b.summary.costs == D("1900.00")
|
||||
|
||||
|
||||
def test_szenario_nur_zugeordnete_kredite_und_flags(db):
|
||||
wohnen, miete, k1, k2 = _basis(db)
|
||||
sc = Scenario(name="S", description="", include_recurring=True, include_planned=False)
|
||||
db.add(sc)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ScenarioLoan(scenario_id=sc.id, loan_id=k1.id),
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=wohnen.id,
|
||||
kind="percent", value=D("50")),
|
||||
ScenarioPlannedItem(scenario_id=sc.id, name="Szenario-Kauf", amount=D("-90.00"),
|
||||
due=date(2030, 3, 5)),
|
||||
])
|
||||
db.commit()
|
||||
b = compute_breakdown(db, M, sc, Filters())
|
||||
got = {r.name: (r.amount, r.category) for r in b.rows}
|
||||
assert got == {"Miete": (D("-500.00"), "Wohnen"), "Gehalt": (D("3000.00"), "unkategorisiert"),
|
||||
"Szenario-Kauf": (D("-90.00"), "unkategorisiert"),
|
||||
"K1": (D("-100.00"), "System-Kreditraten")}
|
||||
|
||||
|
||||
def test_szenario_ohne_fixposten(db):
|
||||
_basis(db)
|
||||
sc = Scenario(name="S", description="", include_recurring=False, include_planned=True)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
assert [r.name for r in compute_breakdown(db, M, sc, Filters()).rows] == ["Reise"]
|
||||
|
||||
|
||||
def test_konflikt_nur_wenn_beide_zeilen_enthalten(db):
|
||||
_basis(db)
|
||||
rate = RecurringItem(name="Autorate", amount=D("-100.00"), rhythm="monthly", due_day=15)
|
||||
db.add(rate)
|
||||
db.commit()
|
||||
b = compute_breakdown(db, M, None, Filters())
|
||||
assert [(k.posten_name, k.kredit_name) for k in b.conflicts] == [("Autorate", "K1")]
|
||||
assert compute_breakdown(db, M, None, Filters(loans=False)).conflicts == []
|
||||
assert compute_breakdown(db, M, None, Filters(recurring=False)).conflicts == []
|
||||
sc = Scenario(name="Ohne K1", description="")
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
assert compute_breakdown(db, M, sc, Filters()).conflicts == []
|
||||
@@ -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
|
||||
|
||||
@@ -223,11 +225,11 @@ def test_modifier_ende_validierung(client, db):
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["end_date"] == "2026-08-31"
|
||||
# andere Arten nullen end_date
|
||||
# andere Arten behalten end_date als „Gültig bis"
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 201 and r.json()["end_date"] is None
|
||||
assert r.status_code == 201 and r.json()["end_date"] == "2026-08-31"
|
||||
|
||||
|
||||
def test_scenario_planned_crud_und_cleanup(client, db):
|
||||
@@ -267,11 +269,11 @@ def test_modifier_patch(client, db):
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||
assert r.status_code == 422
|
||||
# zurueck auf remove: end_date wird genullt
|
||||
# zurueck auf remove: end_date bleibt als „Gültig bis" erhalten
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||
assert r.status_code == 200 and r.json()["end_date"] == "2026-08-31"
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
@@ -294,3 +296,139 @@ 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
|
||||
|
||||
|
||||
def _msgs(r):
|
||||
return " ".join(e["msg"] for e in r.json()["detail"])
|
||||
|
||||
|
||||
def test_modifier_zeitraum_und_neuer_betrag(client):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "Zeitraum"}).json()
|
||||
url = f"/api/scenarios/{sc['id']}/modifiers"
|
||||
r = client.post(url, headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||||
"start_date": "2026-10-16", "end_date": "2027-03-31"})
|
||||
assert r.status_code == 201
|
||||
assert (r.json()["start_date"], r.json()["end_date"]) == ("2026-10-16", "2027-03-31")
|
||||
r = client.post(url, headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||||
"start_date": "2027-04-01", "end_date": "2027-03-31"})
|
||||
assert r.status_code == 422 and "»Gültig ab« liegt nach »Gültig bis«" in _msgs(r)
|
||||
r = client.post(url, headers=H, json={
|
||||
"target_type": "category", "target_id": 1, "kind": "set", "value": "-80.00"})
|
||||
assert r.status_code == 422 and "Art »Neuer Betrag« ist nur für Posten möglich" in _msgs(r)
|
||||
r = client.post(url, headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "set", "value": "-137.50",
|
||||
"start_date": "2026-10-16"})
|
||||
assert r.status_code == 201
|
||||
assert (r.json()["kind"], r.json()["value"], r.json()["end_date"]) == ("set", "-137.50", None)
|
||||
r = client.post(url, headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"start_date": "2026-10-01", "end_date": "2026-10-15"})
|
||||
assert r.status_code == 201
|
||||
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-10-15")
|
||||
|
||||
|
||||
def test_modifier_patch_artwechsel_zeitraum(client):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "Artwechsel"}).json()
|
||||
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||||
"start_date": "2026-10-16", "end_date": "2027-03-31"}).json()
|
||||
url = f"/api/scenarios/{sc['id']}/modifiers/{m['id']}"
|
||||
r = client.patch(url, headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"start_date": "2026-10-16", "end_date": "2026-12-31"})
|
||||
assert r.status_code == 200
|
||||
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-12-31")
|
||||
r = client.patch(url, headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "10",
|
||||
"end_date": "2026-12-31"})
|
||||
assert r.status_code == 200
|
||||
assert (r.json()["kind"], r.json()["start_date"], r.json()["end_date"]) == \
|
||||
("percent", None, "2026-12-31")
|
||||
|
||||
|
||||
def test_scenario_recurring_crud_und_cleanup(client, db):
|
||||
from app.models.tables import Category, ScenarioRecurringItem
|
||||
cat = Category(name="Wohnen-SR")
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-CRUD"}).json()
|
||||
url = f"/api/scenarios/{sc['id']}/recurring"
|
||||
body = {"name": "Miete neu", "amount": "-137.50", "rhythm": "monthly",
|
||||
"due_day": 14, "start_date": "2026-10-16", "category_id": cat.id}
|
||||
r = client.post(url, headers=H, json=body)
|
||||
assert r.status_code == 201
|
||||
item = r.json()
|
||||
assert (item["scenario_id"], item["amount"], item["end_date"], item["category_id"]) == \
|
||||
(sc["id"], "-137.50", None, cat.id)
|
||||
assert [i["name"] for i in client.get(url, headers=H).json()] == ["Miete neu"]
|
||||
r = client.patch(f"{url}/{item['id']}", headers=H, json={
|
||||
"name": "Miete neu", "amount": "-250.00", "rhythm": "quarterly", "due_day": 1,
|
||||
"start_date": None, "end_date": "2027-12-31", "category_id": None})
|
||||
assert r.status_code == 200
|
||||
assert (r.json()["amount"], r.json()["rhythm"], r.json()["category_id"]) == \
|
||||
("-250.00", "quarterly", None)
|
||||
# Validierung wie globale Fixposten
|
||||
assert client.post(url, headers=H, json={**body, "due_day": 0}).status_code == 422
|
||||
assert client.post(url, headers=H, json={**body, "rhythm": "weekly"}).status_code == 422
|
||||
assert client.post(url, headers=H, json={**body, "end_date": "2026-10-01"}).status_code == 422
|
||||
r = client.post(url, headers=H, json={**body, "category_id": 9999})
|
||||
assert r.status_code == 404 and r.json()["detail"] == "Kategorie nicht gefunden"
|
||||
# fremdes/fehlendes Szenario
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "SR-Other"}).json()
|
||||
r = client.delete(f"/api/scenarios/{other['id']}/recurring/{item['id']}", headers=H)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "Wiederkehrende Zahlung nicht gefunden"
|
||||
assert client.get("/api/scenarios/99999/recurring", headers=H).status_code == 404
|
||||
# Szenario löschen räumt mit ab (kein FK-Fehler)
|
||||
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||||
assert db.query(ScenarioRecurringItem).count() == 0
|
||||
|
||||
|
||||
def test_scenario_recurring_delete(client):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-Del"}).json()
|
||||
url = f"/api/scenarios/{sc['id']}/recurring"
|
||||
item = client.post(url, headers=H, json={
|
||||
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 1}).json()
|
||||
assert client.delete(f"{url}/{item['id']}", headers=H).status_code == 204
|
||||
assert client.get(url, headers=H).json() == []
|
||||
|
||||
|
||||
def test_scenario_planned_mit_kategorie(client, db):
|
||||
from app.models.tables import Category
|
||||
cat = Category(name="Kat-SPI")
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-Kat"}).json()
|
||||
url = f"/api/scenarios/{sc['id']}/planned"
|
||||
r = client.post(url, headers=H, json={
|
||||
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": cat.id})
|
||||
assert r.status_code == 201 and r.json()["category_id"] == cat.id
|
||||
assert client.post(url, headers=H, json={
|
||||
"name": "E", "amount": "-5.00", "due": "2026-11-01",
|
||||
"category_id": 9999}).status_code == 404
|
||||
r = client.patch(f"{url}/{r.json()['id']}", headers=H, json={
|
||||
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": None})
|
||||
assert r.status_code == 200 and r.json()["category_id"] is None
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.tables import Loan, RecurringItem
|
||||
from app.services.double_count import (KreditDaten, PostenDaten, all_conflicts,
|
||||
conflicts_for_loan, conflicts_for_recurring,
|
||||
find_conflict, rate_period, regular_rate)
|
||||
|
||||
D = Decimal
|
||||
# 1.200 € zu 0 % auf 12 Monate, Auszahlung 15.01.2026 -> Rate 100,00;
|
||||
# erste Rate 15.02.2026, letzte 15.01.2027.
|
||||
K100 = KreditDaten(1, "Auto", D("1200.00"), D("0"), 12, date(2026, 1, 15), "annuity")
|
||||
|
||||
|
||||
def P(amount, rhythm="monthly", start=None, end=None, name="Rate"):
|
||||
return PostenDaten(7, name, D(amount), rhythm, start, end)
|
||||
|
||||
|
||||
def test_regular_rate_und_zeitraum():
|
||||
assert regular_rate(K100) == D("100.00")
|
||||
assert rate_period(K100) == (date(2026, 2, 15), date(2027, 1, 15))
|
||||
annu = KreditDaten(2, "A", D("5000.00"), D("6.0"), 48, date(2026, 1, 1), "annuity")
|
||||
assert regular_rate(annu) == D("117.43")
|
||||
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert regular_rate(bullet) == D("60.00")
|
||||
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert regular_rate(bullet0) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount,hit", [
|
||||
("-100.00", True), ("-103.00", True), ("-103.01", False),
|
||||
("-97.00", True), ("-96.99", False),
|
||||
])
|
||||
def test_toleranz_drei_prozent(amount, hit):
|
||||
assert (find_conflict(P(amount), K100) is not None) is hit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount,hit", [("-21.00", True), ("-21.01", False), ("-19.00", True)])
|
||||
def test_mindesttoleranz_ein_euro(amount, hit):
|
||||
k20 = KreditDaten(5, "Klein", D("240.00"), D("0"), 12, date(2026, 1, 15), "annuity")
|
||||
assert (find_conflict(P(amount), k20) is not None) is hit
|
||||
|
||||
|
||||
def test_endfaellig_zinsrate_und_nullzins():
|
||||
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert find_conflict(P("-60.00"), bullet) is not None
|
||||
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert find_conflict(P("-60.00"), bullet0) is None
|
||||
assert find_conflict(P("-0.50"), bullet0) is None
|
||||
|
||||
|
||||
def test_nur_monatliche_ausgaben():
|
||||
assert find_conflict(P("-100.00", rhythm="quarterly"), K100) is None
|
||||
assert find_conflict(P("100.00"), K100) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start,end,hit", [
|
||||
(None, date(2026, 2, 14), False), (None, date(2026, 2, 15), True),
|
||||
(date(2027, 1, 16), None, False), (date(2027, 1, 15), None, True),
|
||||
(None, None, True), (date(2025, 1, 1), date(2030, 1, 1), True),
|
||||
])
|
||||
def test_zeitraeume(start, end, hit):
|
||||
assert (find_conflict(P("-100.00", start=start, end=end), K100) is not None) is hit
|
||||
|
||||
|
||||
def test_konflikt_inhalt():
|
||||
k = find_conflict(P("-101.50", name="Rate Auto"), K100)
|
||||
assert (k.posten_id, k.posten_name, k.posten_betrag) == (7, "Rate Auto", D("-101.50"))
|
||||
assert (k.kredit_id, k.kredit_name, k.kredit_rate) == (1, "Auto", D("100.00"))
|
||||
assert k.paar == (7, 1)
|
||||
|
||||
|
||||
def _seed(db):
|
||||
db.add_all([
|
||||
Loan(name="Auto", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||
Loan(name="Möbel", principal=D("240.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||
RecurringItem(name="Rate Auto", amount=D("-100.00"), rhythm="monthly", due_day=15),
|
||||
RecurringItem(name="Rate Möbel", amount=D("-20.00"), rhythm="monthly", due_day=15),
|
||||
RecurringItem(name="Miete", amount=D("-600.00"), rhythm="monthly", due_day=1),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_all_conflicts_findet_paare_sortiert(db):
|
||||
_seed(db)
|
||||
assert [(k.posten_name, k.kredit_name) for k in all_conflicts(db)] == [
|
||||
("Rate Auto", "Auto"), ("Rate Möbel", "Möbel")]
|
||||
|
||||
|
||||
def test_conflicts_for_recurring_und_loan(db):
|
||||
_seed(db)
|
||||
neu = PostenDaten(None, "Neu", D("-99.00"), "monthly", None, None)
|
||||
assert [k.kredit_name for k in conflicts_for_recurring(db, neu)] == ["Auto"]
|
||||
kredit = KreditDaten(None, "Neu", D("7200.00"), D("0"), 12, date(2026, 1, 1), "annuity")
|
||||
assert [k.posten_name for k in conflicts_for_loan(db, kredit)] == ["Miete"]
|
||||
@@ -0,0 +1,91 @@
|
||||
from decimal import Decimal
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
KREDIT = {"name": "Auto", "principal": "1200.00", "annual_rate_pct": "0",
|
||||
"term_months": 12, "payout_date": "2026-01-15", "repayment_type": "annuity"}
|
||||
POSTEN = {"name": "Rate Auto", "amount": "-100.00", "rhythm": "monthly", "due_day": 15}
|
||||
|
||||
|
||||
def _count(client, path):
|
||||
return len(client.get(path, headers=H).json())
|
||||
|
||||
|
||||
def test_posten_zu_bestehendem_kredit_409(client):
|
||||
assert client.post("/api/loans", headers=H, json=KREDIT).status_code == 201
|
||||
r = client.post("/api/recurring", headers=H, json=POSTEN)
|
||||
assert r.status_code == 409
|
||||
d = r.json()["detail"]
|
||||
assert d["code"] == "doppelzaehlung_kredit"
|
||||
assert "doppelt" in d["meldung"]
|
||||
assert d["konflikte"] == [{"posten": "Rate Auto", "posten_betrag": "-100.00",
|
||||
"kredit": "Auto", "kredit_rate": "100.00"}]
|
||||
assert _count(client, "/api/recurring") == 0
|
||||
|
||||
|
||||
def test_posten_mit_bestaetigung_gespeichert(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
r = client.post("/api/recurring", headers=H,
|
||||
json={**POSTEN, "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 201
|
||||
assert "doppelzaehlung_bestaetigt" not in r.json()
|
||||
assert _count(client, "/api/recurring") == 1
|
||||
|
||||
|
||||
def test_kredit_zu_bestehendem_posten_409_und_bestaetigung(client):
|
||||
client.post("/api/recurring", headers=H, json=POSTEN)
|
||||
r = client.post("/api/loans", headers=H, json=KREDIT)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["konflikte"][0]["posten"] == "Rate Auto"
|
||||
assert _count(client, "/api/loans") == 0
|
||||
r = client.post("/api/loans", headers=H, json={**KREDIT, "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 201
|
||||
assert "doppelzaehlung_bestaetigt" not in r.json()
|
||||
|
||||
|
||||
def test_ohne_konflikt_unveraendert(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
r = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"})
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
def test_patch_posten_erzeugt_konflikt_409_ohne_aenderung(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
item = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}).json()
|
||||
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"amount": "-100.00"})
|
||||
assert r.status_code == 409
|
||||
stored = client.get("/api/recurring", headers=H).json()[0]
|
||||
assert Decimal(stored["amount"]) == Decimal("-50.00")
|
||||
r = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
||||
json={"amount": "-100.00", "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 200
|
||||
assert Decimal(r.json()["amount"]) == Decimal("-100.00")
|
||||
|
||||
|
||||
def test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
item = client.post("/api/recurring", headers=H,
|
||||
json={**POSTEN, "doppelzaehlung_bestaetigt": True}).json()
|
||||
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"name": "Rate Auto neu"})
|
||||
assert r.status_code == 200
|
||||
loan = client.get("/api/loans", headers=H).json()[0]
|
||||
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"name": "Auto neu"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_patch_kredit_erzeugt_konflikt_409(client):
|
||||
client.post("/api/recurring", headers=H, json=POSTEN)
|
||||
loan = client.post("/api/loans", headers=H, json={**KREDIT, "principal": "2400.00"}).json()
|
||||
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"principal": "1200.00"})
|
||||
assert r.status_code == 409
|
||||
assert Decimal(client.get("/api/loans", headers=H).json()[0]["principal"]) == Decimal("2400.00")
|
||||
r = client.patch(f"/api/loans/{loan['id']}", headers=H,
|
||||
json={"principal": "1200.00", "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_vorschlag_uebernehmen_nutzt_denselben_endpunkt(client):
|
||||
# „Vorschlag übernehmen" sendet dieselben Felder (inkl. start_date/category_id=null).
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
r = client.post("/api/recurring", headers=H, json={
|
||||
**POSTEN, "start_date": None, "category_id": None})
|
||||
assert r.status_code == 409
|
||||
@@ -35,9 +35,10 @@ def test_de_label_bekannte_und_unbekannte_werte():
|
||||
assert de_label("annuity") == "Annuität"
|
||||
assert de_label("bullet") == "endfällig"
|
||||
assert de_label("percent") == "Prozent"
|
||||
assert de_label("absolute") == "Absolut"
|
||||
assert de_label("absolute") == "Kürzung (€)"
|
||||
assert de_label("set") == "Neuer Betrag"
|
||||
assert de_label("remove") == "Entfällt"
|
||||
assert de_label("ende") == "Ende"
|
||||
assert de_label("weirdvalue") == "weirdvalue"
|
||||
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
|
||||
"bullet", "percent", "absolute", "remove", "ende"}
|
||||
"bullet", "percent", "absolute", "set", "remove", "ende"}
|
||||
|
||||
@@ -49,3 +49,42 @@ 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))
|
||||
|
||||
|
||||
BARCHARTS = ("Monat nach Kategorie", "Planung nach Kategorie")
|
||||
|
||||
|
||||
def test_barcharts_ohne_segment_beschriftung_mit_voller_aufschluesselung():
|
||||
"""Gestapelte Balken mit vielen Kategorien: Segment-Beschriftungen
|
||||
überlagern sich (sichtbar bleibt ein zufälliger kleiner Wert). Daher keine
|
||||
Werte im Balken, stattdessen alle Kategorien im Tooltip, größte Ausgabe
|
||||
zuerst, und den Monat ohne Uhrzeit (Monatserster UTC wirkte in MEZ wie
|
||||
01:00 Uhr)."""
|
||||
panels = {p["title"]: p for p in _load()["panels"]}
|
||||
for title in BARCHARTS:
|
||||
p = panels[title]
|
||||
assert p["options"]["showValue"] == "never", title
|
||||
assert p["options"]["tooltip"] == {"mode": "multi", "sort": "asc"}, title
|
||||
overrides = p["fieldConfig"]["overrides"]
|
||||
assert {"matcher": {"id": "byType", "options": "time"},
|
||||
"properties": [{"id": "unit", "value": "time:MM.YYYY"}]} in overrides, title
|
||||
|
||||
+239
-7
@@ -1,24 +1,32 @@
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.config import get_settings
|
||||
from app.formats import eur
|
||||
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
|
||||
Scenario, ScenarioModifier, ScenarioPlannedItem)
|
||||
from app.models.tables import (Account, Category, Loan, PlannedItem, RecurringItem,
|
||||
Scenario, ScenarioModifier, ScenarioPlannedItem,
|
||||
ScenarioRecurringItem)
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_pages_require_login(client):
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
|
||||
r = client.get(path, follow_redirects=False)
|
||||
assert r.status_code in (302, 303), path
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_aufteilung_fragment_erfordert_login(client):
|
||||
r = client.get("/aufteilung/kategorie", follow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_pages_render_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "Finanzberatung" in r.text
|
||||
@@ -55,7 +63,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):
|
||||
@@ -422,7 +433,7 @@ def test_szenario_eintraege_tabelle(client, db):
|
||||
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
|
||||
assert 'value="einmal"' in r
|
||||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||
assert "onEntryArtChange" in r and "onModKindChange" not in r
|
||||
assert "FBSzenario.aenderungArt" in r and "onEntryArtChange" not in r
|
||||
|
||||
|
||||
def test_szenarien_seite_und_nav(client, db):
|
||||
@@ -449,7 +460,7 @@ def test_neuer_eintrag_formular_struktur(client, db):
|
||||
db.add(Scenario(name="UX-Test", description=""))
|
||||
db.commit()
|
||||
r = client.get("/szenarien").text
|
||||
assert 'class="entry-form"' in r
|
||||
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
|
||||
assert 'class="entry-art"' in r # Eintragsart-Zeile
|
||||
assert 'class="value-label"' in r # dynamisches Wert-Label
|
||||
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
||||
@@ -483,3 +494,224 @@ 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
|
||||
|
||||
|
||||
def test_base_hat_doppelzaehlungs_dialog(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
html = client.get("/").text
|
||||
from app.version import get_version
|
||||
assert f'<script src="/static/doppelzaehlung.js?v={get_version()}"></script>' in html
|
||||
assert '<dialog id="doppelzaehlung-dialog">' in html
|
||||
assert 'id="dz-abbrechen" autofocus' in html
|
||||
assert ">Trotzdem speichern</button>" in html
|
||||
assert "FBDoppel.handle(e)" in html
|
||||
assert "doppelzaehlung_bestaetigt = true" in html
|
||||
|
||||
|
||||
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add_all([
|
||||
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
|
||||
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
|
||||
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
|
||||
])
|
||||
db.commit()
|
||||
html = client.get("/planung").text
|
||||
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
|
||||
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
|
||||
assert html.count("⚠ zählt evtl. doppelt") == 2
|
||||
assert '<td data-sort="Rate Auto">' in html
|
||||
assert '<td data-sort="Auto">' in html
|
||||
|
||||
|
||||
def test_planung_ohne_konflikt_kein_hinweis(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
|
||||
db.commit()
|
||||
assert "zählt evtl. doppelt" not in client.get("/planung").text
|
||||
|
||||
|
||||
def test_vorschlag_formular_nutzt_json_form_post(client):
|
||||
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
|
||||
import re
|
||||
from pathlib import Path
|
||||
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
|
||||
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)
|
||||
|
||||
|
||||
def test_statische_dateien_mit_versionskennung(client):
|
||||
# Cache-Busting: jede neue VERSION erzwingt frische CSS/JS im Browser,
|
||||
# sonst zeigen gecachte Altstände z. B. schwarze Tortensegmente.
|
||||
from app.version import get_version
|
||||
v = get_version()
|
||||
login = client.get("/login").text
|
||||
assert f'href="/static/style.css?v={v}"' in login
|
||||
assert re.search(r'/static/[^"?]+"', login) is None
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
html = client.get("/").text
|
||||
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js", "szenarien.js"):
|
||||
assert f'/static/{datei}?v={v}"' in html
|
||||
assert re.search(r'/static/[^"?]+"', html) is None
|
||||
|
||||
|
||||
def test_szenario_zwei_formulare(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Formulare-A14", description="")
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
r = client.get("/szenarien").text
|
||||
assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r
|
||||
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
|
||||
assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r
|
||||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||
assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r
|
||||
assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r
|
||||
for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"',
|
||||
'name="due_day"', 'name="category_id"', 'name="due"'):
|
||||
assert feld in r, feld
|
||||
assert 'class="zahlung-art"' in r and '<option value="">(keine)</option>' in r
|
||||
assert 'class="bis-label"' in r
|
||||
|
||||
|
||||
def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
cat = Category(name="Kat-Anzeige")
|
||||
sc = Scenario(name="Anzeige-A14", description="")
|
||||
db.add_all([cat, sc])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
|
||||
kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)),
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
|
||||
kind="set", value=Decimal("-80.00"),
|
||||
start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)),
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
|
||||
kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)),
|
||||
ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"),
|
||||
rhythm="monthly", due_day=14, start_date=date(2026, 10, 16),
|
||||
category_id=cat.id),
|
||||
ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"),
|
||||
due=date(2026, 12, 1), category_id=cat.id),
|
||||
])
|
||||
db.commit()
|
||||
r = client.get("/szenarien").text
|
||||
assert "<th>Zeitraum</th>" in r
|
||||
assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r
|
||||
assert ">Neuer Betrag<" in r and "-80,00" in r
|
||||
assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r
|
||||
assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r
|
||||
assert "Kat-Anzeige" in r and "Zahlung-E" in r
|
||||
assert 'id="sri-row-' in r and 'id="sri-edit-' in r
|
||||
|
||||
|
||||
def test_planung_laufzeit_felder_haben_max_1200(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(Loan(name="MaxL", principal=Decimal("1000.00"),
|
||||
annual_rate_pct=Decimal("4.50"), term_months=12,
|
||||
payout_date=date(2026, 8, 1), repayment_type="annuity"))
|
||||
db.commit()
|
||||
r = client.get("/planung").text
|
||||
assert r.count('max="1200"') >= 2
|
||||
|
||||
@@ -27,3 +27,21 @@ def test_bullet_loan():
|
||||
assert all(i.principal == 0 for i in plan[:-1])
|
||||
assert plan[-1].principal == Decimal("10000")
|
||||
assert plan[-1].remaining == Decimal("0.00")
|
||||
|
||||
|
||||
def test_schedule_for_entspricht_loan_schedule():
|
||||
from types import SimpleNamespace
|
||||
from app.engine.loans import schedule_for
|
||||
kredit = SimpleNamespace(principal="1200.00", annual_rate_pct="3.00", term_months=12,
|
||||
payout_date=date(2030, 1, 31), repayment_type="annuity")
|
||||
assert schedule_for(kredit) == loan_schedule(Decimal("1200.00"), Decimal("3.00"), 12,
|
||||
date(2030, 1, 31), "annuity")
|
||||
|
||||
|
||||
def test_keine_manuelle_tilgungsplan_konvertierung_mehr():
|
||||
from pathlib import Path
|
||||
app_dir = Path(__file__).resolve().parent.parent / "app"
|
||||
engine_loans = app_dir / "engine" / "loans.py"
|
||||
treffer = [str(p.relative_to(app_dir)) for p in app_dir.rglob("*.py")
|
||||
if p != engine_loans and "loan_schedule(Decimal(" in p.read_text()]
|
||||
assert treffer == []
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Postgres-only: Alembic-Revision e7f1a9c3b214 (Ausbaustufe 14) hoch/runter
|
||||
auf einer Wegwerf-DB (Name muss auf '_test' enden). Beispiel:
|
||||
podman run -d --rm --name a14-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_migration_pg.py
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
PG_URL = os.environ.get("FB_TEST_PG_URL")
|
||||
pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt")
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def alembic_cfg(monkeypatch):
|
||||
assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!"
|
||||
monkeypatch.setenv("FB_DATABASE_URL", PG_URL)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine(PG_URL)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public"))
|
||||
cfg = Config(str(ROOT / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(ROOT / "alembic"))
|
||||
yield cfg, engine
|
||||
# Schema leeren, damit test_views_pg.py (create_all) nicht auf dem
|
||||
# herabgestuften Alembic-Stand aufsetzt.
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public"))
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_migration_a14_up_down(alembic_cfg):
|
||||
cfg, engine = alembic_cfg
|
||||
command.upgrade(cfg, "d5e8b3c7a410")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO scenarios (id, name, description, include_recurring, "
|
||||
"include_planned) VALUES (1, 'S', '', true, true)"))
|
||||
conn.execute(text(
|
||||
"INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, "
|
||||
"value, end_date) VALUES "
|
||||
"(1, 1, 'recurring', 7, 'ende', 0, '2030-10-15'), "
|
||||
"(2, 1, 'category', 3, 'percent', 10, NULL)"))
|
||||
command.upgrade(cfg, "head")
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("SELECT id, kind, value, start_date, end_date "
|
||||
"FROM scenario_modifiers ORDER BY id")).all()
|
||||
assert [(r.id, r.kind, str(r.value), r.start_date, str(r.end_date))
|
||||
for r in rows] == [(1, "ende", "0.00", None, "2030-10-15"),
|
||||
(2, "percent", "10.00", None, "None")]
|
||||
conn.execute(text(
|
||||
"INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, "
|
||||
"value, start_date, end_date) VALUES "
|
||||
"(3, 1, 'recurring', 7, 'set', -80, '2030-11-01', NULL), "
|
||||
"(4, 1, 'recurring', 7, 'percent', 50, '2030-11-01', '2030-12-31')"))
|
||||
conn.execute(text(
|
||||
"INSERT INTO scenario_recurring_items (scenario_id, name, amount, rhythm, due_day) "
|
||||
"VALUES (1, 'W', -10, 'monthly', 14)"))
|
||||
insp = inspect(engine)
|
||||
assert "scenario_recurring_items" in insp.get_table_names()
|
||||
assert "category_id" in {c["name"] for c in insp.get_columns("scenario_planned_items")}
|
||||
command.downgrade(cfg, "d5e8b3c7a410")
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("SELECT id, kind, end_date FROM scenario_modifiers "
|
||||
"ORDER BY id")).all()
|
||||
assert [(r.id, r.kind, str(r.end_date)) for r in rows] == [
|
||||
(1, "ende", "2030-10-15"), (2, "percent", "None"), (4, "percent", "None")]
|
||||
insp = inspect(engine)
|
||||
assert "scenario_recurring_items" not in insp.get_table_names()
|
||||
assert "start_date" not in {c["name"] for c in insp.get_columns("scenario_modifiers")}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Alembic-Revision e7f1a9c3b214 (A14) muss auch auf SQLite hoch/runter/hoch laufen."""
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_migration_a14_sqlite_up_down_up(tmp_path, monkeypatch):
|
||||
url = f"sqlite:///{tmp_path / 'mig.db'}"
|
||||
monkeypatch.setenv("FB_DATABASE_URL", url)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
cfg = Config(str(ROOT / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(ROOT / "alembic"))
|
||||
engine = create_engine(url)
|
||||
|
||||
def state():
|
||||
insp = inspect(engine)
|
||||
return ("scenario_recurring_items" in insp.get_table_names(),
|
||||
"category_id" in {c["name"] for c in
|
||||
insp.get_columns("scenario_planned_items")})
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert state() == (True, True)
|
||||
command.downgrade(cfg, "d5e8b3c7a410")
|
||||
assert state() == (False, False)
|
||||
command.upgrade(cfg, "head")
|
||||
assert state() == (True, True)
|
||||
engine.dispose()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -34,3 +34,29 @@ def test_scenario_planned_item_roundtrip(db):
|
||||
db.commit()
|
||||
item = db.query(ScenarioPlannedItem).one()
|
||||
assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id
|
||||
|
||||
|
||||
def test_a14_szenario_felder_roundtrip(db):
|
||||
from app.models.tables import (Category, Scenario, ScenarioModifier,
|
||||
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||
cat = Category(name="A14-Kat")
|
||||
sc = Scenario(name="A14-Modell", description="")
|
||||
db.add_all([cat, sc])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
|
||||
kind="set", value=Decimal("-80.00"),
|
||||
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
|
||||
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
|
||||
due=date(2030, 1, 1), category_id=cat.id),
|
||||
ScenarioRecurringItem(scenario_id=sc.id, name="W", amount=Decimal("-10.00"),
|
||||
rhythm="monthly", due_day=14,
|
||||
start_date=date(2030, 1, 16), end_date=None,
|
||||
category_id=cat.id),
|
||||
])
|
||||
db.commit()
|
||||
m = db.query(ScenarioModifier).one()
|
||||
assert (m.kind, m.start_date, m.end_date) == ("set", date(2030, 2, 1), date(2030, 3, 31))
|
||||
assert db.query(ScenarioPlannedItem).one().category_id == cat.id
|
||||
w = db.query(ScenarioRecurringItem).one()
|
||||
assert (w.scenario_id, w.rhythm, w.due_day, w.category_id) == (sc.id, "monthly", 14, cat.id)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.pie import UEBRIGE, pie_slices
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
def test_leer_und_nullwerte():
|
||||
assert pie_slices([]) == []
|
||||
assert pie_slices([("A", D("0")), ("B", D("-5"))]) == []
|
||||
|
||||
|
||||
def test_segmente_lueckenlos_und_anteile():
|
||||
sl = pie_slices([("C", D("20")), ("A", D("50")), ("B", D("30"))])
|
||||
assert [s.label for s in sl] == ["A", "B", "C"]
|
||||
assert [s.color for s in sl] == ["s1", "s2", "s3"]
|
||||
assert [s.share for s in sl] == [D("50.0"), D("30.0"), D("20.0")]
|
||||
assert sl[0].start == 0.0 and sl[-1].end == pytest.approx(1.0)
|
||||
for a, b in zip(sl, sl[1:]):
|
||||
assert a.end == pytest.approx(b.start)
|
||||
assert sl[0].path.startswith("M 0 0 L 0.0000 -1.0000 A 1 1 0 0 1 ")
|
||||
|
||||
|
||||
def test_grosses_segment_als_zwei_teilboegen():
|
||||
a, b = pie_slices([("A", D("70")), ("B", D("30"))])
|
||||
assert a.path.count(" A 1 1 0 0 1 ") == 2
|
||||
assert b.path.count(" A 1 1 0 0 1 ") == 1
|
||||
assert " 0 1 1 " not in a.path
|
||||
|
||||
|
||||
def test_fast_volles_segment_bleibt_sichtbar():
|
||||
gross, klein = pie_slices([("Gross", D("4999.99"))], rest=D("0.01"),
|
||||
rest_label="Frei verfügbar")
|
||||
# Zwei Teilbögen: der Zwischenpunkt liegt gegenüber (unten), der Bogen
|
||||
# kann also nie zu einem Punkt zusammenfallen.
|
||||
assert gross.path.count(" A 1 1 0 0 1 ") == 2
|
||||
assert " A 1 1 0 0 1 0.0000 1.0000 " in gross.path or " A 1 1 0 0 1 -0.0000 1.0000 " in gross.path
|
||||
assert klein.path is not None
|
||||
|
||||
|
||||
def test_letztes_segment_schliesst_bei_zwoelf_uhr():
|
||||
sl = pie_slices([("A", D("50")), ("B", D("30")), ("C", D("20"))])
|
||||
assert sl[-1].path.endswith(" 0.0000 -1.0000 Z") or sl[-1].path.endswith(" -0.0000 -1.0000 Z")
|
||||
|
||||
|
||||
def test_einzelsegment_ist_vollkreis():
|
||||
(s,) = pie_slices([("A", D("12.34"))])
|
||||
assert s.path is None and s.share == D("100.0") and s.color == "s1"
|
||||
|
||||
|
||||
def test_kleine_werte_zu_uebrige():
|
||||
sl = pie_slices([("A", D("95")), ("B", D("2")), ("C", D("2")), ("D", D("1"))])
|
||||
assert [s.label for s in sl] == ["A", UEBRIGE]
|
||||
u = sl[1]
|
||||
assert (u.value, u.color, u.merged, u.members) == (D("5"), "uebrige", True, ("B", "C", "D"))
|
||||
|
||||
|
||||
def test_einzelner_kleiner_wert_behaelt_namen():
|
||||
sl = pie_slices([("A", D("98")), ("B", D("2"))])
|
||||
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("B", "uebrige", False)]
|
||||
|
||||
|
||||
def test_hoechstens_acht_benannte_segmente():
|
||||
sl = pie_slices([(f"K{i}", D("10")) for i in range(10)], rest=D("5"),
|
||||
rest_label="Frei verfügbar")
|
||||
assert [s.color for s in sl] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8",
|
||||
"uebrige", "frei"]
|
||||
assert sl[-2].merged and sl[-2].members == ("K8", "K9")
|
||||
|
||||
|
||||
def test_acht_kategorien_ohne_uebrige():
|
||||
sl = pie_slices([(f"K{i}", D("10")) for i in range(8)])
|
||||
assert [s.color for s in sl] == [f"s{i}" for i in range(1, 9)]
|
||||
assert not any(s.merged for s in sl)
|
||||
|
||||
|
||||
def test_rest_wird_nie_zusammengefasst():
|
||||
sl = pie_slices([("A", D("99.5"))], rest=D("0.5"), rest_label="Frei verfügbar")
|
||||
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("Frei verfügbar", "frei", False)]
|
||||
assert sl[1].share == D("0.5")
|
||||
|
||||
|
||||
def test_nur_rest_ist_vollkreis():
|
||||
(s,) = pie_slices([], rest=D("100"), rest_label="Frei verfügbar")
|
||||
assert (s.label, s.color, s.path) == ("Frei verfügbar", "frei", None)
|
||||
|
||||
|
||||
def test_rest_none_oder_null_ignoriert():
|
||||
assert len(pie_slices([("A", D("1"))], rest=None)) == 1
|
||||
assert len(pie_slices([("A", D("1"))], rest=D("0"), rest_label="X")) == 1
|
||||
@@ -2,6 +2,8 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
from itertools import count
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.tables import Account, Category, RecurringItem, Transaction
|
||||
from app.services.suggestions import _Series, _try_merge, suggest_recurring
|
||||
|
||||
@@ -368,3 +370,42 @@ def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
|
||||
assert len(out) == 1
|
||||
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
|
||||
assert "schwanken" in out[0]["hinweis"].lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------- Ausbaustufe 14: Task 5
|
||||
KREDIT = {"name": "Laufzeit-Test", "principal": "1000.00", "annual_rate_pct": "2.00",
|
||||
"payout_date": "2030-01-01", "repayment_type": "annuity"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("laufzeit", [0, -5, 1201])
|
||||
def test_kredit_laufzeit_ausserhalb_1_bis_1200(client, laufzeit):
|
||||
r = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": laufzeit})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_kredit_laufzeit_grenzen_und_patch(client):
|
||||
a = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": 1})
|
||||
b = client.post("/api/loans", headers=H, json={**KREDIT, "name": "L2", "term_months": 1200})
|
||||
assert (a.status_code, b.status_code) == (201, 201)
|
||||
url = f"/api/loans/{a.json()['id']}"
|
||||
assert client.patch(url, headers=H, json={"term_months": 0}).status_code == 422
|
||||
assert client.patch(url, headers=H, json={"term_months": 1201}).status_code == 422
|
||||
r = client.patch(url, headers=H, json={"term_months": 1200})
|
||||
assert r.status_code == 200 and r.json()["term_months"] == 1200
|
||||
|
||||
|
||||
def test_altbestand_kredit_mit_laufzeit_ausserhalb_grenzen(client, db):
|
||||
"""Regression: Kredite die vor Validierung direkt in DB eingefügt wurden
|
||||
(term_months>1200 möglich) dürfen GET /api/loans nicht crashen.
|
||||
LoanOut-Ausgabe muss Altbestände ohne Validierung unterstützen."""
|
||||
from app.models.tables import Loan
|
||||
from decimal import Decimal
|
||||
old_loan = Loan(name="Legacy", principal=Decimal("500.00"), annual_rate_pct=Decimal("2.0"),
|
||||
term_months=1500, payout_date=date(2025, 1, 1), repayment_type="annuity")
|
||||
db.add(old_loan)
|
||||
db.commit()
|
||||
r = client.get("/api/loans", headers=H)
|
||||
assert r.status_code == 200
|
||||
loans = r.json()
|
||||
found = [l for l in loans if l["term_months"] == 1500]
|
||||
assert len(found) == 1 and found[0]["name"] == "Legacy"
|
||||
|
||||
@@ -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
|
||||
@@ -27,3 +27,10 @@ def test_monthly_leap_year_clamping():
|
||||
def test_quarterly_clamping():
|
||||
got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31))
|
||||
assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)]
|
||||
|
||||
|
||||
def test_due_in_month_klemmt_auf_monatsende():
|
||||
from app.engine.recurrence import due_in_month
|
||||
assert due_in_month(date(2030, 2, 1), 31) == date(2030, 2, 28)
|
||||
assert due_in_month(date(2032, 2, 10), 30) == date(2032, 2, 29)
|
||||
assert due_in_month(date(2030, 10, 1), 14) == date(2030, 10, 14)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from app.models.views import RESERVED_SCENARIO_NAME
|
||||
from app.reserved import SYSTEM_PREFIX, is_reserved_name
|
||||
from app.services.planning_chart import KREDITRATEN
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_systemnamen_leiten_sich_vom_praefix_ab():
|
||||
assert SYSTEM_PREFIX == "System-"
|
||||
assert RESERVED_SCENARIO_NAME == "System-Ist"
|
||||
assert KREDITRATEN == "System-Kreditraten"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("System-X", True), ("system-kreditraten", True), (" SYSTEM-Ist ", True),
|
||||
("Kreditraten", False), ("Systemkosten", False), ("Ist-System", False),
|
||||
("System", False),
|
||||
])
|
||||
def test_is_reserved_name(name, expected):
|
||||
assert is_reserved_name(name) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["System-X", "system-kreditraten", " SYSTEM-Ist "])
|
||||
def test_kategorie_post_reserviert(client, name):
|
||||
r = client.post("/api/categories", headers=H, json={"name": name})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == ("Namen, die mit »System-« beginnen, sind für "
|
||||
"vom Tool erzeugte Einträge reserviert")
|
||||
|
||||
|
||||
def test_kategorie_patch_reserviert(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Normal"}).json()
|
||||
r = client.patch(f"/api/categories/{cat['id']}", headers=H, json={"name": "System-Test"})
|
||||
assert r.status_code == 422
|
||||
names = [c["name"] for c in client.get("/api/categories", headers=H).json()]
|
||||
assert "Normal" in names and "System-Test" not in names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["Kreditraten", "Systemkosten", "Ist-System"])
|
||||
def test_kategorie_normale_namen_erlaubt(client, name):
|
||||
assert client.post("/api/categories", headers=H, json={"name": name}).status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["System-Plan", "system-x"])
|
||||
def test_szenario_praefix_reserviert(client, name):
|
||||
r = client.post("/api/scenarios", headers=H, json={"name": name})
|
||||
assert r.status_code == 422
|
||||
assert "reserviert" in r.json()["detail"]
|
||||
@@ -105,3 +105,70 @@ def test_ende_modifier_aendert_betraege_nicht():
|
||||
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
|
||||
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
|
||||
assert all(a == Decimal("-2000.00") for _, a in flows)
|
||||
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
def _rec(amount="-100.00", due_day=14, id=1, cat=None):
|
||||
return PlainRecurring(id, "R", D(amount), "monthly", due_day, None, None, cat)
|
||||
|
||||
|
||||
def test_zeitraum_grenzen_inklusiv():
|
||||
mods = [PlainModifier("recurring", 1, "percent", D("50"),
|
||||
end_date=date(2030, 3, 14), start_date=date(2030, 2, 14))]
|
||||
flows = build_cashflows([_rec()], [], [], [], mods, date(2030, 1, 1), date(2030, 4, 30))
|
||||
assert flows == [(date(2030, 1, 14), D("-100.00")),
|
||||
(date(2030, 2, 14), D("-50.00")),
|
||||
(date(2030, 3, 14), D("-50.00")),
|
||||
(date(2030, 4, 14), D("-100.00"))]
|
||||
|
||||
|
||||
def test_ab_stichtag_erst_folgemonat():
|
||||
# Fällig am 14., gültig ab 16.10. -> Oktober voll, ab November halbiert.
|
||||
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
|
||||
flows = build_cashflows([_rec("-200.00")], [], [], [], mods,
|
||||
date(2030, 10, 1), date(2030, 12, 31))
|
||||
assert flows == [(date(2030, 10, 14), D("-200.00")),
|
||||
(date(2030, 11, 14), D("-100.00")),
|
||||
(date(2030, 12, 14), D("-100.00"))]
|
||||
|
||||
|
||||
def test_neuer_betrag_dann_prozent():
|
||||
mods = [PlainModifier("recurring", 1, "set", D("-80.00"), start_date=date(2030, 2, 1)),
|
||||
PlainModifier("recurring", 1, "percent", D("25"))]
|
||||
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||||
date(2030, 1, 1), date(2030, 2, 28))
|
||||
assert flows == [(date(2030, 1, 1), D("-75.00")), (date(2030, 2, 1), D("-60.00"))]
|
||||
|
||||
|
||||
def test_entfaellt_nur_im_zeitraum():
|
||||
mods = [PlainModifier("recurring", 1, "remove", D("0"),
|
||||
end_date=date(2030, 2, 28), start_date=date(2030, 2, 1))]
|
||||
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||||
date(2030, 1, 1), date(2030, 3, 31))
|
||||
assert [d for d, _ in flows] == [date(2030, 1, 1), date(2030, 3, 1)]
|
||||
|
||||
|
||||
def test_szenario_zahlung_ohne_id_nur_von_kategorie_getroffen():
|
||||
rec = [PlainRecurring(None, "S", D("-100.00"), "monthly", 1, None, None, 7)]
|
||||
mods = [PlainModifier("recurring", 1, "remove", D("0")),
|
||||
PlainModifier("category", 7, "percent", D("10"))]
|
||||
flows = build_cashflows(rec, [], [], [], mods, date(2030, 1, 1), date(2030, 1, 31))
|
||||
assert flows == [(date(2030, 1, 1), D("-90.00"))]
|
||||
|
||||
|
||||
def test_einmalposten_stichtag_ist_faelligkeit():
|
||||
planned = [PlainPlanned("E", D("-100.00"), date(2030, 1, 31), 7),
|
||||
PlainPlanned("F", D("-100.00"), date(2030, 2, 1), 7)]
|
||||
mods = [PlainModifier("category", 7, "percent", D("50"), start_date=date(2030, 2, 1))]
|
||||
flows = build_cashflows([], planned, [], [], mods, date(2030, 1, 1), date(2030, 2, 28))
|
||||
assert flows == [(date(2030, 1, 31), D("-100.00")), (date(2030, 2, 1), D("-50.00"))]
|
||||
|
||||
|
||||
def test_ende_unveraendert_mit_zeitraum_feldern():
|
||||
# "ende" wirkt weiter nur über effective_end, nie als Betragsänderung.
|
||||
mods = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 15))]
|
||||
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||||
date(2030, 1, 1), date(2030, 4, 30))
|
||||
assert flows == [(date(2030, 1, 1), D("-100.00")), (date(2030, 2, 1), D("-100.00"))]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.engine.scenario import (PlainModifier, PlainRecurring, build_cashflows,
|
||||
effective_end, modified_amount)
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||
from app.services.projection_service import load_scenario_inputs
|
||||
|
||||
|
||||
def test_modifikator_helfer_oeffentlich():
|
||||
mods = [PlainModifier("recurring", 1, "percent", Decimal("10")),
|
||||
PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))]
|
||||
assert modified_amount(Decimal("-100.00"), None, 1, mods, on=date(2030, 1, 1)) == Decimal("-90.00")
|
||||
assert modified_amount(Decimal("-100.00"), None, 2, mods, on=date(2030, 1, 1)) == Decimal("-100.00")
|
||||
r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None,
|
||||
date(2030, 12, 31), None)
|
||||
assert effective_end(r, mods) == date(2030, 6, 30)
|
||||
|
||||
|
||||
def _daten(db):
|
||||
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
|
||||
pln = PlannedItem(name="P", amount=Decimal("-20.00"), due=date(2030, 1, 1))
|
||||
k1 = Loan(name="K1", principal=Decimal("100.00"), annual_rate_pct=Decimal("0"),
|
||||
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity")
|
||||
k2 = Loan(name="K2", principal=Decimal("200.00"), annual_rate_pct=Decimal("0"),
|
||||
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity")
|
||||
sc = Scenario(name="S", description="", include_recurring=True,
|
||||
include_planned=False)
|
||||
db.add_all([rec, pln, k1, k2, sc])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ScenarioLoan(scenario_id=sc.id, loan_id=k1.id),
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||
target_id=rec.id, kind="percent", value=Decimal("50")),
|
||||
ScenarioPlannedItem(scenario_id=sc.id, name="SP", amount=Decimal("-5.00"),
|
||||
due=date(2030, 2, 1)),
|
||||
])
|
||||
db.commit()
|
||||
return sc
|
||||
|
||||
|
||||
def test_load_scenario_inputs_szenario(db):
|
||||
sc = _daten(db)
|
||||
inp = load_scenario_inputs(db, sc)
|
||||
assert [r.name for r in inp.recurring] == ["R"]
|
||||
# include_planned=False: globale Einmalposten fehlen, Szenario-eigene zählen immer.
|
||||
assert [p.name for p in inp.planned] == ["SP"]
|
||||
assert inp.planned[0].category_id is None
|
||||
assert [l.name for l in inp.loans] == ["K1"]
|
||||
assert [(m.kind, m.value) for m in inp.modifiers] == [("percent", Decimal("50"))]
|
||||
|
||||
|
||||
def test_load_scenario_inputs_ohne_fixposten(db):
|
||||
sc = _daten(db)
|
||||
sc.include_recurring = False
|
||||
db.commit()
|
||||
assert load_scenario_inputs(db, sc).recurring == []
|
||||
|
||||
|
||||
def test_load_scenario_inputs_system_plan(db):
|
||||
_daten(db)
|
||||
inp = load_scenario_inputs(db, None)
|
||||
assert [r.name for r in inp.recurring] == ["R"]
|
||||
assert [p.name for p in inp.planned] == ["P"]
|
||||
assert [l.name for l in inp.loans] == ["K1", "K2"]
|
||||
assert inp.modifiers == []
|
||||
|
||||
|
||||
def test_szenario_zahlung_zaehlt_immer_und_id_kollision(db):
|
||||
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
|
||||
sc = Scenario(name="S-A14", description="", include_recurring=False,
|
||||
include_planned=False)
|
||||
db.add_all([rec, sc])
|
||||
db.flush()
|
||||
sri = ScenarioRecurringItem(scenario_id=sc.id, name="SR", amount=Decimal("-100.00"),
|
||||
rhythm="monthly", due_day=1)
|
||||
db.add(sri)
|
||||
db.flush()
|
||||
# Posten-Eintrag, dessen target_id zufällig der ID der Szenario-Zahlung entspricht:
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||
target_id=sri.id, kind="remove", value=Decimal("0")))
|
||||
db.commit()
|
||||
inp = load_scenario_inputs(db, sc)
|
||||
# include_recurring=False: globaler Posten fehlt, Szenario-Zahlung zählt, id=None.
|
||||
assert [(r.id, r.name) for r in inp.recurring] == [(None, "SR")]
|
||||
flows = build_cashflows(inp.recurring, inp.planned, [], [], inp.modifiers,
|
||||
date(2030, 1, 1), date(2030, 1, 31))
|
||||
assert flows == [(date(2030, 1, 1), Decimal("-100.00"))]
|
||||
# System-Plan kennt keine Szenario-Zahlungen.
|
||||
assert [r.name for r in load_scenario_inputs(db, None).recurring] == ["R"]
|
||||
|
||||
|
||||
def test_zeitraum_und_kategorie_werden_geladen(db):
|
||||
cat = Category(name="K-A14")
|
||||
sc = Scenario(name="S-A14b", description="")
|
||||
db.add_all([cat, sc])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
|
||||
kind="percent", value=Decimal("50"),
|
||||
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
|
||||
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
|
||||
due=date(2030, 2, 1), category_id=cat.id),
|
||||
])
|
||||
db.commit()
|
||||
inp = load_scenario_inputs(db, sc)
|
||||
m = inp.modifiers[0]
|
||||
assert (m.start_date, m.end_date) == (date(2030, 2, 1), date(2030, 3, 31))
|
||||
assert [(p.name, p.category_id) for p in inp.planned] == [("E", cat.id)]
|
||||
@@ -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,84 @@
|
||||
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)
|
||||
from app.version import get_version
|
||||
assert f'<script src="/static/sort.js?v={get_version()}"></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
|
||||
|
||||
|
||||
def test_aufteilung_tabelle_sortierbar_tfoot_fest(client, db):
|
||||
_login(client)
|
||||
db.add(RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1))
|
||||
db.add(RecurringItem(name="G", amount=Decimal("10.00"), rhythm="monthly", due_day=1))
|
||||
db.commit()
|
||||
t = client.get("/aufteilung", params={"monat": "2030-03"}).text
|
||||
table = re.search(r"<table[^>]*>.*?</table>", t, re.S).group(0)
|
||||
assert 'data-sort-mode="server"' not in table
|
||||
assert re.search(r"</tbody>\s*<tfoot>", table)
|
||||
@@ -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