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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user