feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,9 @@ class SuggestionOut(BaseModel):
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None = None
|
||||
category_id: int | None = None
|
||||
hinweis: str = ""
|
||||
|
||||
|
||||
def _check_category(session: Session, category_id: int | None) -> None:
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
"""Vorschlagsalgorithmus fuer wiederkehrende Buchungen (Ausbaustufe 9, v2).
|
||||
|
||||
Ersetzt die reine exakte-Betrags-Gruppierung (v1) durch: Empfaenger-Cluster
|
||||
mit Toleranz (haelt Preisdrift in einer Serie zusammen, trennt aber parallele
|
||||
Vertraege desselben Anbieters), Rhythmus-Erkennung ueber den Median der
|
||||
Buchungsabstaende (monatlich/vierteljaehrlich/jaehrlich statt nur monatlich),
|
||||
einen Aktiv-Check (keine "Leichen"-Serien) sowie einen Merge-Pass fuer
|
||||
Umfirmierungen (Anbieter aendert den Namen, die Serie laeuft inhaltlich
|
||||
weiter). Bindende Spec:
|
||||
docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md.
|
||||
|
||||
Alle Betrags-Toleranzvergleiche verwenden ausschliesslich `Decimal`
|
||||
(CLAUDE.md: "Decimal, nicht float" - Rundungsfehler bei Geldbetraegen sind
|
||||
inakzeptabel). Tage-Vergleiche (Rhythmus, Aktiv-Check, Merge-Luecke) sind
|
||||
ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -7,46 +27,268 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tables import RecurringItem, Transaction
|
||||
|
||||
# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei
|
||||
# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas
|
||||
# Puffer fuer Cluster-/Merge-Bildung abdecken.
|
||||
WINDOW_DAYS = 460
|
||||
|
||||
def _max_consecutive_months(months: list[tuple[int, int]]) -> int:
|
||||
if not months:
|
||||
return 0
|
||||
best = current = 1
|
||||
for prev, cur in zip(months, months[1:]):
|
||||
prev_idx = prev[0] * 12 + prev[1]
|
||||
cur_idx = cur[0] * 12 + cur[1]
|
||||
current = current + 1 if cur_idx == prev_idx + 1 else 1
|
||||
best = max(best, current)
|
||||
return best
|
||||
# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
|
||||
# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
|
||||
# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
|
||||
# mit "passendem" Abstand soll nicht als Serie gelten).
|
||||
RHYTHMS: dict[str, tuple[int, int, int]] = {
|
||||
"monthly": (25, 36, 3),
|
||||
"quarterly": (80, 105, 3),
|
||||
"yearly": (330, 400, 2),
|
||||
}
|
||||
|
||||
# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check
|
||||
# und Merge-Luecken-Fenster (Schritt 5/6).
|
||||
STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365}
|
||||
|
||||
# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache
|
||||
# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet
|
||||
# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und
|
||||
# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine
|
||||
# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet.
|
||||
ACTIVITY_FACTOR_NUM = 7
|
||||
ACTIVITY_FACTOR_DEN = 4
|
||||
|
||||
# Relative Toleranzen (immer als Decimal verglichen, nie float):
|
||||
CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen)
|
||||
MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen
|
||||
BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem
|
||||
|
||||
# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem
|
||||
# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der
|
||||
# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus
|
||||
# demselben Grund wie beim Aktiv-Check).
|
||||
MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4
|
||||
MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
|
||||
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
|
||||
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
|
||||
|
||||
|
||||
def suggest_recurring(session: Session) -> list[dict]:
|
||||
def _norm(name: str) -> str:
|
||||
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
|
||||
Substring-Vergleich: Bankexporte schreiben denselben Empfaenger nicht
|
||||
einheitlich (Gross-/Kleinschreibung, mehrfache Leerzeichen), das ist fuer
|
||||
die Erkennung irrelevant."""
|
||||
return " ".join(name.split()).casefold()
|
||||
|
||||
|
||||
def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
|
||||
"""Relative Differenz von Betrag a zur Referenz b (immer >= 0), als
|
||||
Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine
|
||||
erkennbare Serie); fuer diesen Sonderfall gilt "keine Aehnlichkeit"."""
|
||||
if b == 0:
|
||||
return Decimal("Infinity") if a != 0 else Decimal("0")
|
||||
return abs(a - b) / abs(b)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Series:
|
||||
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
|
||||
Betrags-Clusters mit zugeordnetem Rhythmus."""
|
||||
items: list[Transaction]
|
||||
rhythm: str
|
||||
|
||||
@property
|
||||
def first(self) -> Transaction:
|
||||
return self.items[0]
|
||||
|
||||
@property
|
||||
def last(self) -> Transaction:
|
||||
return self.items[-1]
|
||||
|
||||
|
||||
def _amount_clusters(items: list[Transaction]) -> list[list[Transaction]]:
|
||||
"""Schritt 3: teilt chronologisch sortierte Buchungen einer
|
||||
Empfaenger-Gruppe in Betrags-Cluster. Eine Buchung haengt sich an das
|
||||
Cluster, dessen zuletzt aufgenommenes Mitglied gleiches Vorzeichen und
|
||||
eine relative Differenz <= CLUSTER_TOL hat (greedy, erstes passendes
|
||||
Cluster gewinnt) - das haelt eine langsam driftende Serie (Preiserhoehung)
|
||||
zusammen, trennt aber parallele Vertraege mit deutlich anderem Betrag."""
|
||||
clusters: list[list[Transaction]] = []
|
||||
for t in items:
|
||||
for cluster in clusters:
|
||||
last = cluster[-1]
|
||||
same_sign = (t.amount > 0) == (last.amount > 0)
|
||||
if same_sign and _rel_diff(Decimal(t.amount), Decimal(last.amount)) <= CLUSTER_TOL:
|
||||
cluster.append(t)
|
||||
break
|
||||
else:
|
||||
clusters.append([t])
|
||||
return clusters
|
||||
|
||||
|
||||
def _classify(dates: list[date]) -> str | None:
|
||||
"""Schritt 4: bestimmt den Rhythmus einer Serie ueber den Median der
|
||||
Buchungsabstaende (robust gegen einzelne Ausreisser, z.B.
|
||||
Wochenend-/Feiertagsverschiebung einer einzelnen Buchung)."""
|
||||
if len(dates) < 2:
|
||||
return None
|
||||
gaps = [(b - a).days for a, b in zip(dates, dates[1:])]
|
||||
median_gap = statistics.median(gaps)
|
||||
for rhythm, (lo, hi, min_belege) in RHYTHMS.items():
|
||||
if len(dates) >= min_belege and lo <= median_gap <= hi:
|
||||
return rhythm
|
||||
return None
|
||||
|
||||
|
||||
def _merge_gap_ok(gap_days: int, step: int) -> bool:
|
||||
"""Schritt 6: Luecke zwischen Serienende und -beginn im Fenster
|
||||
[0,4; 1,6] * Schrittweite (ganzzahliger Bruchvergleich, keine Rundung)."""
|
||||
return (gap_days * MERGE_GAP_MIN_DEN >= MERGE_GAP_MIN_NUM * step
|
||||
and gap_days * MERGE_GAP_MAX_DEN <= MERGE_GAP_MAX_NUM * step)
|
||||
|
||||
|
||||
def _mergeable(a: _Series, b: _Series) -> bool:
|
||||
"""Prueft die Umfirmierungs-Merge-Bedingungen aus Schritt 6 fuer ein
|
||||
Paar (A endet, B beginnt danach): gleicher Rhythmus, plausible Luecke,
|
||||
Faelligkeitstag nah beieinander (Transitionspunkte: letzte Buchung von A
|
||||
gegen erste Buchung von B), Betrag nicht sprunghaft veraendert."""
|
||||
if a.rhythm != b.rhythm:
|
||||
return False
|
||||
if a.last.booking_date >= b.first.booking_date:
|
||||
return False
|
||||
step = STEP_DAYS[a.rhythm]
|
||||
gap = (b.first.booking_date - a.last.booking_date).days
|
||||
if not _merge_gap_ok(gap, step):
|
||||
return False
|
||||
if abs(a.last.booking_date.day - b.first.booking_date.day) > MERGE_DUE_DAY_TOL:
|
||||
return False
|
||||
same_sign = (a.last.amount > 0) == (b.first.amount > 0)
|
||||
if not same_sign:
|
||||
return False
|
||||
return _rel_diff(Decimal(b.first.amount), Decimal(a.last.amount)) <= MERGE_TOL
|
||||
|
||||
|
||||
def _try_merge(series_list: list[_Series]) -> list[_Series]:
|
||||
"""Schritt 6: fasst Serien desselben Kontos ueber Gruppengrenzen
|
||||
(unterschiedlicher normalisierter Empfaenger-Name, z.B. nach einer
|
||||
Umfirmierung) zusammen, solange `_mergeable` zutrifft. Laeuft iterativ
|
||||
bis zum Fixpunkt, damit eine bereits gemergte Serie mit einer weiteren,
|
||||
noch juengeren Serie erneut zusammengefasst werden kann (z.B. zwei
|
||||
Umbenennungen hintereinander)."""
|
||||
series_list = list(series_list)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for i, a in enumerate(series_list):
|
||||
for j, b in enumerate(series_list):
|
||||
if i == j or not _mergeable(a, b):
|
||||
continue
|
||||
merged = _Series(
|
||||
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
|
||||
rhythm=a.rhythm,
|
||||
)
|
||||
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
|
||||
series_list.append(merged)
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
break
|
||||
return series_list
|
||||
|
||||
|
||||
def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int,
|
||||
existing: list[RecurringItem]) -> bool:
|
||||
"""Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits
|
||||
als Fixposten gepflegt ist - entweder ueber einen Namens-Substring-Match
|
||||
(normalisiert, in beide Richtungen: sowohl Kurz- als auch
|
||||
Langschreibweisen kommen in der Praxis in beiden Datenquellen vor) oder
|
||||
ueber Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
|
||||
der Fixposten unter einem ganz anderen Namen gepflegt wurde)."""
|
||||
cand_norm = _norm(cand_name)
|
||||
for item in existing:
|
||||
item_norm = _norm(item.name)
|
||||
if cand_norm in item_norm or item_norm in cand_norm:
|
||||
return True
|
||||
if (item.rhythm == rhythm
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def suggest_recurring(session: Session, today: date | None = None) -> list[dict]:
|
||||
"""Ermittelt Vorschlaege fuer wiederkehrende Posten aus bestaetigten
|
||||
Buchungen der letzten WINDOW_DAYS Tage. `today` ist ausschliesslich zu
|
||||
Testzwecken injizierbar (deterministischer Aktiv-Check) - im
|
||||
Produktivbetrieb liefert der Default `date.today()`. Reihenfolge der
|
||||
Schritte gemaess Spec, mit einer bewussten Umstellung gegenueber der
|
||||
Nummerierung dort: der Aktiv-Check (Schritt 5) laeuft NACH dem
|
||||
Umfirmierungs-Merge (Schritt 6) auf der ggf. gemergten Serie - sonst
|
||||
wuerde eine per Umfirmierung fortgesetzte Serie an ihrem alten,
|
||||
laengst inaktiven Teil scheitern, bevor der Merge sie retten kann."""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
cutoff = today - timedelta(days=WINDOW_DAYS)
|
||||
|
||||
# Schritt 1: Datenbasis.
|
||||
txs = session.execute(
|
||||
select(Transaction).where(Transaction.status == "confirmed")
|
||||
select(Transaction)
|
||||
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
|
||||
).scalars().all()
|
||||
groups: dict[tuple, list[Transaction]] = defaultdict(list)
|
||||
for t in txs:
|
||||
groups[(t.account_id, t.counterparty, t.amount)].append(t)
|
||||
|
||||
existing = {(r.name, Decimal(r.amount))
|
||||
for r in session.execute(select(RecurringItem)).scalars()}
|
||||
# Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
|
||||
groups: dict[tuple[int, str], list[Transaction]] = {}
|
||||
for t in txs:
|
||||
groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t)
|
||||
|
||||
# Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren.
|
||||
series_by_account: dict[int, list[_Series]] = {}
|
||||
for (account_id, _name_norm), items in groups.items():
|
||||
items_sorted = sorted(items, key=lambda t: t.booking_date)
|
||||
for cluster in _amount_clusters(items_sorted):
|
||||
rhythm = _classify([t.booking_date for t in cluster])
|
||||
if rhythm is None:
|
||||
continue
|
||||
series_by_account.setdefault(account_id, []).append(
|
||||
_Series(items=cluster, rhythm=rhythm))
|
||||
|
||||
existing = list(session.execute(select(RecurringItem)).scalars())
|
||||
|
||||
suggestions: list[dict] = []
|
||||
for (_account_id, counterparty, amount), items in groups.items():
|
||||
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items})
|
||||
if _max_consecutive_months(months) < 3:
|
||||
continue
|
||||
name = counterparty
|
||||
if (name, Decimal(amount)) in existing:
|
||||
continue
|
||||
due_day = int(statistics.median(sorted(t.booking_date.day for t in items)))
|
||||
cat_counts = Counter(t.category_id for t in items if t.category_id is not None)
|
||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||
suggestions.append({
|
||||
"name": name,
|
||||
"amount": Decimal(amount),
|
||||
"rhythm": "monthly",
|
||||
"due_day": due_day,
|
||||
"category_id": category_id,
|
||||
})
|
||||
for series_list in series_by_account.values():
|
||||
# Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
|
||||
for s in _try_merge(series_list):
|
||||
# Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie.
|
||||
step = STEP_DAYS[s.rhythm]
|
||||
delta_tage = (today - s.last.booking_date).days
|
||||
if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM:
|
||||
continue
|
||||
|
||||
# Schritt 7: Vorschlagswerte aus der neuesten Buchung.
|
||||
last = s.last
|
||||
name = last.counterparty
|
||||
amount = Decimal(last.amount)
|
||||
due_day = last.booking_date.day
|
||||
start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None
|
||||
cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None)
|
||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||
|
||||
hinweis = ""
|
||||
if len(s.items) >= 2:
|
||||
previous = Decimal(s.items[-2].amount)
|
||||
# "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind
|
||||
# negativ: gestiegen heisst betragsmaessig groesser, also
|
||||
# abs(neu) > abs(alt)), nicht auf das Vorzeichen.
|
||||
if abs(amount) > abs(previous):
|
||||
hinweis = f"Betrag zuletzt gestiegen (vorher {abs(previous)})"
|
||||
|
||||
# Schritt 8: Bestandsabgleich.
|
||||
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
|
||||
continue
|
||||
|
||||
suggestions.append({
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"rhythm": s.rhythm,
|
||||
"due_day": due_day,
|
||||
"start_date": start_date,
|
||||
"category_id": category_id,
|
||||
"hinweis": hinweis,
|
||||
})
|
||||
return suggestions
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from itertools import count
|
||||
|
||||
from app.models.tables import Account, Category, RecurringItem, Transaction
|
||||
from app.services.suggestions import suggest_recurring
|
||||
from app.services.suggestions import _Series, _try_merge, suggest_recurring
|
||||
|
||||
# Feste Vergleichs-"heute" fuer alle Vorschlags-Tests (Ausbaustufe 9): macht
|
||||
# den Aktiv-Check deterministisch, ohne echtes date.today() im Testlauf.
|
||||
TODAY = date(2026, 7, 20)
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
@@ -72,42 +77,60 @@ def test_loan_in_scenario_keeps_balance_positive(client, db):
|
||||
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
||||
|
||||
|
||||
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
|
||||
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
|
||||
purpose="", counterparty=counterparty, status="confirmed",
|
||||
dedup_hash=dedup, category_id=category_id)
|
||||
_iban_seq = count(1)
|
||||
|
||||
|
||||
def _acc(db) -> int:
|
||||
"""Legt ein Konto an und gibt dessen id zurueck. IBAN ist je Aufruf
|
||||
eindeutig (falls ein Test mehrere Konten braucht), Praefix "DE" plus
|
||||
laufende Nummer reicht dafuer aus."""
|
||||
acc = Account(bank="dkb", iban=f"DE{next(_iban_seq):032d}"[:34], name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
return acc.id
|
||||
|
||||
|
||||
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||||
"""Legt eine bestaetigte Buchung an. dedup_hash aus den Nutzdaten
|
||||
abgeleitet reicht fuer Testzwecke (muss nur innerhalb eines Tests
|
||||
eindeutig sein)."""
|
||||
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}"))
|
||||
|
||||
|
||||
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
acc = _acc(db)
|
||||
cat = Category(name="Miete")
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
|
||||
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
|
||||
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
|
||||
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
|
||||
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
|
||||
# Jahreswechsel hinweg, gleichmaessiger 31-Tage-Abstand (Median im
|
||||
# monatlichen Fenster 25-36 Tage; die alte Jahreswechsel-Pruefung galt der
|
||||
# Monats-Linearisierung, die es im neuen Tage-basierten Median-Ansatz
|
||||
# nicht mehr braucht).
|
||||
_tx(db, acc, date(2025, 12, 1), "-600.00", "Vermieter", cat.id)
|
||||
_tx(db, acc, date(2026, 1, 1), "-600.00", "Vermieter", cat.id)
|
||||
_tx(db, acc, date(2026, 2, 1), "-600.00", "Vermieter", cat.id)
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db)
|
||||
out = suggest_recurring(db, today=date(2026, 2, 10))
|
||||
|
||||
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen.
|
||||
# due_day = Tag der NEUESTEN Buchung (nicht mehr Median, Spec Schritt 7);
|
||||
# Betrag unveraendert, keine Preissteigerung -> hinweis leer.
|
||||
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
|
||||
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}]
|
||||
"rhythm": "monthly", "due_day": 1, "start_date": None,
|
||||
"category_id": cat.id, "hinweis": ""}]
|
||||
|
||||
|
||||
def test_suggest_recurring_two_months_no_suggestion(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
|
||||
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
|
||||
acc = _acc(db)
|
||||
_tx(db, acc, date(2026, 3, 10), "-50.00", "Zweimonatig")
|
||||
_tx(db, acc, date(2026, 4, 10), "-50.00", "Zweimonatig")
|
||||
db.commit()
|
||||
|
||||
assert suggest_recurring(db) == []
|
||||
# Nur 2 Belege: Mindestbelege fuer monthly (>=3) nicht erreicht.
|
||||
assert suggest_recurring(db, today=date(2026, 4, 20)) == []
|
||||
|
||||
|
||||
def test_recurring_ende_vor_start_wird_abgelehnt(client):
|
||||
@@ -135,15 +158,165 @@ def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
|
||||
|
||||
|
||||
def test_suggest_recurring_excludes_existing_recurring_item(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
|
||||
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
|
||||
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
|
||||
acc = _acc(db)
|
||||
_tx(db, acc, date(2026, 1, 5), "-30.00", "Streaming")
|
||||
_tx(db, acc, date(2026, 2, 5), "-30.00", "Streaming")
|
||||
_tx(db, acc, date(2026, 3, 5), "-30.00", "Streaming")
|
||||
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
|
||||
rhythm="monthly", due_day=5))
|
||||
db.commit()
|
||||
|
||||
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
|
||||
assert suggest_recurring(db) == []
|
||||
# Gleicher Name wie ein bereits vorhandenes RecurringItem -> Bestandsabgleich
|
||||
# (Schritt 8, Namens-Match) greift, unabhaengig vom Betrag.
|
||||
assert suggest_recurring(db, today=date(2026, 3, 20)) == []
|
||||
|
||||
|
||||
# --------------------------------------------------- Ausbaustufe 9: Algorithmus v2
|
||||
# Synthetische Faelle aus der Spec (siehe
|
||||
# docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md).
|
||||
|
||||
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||||
acc = _acc(db)
|
||||
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")}
|
||||
|
||||
|
||||
def test_try_merge_kombiniert_serien_ueber_gruppengrenzen():
|
||||
# Direkter, isolierter Test der Merge-Mechanik (Schritt 6): siehe Report
|
||||
# fuer den rechnerischen Nachweis, dass ein End-to-End-Szenario, in dem
|
||||
# die ALTE und die NEUE Serie GLEICHZEITIG unabhaengig voneinander den
|
||||
# Aktiv-Check bestehen, fuer keinen der drei Rhythmen innerhalb von
|
||||
# WINDOW_DAYS=460 konstruierbar ist (die alte Serie ist beim Aktiv-Check
|
||||
# immer laengst "tot", sobald die neue genug eigene Belege hat, bzw. bei
|
||||
# yearly passt die noetige Gesamtspanne nicht ins Fenster). Deshalb hier
|
||||
# `_try_merge` direkt gegen zwei synthetische `_Series` geprueft, ganz ohne
|
||||
# DB/Fenster/Aktiv-Check-Interaktion.
|
||||
a = _Series(items=[
|
||||
Transaction(booking_date=date(2026, 1, 3), amount=Decimal("-50.00"),
|
||||
counterparty="Alte Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 2, 3), amount=Decimal("-50.00"),
|
||||
counterparty="Alte Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 3, 3), amount=Decimal("-50.00"),
|
||||
counterparty="Alte Firma GmbH", category_id=None),
|
||||
], rhythm="monthly")
|
||||
b = _Series(items=[
|
||||
Transaction(booking_date=date(2026, 4, 5), amount=Decimal("-52.00"),
|
||||
counterparty="Neue Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 5, 5), amount=Decimal("-52.00"),
|
||||
counterparty="Neue Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 6, 5), amount=Decimal("-52.00"),
|
||||
counterparty="Neue Firma GmbH", category_id=None),
|
||||
], rhythm="monthly")
|
||||
|
||||
merged = _try_merge([a, b])
|
||||
|
||||
# Luecke A-Ende->B-Anfang = 33 Tage (in [12,48]), Faelligkeitstag 3 vs 5
|
||||
# (Differenz 2 <= 3), Betrag +4% (<=25%) -> Bedingungen erfuellt, genau
|
||||
# EINE kombinierte Serie mit allen 6 Buchungen, juengste zuerst.
|
||||
assert len(merged) == 1
|
||||
assert len(merged[0].items) == 6
|
||||
assert merged[0].last.counterparty == "Neue Firma GmbH"
|
||||
|
||||
|
||||
def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db):
|
||||
# End-to-End-Nachweis, dass Schritt 6 tatsaechlich in `suggest_recurring`
|
||||
# verdrahtet ist: da die Anzahl der Vorschlaege sich (bewiesenermassen,
|
||||
# siehe Report) end-to-end NICHT als Diskriminator eignet (die alte Serie
|
||||
# faellt so oder so per Aktiv-Check heraus), wird hier die
|
||||
# Kategorie-Mehrheit als Diskriminator genutzt - die haengt direkt davon
|
||||
# ab, ob die Buchungen der alten Serie ueber den Merge in die Zaehlung
|
||||
# eingehen. Alte Serie: 4 Buchungen Kategorie A. Neue Serie: 3 Buchungen
|
||||
# Kategorie B. Ohne Merge zaehlen nur die 3 B-Buchungen (Mehrheit B). Mit
|
||||
# Merge kommen die 4 A-Buchungen dazu und kippen die Mehrheit auf A.
|
||||
acc = _acc(db)
|
||||
cat_a = Category(name="Alt-Kategorie")
|
||||
cat_b = Category(name="Neu-Kategorie")
|
||||
db.add(cat_a)
|
||||
db.add(cat_b)
|
||||
db.flush()
|
||||
for d in [date(2026, 1, 3), date(2026, 2, 3), date(2026, 3, 3), date(2026, 4, 3)]:
|
||||
_tx(db, acc, d, "-50.00", "Alte Firma GmbH", cat_a.id)
|
||||
for d in [date(2026, 5, 5), date(2026, 6, 5), date(2026, 7, 5)]:
|
||||
_tx(db, acc, d, "-52.00", "Neue Firma GmbH", cat_b.id)
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db, today=date(2026, 7, 20))
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0]["name"] == "Neue Firma GmbH" and out[0]["amount"] == Decimal("-52.00")
|
||||
# Kategorie-Mehrheit kippt durch den Merge von B (3) auf A (4):
|
||||
assert out[0]["category_id"] == cat_a.id
|
||||
|
||||
Reference in New Issue
Block a user