docs: A13 – höchstens 8 benannte Tortensegmente (Nutzerentscheidung)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:41:21 +02:00
co-authored by Claude Opus 5.5
parent c015b52a70
commit acabc3d2a4
2 changed files with 30 additions and 16 deletions
@@ -16,9 +16,9 @@
- **Geld = `decimal.Decimal`** überall. Einzige Ausnahme: Winkel/Koordinaten in `engine/pie.py` (reine Geometrie) dürfen `float` sein; `Slice.value`/`Slice.share` bleiben `Decimal`. - **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`. - **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`. - **Engine bleibt rein:** `engine/breakdown.py` und `engine/pie.py` importieren nur Standardbibliothek und `app.engine.*` — kein `app.models`, `app.services`, `app.reserved`.
- **Namen/Konstanten (exakt):** `PLAN_NAME = f"{SYSTEM_PREFIX}Plan"` (= „System-Plan", in `services/breakdown.py`); `FREI = "Frei verfügbar"` (`engine/breakdown.py`); `UEBRIGE = "Übrige"`, `MIN_SHARE = Decimal("0.03")`, `MAX_NAMED = 6` (`engine/pie.py`); Kategorie Kredite = `services.planning_chart.KREDITRATEN` („System-Kreditraten"), ohne Kategorie = `services.planning_chart.UNKATEGORISIERT` („unkategorisiert"). - **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. - **Rundung:** Monatsbeträge je Zeile `quantize(Decimal("0.01"), ROUND_HALF_UP)`; Prozente `quantize(Decimal("0.1"), ROUND_HALF_UP)`; Summen aus gerundeten Zeilen.
- **Tortenpalette** (dataviz-Referenzpalette, Slots 1–6, Validator auf `#ffffff`: adjacent CVD ΔE ≥ 9.1, normal ≥ 19.6 — PASS; Kontrast-WARN wird durch Legende + Tabelle entlastet): `--seg-1:#2a78d6; --seg-2:#eb6834; --seg-3:#1baf7a; --seg-4:#eda100; --seg-5:#e87ba4; --seg-6:#008300; --seg-uebrige:#8a8984; --seg-frei:#d6d5cf`. Höchstens **6 benannte farbige Segmente** (dataviz: Torte ≤ 6 Segmente), danach „Übrige" — Ergänzung zur Spec-3-%-Regel. - **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. - **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. - **UX-Regel:** Formular immer sichtbar; alle Fehler inline, Status 200, dasselbe Template.
- **Keine Schemaänderung**, keine Migration, keine View, Grafana unverändert. - **Keine Schemaänderung**, keine Migration, keine View, Grafana unverändert.
@@ -566,8 +566,8 @@ git commit -m "feat: Engine breakdown – Monatsbeträge je Posten, Summen, Kate
**Interfaces:** **Interfaces:**
- Produces (alle in `app.engine.pie`): - Produces (alle in `app.engine.pie`):
- `UEBRIGE = "Übrige"`, `MIN_SHARE = Decimal("0.03")`, `MAX_NAMED = 6` - `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"`…`"s6"`, `"uebrige"`, `"frei"`}, `merged: bool`, `members: tuple[str, ...]`, `start: float`, `end: float` (Anteile 0…1)) - `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]` - `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`. 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`.
@@ -623,10 +623,18 @@ def test_einzelner_kleiner_wert_behaelt_namen():
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("B", "uebrige", False)] assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("B", "uebrige", False)]
def test_hoechstens_sechs_benannte_segmente(): 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)]) sl = pie_slices([(f"K{i}", D("10")) for i in range(8)])
assert [s.color for s in sl] == ["s1", "s2", "s3", "s4", "s5", "s6", "uebrige"] assert [s.color for s in sl] == [f"s{i}" for i in range(1, 9)]
assert sl[-1].merged and sl[-1].members == ("K6", "K7") assert not any(s.merged for s in sl)
def test_rest_wird_nie_zusammengefasst(): def test_rest_wird_nie_zusammengefasst():
@@ -662,7 +670,7 @@ from decimal import ROUND_HALF_UP, Decimal
UEBRIGE = "Übrige" UEBRIGE = "Übrige"
MIN_SHARE = Decimal("0.03") MIN_SHARE = Decimal("0.03")
MAX_NAMED = 6 MAX_NAMED = 8
PCT = Decimal("0.1") PCT = Decimal("0.1")
@@ -672,7 +680,7 @@ class Slice:
value: Decimal value: Decimal
share: Decimal # Prozent der Tortenbasis, eine Nachkommastelle share: Decimal # Prozent der Tortenbasis, eine Nachkommastelle
path: str | None # SVG-d; None = Vollkreis (einziges Segment) path: str | None # SVG-d; None = Vollkreis (einziges Segment)
color: str # "s1".."s6", "uebrige", "frei" color: str # "s1".."s8", "uebrige", "frei"
merged: bool merged: bool
members: tuple[str, ...] members: tuple[str, ...]
start: float # Anteil 0..1 start: float # Anteil 0..1
@@ -736,7 +744,7 @@ 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)" git commit -m "feat: Engine pie – Tortensegmente mit Übrige-Regel und SVG-Pfaden (A13-T3)"
``` ```
- [ ] **Step 6: Fable-Gate** — Geometrie-Faktencheck: für 3–4 eigene Wertelisten Endpunkte der Pfade nachrechnen (12 Uhr Start, Uhrzeigersinn, `large-arc` ab > 50 %), Anteilssumme ≈ 100 % (Rundung), Übrige-Regel (3 %, max. 6, Einzelner behält Namen), `rest` nie zusammengefasst; ein erzeugtes SVG (Wegwerfdatei im Scratchpad, `viewBox="-1.05 -1.05 2.1 2.1"`) rendern und ansehen (z. B. per `rsvg-convert` oder Browser-Container), keine Lücken/Überlappungen. - [ ] **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.
--- ---
@@ -1343,10 +1351,11 @@ def aufteilung_page(request: Request, monat: str | None = None,
```css ```css
/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette, /* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette,
Slots 1-6 (adjacent CVD-validiert auf weißem Grund); Übrige/Frei neutral. */ Slots 1-8 (Nachbarpaare inkl. Kreisschluss und Grautöne CVD-validiert auf
weißem Grund); Übrige/Frei neutral. Max. 8 benannte + Übrige + Frei. */
.aufteilung { .aufteilung {
--seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-4: #eda100;
--seg-4: #eda100; --seg-5: #e87ba4; --seg-6: #008300; --seg-5: #e87ba4; --seg-6: #008300; --seg-7: #4a3aa7; --seg-8: #e34948;
--seg-uebrige: #8a8984; --seg-frei: #d6d5cf; --seg-uebrige: #8a8984; --seg-frei: #d6d5cf;
} }
.kennzahlen { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; } .kennzahlen { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; }
@@ -1367,6 +1376,8 @@ def aufteilung_page(request: Request, monat: str | None = None,
.seg.s4 { fill: var(--seg-4); } .swatch.s4 { background: var(--seg-4); } .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.s5 { fill: var(--seg-5); } .swatch.s5 { background: var(--seg-5); }
.seg.s6 { fill: var(--seg-6); } .swatch.s6 { background: var(--seg-6); } .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.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; } .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 { list-style: none; padding: 0; margin: 0.75rem 0 0; font-size: 0.9rem; }
@@ -1738,7 +1749,8 @@ git commit -m "feat: Aufteilung – Drill-down in Kategorie per Klick auf Torten
Einnahmen und Ausgaben. Das Tortendiagramm zeigt die Einkünfte (Kosten je Einnahmen und Ausgaben. Das Tortendiagramm zeigt die Einkünfte (Kosten je
Kategorie plus „Frei verfügbar"); übersteigen die Kosten die Einkünfte, Kategorie plus „Frei verfügbar"); übersteigen die Kosten die Einkünfte,
zeigt es die Gesamtkosten. Kleine Kategorien (unter 3 %, bzw. ab der zeigt es die Gesamtkosten. Kleine Kategorien (unter 3 %, bzw. ab der
siebten) sind zu „Übrige" zusammengefasst. Ein Klick auf ein Segment oder 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. einen Legendeneintrag zeigt darunter die Posten dieser Kategorie.
</li> </li>
``` ```
@@ -1746,7 +1758,7 @@ git commit -m "feat: Aufteilung – Drill-down in Kategorie per Klick auf Torten
- [ ] **Step 2: `docs/ARCHITEKTUR.md`** — - [ ] **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`. - 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). - Abschnitt 4 „Szenario-Engine ist rein": Satz „nur `scenario.py` importiert innerhalb der Engine …" korrigieren zu „`scenario.py` und `breakdown.py` importieren innerhalb der Engine (`loans.py`/`recurrence.py`/`scenario.py`)"; `services/projection_service.py::load_scenario_inputs` als gemeinsame Ladefunktion nennen (`None` = System-Plan).
- Neuer Konzeptabsatz **„Aufteilung (Ausbaustufe 13)."**: `/aufteilung` (GET-Formular, Auswahl in der URL), `services/breakdown.py::compute_breakdown` (`PLAN_NAME` „System-Plan" = `load_scenario_inputs(session, None)`), Rechenregeln aus `engine/breakdown.py` (Normalisierung per `recurrence.STEP`, Einmalposten/Kreditraten voll im Fälligkeitsmonat, Auszahlung nie), Torte serverseitig aus `engine/pie.py::pie_slices` (`MIN_SHARE` 3 %, `MAX_NAMED` 6, `UEBRIGE`), Drill-down `GET /aufteilung/kategorie` mit `hx-swap-oob` der Haupttorte und `sammel=1` für die Sammelgruppe; keine Tabelle/View, Grafana unberührt. - 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. - Nur Namen verwenden, die im Code existieren.
- [ ] **Step 3: Version** — `finance/VERSION` → `0.13.0`; `.venv/bin/python -m pytest -q` grün. - [ ] **Step 3: Version** — `finance/VERSION` → `0.13.0`; `.venv/bin/python -m pytest -q` grün.
@@ -33,7 +33,9 @@
- **„Frei verfügbar" als Tabellenzeile** im `<tfoot>` (bei Unterdeckung - **„Frei verfügbar" als Tabellenzeile** im `<tfoot>` (bei Unterdeckung
„Unterdeckung", rot). Darunter „Gesamte Einkünfte". „Unterdeckung", rot). Darunter „Gesamte Einkünfte".
- **Kategorien < 3 %** der Tortenbasis werden nur in der Torte zu „Übrige" - **Kategorien < 3 %** der Tortenbasis werden nur in der Torte zu „Übrige"
zusammengefasst. zusammengefasst. Zusätzlich (Nachtrag 2026-09-29): **höchstens 8 benannte
Kategorien** + „Übrige" + „Frei verfügbar" = max. 10 Segmente
(8-Farben-Referenzpalette, validiert).
- **Endfällige Kredite**: Die Schlussrate enthält die volle Tilgung und - **Endfällige Kredite**: Die Schlussrate enthält die volle Tilgung und
erscheint im letzten Monat voll (konsequent zu „Einmalposten voll"). erscheint im letzten Monat voll (konsequent zu „Einmalposten voll").
- **Drill-down**: Klick auf ein Kostensegment (oder dessen Legendeneintrag) - **Drill-down**: Klick auf ein Kostensegment (oder dessen Legendeneintrag)