From c015b52a7099e722ad52cc1472591f2ccb6df6c648b223619d50b16c7f2f73b2 Mon Sep 17 00:00:00 2001 From: wlfb Date: Tue, 29 Sep 2026 13:49:02 +0200 Subject: [PATCH] docs: Plan Ausbaustufe 13 (8 Tasks) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-29-ausbaustufe-13.md | 1778 +++++++++++++++++ 1 file changed, 1778 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-ausbaustufe-13.md diff --git a/docs/superpowers/plans/2026-09-29-ausbaustufe-13.md b/docs/superpowers/plans/2026-09-29-ausbaustufe-13.md new file mode 100644 index 0000000..ae8badf --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-ausbaustufe-13.md @@ -0,0 +1,1778 @@ +# Ausbaustufe 13: Seite „Aufteilung" — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Neue GUI-Seite `/aufteilung`, die für einen gewählten Monat und „System-Plan" bzw. ein Szenario alle Posten auf den Monat umgerechnet als sortierbare Tabelle mit „% der Einkünfte", „Frei verfügbar"/„Unterdeckung" und einem SVG-Tortendiagramm je Kategorie zeigt; Klick auf ein Segment blendet darunter eine zweite Torte mit den Posten dieser Kategorie ein. + +**Architecture:** Zwei neue reine Engine-Module (`engine/breakdown.py` rechnet Zeilen/Summen, `engine/pie.py` Segmente/SVG-Pfade), ein Service `services/breakdown.py` als DB-Brücke, der dieselbe Ladefunktion `load_scenario_inputs` wie die Projektion nutzt (aus `run_projection` herausgezogen, `None` = System-Plan). `routers/gui.py` rendert die Seite per GET-Formular (Auswahl in der URL); der Drill-down lädt per htmx ein Fragment nach und ersetzt die Haupttorte out-of-band, um das gewählte Segment zu markieren. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0, Jinja2, htmx 2.0.6, Inline-SVG, pytest (SQLite-Fixture `db`, `client`). + +**Spec:** `docs/superpowers/specs/2026-09-29-ausbaustufe-13-design.md` + +## Global Constraints + +- **DATENSCHUTZ:** `finance/tests/fixtures/*` = echte Kontodaten, nie lesen/committen/zitieren. Tests nur mit künstlichen Werten; im Live-Smoke nur Statuscodes, Zählwerte, Booleans — keine Namen/Beträge in Commits, Ledger, Chat. +- **Geld = `decimal.Decimal`** überall. Einzige Ausnahme: Winkel/Koordinaten in `engine/pie.py` (reine Geometrie) dürfen `float` sein; `Slice.value`/`Slice.share` bleiben `Decimal`. +- **Deutsch** für GUI-Texte, Meldungen, Kommentare, Commit-Messages; Monatsanzeige `MM.JJJJ`. +- **Engine bleibt rein:** `engine/breakdown.py` und `engine/pie.py` importieren nur Standardbibliothek und `app.engine.*` — kein `app.models`, `app.services`, `app.reserved`. +- **Namen/Konstanten (exakt):** `PLAN_NAME = f"{SYSTEM_PREFIX}Plan"` (= „System-Plan", in `services/breakdown.py`); `FREI = "Frei verfügbar"` (`engine/breakdown.py`); `UEBRIGE = "Übrige"`, `MIN_SHARE = Decimal("0.03")`, `MAX_NAMED = 6` (`engine/pie.py`); Kategorie Kredite = `services.planning_chart.KREDITRATEN` („System-Kreditraten"), ohne Kategorie = `services.planning_chart.UNKATEGORISIERT` („unkategorisiert"). +- **Rundung:** Monatsbeträge je Zeile `quantize(Decimal("0.01"), ROUND_HALF_UP)`; Prozente `quantize(Decimal("0.1"), ROUND_HALF_UP)`; Summen aus gerundeten Zeilen. +- **Tortenpalette** (dataviz-Referenzpalette, Slots 1–6, Validator auf `#ffffff`: adjacent CVD ΔE ≥ 9.1, normal ≥ 19.6 — PASS; Kontrast-WARN wird durch Legende + Tabelle entlastet): `--seg-1:#2a78d6; --seg-2:#eb6834; --seg-3:#1baf7a; --seg-4:#eda100; --seg-5:#e87ba4; --seg-6:#008300; --seg-uebrige:#8a8984; --seg-frei:#d6d5cf`. Höchstens **6 benannte farbige Segmente** (dataviz: Torte ≤ 6 Segmente), danach „Übrige" — Ergänzung zur Spec-3-%-Regel. +- **Kein Dark-Mode:** Die App hat keinen (`style.css` ohne `prefers-color-scheme`); Palette nur für den weißen Hintergrund. +- **UX-Regel:** Formular immer sichtbar; alle Fehler inline, Status 200, dasselbe Template. +- **Keine Schemaänderung**, keine Migration, keine View, Grafana unverändert. +- **Test-Suite grün:** `cd finance && .venv/bin/python -m pytest -q` — Basis **310 passed, 3 skipped**; jede Task erhöht die Zahl. +- **Fable-Testagent-Gate je Task** (CLAUDE.md): Implementer committet, Fable prüft danach; kein Task gilt vor dem Gate als abgeschlossen. +- **Release v0.13.0** (`finance/VERSION`); Backup + Redeploy + Live-Smoke erst nach dem Whole-Branch-Review. + +## Review Focus + +1. **Doppelzählungs-Hinweis nur, wenn Fixposten UND Kredit beide als Zeile enthalten sind** — ohne Filter „Kredite" oder bei einem Szenario ohne diesen Kredit darf kein ⚠ erscheinen — Test in Task 4 (`test_konflikt_nur_wenn_beide_zeilen_enthalten`). +2. **Monatsende-Clamp bei Krediten:** Auszahlung am 31.01. ⇒ Rate am 28.02. muss im Februar zählen, nicht fehlen — Test in Task 2 (`test_kredit_rate_am_monatsende_geclampt`). +3. **Alle Häkchen entfernt ≠ Erstaufruf:** `gesendet=1` ohne Filter ⇒ „keine Posten", Checkboxen bleiben leer; ohne `gesendet` ⇒ alle an — Test in Task 5 (`test_alle_filter_aus_zeigt_keine_posten`). +4. **Sonderzeichen in Kategorienamen** (`Haus & Garten`) müssen im Drill-down-Link korrekt kodiert ankommen und die richtige Kategorie liefern — Test in Task 6 (`test_drilldown_sonderzeichen`). +5. **Echte Kategorie namens „Übrige"** darf nicht mit der Sammelgruppe verwechselt werden (Sammelgruppe über `sammel=1`, echte Kategorie über `kategorie=Übrige`) — Test in Task 6 (`test_echte_kategorie_uebrige_nicht_verwechselt`). + +--- + +### Task 1: Wiederverwendbare Bausteine (Modifikator-Helfer öffentlich, `load_scenario_inputs`) + +**Files:** +- Modify: `finance/app/engine/scenario.py` (`_modified` → `modified_amount`, `_effective_end` → `effective_end`) +- Modify: `finance/app/services/projection_service.py` (`ScenarioInputs`, `load_scenario_inputs`, `run_projection` nutzt sie) +- Create: `finance/tests/test_scenario_inputs.py` + +**Interfaces:** +- Produces: `app.engine.scenario.modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None, modifiers: list[PlainModifier]) -> Decimal | None`; `app.engine.scenario.effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None`; `app.services.projection_service.ScenarioInputs` (frozen dataclass: `recurring: list[PlainRecurring]`, `planned: list[PlainPlanned]`, `loans: list[Loan]` (ORM), `modifiers: list[PlainModifier]`); `app.services.projection_service.load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs` — `None` = System-Plan (alle Fixposten, alle globalen Einmalposten, alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten). + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_scenario_inputs.py`: + +```python +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 == [] +``` + +- [ ] **Step 2: Rot prüfen** — `cd finance && .venv/bin/python -m pytest tests/test_scenario_inputs.py -q` → FAIL (`ImportError: cannot import name 'effective_end'`). + +- [ ] **Step 3: Engine umbenennen** — in `finance/app/engine/scenario.py` `def _modified(` → `def modified_amount(` und `def _effective_end(` → `def effective_end(`; in `build_cashflows` die drei Aufrufe anpassen: + +```python + for r in recurring: + 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)): + flows.append((d, amount)) + for p in planned: + amount = modified_amount(p.amount, p.category_id, None, modifiers) +``` + +Prüfen, dass keine alten Namen übrig sind: `grep -rn "_modified\|_effective_end" app tests` → keine Treffer. + +- [ ] **Step 4: `load_scenario_inputs`** — in `finance/app/services/projection_service.py` Imports ergänzen (`from dataclasses import dataclass`) und vor `run_projection` einfügen; `run_projection` bis einschließlich `flows = …` ersetzen: + +```python +@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 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).order_by(RecurringItem.id)).scalars()] + 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).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) + .order_by(ScenarioPlannedItem.id)).scalars()] + loans = list(session.execute( + select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id) + .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) + .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) +``` + +Der Rest von `run_projection` (ab `proj = project(...)`) bleibt unverändert. + +- [ ] **Step 5: Grün prüfen** — `.venv/bin/python -m pytest -q` → alle grün, 314 passed (310 + 4), 3 skipped; insbesondere `tests/test_projection.py`, `tests/test_projection_refresh.py`, `tests/test_scenario_engine.py` unverändert grün. + +- [ ] **Step 6: Commit** + +```bash +git add finance/app/engine/scenario.py finance/app/services/projection_service.py finance/tests/test_scenario_inputs.py +git commit -m "refactor: Modifikator-Helfer öffentlich, load_scenario_inputs aus run_projection (A13-T1)" +``` + +- [ ] **Step 7: Fable-Gate** — Faktencheck Verhaltensgleichheit: `git diff HEAD~1 -- finance/app/services/projection_service.py` zeigt nur Umzug + `order_by` (Reihenfolge ist für `build_cashflows` irrelevant, da `sorted(flows)`); `include_recurring`/`include_planned`/Szenario-Einmalposten/Szenario-Kredite exakt wie vorher; eine Projektion eines Test-Szenarios vor/nach dem Commit (z. B. per `git stash`-freiem Vergleich zweier Checkouts in einem Wegwerf-Verzeichnis) liefert identische Punkte. + +--- + +### Task 2: Engine `breakdown.py` — Monatszeilen, Summen, Kategorie-Filter + +**Files:** +- Create: `finance/app/engine/breakdown.py` +- Create: `finance/tests/test_breakdown.py` + +**Interfaces:** +- Consumes: `modified_amount`, `effective_end`, `PlainRecurring`, `PlainPlanned`, `PlainModifier`, `CENT` aus `app.engine.scenario`; `STEP` aus `app.engine.recurrence`; `Installment` aus `app.engine.loans`. +- Produces (alle in `app.engine.breakdown`): + - `FREI: str = "Frei verfügbar"` + - `Filters` (frozen dataclass, `recurring: bool = True`, `planned: bool = True`, `loans: bool = True`) + - `PlainLoan` (frozen dataclass, `id: int`, `name: str`, `schedule: list[Installment]`) + - `BreakdownRow` (frozen dataclass, `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`) + - `Summary` (frozen dataclass, `income: Decimal`, `costs: Decimal` (positiv), `free: Decimal`, `deficit: bool`, `pie_on_income: bool`, `category_costs: list[tuple[str, Decimal]]` (positiv, absteigend), `pie_rest: Decimal | None`) + - `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]` (sortiert nach `(amount, name)`, ohne 0-€-Zeilen) + - `summarize(rows: list[BreakdownRow]) -> Summary` + - `share(amount: Decimal, basis: Decimal) -> Decimal | None` (|amount| ÷ basis × 100, eine Nachkommastelle; `basis == 0` ⇒ `None`) + - `category_rows(rows: list[BreakdownRow], categories: set[str]) -> list[BreakdownRow]` (nur Kostenzeilen der Kategorien, Reihenfolge wie `rows`) + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_breakdown.py`: + +```python +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}) == [] +``` + +- [ ] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_breakdown.py -q` → FAIL (`ModuleNotFoundError: app.engine.breakdown`). + +- [ ] **Step 3: Implementierung** — `finance/app/engine/breakdown.py`: + +```python +"""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] +``` + +- [ ] **Step 4: Grün prüfen** — `.venv/bin/python -m pytest tests/test_breakdown.py -q` → PASS; Gesamtsuite grün (Anzahl steigt um die neuen Tests). + +- [ ] **Step 5: Commit** + +```bash +git add finance/app/engine/breakdown.py finance/tests/test_breakdown.py +git commit -m "feat: Engine breakdown – Monatsbeträge je Posten, Summen, Kategorie-Filter (A13-T2)" +``` + +- [ ] **Step 6: Fable-Gate** — unabhängige Handrechnung mit eigenen Werten: jährlicher/quartalsweiser Posten inkl. Rundung auf den Cent, Annuität mit Zins (Rate aus `annuity_payment` nachrechnen), endfällig (Zinsrate und Schlussmonat), Laufzeitgrenzen und `ende`-Modifikator, Modifikator-Kombination wie in `build_cashflows`; Reinheit: `grep -n "^from\|^import" finance/app/engine/breakdown.py` zeigt nur Stdlib und `app.engine.*`; kein `float`. + +--- + +### Task 3: Engine `pie.py` — Tortensegmente und SVG-Pfade + +**Files:** +- Create: `finance/app/engine/pie.py` +- Create: `finance/tests/test_pie.py` + +**Interfaces:** +- Produces (alle in `app.engine.pie`): + - `UEBRIGE = "Übrige"`, `MIN_SHARE = Decimal("0.03")`, `MAX_NAMED = 6` + - `Slice` (frozen dataclass: `label: str`, `value: Decimal`, `share: Decimal` (Prozent, 1 NK), `path: str | None` (SVG-`d` im Einheitskreis, Mittelpunkt 0,0, Start 12 Uhr, im Uhrzeigersinn; `None` = Vollkreis), `color: str` ∈ {`"s1"`…`"s6"`, `"uebrige"`, `"frei"`}, `merged: bool`, `members: tuple[str, ...]`, `start: float`, `end: float` (Anteile 0…1)) + - `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]` + +Regeln: Werte ≤ 0 entfallen; Basis = Summe der Werte + `rest` (falls > 0); sortiert absteigend nach Wert, bei Gleichstand nach Label; benannt werden höchstens `max_named` Werte mit Anteil ≥ `min_share` (Farben `s1`…); die übrigen: genau einer ⇒ bleibt mit eigenem Label, Farbe `"uebrige"`, `merged=False`; zwei oder mehr ⇒ ein Segment `UEBRIGE` (Farbe `"uebrige"`, `merged=True`, `members` in Größenreihenfolge); `rest` (> 0) wird nie zusammengefasst, kommt als letztes Segment mit Label `rest_label` und Farbe `"frei"`; genau ein Segment ⇒ `path=None`. + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_pie.py`: + +```python +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_large_arc_flag(): + a, b = pie_slices([("A", D("70")), ("B", D("30"))]) + assert " A 1 1 0 1 1 " in a.path + assert " A 1 1 0 0 1 " in b.path + + +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_sechs_benannte_segmente(): + sl = pie_slices([(f"K{i}", D("10")) for i in range(8)]) + assert [s.color for s in sl] == ["s1", "s2", "s3", "s4", "s5", "s6", "uebrige"] + assert sl[-1].merged and sl[-1].members == ("K6", "K7") + + +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 +``` + +- [ ] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_pie.py -q` → FAIL (`ModuleNotFoundError: app.engine.pie`). + +- [ ] **Step 3: Implementierung** — `finance/app/engine/pie.py`: + +```python +"""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). +""" +import math +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal + +UEBRIGE = "Übrige" +MIN_SHARE = Decimal("0.03") +MAX_NAMED = 6 +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".."s6", "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) + large = 1 if end - start > 0.5 else 0 + return f"M 0 0 L {x0:.4f} {y0:.4f} A 1 1 0 {large} 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 +``` + +- [ ] **Step 4: Grün prüfen** — `.venv/bin/python -m pytest tests/test_pie.py -q` → PASS; Gesamtsuite grün. + +- [ ] **Step 5: Commit** + +```bash +git add finance/app/engine/pie.py finance/tests/test_pie.py +git commit -m "feat: Engine pie – Tortensegmente mit Übrige-Regel und SVG-Pfaden (A13-T3)" +``` + +- [ ] **Step 6: Fable-Gate** — Geometrie-Faktencheck: für 3–4 eigene Wertelisten Endpunkte der Pfade nachrechnen (12 Uhr Start, Uhrzeigersinn, `large-arc` ab > 50 %), Anteilssumme ≈ 100 % (Rundung), Übrige-Regel (3 %, max. 6, Einzelner behält Namen), `rest` nie zusammengefasst; ein erzeugtes SVG (Wegwerfdatei im Scratchpad, `viewBox="-1.05 -1.05 2.1 2.1"`) rendern und ansehen (z. B. per `rsvg-convert` oder Browser-Container), keine Lücken/Überlappungen. + +--- + +### Task 4: Service `breakdown.py` — DB-Brücke und Doppelzählungs-Hinweis + +**Files:** +- Create: `finance/app/services/breakdown.py` +- Create: `finance/tests/test_breakdown_service.py` + +**Interfaces:** +- Consumes: `load_scenario_inputs`, `ScenarioInputs` (Task 1); `Filters`, `PlainLoan`, `BreakdownRow`, `Summary`, `breakdown_rows`, `summarize` (Task 2); `KREDITRATEN`, `UNKATEGORISIERT` (`services/planning_chart.py`); `all_conflicts`, `Konflikt` (`services/double_count.py`); `SYSTEM_PREFIX` (`app/reserved.py`). +- Produces: `app.services.breakdown.PLAN_NAME: str` (= „System-Plan"); `app.services.breakdown.Breakdown` (frozen dataclass: `month: date` (Monatserster), `rows: list[BreakdownRow]`, `summary: Summary`, `conflicts: list[Konflikt]`); `app.services.breakdown.compute_breakdown(session: Session, month: date, scenario: Scenario | None, filters: Filters) -> Breakdown`. + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_breakdown_service.py`: + +```python +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 == [] +``` + +- [ ] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_breakdown_service.py -q` → FAIL (`ModuleNotFoundError: app.services.breakdown`). + +- [ ] **Step 3: Implementierung** — `finance/app/services/breakdown.py`: + +```python +"""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) +``` + +- [ ] **Step 4: Grün prüfen** — `.venv/bin/python -m pytest tests/test_breakdown_service.py -q` → PASS; Gesamtsuite grün. + +- [ ] **Step 5: Commit** + +```bash +git add finance/app/services/breakdown.py finance/tests/test_breakdown_service.py +git commit -m "feat: Service compute_breakdown – System-Plan/Szenario, Doppelzählungs-Hinweis (A13-T4)" +``` + +- [ ] **Step 6: Fable-Gate** — Faktencheck gegen `run_projection`: gleiche Datenbasis je Szenario (Flags, Szenario-Kredite, Szenario-Einmalposten, Modifikatoren); System-Plan entspricht `planning_chart.planning_monthly_totals` für einen Monat bis auf die Normalisierung der Fixposten (eigenes Beispiel mit nur monatlichen Posten + Einmalposten + Kredit: Kategoriesummen identisch); Konflikt-Filter prüft beide IDs. + +--- + +### Task 5: GUI-Seite `/aufteilung` — Formular, Kennzahlen, Tabelle, Haupttorte + +**Files:** +- Modify: `finance/app/formats.py` (Filter `pct`) +- Modify: `finance/app/routers/gui.py` (Imports, `pct`-Filter registrieren, `_parse_monat`, `_art`, `_segmente`, `_aufteilung_context`, `aufteilung_page`) +- Create: `finance/app/templates/aufteilung.html` +- Create: `finance/app/templates/_aufteilung_torte.html` +- Create: `finance/app/templates/_torte_macros.html` +- Modify: `finance/app/templates/base.html` (Menüpunkt) +- Modify: `finance/app/static/style.css` (Abschnitt Aufteilung) +- Modify: `finance/tests/test_gui.py` (Seitenlisten in `test_pages_require_login`, `test_pages_render_after_login`) +- Modify: `finance/tests/test_sort_templates.py` (tfoot-Test) +- Create: `finance/tests/test_aufteilung_gui.py` + +**Interfaces:** +- Consumes: `compute_breakdown`, `PLAN_NAME` (Task 4); `Filters`, `FREI`, `share`, `BreakdownRow` (Task 2); `pie_slices` (Task 3). +- Produces: `app.formats.pct(value: Decimal | None) -> str` („12,5 %", `None` ⇒ „–"); in `routers/gui.py`: `_aufteilung_context(session, today, monat, szenario, fixposten, einmal, kredite, gesendet) -> dict` mit den Schlüsseln `scenarios`, `filters`, `plan_name`, `monat_wert`, `monat_anzeige`, `szenario_id`, `szenario_name`, `error`, `b` (Breakdown oder None), `s` (Summary), `zeilen` (Liste von `{"r": BreakdownRow, "art": str, "anteil": Decimal | None, "einnahme": bool}`), `frei_anteil`, `haupt` (Liste von Segment-Dicts `{"s": Slice, "q": str | None, "gewaehlt": bool, "zusatz": str | None}`), `haupt_titel`; `_segmente(slices) -> list[dict]`; Jinja-Makro `torte(segmente, titel)` in `_torte_macros.html`; Container-IDs `#aufteilung-torte`. + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_aufteilung_gui.py`: + +```python +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 'Aufteilung' 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'', 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: 3.000,00 €" in t + tfoot = re.search(r"(.*?)", t, re.S).group(1) + assert "Frei verfügbar" in tfoot and "1.900,00 €" in tfoot and "63,3 %" in tfoot + assert '\s*]*>Gehalt.*?", 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 '
]*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"(.*?)", 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'' 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 '
]*>.*?", t, re.S).group(0) + assert 'data-sort-mode="server"' not in table + assert re.search(r"\s*", table) +``` + +- [ ] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_aufteilung_gui.py tests/test_gui.py tests/test_sort_templates.py -q` → FAIL (404 / fehlender Menüpunkt). + +- [ ] **Step 3: `pct`-Filter** — in `finance/app/formats.py` anhängen: + +```python +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(".", ",") + " %" +``` + +- [ ] **Step 4: Router** — in `finance/app/routers/gui.py`: + +Imports ergänzen (`import re` oben; `from app.formats import de_label, eur, pct`; `Scenario` in den `app.models.tables`-Import; neu): + +```python +from app.engine.breakdown import FREI, BreakdownRow, Filters, share +from app.engine.pie import pie_slices +from app.services.breakdown import PLAN_NAME, compute_breakdown +``` + +Nach `templates.env.filters["de_label"] = de_label`: `templates.env.filters["pct"] = pct`. + +Am Dateiende: + +```python +# --- 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: + 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"] = month.strftime("%Y-%m") + ctx["monat_anzeige"] = month.strftime("%m.%Y") + scenario = None + if szenario: + valid_id = szenario.isdigit() and len(szenario) <= 9 # kein Integer-Überlauf in der DB + 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 + + +@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, + session: Session = Depends(get_session)): + ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten, + einmal, kredite, gesendet) + return templates.TemplateResponse(request, "aufteilung.html", ctx) +``` + +- [ ] **Step 5: Templates** — + +`finance/app/templates/_torte_macros.html`: + +```jinja +{# 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}. #} +{% macro torte(segmente, titel) %} +
+
{{ titel }}
+ + {% for seg in segmente %}{% set s = seg.s %} + {% if s.path %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}) + {% else %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}){% endif %} + {% endfor %} + +
+ +{% endmacro %} +``` + +`finance/app/templates/_aufteilung_torte.html`: + +```jinja +{% from "_torte_macros.html" import torte %} +
+ {{ torte(haupt, haupt_titel) }} +
+``` + +`finance/app/templates/aufteilung.html`: + +```jinja +{% extends "base.html" %} +{% block title %}Aufteilung – Finanzberatung{% endblock %} +{% block content %} +
+

Aufteilung

+ +
+ + + + + + + +
+ +{% if monat_anzeige %}

{{ monat_anzeige }} · {{ szenario_name }}

{% endif %} + +{% if error %} + +{% elif b %} +
+
Einkünfte{{ s.income|eur }} €
+
Kosten{{ s.costs|eur }} €
+ {{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}{{ s.free|eur }} €
+
+ {% for k in b.conflicts %} +
⚠ Fixposten „{{ k.posten_name }}“ zählt evtl. doppelt mit Kredit „{{ k.kredit_name }}“.
+ {% endfor %} + {% if not b.rows %} +

Für diese Auswahl gibt es keine Posten.

+ {% else %} + {% if s.deficit %} +
Kosten übersteigen die Einkünfte um {{ (s.costs - s.income)|eur }} € — das Diagramm zeigt die Aufteilung der Gesamtkosten.
+ {% endif %} +
+
+ + + + + + {% for z in zeilen %} + + + + + + + + {% endfor %} + + + + + + + + +
PostenKategorieArtMonatsbetrag% der Einkünfte
{{ z.r.name }}{{ z.r.category }}{{ z.art }}{{ z.r.amount|eur }} €{% if not z.einnahme %}{{ z.anteil|pct }}{% endif %}
{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}{{ s.free|eur }} €{{ frei_anteil|pct }}
+

Gesamte Einkünfte: {{ s.income|eur }} €

+
+
+ {% include "_aufteilung_torte.html" %} +
+
+ {% endif %} +{% endif %} + +{% endblock %} +``` + +`finance/app/templates/base.html` — zwischen Szenarien und Admin: + +```html + Szenarien + Aufteilung + Admin +``` + +- [ ] **Step 6: CSS** — an `finance/app/static/style.css` anhängen: + +```css +/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette, + Slots 1-6 (adjacent CVD-validiert auf weißem Grund); Übrige/Frei neutral. */ +.aufteilung { + --seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; + --seg-4: #eda100; --seg-5: #e87ba4; --seg-6: #008300; + --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; } +.aufteilung-grid { + display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: 1.5rem; align-items: start; +} +@media (max-width: 800px) { .aufteilung-grid { grid-template-columns: minmax(0, 1fr); } } +.torte { margin: 0; } +.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.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; } +``` + +- [ ] **Step 7: Grün prüfen** — `.venv/bin/python -m pytest -q` → alle grün. + +- [ ] **Step 8: Commit** + +```bash +git add finance/app/formats.py finance/app/routers/gui.py finance/app/templates/aufteilung.html finance/app/templates/_aufteilung_torte.html finance/app/templates/_torte_macros.html finance/app/templates/base.html finance/app/static/style.css finance/tests/test_aufteilung_gui.py finance/tests/test_gui.py finance/tests/test_sort_templates.py +git commit -m "feat: Seite Aufteilung – Auswahl, Kennzahlen, Tabelle, Tortendiagramm (A13-T5)" +``` + +- [ ] **Step 9: Fable-Gate** — Live-Approximation: lokaler uvicorn gegen Wegwerf-SQLite (`FB_DATABASE_URL=sqlite://///a13.sqlite`, `FB_GUI_PASSWORD_HASH` per `app.auth.hash_password`, `alembic upgrade head`, `uvicorn app.main:app --port 8199`), synthetische Daten per API; prüfen: Menüpunkt, Erstaufruf alle Filter an, alle Häkchen aus ⇒ „keine Posten", Szenario + Vormonat ⇒ Hinweis und Formular bleibt, Unterdeckung, Tabellensortierung per Klick (`sort.js`, `tfoot` bleibt unten), Torte sichtbar mit Legende; Screenshot per Playwright-Container (`podman run --rm --network host mcr.microsoft.com/playwright/python:v1.47.0-jammy …`) bei 1200 px und 390 px Breite ansehen (kein horizontales Scrollen, Torte unter der Tabelle auf schmal); danach aufräumen. + +--- + +### Task 6: Drill-down — zweite Torte per Klick auf ein Segment + +**Files:** +- Modify: `finance/app/routers/gui.py` (`_basis_query`, `_drilldown`, `aufteilung_page` um `kategorie`/`sammel`, neu `aufteilung_kategorie_fragment`) +- Modify: `finance/app/templates/_torte_macros.html` (Links/htmx, Hervorhebung) +- Modify: `finance/app/templates/_aufteilung_torte.html` (oob) +- Modify: `finance/app/templates/aufteilung.html` (Hinweistext, Container) +- Create: `finance/app/templates/_aufteilung_kategorie.html` +- Modify: `finance/app/static/style.css` (klickbar/gewählt) +- Modify: `finance/tests/test_aufteilung_gui.py` + +**Interfaces:** +- Consumes: `_aufteilung_context`, `_segmente` (Task 5); `category_rows`, `share` (Task 2); `pie_slices`, `UEBRIGE` (Task 3); `pct` (Task 5). +- Produces: `GET /aufteilung/kategorie` (Parameter wie `/aufteilung` plus `kategorie` bzw. `sammel=1`) → Fragment `_aufteilung_kategorie.html` (Status 200) mit `#aufteilung-torte` als `hx-swap-oob="true"`; `aufteilung_page` versteht `kategorie`/`sammel`; ctx-Schlüssel `detail` (`{"titel", "summe", "enthaelt", "segmente"}` oder None), `detail_hinweis` (str oder None), `oob` (bool, nur Fragment). + +- [ ] **Step 1: Failing Tests** — an `finance/tests/test_aufteilung_gui.py` anhängen: + +```python +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 '
' 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']*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 '
' in r.text + assert "gewaehlt" in r.text + assert " 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"))} +``` + +`aufteilung_page` ersetzen und Fragment-Route anhängen: + +```python +@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) +``` + +- [ ] **Step 4: Templates** — + +`finance/app/templates/_torte_macros.html` komplett ersetzen: + +```jinja +{# 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 , kein SVG-, 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) %} +
+
{{ titel }}
+ + {% for seg in segmente %}{% set s = seg.s %} + {% if s.path %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}) + {% else %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}){% endif %} + {% endfor %} + +
+
+{% endmacro %} +``` + +`finance/app/templates/_aufteilung_torte.html` komplett ersetzen: + +```jinja +{% from "_torte_macros.html" import torte %} +
+ {{ torte(haupt, haupt_titel) }} +
+``` + +`finance/app/templates/_aufteilung_kategorie.html` (neu): + +```jinja +{% from "_torte_macros.html" import torte %} +{% if detail_hinweis %} +
{{ detail_hinweis }}
+{% elif detail %} +

{{ detail.titel }}: {{ detail.summe|eur }} € / Monat

+{% if detail.enthaelt %}

Enthält: {{ detail.enthaelt|join(', ') }}

{% endif %} +{{ torte(detail.segmente, 'Posten: ' ~ detail.titel) }} +{% endif %} +{% if oob %}{% include "_aufteilung_torte.html" %}{% endif %} +``` + +`finance/app/templates/aufteilung.html` — den Block + +```jinja +
+ {% include "_aufteilung_torte.html" %} +
+``` + +ersetzen durch: + +```jinja +
+ {% include "_aufteilung_torte.html" %} +

Für Kategorie-Aufteilung auf Segment klicken

+
{% include "_aufteilung_kategorie.html" %}
+
+``` + +- [ ] **Step 5: CSS** — an `finance/app/static/style.css` anhängen: + +```css +.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; } +``` + +- [ ] **Step 6: Grün prüfen** — `.venv/bin/python -m pytest -q` → alle grün. + +- [ ] **Step 7: Commit** + +```bash +git add finance/app/routers/gui.py finance/app/templates/_torte_macros.html finance/app/templates/_aufteilung_torte.html finance/app/templates/_aufteilung_kategorie.html finance/app/templates/aufteilung.html finance/app/static/style.css finance/tests/test_aufteilung_gui.py +git commit -m "feat: Aufteilung – Drill-down in Kategorie per Klick auf Tortensegment (A13-T6)" +``` + +- [ ] **Step 8: Fable-Gate** — Browser-Test gegen lokalen uvicorn (Wegwerf-SQLite wie Task 5, synthetische Daten mit ≥ 3 Kategorien, davon 2 unter 3 %) per Playwright-Container (`--network host`): Login; Klick auf ein SVG-Segment ⇒ `#aufteilung-kategorie` enthält eine Torte, URL enthält `kategorie=`, Seite navigiert NICHT komplett (kein voller Reload: Formularfeld-Wert bleibt, Netzwerk-Request geht an `/aufteilung/kategorie`); Segment in Haupttorte markiert (`.gewaehlt`); Klick auf Legenden-Link ebenso; „Übrige" ⇒ Sammeldetail; Zurück-Button ⇒ vorherige URL; Neuladen der URL mit `kategorie=` zeigt dieselbe zweite Torte; „Frei verfügbar" nicht klickbar; Screenshots ansehen; aufräumen. + +--- + +### Task 7: Doku, Beratungsumgebung, Release-Commit v0.13.0 + +**Files:** +- Modify: `finance/app/templates/hilfe.html` +- Modify: `docs/ARCHITEKTUR.md` +- Modify: `finance/VERSION` +- Modify (anderes Repo): `/home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md` + +- [ ] **Step 1: `hilfe.html`** — im Abschnitt „Konzepte" (Liste `
    `) einen neuen `
  • ` im Stil der vorhandenen: + +```html +
  • + Aufteilung: 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 + siebten) sind zu „Übrige" zusammengefasst. Ein Klick auf ein Segment oder + einen Legendeneintrag zeigt darunter die Posten dieser Kategorie. +
  • +``` + +- [ ] **Step 2: `docs/ARCHITEKTUR.md`** — + - Komponenten-Diagramm: `T_pages` um `aufteilung.html` ergänzen; `R_gui`-Label um `aufteilung_page, aufteilung_kategorie_fragment`; `S_projection`-Label um `load_scenario_inputs`; neue Knoten `S_breakdown["breakdown.py
    compute_breakdown"]` (SERVICES), `E_breakdown["breakdown.py
    breakdown_rows, summarize,
    category_rows, share"]` und `E_pie["pie.py
    pie_slices"]` (ENGINE); Kanten `R_gui --> S_breakdown`, `R_gui --> E_breakdown`, `R_gui --> E_pie`, `S_breakdown --> S_projection`, `S_breakdown --> E_breakdown`, `S_breakdown --> E_loans`, `S_breakdown --> S_doublecount`, `E_breakdown --> E_scenario`, `E_breakdown --> E_recurrence`. + - Abschnitt 4 „Szenario-Engine ist rein": Satz „nur `scenario.py` importiert innerhalb der Engine …" korrigieren zu „`scenario.py` und `breakdown.py` importieren innerhalb der Engine (`loans.py`/`recurrence.py`/`scenario.py`)"; `services/projection_service.py::load_scenario_inputs` als gemeinsame Ladefunktion nennen (`None` = System-Plan). + - Neuer Konzeptabsatz **„Aufteilung (Ausbaustufe 13)."**: `/aufteilung` (GET-Formular, Auswahl in der URL), `services/breakdown.py::compute_breakdown` (`PLAN_NAME` „System-Plan" = `load_scenario_inputs(session, None)`), Rechenregeln aus `engine/breakdown.py` (Normalisierung per `recurrence.STEP`, Einmalposten/Kreditraten voll im Fälligkeitsmonat, Auszahlung nie), Torte serverseitig aus `engine/pie.py::pie_slices` (`MIN_SHARE` 3 %, `MAX_NAMED` 6, `UEBRIGE`), Drill-down `GET /aufteilung/kategorie` mit `hx-swap-oob` der Haupttorte und `sammel=1` für die Sammelgruppe; keine Tabelle/View, Grafana unberührt. + - Nur Namen verwenden, die im Code existieren. + +- [ ] **Step 3: Version** — `finance/VERSION` → `0.13.0`; `.venv/bin/python -m pytest -q` grün. + +- [ ] **Step 4: Commit (Repo `~/bin`)** + +```bash +git add finance/app/templates/hilfe.html docs/ARCHITEKTUR.md finance/VERSION +git commit -m "chore: Release v0.13.0 (Seite Aufteilung)" +``` + +- [ ] **Step 5: Beratungsumgebung** — in `/home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md`, Abschnitt „5. Vergleichen und Empfehlung formulieren", nach dem Grafana-Hinweis einen Punkt ergänzen: „Für die Monatsstruktur einer Variante auf die GUI-Seite **Aufteilung** (`/aufteilung`) verweisen: Monat + Szenario wählen, zeigt Kosten je Kategorie in % der Einkünfte und „Frei verfügbar"; Klick auf ein Tortensegment zeigt die Posten der Kategorie. Rechnet mit Planungsdaten (Fixposten auf den Monat umgerechnet), nicht mit Ist-Buchungen." Commit im Repo `/home/wlfb/fb`: `git -C /home/wlfb/fb add .claude/skills/finanzberatung/SKILL.md && git -C /home/wlfb/fb commit -m "docs: finanzberatung – Hinweis auf Seite Aufteilung (Tool v0.13.0)"` (mit Co-Authored-By-Zeile). Nicht pushen. + +- [ ] **Step 6: Fable-Gate** — Faktencheck aller Doku-Aussagen gegen Code (Konstanten, Routen, Rechenregeln, Funktionsnamen), Mermaid valide (z. B. per `mermaid-cli`-Container rendern), Hilfe-Text stimmt mit dem Verhalten überein. + +--- + +### Task 8: Backup, Redeploy, Live-Smoke (nach Whole-Branch-Review) + +- [ ] **Step 1: Backup** — `./backup_finance_pod.sh` (vor Redeploy). +- [ ] **Step 2: Redeploy** — `./create_pod_finance.sh`; `GET /api/version` (mit Bearer) → `0.13.0`; `alembic current` unverändert gegenüber vor dem Deploy (keine Migration). +- [ ] **Step 3: Live-Smoke** (curl mit Session-Cookie gegen `127.0.0.1:8096`; nur Statuscodes, Zählwerte, Booleans — DATENSCHUTZ): + - Seiten `/`, `/buchungen`, `/salden`, `/import`, `/planung`, `/szenarien`, `/aufteilung`, `/admin`, `/hilfe` → 200; Menüpunkt „Aufteilung" vorhanden. + - `/aufteilung` (laufender Monat, System-Plan): Anzahl ``-Zeilen > 0, `` vorhanden, `&szenario=` → 200 mit Hinweis „Szenarien sind erst ab dem laufenden Monat wählbar" (Boolean). + - Ein `hx-get`-Link der Haupttorte (ersten per grep, ohne ihn auszugeben) als `/aufteilung/kategorie?…` abrufen → 200, enthält `hx-swap-oob` und `