Files
bin/docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
2026-07-20 20:30:31 +02:00

197 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`).