feat: Salden-Seite mit Stichtag und Monatsuebersicht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<a href="/">Übersicht</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/buchungen">Buchungen</a>
|
||||
<a href="/salden">Salden</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="/hilfe">Hilfe</a>
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
|
||||
zu importieren; einzelne Buchungen bleiben unlöschbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Salden-Seite:</strong> zeigt den Saldo je Konto zu einem
|
||||
wählbaren Stichtag (Default: heute) sowie eine Monatsanfangs-Übersicht
|
||||
vom frühesten Buchungsmonat bis heute, absteigend. Die
|
||||
Monatsanfangs-Werte gelten "Stand: Ende des Vortags" (letzter Tag des
|
||||
Vormonats), damit Buchungen am 1. eines Monats den Ausweis des
|
||||
Monatsanfangs nicht verfälschen. Konten ohne Anker sind mit "ohne Anker —
|
||||
relative Werte" gekennzeichnet.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kategorien & Regeln:</strong> Buchungen lassen sich kategorisieren;
|
||||
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe
|
||||
|
||||
67
finance/app/templates/salden.html
Normal file
67
finance/app/templates/salden.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Salden – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Salden</h1>
|
||||
|
||||
<h2>Salden am {{ stichtag.strftime('%d.%m.%Y') }}</h2>
|
||||
<form method="get" action="/salden" class="inline-form">
|
||||
<input type="date" name="stichtag" value="{{ stichtag.isoformat() }}">
|
||||
<button type="submit">Anzeigen</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Konto</th><th>Saldo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account, balance in balances %}
|
||||
<tr>
|
||||
<td class="account">{{ account.name }}</td>
|
||||
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td><strong>Gesamt</strong></td>
|
||||
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ '%.2f'|format(total) }} €</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Monatsanfangs-Übersicht</h2>
|
||||
<p class="hint">
|
||||
Stand: Ende des Vortags (letzter Tag des Vormonats) — Buchungen am 1. eines
|
||||
Monats zählen bewusst noch nicht zum "Anfangs"-Saldo, sonst würde der
|
||||
Monatsanfang durch Buchungen des laufenden Monats verzerrt.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
{% for account in accounts %}<th>{{ account.name }}</th>{% endfor %}
|
||||
<th>Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in months %}
|
||||
<tr data-month="{{ row['month'].isoformat() }}">
|
||||
<td>{{ row['month'].strftime('%m.%Y') }}</td>
|
||||
{% for account, value in row['values'] %}
|
||||
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ '%.2f'|format(value) }} €</td>
|
||||
{% endfor %}
|
||||
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ '%.2f'|format(row['total']) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if accounts_without_anchor %}
|
||||
<p class="hint">
|
||||
Fußnote „ohne Anker — relative Werte" (Basis 0 statt Datei-Kontostand) gilt für:
|
||||
{{ accounts_without_anchor|map(attribute='name')|join(', ') }}.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user