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 %}
|
||||
@@ -7,7 +7,7 @@ H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_pages_require_login(client):
|
||||
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"):
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
|
||||
r = client.get(path, follow_redirects=False)
|
||||
assert r.status_code in (302, 303), path
|
||||
assert r.headers["location"] == "/login"
|
||||
@@ -15,7 +15,7 @@ def test_pages_require_login(client):
|
||||
|
||||
def test_pages_render_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"):
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "Finanzberatung" in r.text
|
||||
@@ -193,3 +193,78 @@ def test_planning_page_shows_empty_suggestions_hint(client):
|
||||
assert r.status_code == 200
|
||||
assert "Vorschläge aus Buchungen" in r.text
|
||||
assert "Keine Vorschläge" in r.text
|
||||
|
||||
|
||||
def test_salden_page_stichtag_and_month_overview(client, db):
|
||||
from app.engine.loans import add_months
|
||||
from app.models.tables import Transaction
|
||||
|
||||
# Drei Monate relativ zu "heute" verwenden statt fester Kalenderdaten,
|
||||
# damit der Test unabhaengig vom Ausfuehrungsdatum immer genau 3
|
||||
# Monatsanfangs-Zeilen (frühester Buchungsmonat bis heute) erzeugt.
|
||||
today = date.today()
|
||||
m0 = date(today.year, today.month, 1)
|
||||
m1 = add_months(m0, -1)
|
||||
m2 = add_months(m0, -2)
|
||||
|
||||
acc = Account(bank="dkb", iban="DE-SALDEN-1", name="Salden-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
acc.anchor_date = m2
|
||||
acc.anchor_balance = Decimal("1000.00")
|
||||
db.flush()
|
||||
|
||||
db.add_all([
|
||||
Transaction(account_id=acc.id, booking_date=m2 + timedelta(days=5),
|
||||
amount=Decimal("111.11"), purpose="SALDEN-TEST-1",
|
||||
counterparty="X", status="confirmed", dedup_hash="salden-h1"),
|
||||
Transaction(account_id=acc.id, booking_date=m1 + timedelta(days=3),
|
||||
amount=Decimal("22.22"), purpose="SALDEN-TEST-2",
|
||||
counterparty="X", status="confirmed", dedup_hash="salden-h2"),
|
||||
Transaction(account_id=acc.id, booking_date=m0 + timedelta(days=2),
|
||||
amount=Decimal("-33.33"), purpose="SALDEN-TEST-3",
|
||||
counterparty="X", status="confirmed", dedup_hash="salden-h3"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
|
||||
# Stichtag zwischen Buchung 2 und Buchung 3: Saldo = Anker + Buchung1 + Buchung2.
|
||||
stichtag = m1 + timedelta(days=10)
|
||||
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
|
||||
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
|
||||
assert r.status_code == 200
|
||||
assert f"{expected_stichtag:.2f}" in r.text
|
||||
|
||||
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
|
||||
assert r.text.count('data-month="') == 3
|
||||
for m in (m0, m1, m2):
|
||||
assert f'data-month="{m.isoformat()}"' in r.text
|
||||
|
||||
# Monatsanfangs-Saldo fuer m1 = Stand Ende Vortag von m1 (letzter Tag von
|
||||
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
|
||||
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
|
||||
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
|
||||
assert f"{expected_m1_start:.2f}" in r.text
|
||||
|
||||
|
||||
def test_salden_stichtag_garbage_falls_back_to_today(client):
|
||||
# Lenient wie die Filter auf /buchungen: ein kaputter stichtag-Parameter
|
||||
# darf nie zu 422/500 fuehren, sondern degradiert auf "heute".
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/salden?stichtag=garbage")
|
||||
assert r.status_code == 200
|
||||
assert date.today().strftime('%d.%m.%Y') in r.text
|
||||
|
||||
|
||||
def test_salden_shows_ohne_anker_footnote(client, db):
|
||||
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
|
||||
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
|
||||
acc = Account(bank="vr", iban="DE-SALDEN-2", name="Ohne-Anker-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/salden")
|
||||
assert r.status_code == 200
|
||||
assert "ohne Anker" in r.text and "relative Werte" in r.text
|
||||
|
||||
Reference in New Issue
Block a user