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:
@@ -0,0 +1,91 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||
RecurringItem)
|
||||
from app.services.planning_chart import (KREDITRATEN, UNKATEGORISIERT,
|
||||
planning_monthly_totals,
|
||||
refresh_planning_chart)
|
||||
|
||||
TODAY = date(2026, 9, 28)
|
||||
M = lambda y, m: date(y, m, 1) # noqa: E731
|
||||
|
||||
|
||||
def _setup(db):
|
||||
wohnen = Category(name="Wohnen")
|
||||
vers = Category(name="Versicherung")
|
||||
eink = Category(name="Einkommen")
|
||||
db.add_all([wohnen, vers, eink])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
# monatlich, ohne Start -> 15.09., 15.10., 15.11., 15.12.
|
||||
RecurringItem(name="Miete", amount=Decimal("-50.00"), rhythm="monthly",
|
||||
due_day=15, category_id=wohnen.id),
|
||||
# quartalsweise, Phase ab 01.08. -> nur 01.11. im Fenster
|
||||
RecurringItem(name="Police", amount=Decimal("-300.00"), rhythm="quarterly",
|
||||
due_day=1, start_date=date(2026, 8, 1), category_id=vers.id),
|
||||
# jährlich ohne Kategorie, Phase Dezember -> 10.12.2026
|
||||
RecurringItem(name="Beitrag", amount=Decimal("-120.00"), rhythm="yearly",
|
||||
due_day=10, start_date=date(2025, 12, 10)),
|
||||
# endet 31.10. -> nur 01.09. und 01.10.
|
||||
RecurringItem(name="Gehalt", amount=Decimal("1000.00"), rhythm="monthly",
|
||||
due_day=1, end_date=date(2026, 10, 31), category_id=eink.id),
|
||||
# Einmalposten: innerhalb (Okt), vor dem Fenster, nach dem Fenster
|
||||
PlannedItem(name="Reparatur", amount=Decimal("-200.00"),
|
||||
due=date(2026, 10, 5), category_id=wohnen.id),
|
||||
PlannedItem(name="Vorher", amount=Decimal("-999.00"), due=date(2026, 8, 31)),
|
||||
PlannedItem(name="Nachher", amount=Decimal("-999.00"), due=date(2027, 2, 1)),
|
||||
# Kredit 1.200 zu 0 % auf 12 Monate, Auszahlung 10.09. -> Raten je -100
|
||||
# am 10.10., 10.11., 10.12. (10.01.2027 liegt hinter dem Fenster).
|
||||
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
|
||||
term_months=12, payout_date=date(2026, 9, 10), repayment_type="annuity"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_planung_nach_kategorie_handrechnung(db):
|
||||
_setup(db)
|
||||
assert planning_monthly_totals(db, TODAY, 100) == {
|
||||
(M(2026, 9), "Wohnen"): Decimal("-50.00"),
|
||||
(M(2026, 10), "Wohnen"): Decimal("-250.00"),
|
||||
(M(2026, 11), "Wohnen"): Decimal("-50.00"),
|
||||
(M(2026, 12), "Wohnen"): Decimal("-50.00"),
|
||||
(M(2026, 11), "Versicherung"): Decimal("-300.00"),
|
||||
(M(2026, 12), UNKATEGORISIERT): Decimal("-120.00"),
|
||||
(M(2026, 9), "Einkommen"): Decimal("1000.00"),
|
||||
(M(2026, 10), "Einkommen"): Decimal("1000.00"),
|
||||
(M(2026, 10), KREDITRATEN): Decimal("-100.00"),
|
||||
(M(2026, 11), KREDITRATEN): Decimal("-100.00"),
|
||||
(M(2026, 12), KREDITRATEN): Decimal("-100.00"),
|
||||
}
|
||||
|
||||
|
||||
def test_kredit_auszahlung_zaehlt_nicht(db):
|
||||
_setup(db)
|
||||
totals = planning_monthly_totals(db, TODAY, 100)
|
||||
assert all(v < 0 for (m, c), v in totals.items() if c == KREDITRATEN)
|
||||
assert (M(2026, 9), KREDITRATEN) not in totals
|
||||
|
||||
|
||||
def test_nullsummen_entfallen(db):
|
||||
c = Category(name="Durchlauf")
|
||||
db.add(c)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlannedItem(name="rein", amount=Decimal("10.00"), due=date(2026, 10, 1), category_id=c.id),
|
||||
PlannedItem(name="raus", amount=Decimal("-10.00"), due=date(2026, 10, 2), category_id=c.id),
|
||||
])
|
||||
db.commit()
|
||||
assert planning_monthly_totals(db, TODAY, 100) == {}
|
||||
|
||||
|
||||
def test_refresh_schreibt_tabelle_idempotent(db):
|
||||
_setup(db)
|
||||
n1 = refresh_planning_chart(db, TODAY)
|
||||
n2 = refresh_planning_chart(db, TODAY)
|
||||
assert n1 == n2 > 0
|
||||
assert db.execute(select(func.count()).select_from(PlanningMonthly)).scalar() == n2
|
||||
row = db.get(PlanningMonthly, (M(2026, 11), "Versicherung"))
|
||||
assert row.total == Decimal("-300.00")
|
||||
Reference in New Issue
Block a user