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 %} +
{{ monat_anzeige }} · {{ szenario_name }}
{% endif %} + +{% if error %} +Für diese Auswahl gibt es keine Posten.
+ {% else %} + {% if s.deficit %} +| Posten | Kategorie | Art | Monatsbetrag | % 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 }} €
+