diff --git a/docs/superpowers/specs/2026-09-29-ausbaustufe-13-design.md b/docs/superpowers/specs/2026-09-29-ausbaustufe-13-design.md new file mode 100644 index 0000000..b14f82b --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-ausbaustufe-13-design.md @@ -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 `` (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 ``; `` 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 ``. +- 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=` (leer = System-Plan), `fixposten=1`, `einmal=1`, +`kredite=1`, `gesendet=1` (unterscheidet Erstaufruf — alle Filter an — von +„alle Häkchen entfernt"), `kategorie=` (optional, Drill-down). + +**Aufbau von oben nach unten:** +1. **Formular** (GET, immer sichtbar): ``, `