105 lines
5.5 KiB
Markdown
105 lines
5.5 KiB
Markdown
# Design — Ausbaustufe 9: Vorschlags-Algorithmus v2 (v0.9.0)
|
||
|
||
> Status: vom Nutzer freigegeben (Chat 2026-07-20, Direktdurchlauf). Anlass:
|
||
> Der bisherige `suggest_recurring` gruppiert nach exaktem Betrag (jede
|
||
> Preiserhöhung zerreißt die Serie), erkennt nur monatliche Serien, prüft
|
||
> keine Aktivität (schlägt tote Serien vor) und nutzt Median-Werte. Ein
|
||
> manueller Vollabgleich (A8-Datenpflege, siehe Ledger) fand 16 fehlende
|
||
> Posten — der Algorithmus soll solche Serien künftig selbst finden.
|
||
|
||
## Anforderungen (Nutzer)
|
||
|
||
- Betrag = **letzte** Buchung, nicht Median (Preissteigerungen relevant).
|
||
- Erkennung **monatlich, vierteljährlich, jährlich**.
|
||
- Empfänger-Gruppierung robust (Schreibweisen, Preisänderungen,
|
||
Umfirmierungen); mehrere Verträge desselben Anbieters getrennt.
|
||
- Keine „Leichen": abgerissene Serien werden nicht vorgeschlagen.
|
||
- Kein Wiedervorschlagen bereits gepflegter Posten (auch bei zwischenzeitlich
|
||
geändertem Betrag).
|
||
|
||
## Algorithmus (`app/services/suggestions.py`, vollständiger Rewrite)
|
||
|
||
Parameter als Modul-Konstanten (Toleranzen zentral änderbar):
|
||
`WINDOW_DAYS=460` (~15 Monate), Rhythmen mit Intervallgrenzen und
|
||
Mindestbelegen: monthly 25–36 Tage/≥3, quarterly 80–105/≥3, yearly
|
||
330–400/≥2; `ACTIVITY_FACTOR=1.75`; Betrags-Cluster-Toleranz 35 %;
|
||
Merge-Toleranz 25 %; Bestandsabgleich-Toleranz 10 %.
|
||
|
||
1. **Datenbasis:** bestätigte Buchungen der letzten `WINDOW_DAYS`, je Konto.
|
||
2. **Gruppierung:** Schlüssel = (account_id, normalisierter Empfänger)
|
||
(`casefold`, Whitespace kollabiert).
|
||
3. **Betrags-Cluster** innerhalb der Gruppe (chronologisch, greedy gegen das
|
||
jeweils letzte Cluster-Mitglied, gleiches Vorzeichen, relative Differenz
|
||
≤ 35 %) — trennt parallele Verträge, hält Preisdrift zusammen.
|
||
4. **Rhythmus je Cluster:** Median der Buchungsabstände gegen die
|
||
Intervallgrenzen; Mindestbelege je Rhythmus.
|
||
5. **Aktiv-Check:** letzte Buchung ≤ `ACTIVITY_FACTOR` × Rhythmus-Schrittweite
|
||
(30/91/365 Tage) her, sonst kein Vorschlag.
|
||
6. **Umfirmierungs-Merge** (über Gruppengrenzen, je Konto): Serie A endet,
|
||
Serie B beginnt danach (Lücke 0,4–1,6 Schrittweiten), gleicher Rhythmus,
|
||
Fälligkeitstag ±3, Betrag ±25 % → eine Serie; Name/Betrag der neueren.
|
||
7. **Vorschlagswerte:** Name = Empfänger-Schreibweise der neuesten Buchung;
|
||
Betrag = neueste Buchung; Fälligkeitstag = Tag der neuesten Buchung;
|
||
`start_date` = Datum der neuesten Buchung bei quarterly/yearly (Phase!),
|
||
sonst None; Kategorie = häufigste in der Serie; `hinweis` = Text
|
||
„Betrag zuletzt gestiegen (vorher X)" wenn die vorletzte Buchung
|
||
betragskleiner war, sonst leer.
|
||
8. **Bestandsabgleich:** Vorschlag entfällt, wenn ein `RecurringItem`
|
||
existiert mit (a) Namens-Substring-Match (normalisiert, in beide
|
||
Richtungen) ODER (b) gleichem Rhythmus + Fälligkeitstag ±2 + Betrag
|
||
±10 %.
|
||
|
||
## API/GUI
|
||
|
||
- `SuggestionOut` (routers/planning.py): + `start_date: date | None`,
|
||
+ `hinweis: str = ""`.
|
||
- Vorschlags-Tabelle (planning.html): Spalten Rhythmus (`|de_label`) und
|
||
Start (TT.MM.JJJJ bzw. „–"); `hinweis` als `muted`-Text hinter dem Betrag;
|
||
„Vorschlag übernehmen" überträgt `start_date` mit (hidden input).
|
||
- Hinweistext unter der Tabelle aktualisiert: monatliche/vierteljährliche/
|
||
jährliche Serien, Betrag = letzte Buchung.
|
||
|
||
## Tests (synthetische Daten, keine Fixtures)
|
||
|
||
Preiserhöhungs-Serie → letzter Betrag + hinweis; Quartals-/Jahres-Serie mit
|
||
korrektem start_date; tote Serie (letzte Buchung zu alt) → kein Vorschlag;
|
||
Umbenennungs-Merge → ein Vorschlag mit neuem Namen; Bestandsabgleich:
|
||
existierender Posten mit altem Betrag verhindert Wiedervorschlag; zwei
|
||
parallele Verträge eines Anbieters → zwei getrennte Vorschläge; bestehende
|
||
drei Suggestion-Tests an die neue Semantik anpassen.
|
||
|
||
## Release
|
||
|
||
`VERSION` → 0.9.0, Redeploy, **Live-Gate gegen echte Daten**: kein einziger
|
||
der bestehenden Fixposten darf erneut vorgeschlagen werden; keine als
|
||
beendet bekannten Serien (z.B. gelöschte PayPal-Leiche, ausgelaufene
|
||
WWK-Police) im Ergebnis; verbleibende Vorschläge werden dem Nutzer als
|
||
Kandidatenliste berichtet (nur Chat, kein Commit). Fable-Gate je Task.
|
||
|
||
**Außerhalb des Scopes:** halbjährliche Rhythmen (nicht im Datenmodell),
|
||
automatische Übernahme ohne Nutzer-Klick, Einnahmen-Prognose des
|
||
Geschäftskontos.
|
||
|
||
## Nachtrag (nach Live-Release-Gate, gleiche Session)
|
||
|
||
Das erste Live-Gate scheiterte an einem Duplikat: ein kuratierter
|
||
„variabel"-Fixposten unter Alias-Namen des Anbieters wurde vom
|
||
Bestandsabgleich (a)/(b) nicht erkannt. Daraus zwei Ergänzungen:
|
||
|
||
- **Bestandsabgleich-Regel (c) Token-Match:** Vorschlag entfällt auch, wenn
|
||
ein Fixposten mit gleichem Rhythmus, Fälligkeitstag ±2 und mindestens
|
||
einem gemeinsamen Namens-Token (≥ 5 Zeichen, normalisiert, Split an
|
||
Nicht-Alphanumerik) existiert.
|
||
- **Volatilitäts-Hinweis:** Wurde die neueste Buchung einer Empfänger-Gruppe
|
||
durch den Betrags-Cluster-Split abgetrennt UND gehört sie zu keiner
|
||
anderen qualifizierten Serie der Gruppe, erhält der Vorschlag den Zusatz
|
||
„Beträge schwanken stark – letzte Buchung weicht ab" (keine
|
||
Unterdrückung; die Ausnahme verhindert False-Positives bei parallelen
|
||
Verträgen desselben Anbieters).
|
||
|
||
Bewiesene Pipeline-Eigenschaft (bindend fürs Verständnis): der
|
||
Umfirmierungs-Merge kann die Vorschlagsanzahl nie ändern (Aktiv-Check/
|
||
Fenster erledigen das allein); sein Nutzen ist Kategorie-/Historien-
|
||
Kontinuität. Nach einer Umfirmierung entsteht eine Vorschlags-Lücke, bis
|
||
der neue Name selbst die Mindestbelege erreicht.
|