197 lines
12 KiB
Markdown
197 lines
12 KiB
Markdown
# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.0)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten.
|
||
|
||
**Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf.
|
||
|
||
**Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans.
|
||
|
||
## Global Constraints
|
||
|
||
- Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten).
|
||
- `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum.
|
||
- Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits).
|
||
- GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal.
|
||
- Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task.
|
||
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4).
|
||
|
||
---
|
||
|
||
### Task 1: Algorithmus-Rewrite + API-Schema
|
||
|
||
**Files:**
|
||
- Rewrite: `app/services/suggestions.py`
|
||
- Modify: `app/routers/planning.py` (`SuggestionOut`)
|
||
- Modify: `tests/test_planning_api.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder.
|
||
|
||
- [ ] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren):
|
||
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
|
||
purpose="p", counterparty=cp, category_id=cat,
|
||
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
|
||
|
||
TODAY = date(2026, 7, 20)
|
||
|
||
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||
acc = _acc(db) # Helper: Account anlegen, gibt id zurueck
|
||
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
|
||
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
|
||
(date(2026, 7, 1), "-202.94")]:
|
||
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
|
||
db.commit()
|
||
out = suggest_recurring(db, today=TODAY)
|
||
assert len(out) == 1
|
||
s = out[0]
|
||
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
|
||
assert s["due_day"] == 1 and s["start_date"] is None
|
||
|
||
def test_suggest_quartal_mit_phase(db):
|
||
acc = _acc(db)
|
||
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
|
||
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
|
||
db.commit()
|
||
out = suggest_recurring(db, today=TODAY)
|
||
assert len(out) == 1
|
||
assert out[0]["rhythm"] == "quarterly"
|
||
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
|
||
|
||
def test_suggest_jahr_mit_zwei_belegen(db):
|
||
acc = _acc(db)
|
||
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
|
||
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
|
||
db.commit()
|
||
out = suggest_recurring(db, today=TODAY)
|
||
assert len(out) == 1
|
||
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
|
||
assert out[0]["start_date"] == date(2026, 6, 16)
|
||
assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen
|
||
|
||
def test_suggest_tote_serie_kein_vorschlag(db):
|
||
acc = _acc(db)
|
||
for m in (9, 10, 11, 12):
|
||
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
|
||
db.commit()
|
||
assert suggest_recurring(db, today=TODAY) == []
|
||
|
||
def test_suggest_umfirmierung_merge(db):
|
||
acc = _acc(db)
|
||
for m in (11, 12):
|
||
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
|
||
for m in (1, 2, 3):
|
||
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
|
||
for m in (4, 5, 6, 7):
|
||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
|
||
db.commit()
|
||
out = suggest_recurring(db, today=TODAY)
|
||
assert len(out) == 1
|
||
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
|
||
|
||
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
|
||
acc = _acc(db)
|
||
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
|
||
rhythm="monthly", due_day=1))
|
||
for m in (4, 5, 6, 7):
|
||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
|
||
db.commit()
|
||
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
|
||
|
||
def test_suggest_zwei_vertraege_getrennt(db):
|
||
acc = _acc(db)
|
||
for m in (4, 5, 6, 7):
|
||
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
|
||
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
|
||
db.commit()
|
||
out = suggest_recurring(db, today=TODAY)
|
||
assert len(out) == 2
|
||
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||
```
|
||
|
||
(`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match).
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL
|
||
|
||
- [ ] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen.
|
||
|
||
- [ ] **Step 3: `SuggestionOut` erweitern** — `routers/planning.py`:
|
||
|
||
```python
|
||
class SuggestionOut(BaseModel):
|
||
name: str
|
||
amount: Decimal
|
||
rhythm: str
|
||
due_day: int
|
||
start_date: date | None = None
|
||
category_id: int | None = None
|
||
hinweis: str = ""
|
||
```
|
||
|
||
- [ ] **Step 4: Tests + Suite grün** — `.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen).
|
||
|
||
- [ ] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 6: Commit** — `git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"`
|
||
|
||
---
|
||
|
||
### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen")
|
||
- Modify: `tests/test_gui.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`.
|
||
|
||
- [ ] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden):
|
||
|
||
```python
|
||
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
|
||
db.add(acc)
|
||
db.flush()
|
||
for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)):
|
||
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
|
||
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
|
||
dedup_hash=f"sug-{d}"))
|
||
db.commit()
|
||
r = client.get("/planung").text
|
||
assert "vierteljährlich" in r # de_label des Rhythmus
|
||
assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ
|
||
assert 'name="start_date"' in r # hidden input der Uebernahme
|
||
```
|
||
|
||
WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`).
|
||
|
||
- [ ] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung."
|
||
|
||
- [ ] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`.
|
||
|
||
---
|
||
|
||
### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten
|
||
|
||
- [ ] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0.
|
||
- [ ] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-−4,99-Serie, ausgelaufene WWK-−35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate.
|
||
- [ ] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
- Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert.
|
||
- Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt.
|
||
- Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`).
|