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:
+9
-1
@@ -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})
|
||||
|
||||
@@ -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""",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user