fix: Szenario-Vergleich zeigt vor heute Ist-Saldo, veraltete Szenarien werden neu gerechnet (A11-T2)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:10:51 +02:00
co-authored by Claude Opus 5.5
parent 32d78fdbe1
commit 979e1c0edc
7 changed files with 261 additions and 5 deletions
+9 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, Session
from app.config import get_settings
@@ -21,3 +21,11 @@ def get_engine():
def get_session():
with Session(get_engine()) as session:
yield session
def advisory_xact_lock(session: Session, key: int) -> None:
"""Serialisiert konkurrierende Schreiber abgeleiteter Daten (Projektionen,
Planungs-Diagramm) über einen Postgres-Transaktions-Advisory-Lock. Der
Lock endet mit dem nächsten commit/rollback. Auf SQLite (Tests) No-Op."""
if session.get_bind().dialect.name == "postgresql":
session.execute(text("SELECT pg_advisory_xact_lock(:k)"), {"k": key})
+19 -3
View File
@@ -114,9 +114,25 @@ VIEWS: dict[str, str] = {
SUM(t.amount) AS total
FROM transactions t LEFT JOIN categories c ON c.id = t.category_id
WHERE t.status = 'confirmed' GROUP BY 1, 2""",
"v_projection": """
SELECT s.name AS scenario, p.day, p.balance
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""",
# Szenario-Vergleich (Ausbaustufe 11): Ist-Gesamtsaldo bis heute als eigene
# Serie RESERVED_SCENARIO_NAME; jede Szenario-Kurve beginnt heute am
# Ist-Saldo und zeigt danach nur Projektionspunkte NACH heute. Punkte
# <= heute einer älteren Berechnung (Momentaufnahme des letzten
# „Durchrechnen") erscheinen damit nie - auch wenn ein Szenario noch nicht
# neu gerechnet wurde. Muss in VIEWS NACH v_balance_total stehen.
"v_projection": f"""
SELECT '{RESERVED_SCENARIO_NAME}'::text AS scenario, t.day, t.balance
FROM v_balance_total t
UNION ALL
SELECT s.name::text, t.day, t.balance
FROM scenarios s
JOIN v_balance_total t ON t.day = CURRENT_DATE
WHERE EXISTS (SELECT 1 FROM projection_points p0
WHERE p0.scenario_id = s.id)
UNION ALL
SELECT s.name::text, p.day, p.balance
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id
WHERE p.day > CURRENT_DATE""",
}
+20
View File
@@ -1,5 +1,7 @@
import logging
from datetime import date, timedelta
from decimal import Decimal
from typing import Callable
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
@@ -25,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.projection_service import refresh_stale_projections
from app.version import get_version
PAGE_SIZE = 50
@@ -51,6 +54,21 @@ templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
router = APIRouter()
log = logging.getLogger(__name__)
def _safe_refresh(session: Session, refresh: Callable[[Session, date], object],
today: date) -> None:
"""Frischt abgeleitete Daten (Projektionen, Planungs-Diagramm) vor dem
Rendern auf. Ein Fehler darf die Seite nie brechen: rollback, Warnung ins
Log, die Seite rendert mit dem vorhandenen Stand."""
try:
refresh(session, today)
except Exception:
session.rollback()
log.warning("Auffrischen fehlgeschlagen: %s",
getattr(refresh, "__name__", refresh), exc_info=True)
def gui_session(request: Request):
if not session_valid(request.cookies.get(COOKIE)):
@@ -127,6 +145,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())
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 +342,7 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
_safe_refresh(session, refresh_stale_projections, date.today())
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
+31 -1
View File
@@ -2,10 +2,11 @@ from datetime import date, timedelta
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy import delete, func, 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.projection import project
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
@@ -63,3 +64,32 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
session.add(result)
session.commit()
return result
# Advisory-Lock-Schlüssel (beliebig, aber projektweit eindeutig).
STALE_PROJECTION_LOCK = 110_001
def refresh_stale_projections(session: Session, today: date) -> int:
"""Rechnet jedes bereits durchgerechnete Szenario neu, dessen Projektion
vor `today` beginnt (erster gespeicherter Punkt <= today, d.h. Rechen-Start
< today). Maßstab ist der Projektionsbeginn, nicht computed_at (UTC-
Zeitstempel, würde um Mitternacht falsch schneiden). Nie gerechnete
Szenarien bleiben unberührt. Je Szenario: Lock nehmen, Staleness NEU
prüfen (ein paralleler Seitenaufruf kann schon gerechnet haben), rechnen;
run_projection committet und gibt damit den Lock frei.
Rückgabe: Anzahl neu gerechneter Szenarien."""
horizon = get_settings().horizon_days
ids = list(session.execute(select(ProjectionResult.scenario_id)).scalars())
refreshed = 0
for sid in ids:
advisory_xact_lock(session, STALE_PROJECTION_LOCK)
first = session.execute(select(func.min(ProjectionPoint.day))
.where(ProjectionPoint.scenario_id == sid)).scalar()
scenario = session.get(Scenario, sid)
if scenario is None or first is None or first > today:
session.commit() # Lock freigeben
continue
run_projection(session, scenario, horizon, today)
refreshed += 1
return refreshed