Compare commits
14
Commits
d2b21c8f8b
...
23557db0fb
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
23557db0fb | ||
|
|
987e28847c | ||
|
|
b32f28fff5 | ||
|
|
3ef20034d3 | ||
|
|
4b6144d812 | ||
|
|
2a62bdca6c | ||
|
|
1cded22e90 | ||
|
|
9e804cd349 | ||
|
|
125d370497 | ||
|
|
7555d8e6a4 | ||
|
|
0f5bb45a55 | ||
|
|
acabc3d2a4 | ||
|
|
c015b52a70 | ||
|
|
a5f2f23a7e |
+43
-7
@@ -18,11 +18,11 @@ direkt aus `routers/gui.py`.
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph TPL["templates (Jinja2, app/templates/)"]
|
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
|
end
|
||||||
|
|
||||||
subgraph ROUTERS["routers/"]
|
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_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_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
|
||||||
R_categories["categories.py<br/>list_categories, create_category_rule"]
|
R_categories["categories.py<br/>list_categories, create_category_rule"]
|
||||||
@@ -37,7 +37,8 @@ flowchart TD
|
|||||||
S_balances["balances.py<br/>account_balance(at), total_balance"]
|
S_balances["balances.py<br/>account_balance(at), total_balance"]
|
||||||
S_categorize["categorize.py<br/>apply_rules"]
|
S_categorize["categorize.py<br/>apply_rules"]
|
||||||
S_suggestions["suggestions.py<br/>suggest_recurring"]
|
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_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
|
||||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
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"]
|
S_doublecount["double_count.py<br/>all_conflicts, conflicts_for_recurring,<br/>conflicts_for_loan"]
|
||||||
@@ -50,6 +51,8 @@ flowchart TD
|
|||||||
E_recurrence["recurrence.py<br/>occurrences"]
|
E_recurrence["recurrence.py<br/>occurrences"]
|
||||||
E_projection["projection.py<br/>project"]
|
E_projection["projection.py<br/>project"]
|
||||||
E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"]
|
E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"]
|
||||||
|
E_breakdown["breakdown.py<br/>breakdown_rows, summarize,<br/>category_rows, share"]
|
||||||
|
E_pie["pie.py<br/>pie_slices"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph PARSERS["parsers/"]
|
subgraph PARSERS["parsers/"]
|
||||||
@@ -94,6 +97,9 @@ flowchart TD
|
|||||||
R_scenarios --> N_reserved
|
R_scenarios --> N_reserved
|
||||||
R_categories --> N_reserved
|
R_categories --> N_reserved
|
||||||
R_gui --> S_doublecount
|
R_gui --> S_doublecount
|
||||||
|
R_gui --> S_breakdown
|
||||||
|
R_gui --> E_breakdown
|
||||||
|
R_gui --> E_pie
|
||||||
R_admin --> S_admin
|
R_admin --> S_admin
|
||||||
|
|
||||||
ROUTERS -.-> M_tables
|
ROUTERS -.-> M_tables
|
||||||
@@ -106,6 +112,10 @@ flowchart TD
|
|||||||
S_projection --> E_projection
|
S_projection --> E_projection
|
||||||
S_projection --> E_scenario
|
S_projection --> E_scenario
|
||||||
S_projection --> S_balances
|
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_loans
|
||||||
S_planning_chart --> E_recurrence
|
S_planning_chart --> E_recurrence
|
||||||
S_planning_chart --> M_tables
|
S_planning_chart --> M_tables
|
||||||
@@ -113,6 +123,9 @@ flowchart TD
|
|||||||
|
|
||||||
E_scenario --> E_loans
|
E_scenario --> E_loans
|
||||||
E_scenario --> E_recurrence
|
E_scenario --> E_recurrence
|
||||||
|
E_breakdown --> E_scenario
|
||||||
|
E_breakdown --> E_recurrence
|
||||||
|
E_breakdown --> E_loans
|
||||||
|
|
||||||
P_registry --> P_vr
|
P_registry --> P_vr
|
||||||
P_registry --> P_hvb
|
P_registry --> P_hvb
|
||||||
@@ -304,12 +317,15 @@ 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).
|
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`,
|
**Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`,
|
||||||
`projection.py`, `scenario.py`) hat **keinen** Datenbank- oder
|
`projection.py`, `scenario.py`, `breakdown.py`, `pie.py`) hat **keinen**
|
||||||
Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von
|
Datenbank- oder Request-Zugriff — `scenario.py` und `breakdown.py`
|
||||||
`loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/
|
importieren innerhalb der Engine (`loans.py`/`recurrence.py`/`scenario.py`),
|
||||||
|
sonst keine Abhängigkeiten auf `app.*`. Ein-/
|
||||||
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
|
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
|
||||||
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
|
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
|
||||||
ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`,
|
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`), baut daraus die Plain-Objekte
|
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte
|
||||||
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
|
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
|
||||||
|
|
||||||
@@ -352,6 +368,26 @@ Formular (`data-doppelzaehlung-bestaetigt="1"`) und sendet es erneut, die
|
|||||||
einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`,
|
einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`,
|
||||||
solange der Konflikt fortbesteht.
|
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
|
**Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände
|
||||||
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
|
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
|
||||||
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
|
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
|
||||||
|
|||||||
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.
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.12.0
|
0.13.0
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""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 exakt wie in build_cashflows (gleiche Helfer).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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]
|
||||||
@@ -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
|
||||||
@@ -37,7 +37,7 @@ class PlainModifier:
|
|||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||||
modifiers: list[PlainModifier]) -> Decimal | None:
|
modifiers: list[PlainModifier]) -> Decimal | None:
|
||||||
for m in modifiers:
|
for m in modifiers:
|
||||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||||||
@@ -56,7 +56,7 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
|
|||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
|
||||||
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||||
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||||
end = r.end_date
|
end = r.end_date
|
||||||
for m in modifiers:
|
for m in modifiers:
|
||||||
@@ -76,14 +76,14 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
|
|||||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||||
flows: list[tuple[date, Decimal]] = []
|
flows: list[tuple[date, Decimal]] = []
|
||||||
for r in recurring:
|
for r in recurring:
|
||||||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
amount = modified_amount(r.amount, r.category_id, r.id, modifiers)
|
||||||
if amount is None:
|
if amount is None:
|
||||||
continue
|
continue
|
||||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||||
r.start_date, _effective_end(r, modifiers)):
|
r.start_date, effective_end(r, modifiers)):
|
||||||
flows.append((d, amount))
|
flows.append((d, amount))
|
||||||
for p in planned:
|
for p in planned:
|
||||||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
amount = modified_amount(p.amount, p.category_id, None, modifiers)
|
||||||
if amount is not None and window_start <= p.due <= window_end:
|
if amount is not None and window_start <= p.due <= window_end:
|
||||||
flows.append((p.due, amount))
|
flows.append((p.due, amount))
|
||||||
for d, payout in loan_payouts:
|
for d, payout in loan_payouts:
|
||||||
|
|||||||
@@ -28,3 +28,11 @@ def eur(value) -> str:
|
|||||||
|
|
||||||
def de_label(value: str) -> str:
|
def de_label(value: str) -> str:
|
||||||
return DE_LABELS.get(value, value)
|
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(".", ",") + " %"
|
||||||
|
|||||||
+159
-2
@@ -1,7 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -13,10 +15,12 @@ from app.config import get_settings
|
|||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.engine.loans import add_months
|
from app.engine.loans import add_months
|
||||||
from app.engine.recurrence import occurrences
|
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,
|
from app.models.tables import (Account, Category, PlannedItem,
|
||||||
ProjectionResult, RecurringItem,
|
ProjectionResult, RecurringItem,
|
||||||
ScenarioLoan, ScenarioModifier,
|
Scenario, ScenarioLoan, ScenarioModifier,
|
||||||
ScenarioPlannedItem, Transaction)
|
ScenarioPlannedItem, Transaction)
|
||||||
from app.routers.imports import list_imports as _list_imports
|
from app.routers.imports import list_imports as _list_imports
|
||||||
from app.routers.imports import preview as _preview_import
|
from app.routers.imports import preview as _preview_import
|
||||||
@@ -27,6 +31,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions
|
|||||||
from app.routers.scenarios import list_scenarios as _list_scenarios
|
from app.routers.scenarios import list_scenarios as _list_scenarios
|
||||||
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
||||||
from app.services.balances import account_balance, total_balance
|
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.double_count import all_conflicts
|
||||||
from app.services.planning_chart import refresh_planning_chart
|
from app.services.planning_chart import refresh_planning_chart
|
||||||
from app.services.projection_service import refresh_stale_projections
|
from app.services.projection_service import refresh_stale_projections
|
||||||
@@ -54,6 +59,7 @@ def grafana_public_base(request: Request) -> str:
|
|||||||
templates.env.globals["grafana_public_base"] = grafana_public_base
|
templates.env.globals["grafana_public_base"] = grafana_public_base
|
||||||
templates.env.filters["eur"] = eur
|
templates.env.filters["eur"] = eur
|
||||||
templates.env.filters["de_label"] = de_label
|
templates.env.filters["de_label"] = de_label
|
||||||
|
templates.env.filters["pct"] = pct
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -374,3 +380,154 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
|||||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||||
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""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 decimal import Decimal
|
||||||
|
|
||||||
|
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 loan_schedule
|
||||||
|
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,
|
||||||
|
loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||||||
|
l.term_months, l.payout_date, l.repayment_type))
|
||||||
|
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)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -18,36 +19,61 @@ from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
|||||||
from app.services.balances import total_balance
|
from app.services.balances import total_balance
|
||||||
|
|
||||||
|
|
||||||
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
@dataclass(frozen=True)
|
||||||
start_date: date) -> ProjectionResult:
|
class ScenarioInputs:
|
||||||
end = start_date + timedelta(days=horizon_days)
|
"""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."""
|
||||||
recurring = []
|
recurring = []
|
||||||
if scenario.include_recurring:
|
if scenario is None or scenario.include_recurring:
|
||||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||||
r.due_day, r.start_date, r.end_date, r.category_id)
|
r.due_day, r.start_date, r.end_date, r.category_id)
|
||||||
for r in session.execute(select(RecurringItem)).scalars()]
|
for r in session.execute(
|
||||||
|
select(RecurringItem).order_by(RecurringItem.id)).scalars()]
|
||||||
planned = []
|
planned = []
|
||||||
if scenario.include_planned:
|
if scenario is None or scenario.include_planned:
|
||||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||||
for p in session.execute(select(PlannedItem)).scalars()]
|
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 Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
||||||
# include_planned steuert nur die globalen PlannedItems.
|
# include_planned steuert nur die globalen PlannedItems.
|
||||||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
||||||
for p in session.execute(select(ScenarioPlannedItem).where(
|
for p in session.execute(select(ScenarioPlannedItem).where(
|
||||||
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
|
ScenarioPlannedItem.scenario_id == scenario.id)
|
||||||
loans = session.execute(
|
.order_by(ScenarioPlannedItem.id)).scalars()]
|
||||||
|
loans = list(session.execute(
|
||||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||||
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
|
.where(ScenarioLoan.scenario_id == scenario.id)
|
||||||
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
.order_by(Loan.id)).scalars())
|
||||||
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,
|
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||||||
Decimal(m.value), m.end_date)
|
Decimal(m.value), m.end_date)
|
||||||
for m in session.execute(select(ScenarioModifier).where(
|
for m in session.execute(select(ScenarioModifier).where(
|
||||||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
ScenarioModifier.scenario_id == scenario.id)
|
||||||
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
|
.order_by(ScenarioModifier.id)).scalars()]
|
||||||
start_date, end)
|
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)
|
||||||
|
inputs = load_scenario_inputs(session, scenario)
|
||||||
|
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||||||
|
l.term_months, l.payout_date, l.repayment_type)
|
||||||
|
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,
|
proj = project(total_balance(session), start_date, flows, horizon_days,
|
||||||
threshold=get_settings().warn_threshold)
|
threshold=get_settings().warn_threshold)
|
||||||
session.execute(delete(ProjectionPoint).where(
|
session.execute(delete(ProjectionPoint).where(
|
||||||
|
|||||||
@@ -236,3 +236,46 @@ label:has(input:disabled), label:has(select:disabled) {
|
|||||||
#doppelzaehlung-dialog {
|
#doppelzaehlung-dialog {
|
||||||
max-width: 32rem;
|
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; }
|
||||||
|
#aufteilung-kategorie h2 { font-size: 1.1rem; margin-top: 1.5rem; }
|
||||||
|
|||||||
@@ -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,31 @@
|
|||||||
|
{# 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 }}">
|
||||||
|
{% for seg in segmente %}{% 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 %}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<a href="/salden">Salden</a>
|
<a href="/salden">Salden</a>
|
||||||
<a href="/planung">Planung</a>
|
<a href="/planung">Planung</a>
|
||||||
<a href="/szenarien">Szenarien</a>
|
<a href="/szenarien">Szenarien</a>
|
||||||
|
<a href="/aufteilung">Aufteilung</a>
|
||||||
<a href="/admin">Admin</a>
|
<a href="/admin">Admin</a>
|
||||||
<a href="/hilfe">Hilfe</a>
|
<a href="/hilfe">Hilfe</a>
|
||||||
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
|
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
|
||||||
|
|||||||
@@ -91,6 +91,24 @@
|
|||||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||||
Übersicht.
|
Übersicht.
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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}) == []
|
||||||
@@ -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 == []
|
||||||
@@ -11,15 +11,21 @@ H = {"Authorization": "Bearer test-key"}
|
|||||||
|
|
||||||
|
|
||||||
def test_pages_require_login(client):
|
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)
|
r = client.get(path, follow_redirects=False)
|
||||||
assert r.status_code in (302, 303), path
|
assert r.status_code in (302, 303), path
|
||||||
assert r.headers["location"] == "/login"
|
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):
|
def test_pages_render_after_login(client):
|
||||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
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)
|
r = client.get(path)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Finanzberatung" in r.text
|
assert "Finanzberatung" in r.text
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.engine.scenario import (PlainModifier, PlainRecurring, effective_end,
|
||||||
|
modified_amount)
|
||||||
|
from app.models.tables import (Loan, PlannedItem, RecurringItem, Scenario,
|
||||||
|
ScenarioLoan, ScenarioModifier,
|
||||||
|
ScenarioPlannedItem)
|
||||||
|
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) == Decimal("-90.00")
|
||||||
|
assert modified_amount(Decimal("-100.00"), None, 2, mods) == 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 == []
|
||||||
@@ -70,3 +70,14 @@ def test_index_aktionsspalten_ohne_sortierung(client):
|
|||||||
def test_buchungen_tabelle_ist_serverseitig(client):
|
def test_buchungen_tabelle_ist_serverseitig(client):
|
||||||
_login(client)
|
_login(client)
|
||||||
assert 'data-sort-mode="server"' in client.get("/buchungen").text
|
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