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