# 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). - [x] **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 == [] ``` - [x] **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'`). - [x] **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. - [x] **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. - [x] **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. - [x] **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)" ``` - [x] **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`) - [x] **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}) == [] ``` - [x] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_breakdown.py -q` → FAIL (`ModuleNotFoundError: app.engine.breakdown`). - [x] **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] ``` - [x] **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). - [x] **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)" ``` - [x] **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`. - [x] **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 ``` - [x] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_pie.py -q` → FAIL (`ModuleNotFoundError: app.engine.pie`). - [x] **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 ``` - [x] **Step 4: Grün prüfen** — `.venv/bin/python -m pytest tests/test_pie.py -q` → PASS; Gesamtsuite grün. - [x] **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)" ``` - [x] **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`. - [x] **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 == [] ``` - [x] **Step 2: Rot prüfen** — `.venv/bin/python -m pytest tests/test_breakdown_service.py -q` → FAIL (`ModuleNotFoundError: app.services.breakdown`). - [x] **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) ``` - [x] **Step 4: Grün prüfen** — `.venv/bin/python -m pytest tests/test_breakdown_service.py -q` → PASS; Gesamtsuite grün. - [x] **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)" ``` - [x] **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`. - [x] **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 '