Files
bin/docs/superpowers/specs/2026-09-29-ausbaustufe-13-design.md
2026-09-29 14:41:21 +02:00

245 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.