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

1791 lines
88 KiB
Markdown
Raw 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.
# 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 = 8` (`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, alle 8 Slots, Validator auf `#ffffff`: adjacent CVD ΔE ≥ 9.1, normal ≥ 19.6 — PASS; kreisförmige Nachbarschaften ebenfalls geprüft: s8↔s1 ΔE 21.6/32.3, s7/s8↔Übrige ≥ 8.8/18.9, s8↔Frei 25.1/31.3, Übrige↔Frei 24.3 — PASS; Kontrast-WARN für s3/s4/s5 wird durch Legende + Tabelle entlastet): `--seg-1:#2a78d6; --seg-2:#eb6834; --seg-3:#1baf7a; --seg-4:#eda100; --seg-5:#e87ba4; --seg-6:#008300; --seg-7:#4a3aa7; --seg-8:#e34948; --seg-uebrige:#8a8984; --seg-frei:#d6d5cf`. **Nutzerentscheidung (2026-09-29): höchstens 8 benannte farbige Segmente + „Übrige" + „Frei verfügbar" = max. 10 Segmente** — 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 = 8`
- `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"`…`"s8"`, `"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_acht_benannte_segmente():
sl = pie_slices([(f"K{i}", D("10")) for i in range(10)], rest=D("5"),
rest_label="Frei verfügbar")
assert [s.color for s in sl] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8",
"uebrige", "frei"]
assert sl[-2].merged and sl[-2].members == ("K8", "K9")
def test_acht_kategorien_ohne_uebrige():
sl = pie_slices([(f"K{i}", D("10")) for i in range(8)])
assert [s.color for s in sl] == [f"s{i}" for i in range(1, 9)]
assert not any(s.merged for s in sl)
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 = 8
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".."s8", "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. 8 benannte, Einzelner behält Namen, max. 10 Segmente), `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 '<a href="/aufteilung">Aufteilung</a>' 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'<option value="">System-Plan</option>', 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: <strong>3.000,00 €</strong>" in t
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
assert "Frei verfügbar" in tfoot and "1.900,00 €" in tfoot and "63,3 %" in tfoot
assert '<svg class="torte-svg"' in t
# Präfix ohne schließendes Anführungszeichen: ab Task 6 folgt „ klickbar".
assert 'class="seg s1' in t and 'class="seg frei"' in t
assert "Anteile an den Einkünften" in t
def test_einnahmezeile_ohne_prozent(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
zeile = re.search(r"<tr>\s*<td[^>]*>Gehalt</td>.*?</tr>", 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 '<form method="get" action="/aufteilung"' in t
assert not re.findall(r'type="checkbox"[^>]*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"<tfoot>(.*?)</tfoot>", 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'<option value="{sc.id}" selected>Sparen</option>' 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 '<form method="get" action="/aufteilung"' in r.text
assert "<table" not in r.text
def test_system_plan_in_vergangenheit_erlaubt(client, db):
_login(client)
_daten(db)
r = _get(client, monat="2020-01")
assert r.status_code == 200 and "<table" in r.text
def test_ungueltiger_monat_und_unbekanntes_szenario(client):
_login(client)
for params, text in (({"monat": "2030-13"}, "Ungültiger Monat"),
({"monat": "abc"}, "Ungültiger Monat"),
({"monat": MONAT, "szenario": "999"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "x"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "9" * 30}, "Unbekanntes Szenario")):
r = _get(client, **params)
assert r.status_code == 200 and text in r.text
assert '<form method="get" action="/aufteilung"' in r.text
def test_doppelzaehlungs_hinweis(client, db):
_login(client)
_daten(db)
db.add_all([
RecurringItem(name="Autorate", amount=D("-100.00"), rhythm="monthly", due_day=15),
Loan(name="Autokredit", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2030, 1, 15), repayment_type="annuity"),
])
db.commit()
assert "⚠ Fixposten „Autorate“ zählt evtl. doppelt mit Kredit „Autokredit“" in _get(client, monat=MONAT).text
assert "⚠" not in _get(client, monat=MONAT, gesendet="1", fixposten="1", einmal="1").text
```
In `finance/tests/test_gui.py` beide Seitentupel um `"/aufteilung"` ergänzen:
```python
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
```
In `finance/tests/test_sort_templates.py` anhängen:
```python
def test_aufteilung_tabelle_sortierbar_tfoot_fest(client, db):
_login(client)
db.add(RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1))
db.add(RecurringItem(name="G", amount=Decimal("10.00"), rhythm="monthly", due_day=1))
db.commit()
t = client.get("/aufteilung", params={"monat": "2030-03"}).text
table = re.search(r"<table[^>]*>.*?</table>", t, re.S).group(0)
assert 'data-sort-mode="server"' not in table
assert re.search(r"</tbody>\s*<tfoot>", 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) %}
<figure class="torte">
<figcaption>{{ titel }}</figcaption>
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
{% for seg in segmente %}{% set s = seg.s %}
{% if s.path %}<path d="{{ s.path }}" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path>
{% else %}<circle r="1" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
{% endfor %}
</svg>
<ul class="legende">
{% for seg in segmente %}{% set s = seg.s %}
<li><span class="swatch {{ s.color }}"></span><span>{{ s.label }}</span><span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li>
{% endfor %}
</ul>
</figure>
{% endmacro %}
```
`finance/app/templates/_aufteilung_torte.html`:
```jinja
{% from "_torte_macros.html" import torte %}
<div id="aufteilung-torte">
{{ torte(haupt, haupt_titel) }}
</div>
```
`finance/app/templates/aufteilung.html`:
```jinja
{% extends "base.html" %}
{% block title %}Aufteilung – Finanzberatung{% endblock %}
{% block content %}
<div class="aufteilung">
<h1>Aufteilung</h1>
<form method="get" action="/aufteilung" class="filter-form">
<input type="hidden" name="gesendet" value="1">
<label>Monat <input type="month" name="monat" value="{{ monat_wert }}" required></label>
<label>Szenario
<select name="szenario">
<option value="">{{ plan_name }}</option>
{% for sc in scenarios %}<option value="{{ sc.id }}"{% if sc.id == szenario_id %} selected{% endif %}>{{ sc.name }}</option>{% endfor %}
</select>
</label>
<label><span><input type="checkbox" name="fixposten" value="1"{% if filters.recurring %} checked{% endif %}> Fixposten</span></label>
<label><span><input type="checkbox" name="einmal" value="1"{% if filters.planned %} checked{% endif %}> Einmalposten</span></label>
<label><span><input type="checkbox" name="kredite" value="1"{% if filters.loans %} checked{% endif %}> Kredite</span></label>
<button type="submit">Anzeigen</button>
</form>
{% if monat_anzeige %}<p class="hinweis-klein">{{ monat_anzeige }} · {{ szenario_name }}</p>{% endif %}
{% if error %}
<div class="warning" role="alert">{{ error }}</div>
{% elif b %}
<div class="kennzahlen">
<div><span>Einkünfte</span><strong>{{ s.income|eur }} €</strong></div>
<div><span>Kosten</span><strong>{{ s.costs|eur }} €</strong></div>
<div{% if s.deficit %} class="neg"{% endif %}><span>{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</span><strong>{{ s.free|eur }} €</strong></div>
</div>
{% for k in b.conflicts %}
<div class="warning">⚠ Fixposten „{{ k.posten_name }}“ zählt evtl. doppelt mit Kredit „{{ k.kredit_name }}“.</div>
{% endfor %}
{% if not b.rows %}
<p>Für diese Auswahl gibt es keine Posten.</p>
{% else %}
{% if s.deficit %}
<div class="warning">Kosten übersteigen die Einkünfte um {{ (s.costs - s.income)|eur }} € — das Diagramm zeigt die Aufteilung der Gesamtkosten.</div>
{% endif %}
<div class="aufteilung-grid">
<div>
<table>
<thead>
<tr><th>Posten</th><th>Kategorie</th><th>Art</th><th>Monatsbetrag</th><th>% der Einkünfte</th></tr>
</thead>
<tbody>
{% for z in zeilen %}
<tr>
<td data-sort="{{ z.r.name }}">{{ z.r.name }}</td>
<td>{{ z.r.category }}</td>
<td>{{ z.art }}</td>
<td class="amount{% if z.r.amount < 0 %} neg{% endif %}" data-sort="{{ z.r.amount }}">{{ z.r.amount|eur }} €</td>
<td class="amount" data-sort="{{ z.anteil if z.anteil is not none else '' }}">{% if not z.einnahme %}{{ z.anteil|pct }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr{% if s.deficit %} class="neg"{% endif %}>
<th colspan="3">{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</th>
<td class="amount">{{ s.free|eur }} €</td>
<td class="amount">{{ frei_anteil|pct }}</td>
</tr>
</tfoot>
</table>
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
</div>
<div>
{% include "_aufteilung_torte.html" %}
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
```
`finance/app/templates/base.html` — zwischen Szenarien und Admin:
```html
<a href="/szenarien">Szenarien</a>
<a href="/aufteilung">Aufteilung</a>
<a href="/admin">Admin</a>
```
- [ ] **Step 6: CSS** — an `finance/app/static/style.css` anhängen:
```css
/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette,
Slots 1-8 (Nachbarpaare inkl. Kreisschluss und Grautöne CVD-validiert auf
weißem Grund); Übrige/Frei neutral. Max. 8 benannte + Übrige + Frei. */
.aufteilung {
--seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-4: #eda100;
--seg-5: #e87ba4; --seg-6: #008300; --seg-7: #4a3aa7; --seg-8: #e34948;
--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.s7 { fill: var(--seg-7); } .swatch.s7 { background: var(--seg-7); }
.seg.s8 { fill: var(--seg-8); } .swatch.s8 { background: var(--seg-8); }
.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:////<scratchpad>/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 '<div id="aufteilung-kategorie">' 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'<path[^>]*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 '<div id="aufteilung-torte" hx-swap-oob="true">' in r.text
assert "gewaehlt" in r.text
assert "<html" not in r.text
def test_seite_mit_kategorie_zeigt_zweite_torte(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT, kategorie="Versicherung").text
assert "Versicherung: 100,00 € / Monat" in t
assert 'hx-swap-oob' not in t
def test_unbekannte_kategorie_hinweis(client, db):
_login(client)
_daten(db)
for pfad in ("/aufteilung", "/aufteilung/kategorie"):
r = _drill(client, pfad, kategorie="Gibtsnicht")
assert r.status_code == 200
assert "Kategorie „Gibtsnicht“ hat in dieser Auswahl keine Kosten." in r.text
def test_fragment_bei_fehler_nur_hinweis(client):
_login(client)
r = client.get("/aufteilung/kategorie", params={"monat": "2030-13", "kategorie": "X"})
assert r.status_code == 200
assert "Ungültiger Monat" in r.text and "hx-swap-oob" not in r.text
def _viele_kategorien(db):
eink = Category(name="Einkommen")
kleine = [Category(name="Klein A"), Category(name="Klein B")]
gross = Category(name="Groß")
db.add_all([eink, gross, *kleine])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Posten G", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=gross.id),
RecurringItem(name="Posten A", amount=D("-10.00"), rhythm="monthly", due_day=1, category_id=kleine[0].id),
RecurringItem(name="Posten B", amount=D("-20.00"), rhythm="monthly", due_day=1, category_id=kleine[1].id),
])
db.commit()
def test_sammelgruppe_uebrige(client, db):
_login(client)
_viele_kategorien(db)
t = _get(client, monat=MONAT).text
assert "sammel=1" in t
r = _drill(client, sammel="1")
assert "Übrige: 30,00 € / Monat" in r.text
assert "Enthält: Klein B, Klein A" in r.text
assert "Posten A" in r.text and "Posten B" in r.text
def test_echte_kategorie_uebrige_nicht_verwechselt(client, db):
_login(client)
_viele_kategorien(db)
echte = Category(name="Übrige")
db.add(echte)
db.flush()
db.add(RecurringItem(name="Echt", amount=D("-500.00"), rhythm="monthly", due_day=1,
category_id=echte.id))
db.commit()
r = _drill(client, kategorie="Übrige")
assert "Übrige: 500,00 € / Monat" in r.text
assert "Echt" in r.text and "Posten A" not in r.text
r = _drill(client, sammel="1")
assert "Übrige: 30,00 € / Monat" in r.text and "Echt" not in r.text
def test_drilldown_sonderzeichen(client, db):
_login(client)
eink, haus = Category(name="Einkommen"), Category(name="Haus & Garten")
db.add_all([eink, haus])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Rasen", amount=D("-300.00"), rhythm="monthly", due_day=1, category_id=haus.id),
])
db.commit()
t = _get(client, monat=MONAT).text
assert "kategorie=Haus+%26+Garten" in t
r = _drill(client, kategorie="Haus & Garten")
assert "Haus &amp; Garten: 300,00 € / Monat" in r.text and "Rasen" in r.text
```
- [ ] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_aufteilung_gui.py -q` → die neuen Tests FAIL (404 bzw. fehlende Links).
- [ ] **Step 3: Router** — in `finance/app/routers/gui.py`: Imports ergänzen (`from urllib.parse import urlencode`; `category_rows` in den `app.engine.breakdown`-Import; `UEBRIGE` zum `app.engine.pie`-Import). Nach `_aufteilung_context` einfügen:
```python
def _basis_query(ctx: dict) -> 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 <path>, kein SVG-<a>, 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) %}
<figure class="torte">
<figcaption>{{ titel }}</figcaption>
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
{% for seg in segmente %}{% set s = seg.s %}
{% if s.path %}<path d="{{ s.path }}" {{ seg_attrs(seg) }}><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path>
{% else %}<circle r="1" {{ seg_attrs(seg) }}><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
{% endfor %}
</svg>
<ul class="legende">
{% for seg in segmente %}{% set s = seg.s %}
<li{% if seg.gewaehlt %} class="gewaehlt"{% endif %}><span class="swatch {{ s.color }}"></span>
{%- if seg.q %}<a href="/aufteilung?{{ seg.q }}" hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}">{{ s.label }}</a>
{%- else %}<span>{{ s.label }}</span>{% endif %}
<span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li>
{% endfor %}
</ul>
</figure>
{% endmacro %}
```
`finance/app/templates/_aufteilung_torte.html` komplett ersetzen:
```jinja
{% from "_torte_macros.html" import torte %}
<div id="aufteilung-torte"{% if oob %} hx-swap-oob="true"{% endif %}>
{{ torte(haupt, haupt_titel) }}
</div>
```
`finance/app/templates/_aufteilung_kategorie.html` (neu):
```jinja
{% from "_torte_macros.html" import torte %}
{% if detail_hinweis %}
<div class="warning">{{ detail_hinweis }}</div>
{% elif detail %}
<h2>{{ detail.titel }}: {{ detail.summe|eur }} € / Monat</h2>
{% if detail.enthaelt %}<p class="hinweis-klein">Enthält: {{ detail.enthaelt|join(', ') }}</p>{% endif %}
{{ torte(detail.segmente, 'Posten: ' ~ detail.titel) }}
{% endif %}
{% if oob %}{% include "_aufteilung_torte.html" %}{% endif %}
```
`finance/app/templates/aufteilung.html` — den Block
```jinja
<div>
{% include "_aufteilung_torte.html" %}
</div>
```
ersetzen durch:
```jinja
<div>
{% include "_aufteilung_torte.html" %}
<p class="hinweis-klein">Für Kategorie-Aufteilung auf Segment klicken</p>
<div id="aufteilung-kategorie">{% include "_aufteilung_kategorie.html" %}</div>
</div>
```
- [ ] **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 `<ul>`) einen neuen `<li>` im Stil der vorhandenen:
```html
<li>
<strong>Aufteilung:</strong> 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
neunten) sind zu „Übrige" zusammengefasst – höchstens 8 benannte
Kategorien plus „Übrige" und „Frei verfügbar". Ein Klick auf ein Segment oder
einen Legendeneintrag zeigt darunter die Posten dieser Kategorie.
</li>
```
- [ ] **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<br/>compute_breakdown"]` (SERVICES), `E_breakdown["breakdown.py<br/>breakdown_rows, summarize,<br/>category_rows, share"]` und `E_pie["pie.py<br/>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` 8, `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 `<tbody>`-Zeilen > 0, `<tfoot>` vorhanden, `<svg class="torte-svg"` vorhanden.
- `/aufteilung?monat=<Vormonat>&szenario=<irgendeine ID>` → 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 `<svg` (Booleans).
- Bestandszahlen (Buchungen, Konten, Kategorien, Szenarien, Kredite, wiederkehrende/Einmalposten) vor = nach; `planning_monthly`/`projection_points` unberührt von Seitenaufrufen der Aufteilung (Zeilenzahl vor/nach Aufruf gleich).
- [ ] **Step 4: Live-Klicktest** — Playwright-Container gegen `https://fbwl.creature-go.com/` (Secure-Cookie aktiv, Login nur dort): Passwort innerhalb des Skripts aus `~/.local/share/finance_pod/.env` lesen (nie ausgeben); `/aufteilung` öffnen, erstes `.seg.klickbar` klicken ⇒ Boolean „zweite Torte geladen", „URL enthält kategorie= oder sammel=", „Segment markiert". Keine Screenshots/Inhalte weitergeben.
- [ ] **Step 5: Fable-Release-Gate** (read-only Live-Verifikation) + Ledger-Eintrag „AUSBAUSTUFE 13 ABGESCHLOSSEN" in `.superpowers/sdd/progress.md`; Plan-Haken; Commit `docs: A13 Plan-Haken`.