feat: Salden-Seite mit Stichtag und Monatsuebersicht

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:53:23 +02:00
parent ea4858c4de
commit 7a0f71aa8c
5 changed files with 220 additions and 4 deletions

View File

@@ -3,15 +3,16 @@ from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import COOKIE, session_valid
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier)
ScenarioLoan, ScenarioModifier, Transaction)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
@@ -84,6 +85,25 @@ def _opt_date(value: str | None) -> date | None:
return None
def _month_starts_desc(session: Session, today: date) -> list[date]:
"""Liefert die Monatsersten von "heute" rueckwaerts bis zum Monat der
fruehesten bestaetigten Buchung, absteigend sortiert (heute zuerst).
Ohne bestaetigte Buchungen: leere Liste."""
earliest = session.execute(
select(func.min(Transaction.booking_date)).where(Transaction.status == "confirmed")
).scalar_one_or_none()
if earliest is None:
return []
start = date(earliest.year, earliest.month, 1)
current = date(today.year, today.month, 1)
months = []
m = current
while m >= start:
months.append(m)
m = add_months(m, -1)
return months
@router.get("/", dependencies=[Depends(gui_session)])
def index(request: Request, session: Session = Depends(get_session)):
accounts = session.execute(select(Account)).scalars().all()
@@ -158,6 +178,50 @@ def buchungen(
})
@router.get("/salden", dependencies=[Depends(gui_session)])
def salden_page(
request: Request,
stichtag: str | None = None,
session: Session = Depends(get_session),
):
# Lenient wie die Filter auf /buchungen: ein leerer oder kaputter
# stichtag-Parameter fuehrt nie zu 422, sondern faellt auf "heute" zurueck.
today = date.today()
parsed_stichtag = _opt_date(stichtag) or today
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a, at=parsed_stichtag)) for a in accounts]
total = sum((b for _, b in balances), Decimal("0"))
month_rows = []
for month in _month_starts_desc(session, today):
# Saldo am Monatsanfang = Stand am ENDE DES VORTAGS (letzter Tag des
# Vormonats), NICHT account_balance(at=month) - sonst wuerden
# Buchungen AM 1. bereits in den "Anfangs"-Saldo einfliessen und den
# Ausweis verzerren. Siehe Fussnote im Template ("Stand: Ende des
# Vortags").
vortag = month - timedelta(days=1)
values = [(a, account_balance(session, a, at=vortag)) for a in accounts]
month_rows.append({
"month": month,
"values": values,
"total": sum((v for _, v in values), Decimal("0")),
})
accounts_without_anchor = [
a for a in accounts if a.anchor_date is None or a.anchor_balance is None
]
return templates.TemplateResponse(request, "salden.html", {
"stichtag": parsed_stichtag,
"balances": balances,
"total": total,
"accounts": accounts,
"months": month_rows,
"accounts_without_anchor": accounts_without_anchor,
})
@router.get("/import", dependencies=[Depends(gui_session)])
def import_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "import.html", {