feat: Grafana-Panel Planung nach Kategorie inkl. Kreditraten (A11-T3)

Neue Tabelle planning_monthly (Monatssummen je Kategorie aus wiederkehrenden
Posten, Einmalposten und allen Kreditraten), Service planning_chart mit
refresh_planning_chart (Advisory-Lock, komplette Neuschreibung), GUI-Hooks
in index/planung_page ueber _safe_refresh, neues Barchart-Panel im
Grafana-Dashboard (Panel 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:15:26 +02:00
co-authored by Claude Opus 5.5
parent 979e1c0edc
commit 42b410446d
9 changed files with 273 additions and 0 deletions
+11
View File
@@ -156,3 +156,14 @@ class ProjectionResult(Base):
low_point_balance: Mapped[Decimal] = mapped_column(MONEY)
below_zero_date: Mapped[date | None] = mapped_column(Date, nullable=True)
below_threshold_date: Mapped[date | None] = mapped_column(Date, nullable=True)
class PlanningMonthly(Base):
"""Abgeleitete Daten für das Grafana-Panel „Planung nach Kategorie"
(Ausbaustufe 11): Monatssummen je Kategorie aus der Planung. Wird von
services/planning_chart.refresh_planning_chart komplett neu geschrieben;
jederzeit reproduzierbar, kein Backup-Sonderfall."""
__tablename__ = "planning_monthly"
month: Mapped[date] = mapped_column(Date, primary_key=True)
category: Mapped[str] = mapped_column(String(100), primary_key=True)
total: Mapped[Decimal] = mapped_column(MONEY)
+3
View File
@@ -27,6 +27,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
from app.services.balances import account_balance, total_balance
from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections
from app.version import get_version
@@ -146,6 +147,7 @@ def _month_starts_desc(session: Session, today: date) -> list[date]:
@router.get("/", dependencies=[Depends(gui_session)])
def index(request: Request, session: Session = Depends(get_session)):
_safe_refresh(session, refresh_stale_projections, date.today())
_safe_refresh(session, refresh_planning_chart, date.today())
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a)) for a in accounts]
return templates.TemplateResponse(request, "index.html", {
@@ -323,6 +325,7 @@ def hilfe_page(request: Request):
@router.get("/planung", dependencies=[Depends(gui_session)])
def planung_page(request: Request, session: Session = Depends(get_session)):
_safe_refresh(session, refresh_planning_chart, date.today())
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
planned = _list_planned(session=session)
+66
View File
@@ -0,0 +1,66 @@
"""Planungs-Diagramm (Grafana-Panel „Planung nach Kategorie", Ausbaustufe 11).
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen,
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel
(engine/loans.py) - daher Python + Tabelle statt SQL-View.
"""
from collections import defaultdict
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db import advisory_xact_lock
from app.engine.loans import loan_schedule
from app.engine.recurrence import occurrences
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
RecurringItem)
UNKATEGORISIERT = "unkategorisiert"
KREDITRATEN = "Kreditraten"
PLANNING_CHART_LOCK = 110_002
def planning_monthly_totals(session: Session, today: date,
horizon_days: int) -> dict[tuple[date, str], Decimal]:
"""Fenster: 1. des laufenden Monats bis today + horizon_days. Schlüssel
(Monatserster, Kategoriename); Summen 0 entfallen."""
window_start = today.replace(day=1)
window_end = today + timedelta(days=horizon_days)
names = {c.id: c.name for c in session.execute(select(Category)).scalars()}
totals: dict[tuple[date, str], Decimal] = defaultdict(lambda: Decimal("0"))
def category(category_id: int | None) -> str:
return names.get(category_id, UNKATEGORISIERT) if category_id else UNKATEGORISIERT
for r in session.execute(select(RecurringItem)).scalars():
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, r.end_date):
totals[(d.replace(day=1), category(r.category_id))] += Decimal(r.amount)
for p in session.execute(select(PlannedItem)).scalars():
if window_start <= p.due <= window_end:
totals[(p.due.replace(day=1), category(p.category_id))] += Decimal(p.amount)
for loan in session.execute(select(Loan)).scalars():
for inst in loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
loan.term_months, loan.payout_date,
loan.repayment_type):
if window_start <= inst.due <= window_end:
# payment ist bereits negativ (Abfluss), wie in build_cashflows.
totals[(inst.due.replace(day=1), KREDITRATEN)] += inst.payment
return {k: v for k, v in totals.items() if v != 0}
def refresh_planning_chart(session: Session, today: date) -> int:
"""Schreibt planning_monthly in EINER Transaktion komplett neu (Lock
gegen parallele Seitenaufrufe). Rückgabe: Anzahl geschriebener Zeilen."""
advisory_xact_lock(session, PLANNING_CHART_LOCK)
totals = planning_monthly_totals(session, today, get_settings().horizon_days)
session.execute(delete(PlanningMonthly))
session.add_all(PlanningMonthly(month=m, category=c, total=v)
for (m, c), v in sorted(totals.items()))
session.commit()
return len(totals)