# 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): ``, `