Compare commits
29
Commits
d2b21c8f8b
..
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 |
+72
-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,7 +37,8 @@ flowchart TD
|
||||
S_balances["balances.py<br/>account_balance(at), total_balance"]
|
||||
S_categorize["categorize.py<br/>apply_rules"]
|
||||
S_suggestions["suggestions.py<br/>suggest_recurring"]
|
||||
S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"]
|
||||
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"]
|
||||
@@ -46,10 +47,12 @@ flowchart TD
|
||||
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/"]
|
||||
@@ -64,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
|
||||
@@ -94,6 +97,9 @@ flowchart TD
|
||||
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
|
||||
@@ -106,6 +112,10 @@ 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
|
||||
@@ -113,6 +123,9 @@ flowchart TD
|
||||
|
||||
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
|
||||
@@ -166,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
|
||||
@@ -304,15 +317,36 @@ 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
|
||||
@@ -352,6 +386,26 @@ Formular (`data-doppelzaehlung-bestaetigt="1"`) und sendet es erneut, die
|
||||
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
|
||||
@@ -378,8 +432,10 @@ markiert und sortiert stattdessen serverseitig über alle Seiten
|
||||
**`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
@@ -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.12.0
|
||||
0.14.0
|
||||
|
||||
@@ -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')
|
||||
@@ -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,16 +73,13 @@ 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)
|
||||
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)):
|
||||
flows.append((d, amount))
|
||||
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):
|
||||
|
||||
+168
-4
@@ -1,7 +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
|
||||
@@ -13,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
|
||||
@@ -27,6 +32,7 @@ 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, 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
|
||||
@@ -54,6 +60,7 @@ 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__)
|
||||
@@ -316,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
|
||||
@@ -372,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,7 +9,7 @@ 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,
|
||||
@@ -227,7 +227,7 @@ 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"
|
||||
|
||||
@@ -236,16 +236,23 @@ class LoanCreate(LoanIn):
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class LoanOut(LoanIn):
|
||||
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
|
||||
@@ -318,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,8 @@ 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
|
||||
|
||||
@@ -47,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:
|
||||
raise ValueError("Art »Ende« erfordert ein Datum")
|
||||
if self.kind != "ende":
|
||||
self.end_date = 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")
|
||||
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
|
||||
|
||||
|
||||
@@ -127,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(
|
||||
@@ -203,6 +213,7 @@ class ScenarioPlannedIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class ScenarioPlannedOut(ScenarioPlannedIn):
|
||||
@@ -225,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()
|
||||
@@ -248,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")
|
||||
@@ -278,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()
|
||||
|
||||
@@ -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)
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import advisory_xact_lock
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.loans import schedule_for
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||
RecurringItem)
|
||||
@@ -46,9 +46,7 @@ def planning_monthly_totals(session: Session, today: date,
|
||||
if window_start <= p.due <= window_end:
|
||||
totals[(p.due.replace(day=1), category(p.category_id))] += Decimal(p.amount)
|
||||
for loan in session.execute(select(Loan)).scalars():
|
||||
for inst in loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||||
loan.term_months, loan.payout_date,
|
||||
loan.repayment_type):
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
@@ -7,47 +8,78 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import advisory_xact_lock
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.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(
|
||||
|
||||
@@ -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;
|
||||
@@ -236,3 +244,47 @@ label:has(input:disabled), label:has(select:disabled) {
|
||||
#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>
|
||||
@@ -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 %}
|
||||
@@ -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,10 +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>
|
||||
<script src="/static/sort.js"></script>
|
||||
<script src="/static/doppelzaehlung.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>
|
||||
@@ -17,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>
|
||||
@@ -99,6 +101,7 @@
|
||||
}
|
||||
|
||||
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,16 +67,26 @@
|
||||
<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. Ein Szenario lässt sich über „Löschen“ mit allen Einträgen
|
||||
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
|
||||
@@ -91,6 +101,24 @@
|
||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||
Ü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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
<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">
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
@@ -45,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 %}
|
||||
@@ -55,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"
|
||||
@@ -67,10 +75,15 @@
|
||||
</tr>
|
||||
<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>
|
||||
@@ -89,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>
|
||||
@@ -123,6 +173,12 @@
|
||||
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>
|
||||
@@ -130,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>
|
||||
@@ -165,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>
|
||||
|
||||
@@ -215,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) {
|
||||
|
||||
@@ -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 == []
|
||||
@@ -225,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):
|
||||
@@ -269,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={
|
||||
@@ -318,3 +318,117 @@ def test_szenario_name_system_ist_ist_reserviert_patch(client):
|
||||
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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -4,22 +4,29 @@ 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
|
||||
@@ -426,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):
|
||||
@@ -453,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
|
||||
@@ -593,7 +600,8 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
|
||||
def test_base_hat_doppelzaehlungs_dialog(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
html = client.get("/").text
|
||||
assert '<script src="/static/doppelzaehlung.js"></script>' in html
|
||||
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
|
||||
@@ -631,3 +639,79 @@ def test_vorschlag_formular_nutzt_json_form_post(client):
|
||||
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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
@@ -17,7 +17,8 @@ def _follow_ids(html):
|
||||
|
||||
def test_base_bindet_sort_js_ein(client):
|
||||
_login(client)
|
||||
assert '<script src="/static/sort.js"></script>' in client.get("/").text
|
||||
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):
|
||||
@@ -70,3 +71,14 @@ def test_index_aktionsspalten_ohne_sortierung(client):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user