Compare commits

..
14 Commits
Author SHA256 Message Date
rdmsandClaude Opus 5.5 23557db0fb docs: A13 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 17:01:38 +02:00
rdmsandClaude Opus 5.5 987e28847c fix: Aufteilung – Final-Review-Nachträge (Monat <1000, Übrige-Mitglieder, Auth-Test, Doku) (A13)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:56:57 +02:00
rdmsandClaude Opus 5.5 b32f28fff5 chore: Release v0.13.0 (Seite Aufteilung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:49:50 +02:00
rdmsandClaude Opus 5.5 3ef20034d3 feat: Aufteilung – Drill-down in Kategorie per Klick auf Tortensegment (A13-T6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:45:44 +02:00
rdmsandClaude Opus 5.5 4b6144d812 fix: Aufteilung – Tabelle volle Breite, Torte darunter; ungültige Eingaben inline (A13-T5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:29:24 +02:00
rdmsandClaude Opus 5.5 2a62bdca6c feat: Seite Aufteilung – Auswahl, Kennzahlen, Tabelle, Tortendiagramm (A13-T5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:24:20 +02:00
rdmsandClaude Opus 5.5 1cded22e90 feat: Service compute_breakdown – System-Plan/Szenario, Doppelzählungs-Hinweis (A13-T4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:20:38 +02:00
rdmsandClaude Opus 5.5 9e804cd349 fix: Torte – große Segmente als zwei Teilbögen (A13-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:16:49 +02:00
rdmsandClaude Opus 5.5 125d370497 feat: Engine pie – Tortensegmente mit Übrige-Regel und SVG-Pfaden (A13-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:13:11 +02:00
rdmsandClaude Opus 5.5 7555d8e6a4 feat: Engine breakdown – Monatsbeträge je Posten, Summen, Kategorie-Filter (A13-T2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:09:30 +02:00
rdmsandClaude Opus 5.5 0f5bb45a55 refactor: Modifikator-Helfer öffentlich, load_scenario_inputs aus run_projection (A13-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:05:43 +02:00
rdmsandClaude Opus 5.5 acabc3d2a4 docs: A13 – höchstens 8 benannte Tortensegmente (Nutzerentscheidung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:41:21 +02:00
rdmsandClaude Opus 5.5 c015b52a70 docs: Plan Ausbaustufe 13 (8 Tasks)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:49:02 +02:00
rdmsandClaude Opus 5.5 a5f2f23a7e docs: Design Ausbaustufe 13 – Seite Aufteilung (Monatskosten je Kategorie)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:27:58 +02:00
25 changed files with 3486 additions and 35 deletions
+43 -7
View File
@@ -18,11 +18,11 @@ direkt aus `routers/gui.py`.
```mermaid
flowchart TD
subgraph TPL["templates (Jinja2, app/templates/)"]
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html"]
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html<br/>aufteilung.html"]
end
subgraph ROUTERS["routers/"]
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page"]
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page,<br/>aufteilung_page, aufteilung_kategorie_fragment"]
R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"]
R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
R_categories["categories.py<br/>list_categories, create_category_rule"]
@@ -37,7 +37,8 @@ flowchart TD
S_balances["balances.py<br/>account_balance(at), total_balance"]
S_categorize["categorize.py<br/>apply_rules"]
S_suggestions["suggestions.py<br/>suggest_recurring"]
S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"]
S_projection["projection_service.py<br/>run_projection, load_scenario_inputs,<br/>refresh_stale_projections"]
S_breakdown["breakdown.py<br/>compute_breakdown"]
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
S_doublecount["double_count.py<br/>all_conflicts, conflicts_for_recurring,<br/>conflicts_for_loan"]
@@ -50,6 +51,8 @@ flowchart TD
E_recurrence["recurrence.py<br/>occurrences"]
E_projection["projection.py<br/>project"]
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
subgraph PARSERS["parsers/"]
@@ -94,6 +97,9 @@ flowchart TD
R_scenarios --> N_reserved
R_categories --> N_reserved
R_gui --> S_doublecount
R_gui --> S_breakdown
R_gui --> E_breakdown
R_gui --> E_pie
R_admin --> S_admin
ROUTERS -.-> M_tables
@@ -106,6 +112,10 @@ flowchart TD
S_projection --> E_projection
S_projection --> E_scenario
S_projection --> S_balances
S_breakdown --> S_projection
S_breakdown --> E_breakdown
S_breakdown --> E_loans
S_breakdown --> S_doublecount
S_planning_chart --> E_loans
S_planning_chart --> E_recurrence
S_planning_chart --> M_tables
@@ -113,6 +123,9 @@ flowchart TD
E_scenario --> E_loans
E_scenario --> E_recurrence
E_breakdown --> E_scenario
E_breakdown --> E_recurrence
E_breakdown --> E_loans
P_registry --> P_vr
P_registry --> P_hvb
@@ -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).
**Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`,
`projection.py`, `scenario.py`) hat **keinen** Datenbank- oder
Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von
`loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/
`projection.py`, `scenario.py`, `breakdown.py`, `pie.py`) hat **keinen**
Datenbank- oder Request-Zugriff — `scenario.py` und `breakdown.py`
importieren innerhalb der Engine (`loans.py`/`recurrence.py`/`scenario.py`),
sonst keine Abhängigkeiten auf `app.*`. Ein-/
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`,
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
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`,
solange der Konflikt fortbesteht.
**Aufteilung (Ausbaustufe 13).** `/aufteilung` (`routers/gui.py::aufteilung_page`,
GET-Formular, die Auswahl steht in der URL) zeigt für einen Monat die Kosten
je Kategorie aus reiner Planung, nie aus Ist-Buchungen.
`services/breakdown.py::compute_breakdown` lädt die Planung über
`load_scenario_inputs`; `PLAN_NAME` („System-Plan") entspricht
`load_scenario_inputs(session, None)`, ein Szenario wird mit seinen
Änderungen und Krediten gerechnet. Die Rechenregeln stehen in
`engine/breakdown.py` (`breakdown_rows`, `summarize`, `category_rows`,
`share`): Fixposten werden per `recurrence.STEP` auf den Monat normalisiert
(vierteljährlich ÷ 3, jährlich ÷ 12), Einmalposten und Kreditraten zählen
voll im Fälligkeitsmonat, eine Kredit-Auszahlung nie als Einkunft; `FREI`
(„Frei verfügbar") ist der Rest der Einkünfte. Das Tortendiagramm entsteht
serverseitig in `engine/pie.py::pie_slices` (`MIN_SHARE` 3 %, `MAX_NAMED` 8,
Sammelsegment `UEBRIGE`). Drill-down: `GET /aufteilung/kategorie`
(`aufteilung_kategorie_fragment`) liefert die Posten der Kategorie und
tauscht die Haupttorte per `hx-swap-oob` aus (gewähltes Segment markiert);
`sammel=1` wählt die Sammelgruppe „Übrige". Der Doppelzählungs-Hinweis nutzt
`services/double_count.py::all_conflicts`. Keine Tabelle, keine View,
Grafana unberührt.
**Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
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
View File
@@ -1 +1 @@
0.12.0
0.13.0
+128
View File
@@ -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]
+81
View File
@@ -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
+5 -5
View File
@@ -37,7 +37,7 @@ class PlainModifier:
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:
for m in modifiers:
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
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."""
end = r.end_date
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]]:
flows: list[tuple[date, Decimal]] = []
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:
continue
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, _effective_end(r, modifiers)):
r.start_date, effective_end(r, modifiers)):
flows.append((d, amount))
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:
flows.append((p.due, amount))
for d, payout in loan_payouts:
+8
View File
@@ -28,3 +28,11 @@ def eur(value) -> str:
def de_label(value: str) -> str:
return DE_LABELS.get(value, value)
def pct(value) -> str:
"""Decimal("12.5") -> "12,5 %"; None -> "–" (z. B. keine Einkünfte)."""
if value is None:
return "–"
d = Decimal(str(value)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return f"{d}".replace(".", ",") + " %"
+159 -2
View File
@@ -1,7 +1,9 @@
import logging
import re
from datetime import date, timedelta
from decimal import Decimal
from typing import Callable
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
@@ -13,10 +15,12 @@ from app.config import get_settings
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
from app.formats import de_label, eur
from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share
from app.engine.pie import UEBRIGE, pie_slices
from app.formats import de_label, eur, pct
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier,
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction)
from app.routers.imports import list_imports as _list_imports
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.transactions import count_transactions, list_transactions, normalize_sort
from app.services.balances import account_balance, total_balance
from app.services.breakdown import PLAN_NAME, compute_breakdown
from app.services.double_count import all_conflicts
from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections
@@ -54,6 +59,7 @@ def grafana_public_base(request: Request) -> str:
templates.env.globals["grafana_public_base"] = grafana_public_base
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
templates.env.filters["pct"] = pct
router = APIRouter()
log = logging.getLogger(__name__)
@@ -374,3 +380,154 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)):
"scenario_rows": _scenario_rows(session, scenarios, loans),
"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)
+49
View File
@@ -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)
+43 -17
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import date, timedelta
from datetime import datetime, timezone
from decimal import Decimal
@@ -18,36 +19,61 @@ from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
from app.services.balances import total_balance
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
start_date: date) -> ProjectionResult:
end = start_date + timedelta(days=horizon_days)
@dataclass(frozen=True)
class ScenarioInputs:
"""Eingaben einer Szenario-Rechnung (Projektion wie Aufteilung)."""
recurring: list[PlainRecurring]
planned: list[PlainPlanned]
loans: list[Loan]
modifiers: list[PlainModifier]
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten."""
recurring = []
if scenario.include_recurring:
if scenario is None or scenario.include_recurring:
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
r.due_day, r.start_date, r.end_date, r.category_id)
for r in session.execute(select(RecurringItem)).scalars()]
for r in session.execute(
select(RecurringItem).order_by(RecurringItem.id)).scalars()]
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)
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 -
# include_planned steuert nur die globalen PlannedItems.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
loans = session.execute(
ScenarioPlannedItem.scenario_id == scenario.id)
.order_by(ScenarioPlannedItem.id)).scalars()]
loans = list(session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
l.term_months, l.payout_date, l.repayment_type)
for l in loans]
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
.where(ScenarioLoan.scenario_id == scenario.id)
.order_by(Loan.id)).scalars())
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date)
for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)).scalars()]
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
start_date, end)
ScenarioModifier.scenario_id == scenario.id)
.order_by(ScenarioModifier.id)).scalars()]
return ScenarioInputs(recurring, planned, loans, modifiers)
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
start_date: date) -> ProjectionResult:
end = start_date + timedelta(days=horizon_days)
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,
threshold=get_settings().warn_threshold)
session.execute(delete(ProjectionPoint).where(
+43
View File
@@ -236,3 +236,46 @@ label:has(input:disabled), label:has(select:disabled) {
#doppelzaehlung-dialog {
max-width: 32rem;
}
/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette,
Slots 1-8 (Nachbarpaare inkl. Kreisschluss und Grautöne CVD-validiert auf
weißem Grund); Übrige/Frei neutral. Max. 8 benannte + Übrige + Frei. */
.aufteilung {
--seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-4: #eda100;
--seg-5: #e87ba4; --seg-6: #008300; --seg-7: #4a3aa7; --seg-8: #e34948;
--seg-uebrige: #8a8984; --seg-frei: #d6d5cf;
}
.kennzahlen { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; }
.kennzahlen div { display: flex; flex-direction: column; }
.kennzahlen strong { font-size: 1.25rem; }
.tabelle-scroll { overflow-x: auto; }
/* Torte: schmal untereinander, breit SVG neben Legende */
.torte { margin: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; align-items: start; }
@media (min-width: 700px) {
.torte { grid-template-columns: 280px minmax(0, 1fr); }
.torte figcaption { grid-column: 1 / -1; }
}
.torte figcaption { font-weight: bold; margin-bottom: 0.5rem; }
.torte-svg { width: 100%; max-width: 280px; display: block; margin: 0 auto; }
.torte-svg .seg { stroke: #fff; stroke-width: 0.02; }
.seg.s1 { fill: var(--seg-1); } .swatch.s1 { background: var(--seg-1); }
.seg.s2 { fill: var(--seg-2); } .swatch.s2 { background: var(--seg-2); }
.seg.s3 { fill: var(--seg-3); } .swatch.s3 { background: var(--seg-3); }
.seg.s4 { fill: var(--seg-4); } .swatch.s4 { background: var(--seg-4); }
.seg.s5 { fill: var(--seg-5); } .swatch.s5 { background: var(--seg-5); }
.seg.s6 { fill: var(--seg-6); } .swatch.s6 { background: var(--seg-6); }
.seg.s7 { fill: var(--seg-7); } .swatch.s7 { background: var(--seg-7); }
.seg.s8 { fill: var(--seg-8); } .swatch.s8 { background: var(--seg-8); }
.seg.uebrige { fill: var(--seg-uebrige); } .swatch.uebrige { background: var(--seg-uebrige); }
.seg.frei { fill: var(--seg-frei); } .swatch.frei { background: var(--seg-frei); box-shadow: inset 0 0 0 1px #aaa; }
.legende { list-style: none; padding: 0; margin: 0.75rem 0 0; font-size: 0.9rem; }
.legende li { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
.swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; }
.legende-wert { margin-left: auto; color: #555; white-space: nowrap; }
.hinweis-klein { font-size: 0.85rem; color: #555; }
.torte-svg .seg.klickbar { cursor: pointer; }
.torte-svg .seg.klickbar:hover { opacity: 0.85; }
.torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; }
.legende li.gewaehlt { font-weight: bold; }
#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>
+31
View File
@@ -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 %}
+75
View File
@@ -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 %}
+1
View File
@@ -17,6 +17,7 @@
<a href="/salden">Salden</a>
<a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a>
<a href="/aufteilung">Aufteilung</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a>
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
+18
View File
@@ -91,6 +91,24 @@
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
Übersicht.
</li>
<li>
<strong>Aufteilung:</strong> zeigt für einen Monat, welche monatlichen
Kosten je Kategorie anfallen und wie viel der Einkünfte frei verfügbar
bleibt. Grundlage ist immer die Planung (Fixposten, Einmalposten,
Kredite), nie die tatsächlichen Buchungen: „System-Plan“ rechnet mit
allen Posten und Krediten ohne Szenario-Änderungen und ist für jeden
Monat wählbar; ein Szenario (mit seinen Änderungen und Krediten) erst ab
dem laufenden Monat. Fixposten werden auf den Monat umgerechnet
(vierteljährlich ÷ 3, jährlich ÷ 12), Einmalposten und Kreditraten zählen
voll im Monat ihrer Fälligkeit, eine Kredit-Auszahlung zählt nicht als
Einkunft. Die Häkchen „Fixposten“, „Einmalposten“, „Kredite“ gelten für
Einnahmen und Ausgaben. Das Tortendiagramm zeigt die Einkünfte (Kosten je
Kategorie plus „Frei verfügbar“); übersteigen die Kosten die Einkünfte,
zeigt es die Gesamtkosten. Kleine Kategorien (unter 3 %, bzw. ab der
neunten) sind zu „Übrige“ zusammengefasst – höchstens 8 benannte
Kategorien plus „Übrige“ und „Frei verfügbar“. Ein Klick auf ein Segment oder
einen Legendeneintrag zeigt darunter die Posten dieser Kategorie.
</li>
</ul>
<p>
+312
View File
@@ -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 &amp; 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
+174
View File
@@ -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}) == []
+88
View File
@@ -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 == []
+8 -2
View File
@@ -11,15 +11,21 @@ H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
def test_aufteilung_fragment_erfordert_login(client):
r = client.get("/aufteilung/kategorie", follow_redirects=False)
assert r.status_code in (302, 303)
assert r.headers["location"] == "/login"
def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path)
assert r.status_code == 200
assert "Finanzberatung" in r.text
+92
View File
@@ -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
+68
View File
@@ -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 == []
+11
View File
@@ -70,3 +70,14 @@ def test_index_aktionsspalten_ohne_sortierung(client):
def test_buchungen_tabelle_ist_serverseitig(client):
_login(client)
assert 'data-sort-mode="server"' in client.get("/buchungen").text
def test_aufteilung_tabelle_sortierbar_tfoot_fest(client, db):
_login(client)
db.add(RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1))
db.add(RecurringItem(name="G", amount=Decimal("10.00"), rhythm="monthly", due_day=1))
db.commit()
t = client.get("/aufteilung", params={"monat": "2030-03"}).text
table = re.search(r"<table[^>]*>.*?</table>", t, re.S).group(0)
assert 'data-sort-mode="server"' not in table
assert re.search(r"</tbody>\s*<tfoot>", table)