Files
bin/docs/superpowers/plans/2026-09-29-ausbaustufe-13.md
rdmsandClaude Opus 5.5 23557db0fb docs: A13 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 17:01:38 +02:00

88 KiB
Raw Permalink Blame History

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:

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:

    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:
@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

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:

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:

"""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

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:
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:

"""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

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:

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:

"""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

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:

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:

    for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):

In finance/tests/test_sort_templates.py anhängen:

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:

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):

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:

# --- 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:

{# 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:

{% from "_torte_macros.html" import torte %}
<div id="aufteilung-torte">
  {{ torte(haupt, haupt_titel) }}
</div>

finance/app/templates/aufteilung.html:

{% 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:

    <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:
/* 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

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:

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:

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:

@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:

{# 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:

{% 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):

{% 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

    <div>
      {% include "_aufteilung_torte.html" %}
    </div>

ersetzen durch:

    <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:
.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

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:

  <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)

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.