From 2a62bdca6c74a7ea0efd5c49b5a6f8dee1a05d0f7dd8cbf7a2cc583620f5f2e4 Mon Sep 17 00:00:00 2001 From: wlfb Date: Tue, 29 Sep 2026 16:24:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Seite=20Aufteilung=20=E2=80=93=20Auswah?= =?UTF-8?q?l,=20Kennzahlen,=20Tabelle,=20Tortendiagramm=20(A13-T5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- finance/app/formats.py | 8 + finance/app/routers/gui.py | 92 +++++++++++- finance/app/static/style.css | 36 +++++ finance/app/templates/_aufteilung_torte.html | 4 + finance/app/templates/_torte_macros.html | 19 +++ finance/app/templates/aufteilung.html | 75 ++++++++++ finance/app/templates/base.html | 1 + finance/tests/test_aufteilung_gui.py | 150 +++++++++++++++++++ finance/tests/test_gui.py | 4 +- finance/tests/test_sort_templates.py | 11 ++ 10 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 finance/app/templates/_aufteilung_torte.html create mode 100644 finance/app/templates/_torte_macros.html create mode 100644 finance/app/templates/aufteilung.html create mode 100644 finance/tests/test_aufteilung_gui.py diff --git a/finance/app/formats.py b/finance/app/formats.py index bf13b7d..d22d7dc 100644 --- a/finance/app/formats.py +++ b/finance/app/formats.py @@ -28,3 +28,11 @@ def eur(value) -> str: def de_label(value: str) -> str: return DE_LABELS.get(value, value) + + +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(".", ",") + " %" diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index 36753f2..992586f 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -1,4 +1,5 @@ import logging +import re from datetime import date, timedelta from decimal import Decimal from typing import Callable @@ -13,10 +14,12 @@ from app.config import get_settings from app.db import get_session from app.engine.loans import add_months from app.engine.recurrence import occurrences -from app.formats import de_label, eur +from app.engine.breakdown import FREI, BreakdownRow, Filters, share +from app.engine.pie import pie_slices +from app.formats import de_label, eur, pct from app.models.tables import (Account, Category, PlannedItem, ProjectionResult, RecurringItem, - ScenarioLoan, ScenarioModifier, + Scenario, ScenarioLoan, ScenarioModifier, ScenarioPlannedItem, Transaction) from app.routers.imports import list_imports as _list_imports from app.routers.imports import preview as _preview_import @@ -27,6 +30,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions from app.routers.scenarios import list_scenarios as _list_scenarios from app.routers.transactions import count_transactions, list_transactions, normalize_sort from app.services.balances import account_balance, total_balance +from app.services.breakdown import PLAN_NAME, compute_breakdown from app.services.double_count import all_conflicts from app.services.planning_chart import refresh_planning_chart from app.services.projection_service import refresh_stale_projections @@ -54,6 +58,7 @@ def grafana_public_base(request: Request) -> str: templates.env.globals["grafana_public_base"] = grafana_public_base templates.env.filters["eur"] = eur templates.env.filters["de_label"] = de_label +templates.env.filters["pct"] = pct router = APIRouter() log = logging.getLogger(__name__) @@ -374,3 +379,86 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)): "scenario_rows": _scenario_rows(session, scenarios, loans), "modifier_kinds": ["percent", "absolute", "remove", "ende"], }) + + +# --- 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) diff --git a/finance/app/static/style.css b/finance/app/static/style.css index 55b7e93..e56471f 100644 --- a/finance/app/static/style.css +++ b/finance/app/static/style.css @@ -236,3 +236,39 @@ label:has(input:disabled), label:has(select:disabled) { #doppelzaehlung-dialog { max-width: 32rem; } + +/* 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; } diff --git a/finance/app/templates/_aufteilung_torte.html b/finance/app/templates/_aufteilung_torte.html new file mode 100644 index 0000000..f0d3d40 --- /dev/null +++ b/finance/app/templates/_aufteilung_torte.html @@ -0,0 +1,4 @@ +{% from "_torte_macros.html" import torte %} +
+ {{ torte(haupt, haupt_titel) }} +
diff --git a/finance/app/templates/_torte_macros.html b/finance/app/templates/_torte_macros.html new file mode 100644 index 0000000..fedad93 --- /dev/null +++ b/finance/app/templates/_torte_macros.html @@ -0,0 +1,19 @@ +{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts + {s: engine.pie.Slice, q: Query-String für den Drill-down oder None, + gewaehlt: bool, zusatz: Zusatztext oder None}. #} +{% macro torte(segmente, titel) %} +
+
{{ titel }}
+ + {% for seg in segmente %}{% set s = seg.s %} + {% if s.path %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}) + {% else %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}){% endif %} + {% endfor %} + +
    + {% for seg in segmente %}{% set s = seg.s %} +
  • {{ s.label }}{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}
  • + {% endfor %} +
+
+{% endmacro %} diff --git a/finance/app/templates/aufteilung.html b/finance/app/templates/aufteilung.html new file mode 100644 index 0000000..222f52a --- /dev/null +++ b/finance/app/templates/aufteilung.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Aufteilung – Finanzberatung{% endblock %} +{% block content %} +
+

Aufteilung

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

{{ monat_anzeige }} · {{ szenario_name }}

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

Für diese Auswahl gibt es keine Posten.

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

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

+
+
+ {% include "_aufteilung_torte.html" %} +
+
+ {% endif %} +{% endif %} + +{% endblock %} diff --git a/finance/app/templates/base.html b/finance/app/templates/base.html index 663e4ff..1f0798f 100644 --- a/finance/app/templates/base.html +++ b/finance/app/templates/base.html @@ -17,6 +17,7 @@ Salden Planung Szenarien + Aufteilung Admin Hilfe Grafana diff --git a/finance/tests/test_aufteilung_gui.py b/finance/tests/test_aufteilung_gui.py new file mode 100644 index 0000000..3a69692 --- /dev/null +++ b/finance/tests/test_aufteilung_gui.py @@ -0,0 +1,150 @@ +import re +from datetime import date +from decimal import Decimal + +from app.engine.loans import add_months +from app.models.tables import (Category, Loan, RecurringItem, Scenario, + ScenarioModifier) + +D = Decimal +MONAT = "2030-03" + + +def _login(client): + client.post("/login", data={"username": "admin", "password": "geheim"}) + + +def _daten(db, gehalt="3000.00"): + wohnen, eink, vers = Category(name="Wohnen"), Category(name="Einkommen"), Category(name="Versicherung") + db.add_all([wohnen, eink, vers]) + db.flush() + db.add_all([ + RecurringItem(name="Gehalt", amount=D(gehalt), rhythm="monthly", due_day=1, category_id=eink.id), + RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=wohnen.id), + RecurringItem(name="Police", amount=D("-1200.00"), rhythm="yearly", due_day=1, category_id=vers.id), + ]) + db.commit() + return wohnen + + +def _get(client, **params): + return client.get("/aufteilung", params=params) + + +def test_menuepunkt(client): + _login(client) + assert 'Aufteilung' in client.get("/").text + + +def test_standardauswahl(client): + _login(client) + r = _get(client) + assert r.status_code == 200 + assert f'value="{date.today().strftime("%Y-%m")}"' in r.text + assert re.search(r'', r.text) + assert len(re.findall(r'type="checkbox"[^>]*checked', r.text)) == 3 + + +def test_tabelle_kennzahlen_torte(client, db): + _login(client) + _daten(db) + t = _get(client, monat=MONAT).text + assert "03.2030" in t + assert "Fixposten (jährlich)" in t + assert "-100,00 €" in t # Police 1.200 / 12 + assert "33,3 %" in t # Miete 1.000 von 3.000 + assert "Gesamte Einkünfte: 3.000,00 €" in t + tfoot = re.search(r"(.*?)", t, re.S).group(1) + assert "Frei verfügbar" in tfoot and "1.900,00 €" in tfoot and "63,3 %" in tfoot + assert '\s*]*>Gehalt.*?", t, re.S).group(0) + assert "%" not in zeile + + +def test_alle_filter_aus_zeigt_keine_posten(client, db): + _login(client) + _daten(db) + t = _get(client, monat=MONAT, gesendet="1").text + assert "Für diese Auswahl gibt es keine Posten." in t + assert '
]*checked', t) + + +def test_unterdeckung(client, db): + _login(client) + _daten(db, gehalt="500.00") + t = _get(client, monat=MONAT).text + assert "Kosten übersteigen die Einkünfte um 600,00 €" in t + assert "Anteile an den Gesamtkosten" in t + assert 'class="seg frei"' not in t + tfoot = re.search(r"(.*?)", t, re.S).group(1) + assert "Unterdeckung" in tfoot and "-600,00 €" in tfoot + + +def test_szenario_mit_modifikator(client, db): + _login(client) + wohnen = _daten(db) + sc = Scenario(name="Sparen", description="") + db.add(sc) + db.flush() + db.add(ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=wohnen.id, + kind="percent", value=D("50"))) + db.commit() + t = _get(client, monat=MONAT, szenario=str(sc.id)).text + assert "-500,00 €" in t and "Sparen" in t + assert f'' in t + + +def test_szenario_vergangenheit_fehler_inline(client, db): + _login(client) + _daten(db) + sc = Scenario(name="S", description="") + db.add(sc) + db.commit() + vormonat = add_months(date.today().replace(day=1), -1).strftime("%Y-%m") + r = _get(client, monat=vormonat, szenario=str(sc.id)) + assert r.status_code == 200 + assert "Szenarien sind erst ab dem laufenden Monat wählbar" in r.text + assert '
]*>.*?", t, re.S).group(0) + assert 'data-sort-mode="server"' not in table + assert re.search(r"\s*", table)