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:
@@ -3,6 +3,7 @@ import re
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -14,8 +15,8 @@ from app.config import get_settings
|
|||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.engine.loans import add_months
|
from app.engine.loans import add_months
|
||||||
from app.engine.recurrence import occurrences
|
from app.engine.recurrence import occurrences
|
||||||
from app.engine.breakdown import FREI, BreakdownRow, Filters, share
|
from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share
|
||||||
from app.engine.pie import pie_slices
|
from app.engine.pie import UEBRIGE, pie_slices
|
||||||
from app.formats import de_label, eur, pct
|
from app.formats import de_label, eur, pct
|
||||||
from app.models.tables import (Account, Category, PlannedItem,
|
from app.models.tables import (Account, Category, PlannedItem,
|
||||||
ProjectionResult, RecurringItem,
|
ProjectionResult, RecurringItem,
|
||||||
@@ -453,12 +454,80 @@ def _aufteilung_context(session: Session, today: date, monat: str | None,
|
|||||||
return ctx
|
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)])
|
@router.get("/aufteilung", dependencies=[Depends(gui_session)])
|
||||||
def aufteilung_page(request: Request, monat: str | None = None,
|
def aufteilung_page(request: Request, monat: str | None = None,
|
||||||
szenario: str | None = None, fixposten: str | None = None,
|
szenario: str | None = None, fixposten: str | None = None,
|
||||||
einmal: str | None = None, kredite: 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)):
|
session: Session = Depends(get_session)):
|
||||||
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
|
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
|
||||||
einmal, kredite, gesendet)
|
einmal, kredite, gesendet)
|
||||||
|
_drilldown(ctx, kategorie, sammel)
|
||||||
return templates.TemplateResponse(request, "aufteilung.html", ctx)
|
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)
|
||||||
|
|||||||
@@ -273,3 +273,9 @@ label:has(input:disabled), label:has(select:disabled) {
|
|||||||
.swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; }
|
.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; }
|
.legende-wert { margin-left: auto; color: #555; white-space: nowrap; }
|
||||||
.hinweis-klein { font-size: 0.85rem; color: #555; }
|
.hinweis-klein { font-size: 0.85rem; color: #555; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{% from "_torte_macros.html" import torte %}
|
{% from "_torte_macros.html" import torte %}
|
||||||
<div id="aufteilung-torte">
|
<div id="aufteilung-torte"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||||
{{ torte(haupt, haupt_titel) }}
|
{{ torte(haupt, haupt_titel) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts
|
{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts
|
||||||
{s: engine.pie.Slice, q: Query-String für den Drill-down oder None,
|
{s: engine.pie.Slice, q: Query-String für den Drill-down oder None,
|
||||||
gewaehlt: bool, zusatz: Zusatztext 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) %}
|
{% macro torte(segmente, titel) %}
|
||||||
<figure class="torte">
|
<figure class="torte">
|
||||||
<figcaption>{{ titel }}</figcaption>
|
<figcaption>{{ titel }}</figcaption>
|
||||||
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
|
<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 %}
|
{% 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>
|
{% 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" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
|
{% else %}<circle r="1" {{ seg_attrs(seg) }}><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</svg>
|
</svg>
|
||||||
<ul class="legende">
|
<ul class="legende">
|
||||||
{% for seg in segmente %}{% set s = seg.s %}
|
{% 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>
|
<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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</figure>
|
</figure>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@
|
|||||||
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
|
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
|
||||||
<div>
|
<div>
|
||||||
{% include "_aufteilung_torte.html" %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -158,3 +158,121 @@ def test_tabelle_scrollbar_torte_darunter(client, db):
|
|||||||
t = _get(client, monat=MONAT).text
|
t = _get(client, monat=MONAT).text
|
||||||
assert re.search(r'<div class="tabelle-scroll">\s*<table', t)
|
assert re.search(r'<div class="tabelle-scroll">\s*<table', t)
|
||||||
assert t.index('id="aufteilung-torte"') > t.index("</table>")
|
assert t.index('id="aufteilung-torte"') > t.index("</table>")
|
||||||
|
|
||||||
|
|
||||||
|
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 & Garten: 300,00 € / Monat" in r.text and "Rasen" in r.text
|
||||||
|
|||||||
Reference in New Issue
Block a user