feat: Seite Aufteilung – Auswahl, Kennzahlen, Tabelle, Tortendiagramm (A13-T5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(".", ",") + " %"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{% from "_torte_macros.html" import torte %}
|
||||
<div id="aufteilung-torte">
|
||||
{{ torte(haupt, haupt_titel) }}
|
||||
</div>
|
||||
@@ -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) %}
|
||||
<figure class="torte">
|
||||
<figcaption>{{ titel }}</figcaption>
|
||||
<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 %}
|
||||
{% if s.path %}<path d="{{ s.path }}" class="seg {{ s.color }}"><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 %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
<ul class="legende">
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</figure>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Aufteilung – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<div class="aufteilung">
|
||||
<h1>Aufteilung</h1>
|
||||
|
||||
<form method="get" action="/aufteilung" class="filter-form">
|
||||
<input type="hidden" name="gesendet" value="1">
|
||||
<label>Monat <input type="month" name="monat" value="{{ monat_wert }}" required></label>
|
||||
<label>Szenario
|
||||
<select name="szenario">
|
||||
<option value="">{{ plan_name }}</option>
|
||||
{% for sc in scenarios %}<option value="{{ sc.id }}"{% if sc.id == szenario_id %} selected{% endif %}>{{ sc.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label><span><input type="checkbox" name="fixposten" value="1"{% if filters.recurring %} checked{% endif %}> Fixposten</span></label>
|
||||
<label><span><input type="checkbox" name="einmal" value="1"{% if filters.planned %} checked{% endif %}> Einmalposten</span></label>
|
||||
<label><span><input type="checkbox" name="kredite" value="1"{% if filters.loans %} checked{% endif %}> Kredite</span></label>
|
||||
<button type="submit">Anzeigen</button>
|
||||
</form>
|
||||
|
||||
{% if monat_anzeige %}<p class="hinweis-klein">{{ monat_anzeige }} · {{ szenario_name }}</p>{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="warning" role="alert">{{ error }}</div>
|
||||
{% elif b %}
|
||||
<div class="kennzahlen">
|
||||
<div><span>Einkünfte</span><strong>{{ s.income|eur }} €</strong></div>
|
||||
<div><span>Kosten</span><strong>{{ s.costs|eur }} €</strong></div>
|
||||
<div{% if s.deficit %} class="neg"{% endif %}><span>{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</span><strong>{{ s.free|eur }} €</strong></div>
|
||||
</div>
|
||||
{% for k in b.conflicts %}
|
||||
<div class="warning">⚠ Fixposten „{{ k.posten_name }}“ zählt evtl. doppelt mit Kredit „{{ k.kredit_name }}“.</div>
|
||||
{% endfor %}
|
||||
{% if not b.rows %}
|
||||
<p>Für diese Auswahl gibt es keine Posten.</p>
|
||||
{% else %}
|
||||
{% if s.deficit %}
|
||||
<div class="warning">Kosten übersteigen die Einkünfte um {{ (s.costs - s.income)|eur }} € — das Diagramm zeigt die Aufteilung der Gesamtkosten.</div>
|
||||
{% endif %}
|
||||
<div class="aufteilung-grid">
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Posten</th><th>Kategorie</th><th>Art</th><th>Monatsbetrag</th><th>% der Einkünfte</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for z in zeilen %}
|
||||
<tr>
|
||||
<td data-sort="{{ z.r.name }}">{{ z.r.name }}</td>
|
||||
<td>{{ z.r.category }}</td>
|
||||
<td>{{ z.art }}</td>
|
||||
<td class="amount{% if z.r.amount < 0 %} neg{% endif %}" data-sort="{{ z.r.amount }}">{{ z.r.amount|eur }} €</td>
|
||||
<td class="amount" data-sort="{{ z.anteil if z.anteil is not none else '' }}">{% if not z.einnahme %}{{ z.anteil|pct }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr{% if s.deficit %} class="neg"{% endif %}>
|
||||
<th colspan="3">{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</th>
|
||||
<td class="amount">{{ s.free|eur }} €</td>
|
||||
<td class="amount">{{ frei_anteil|pct }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
|
||||
</div>
|
||||
<div>
|
||||
{% include "_aufteilung_torte.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/salden">Salden</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="/szenarien">Szenarien</a>
|
||||
<a href="/aufteilung">Aufteilung</a>
|
||||
<a href="/admin">Admin</a>
|
||||
<a href="/hilfe">Hilfe</a>
|
||||
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
|
||||
|
||||
Reference in New Issue
Block a user