docs: Design Ausbaustufe 13 – Seite Aufteilung (Monatskosten je Kategorie)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
Reference in New Issue
Block a user