feat: Aufteilung – Drill-down in Kategorie per Klick auf Tortensegment (A13-T6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:45:44 +02:00
co-authored by Claude Opus 5.5
parent 4b6144d812
commit 3ef20034d3
7 changed files with 223 additions and 8 deletions
+72 -3
View File
@@ -3,6 +3,7 @@ import re
from datetime import date, timedelta
from decimal import Decimal
from typing import Callable
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
@@ -14,8 +15,8 @@ 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.engine.breakdown import FREI, BreakdownRow, Filters, share
from app.engine.pie import pie_slices
from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share
from app.engine.pie import UEBRIGE, pie_slices
from app.formats import de_label, eur, pct
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
@@ -453,12 +454,80 @@ def _aufteilung_context(session: Session, today: date, monat: str | None,
return ctx
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"))}
@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,
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)