diff --git a/finance/app/db.py b/finance/app/db.py index 4131550..e9ae606 100644 --- a/finance/app/db.py +++ b/finance/app/db.py @@ -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}) diff --git a/finance/app/models/views.py b/finance/app/models/views.py index 4c5854f..e537fc4 100644 --- a/finance/app/models/views.py +++ b/finance/app/models/views.py @@ -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""", } diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index eb75313..544f6bb 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -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) diff --git a/finance/app/services/projection_service.py b/finance/app/services/projection_service.py index b2991d8..317cd49 100644 --- a/finance/app/services/projection_service.py +++ b/finance/app/services/projection_service.py @@ -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 diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py index e5d4e88..7cd304d 100644 --- a/finance/tests/test_gui.py +++ b/finance/tests/test_gui.py @@ -531,3 +531,37 @@ def test_szenario_loeschen_roundtrip_gui(client, db): db.commit() assert client.delete(f"/api/scenarios/{sc.id}", headers=H).status_code == 204 assert "Wegwerf-Roundtrip" not in client.get("/szenarien").text + + +def test_szenarien_seite_rechnet_veraltete_projektion_neu(client, db): + from sqlalchemy import func, select + from app.models.tables import ProjectionPoint + from app.services.projection_service import run_projection + client.post("/login", data={"username": "admin", "password": "geheim"}) + sc = Scenario(name="Veraltet", description="", include_recurring=False, + include_planned=False) + db.add(sc) + db.commit() + run_projection(db, sc, 10, date.today() - timedelta(days=3)) + assert client.get("/szenarien").status_code == 200 + first = db.execute(select(func.min(ProjectionPoint.day)) + .where(ProjectionPoint.scenario_id == sc.id)).scalar() + assert first == date.today() + timedelta(days=1) + + +def test_refresh_fehler_bricht_seiten_nicht(client, monkeypatch): + def kaputt(session, today): + raise RuntimeError("absichtlich kaputt") + monkeypatch.setattr("app.routers.gui.refresh_stale_projections", kaputt) + client.post("/login", data={"username": "admin", "password": "geheim"}) + for path in ("/", "/szenarien"): + assert client.get(path).status_code == 200, path + + +def test_v_projection_zeigt_ist_serie_und_keine_alten_punkte(): + from app.models.views import RESERVED_SCENARIO_NAME, VIEWS + body = VIEWS["v_projection"] + assert f"'{RESERVED_SCENARIO_NAME}'" in body + assert "p.day > CURRENT_DATE" in body + names = list(VIEWS) + assert names.index("v_balance_total") < names.index("v_projection") diff --git a/finance/tests/test_projection_refresh.py b/finance/tests/test_projection_refresh.py new file mode 100644 index 0000000..f73b120 --- /dev/null +++ b/finance/tests/test_projection_refresh.py @@ -0,0 +1,66 @@ +from datetime import date, timedelta + +from sqlalchemy import func, select + +from app.config import get_settings +from app.models.tables import ProjectionPoint, ProjectionResult, Scenario +from app.services.projection_service import refresh_stale_projections, run_projection + +TODAY = date(2026, 9, 28) + + +def _scenario(db, name): + sc = Scenario(name=name, description="", include_recurring=False, + include_planned=False) + db.add(sc) + db.commit() + return sc + + +def _first_day(db, sid): + return db.execute(select(func.min(ProjectionPoint.day)) + .where(ProjectionPoint.scenario_id == sid)).scalar() + + +def _count(db, sid): + return db.execute(select(func.count()).select_from(ProjectionPoint) + .where(ProjectionPoint.scenario_id == sid)).scalar() + + +def test_veraltetes_szenario_wird_ab_heute_neu_gerechnet(db): + sc = _scenario(db, "Alt") + run_projection(db, sc, 10, TODAY - timedelta(days=5)) + assert _first_day(db, sc.id) == TODAY - timedelta(days=4) + assert refresh_stale_projections(db, TODAY) == 1 + assert _first_day(db, sc.id) == TODAY + timedelta(days=1) + assert _count(db, sc.id) == get_settings().horizon_days + + +def test_gestern_gerechnet_gilt_als_veraltet(db): + sc = _scenario(db, "Gestern") + run_projection(db, sc, 10, TODAY - timedelta(days=1)) + assert refresh_stale_projections(db, TODAY) == 1 + + +def test_heute_gerechnet_bleibt_unveraendert(db): + sc = _scenario(db, "Heute") + run_projection(db, sc, 10, TODAY) + before = db.get(ProjectionResult, sc.id).computed_at + assert refresh_stale_projections(db, TODAY) == 0 + assert _count(db, sc.id) == 10 + assert db.get(ProjectionResult, sc.id).computed_at == before + + +def test_nie_gerechnetes_szenario_bleibt_ohne_ergebnis(db): + sc = _scenario(db, "Nie") + assert refresh_stale_projections(db, TODAY) == 0 + assert db.get(ProjectionResult, sc.id) is None + assert _count(db, sc.id) == 0 + + +def test_refresh_ist_idempotent(db): + sc = _scenario(db, "Zweimal") + run_projection(db, sc, 10, TODAY - timedelta(days=3)) + assert refresh_stale_projections(db, TODAY) == 1 + assert refresh_stale_projections(db, TODAY) == 0 + assert _count(db, sc.id) == get_settings().horizon_days diff --git a/finance/tests/test_views_pg.py b/finance/tests/test_views_pg.py new file mode 100644 index 0000000..056f7af --- /dev/null +++ b/finance/tests/test_views_pg.py @@ -0,0 +1,82 @@ +"""Postgres-only-Prüfung der Grafana-Views (Ausbaustufe 11). + +Läuft nur mit FB_TEST_PG_URL (Wegwerf-Postgres, DB-Name muss auf '_test' +enden - Schutz gegen versehentliche Ausführung gegen die Produktiv-DB). +Beispiel: + podman run -d --rm --name a11-pg -e POSTGRES_PASSWORD=wegwerf \ + -e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10 + FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \ + .venv/bin/python -m pytest -q tests/test_views_pg.py +""" +import os +from datetime import timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.engine import make_url +from sqlalchemy.orm import Session + +from app.db import Base +import app.models # noqa: F401 +from app.models.tables import (Account, ProjectionPoint, ProjectionResult, + Scenario, Transaction) +from app.models.views import RESERVED_SCENARIO_NAME, VIEWS, create_views + +PG_URL = os.environ.get("FB_TEST_PG_URL") +pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt") + + +def _reset(engine): + with engine.begin() as conn: + for name in VIEWS: + conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE")) + Base.metadata.drop_all(engine) + + +@pytest.fixture +def pg(): + assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!" + engine = create_engine(PG_URL) + _reset(engine) + Base.metadata.create_all(engine) + create_views(engine) + with Session(engine) as session: + yield session + _reset(engine) + engine.dispose() + + +def test_v_projection_ist_serie_und_szenario_ab_heute(pg): + today = pg.execute(text("SELECT CURRENT_DATE")).scalar() + acc = Account(bank="t", iban="DE-PG-1", name="Testkonto", type="giro", + anchor_date=today - timedelta(days=10), + anchor_balance=Decimal("1000.00")) + pg.add(acc) + pg.flush() + pg.add(Transaction(account_id=acc.id, booking_date=today - timedelta(days=2), + amount=Decimal("-100.00"), purpose="t", counterparty="t", + status="confirmed", dedup_hash="pg-1")) + stale = Scenario(name="Veraltet", description="") + empty = Scenario(name="Ohne Punkte", description="") + pg.add_all([stale, empty]) + pg.flush() + # Veraltete Momentaufnahme: Punkte von heute-5 bis heute+5. + for off in range(-5, 6): + pg.add(ProjectionPoint(scenario_id=stale.id, day=today + timedelta(days=off), + balance=Decimal("5000.00"))) + pg.add(ProjectionResult(scenario_id=stale.id, computed_at=today, + low_point_date=today, low_point_balance=Decimal("0"))) + pg.commit() + + rows = pg.execute(text( + "SELECT scenario, day, balance FROM v_projection ORDER BY scenario, day")).all() + ist = [r for r in rows if r.scenario == RESERVED_SCENARIO_NAME] + sc = [r for r in rows if r.scenario == "Veraltet"] + assert max(r.day for r in ist) == today + assert [r.balance for r in ist if r.day == today] == [Decimal("900.00")] + # Szenario startet heute am Ist-Saldo, keine alten Punkte davor. + assert sc[0].day == today and sc[0].balance == Decimal("900.00") + assert all(r.day >= today for r in sc) + assert [r.balance for r in sc[1:]] == [Decimal("5000.00")] * 5 + assert not any(r.scenario == "Ohne Punkte" for r in rows)