fix: Bestandsabgleich per Token-Match und Volatilitaets-Hinweis fuer Vorschlaege
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
@@ -69,6 +70,24 @@ 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
|
||||
|
||||
# Bestandsabgleich, Token-Match (Live-Gate A9-Fund, Nachtrag 4): kuratierte
|
||||
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
|
||||
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Beispiel aus
|
||||
# der echten Datenbasis: Fixposten "Mastercard-Abrechnung Volksbank
|
||||
# (variabel, letzter Wert)" vs. erkannter Vorschlag "Volksbank Ulm-Biberach
|
||||
# eG" - Betrag weicht um >10% ab, kein Substring-Treffer). Ein gemeinsames,
|
||||
# hinreichend spezifisches Namens-Token (>=5 Zeichen, um generische Woerter
|
||||
# wie "Bank" nicht faelschlich matchen zu lassen) bei gleichem Rhythmus und
|
||||
# nahem Faelligkeitstag gilt als ausreichendes Indiz fuer denselben Fixposten.
|
||||
TOKEN_MIN_LEN = 5
|
||||
|
||||
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
|
||||
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
|
||||
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
|
||||
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
|
||||
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
|
||||
VOLATILITAETS_HINWEIS = "Beträge schwanken stark – letzte Buchung weicht ab"
|
||||
|
||||
|
||||
def _norm(name: str) -> str:
|
||||
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
|
||||
@@ -78,6 +97,14 @@ def _norm(name: str) -> str:
|
||||
return " ".join(name.split()).casefold()
|
||||
|
||||
|
||||
def _tokens(name: str) -> set[str]:
|
||||
"""Zerlegt einen normalisierten Namen an Nicht-Alphanumerik in Tokens
|
||||
(fuer den Token-Match im Bestandsabgleich, Schritt 8). Nur Tokens ab
|
||||
TOKEN_MIN_LEN Zeichen zaehlen, damit kurze generische Woerter ("eG",
|
||||
"AG", "Bank") keine falschen Treffer erzeugen."""
|
||||
return {tok for tok in re.split(r"[^a-z0-9]+", _norm(name)) if len(tok) >= TOKEN_MIN_LEN}
|
||||
|
||||
|
||||
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
|
||||
@@ -90,9 +117,15 @@ def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
|
||||
@dataclass
|
||||
class _Series:
|
||||
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
|
||||
Betrags-Clusters mit zugeordnetem Rhythmus."""
|
||||
Betrags-Clusters mit zugeordnetem Rhythmus.
|
||||
|
||||
`volatile` markiert, dass der Cluster-Split (Schritt 3) innerhalb der
|
||||
Empfaenger-Gruppe eine NEUERE, betragsmaessig abweichende Buchung
|
||||
abgetrennt hat - der hier vorgeschlagene Betrag koennte also schon
|
||||
wieder veraltet sein (siehe VOLATILITAETS_HINWEIS)."""
|
||||
items: list[Transaction]
|
||||
rhythm: str
|
||||
volatile: bool = False
|
||||
|
||||
@property
|
||||
def first(self) -> Transaction:
|
||||
@@ -183,6 +216,7 @@ def _try_merge(series_list: list[_Series]) -> list[_Series]:
|
||||
merged = _Series(
|
||||
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
|
||||
rhythm=a.rhythm,
|
||||
volatile=a.volatile or b.volatile,
|
||||
)
|
||||
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
|
||||
series_list.append(merged)
|
||||
@@ -196,12 +230,21 @@ def _try_merge(series_list: list[_Series]) -> list[_Series]:
|
||||
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)."""
|
||||
als Fixposten gepflegt ist - ueber einen von drei Wegen:
|
||||
(a) Namens-Substring-Match (normalisiert, in beide Richtungen: sowohl
|
||||
Kurz- als auch Langschreibweisen kommen in der Praxis in beiden
|
||||
Datenquellen vor);
|
||||
(b) Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
|
||||
der Fixposten unter einem ganz anderen Namen gepflegt wurde);
|
||||
(c) Token-Match: gleicher Rhythmus, Faelligkeitstag-Differenz <= 2 UND
|
||||
mindestens ein gemeinsames Namens-Token (>=5 Zeichen) - faengt
|
||||
kuratierte Alias-/Variabel-Fixposten, deren Name UND Betrag stark
|
||||
vom automatisch erkannten Vorschlag abweichen (Live-Gate A9-Fund:
|
||||
Fixposten "Mastercard-Abrechnung Volksbank (variabel, letzter
|
||||
Wert)" deckt den Vorschlag "Volksbank Ulm-Biberach eG" ab, obwohl
|
||||
weder (a) noch (b) greifen)."""
|
||||
cand_norm = _norm(cand_name)
|
||||
cand_tokens = _tokens(cand_name)
|
||||
for item in existing:
|
||||
item_norm = _norm(item.name)
|
||||
if cand_norm in item_norm or item_norm in cand_norm:
|
||||
@@ -210,6 +253,10 @@ def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
|
||||
return True
|
||||
if (item.rhythm == rhythm
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and cand_tokens & _tokens(item.name)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -242,12 +289,36 @@ def suggest_recurring(session: Session, today: date | None = None) -> list[dict]
|
||||
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])
|
||||
clusters = _amount_clusters(items_sorted)
|
||||
classified = [(cluster, _classify([t.booking_date for t in cluster]))
|
||||
for cluster in clusters]
|
||||
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
|
||||
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
|
||||
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
|
||||
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
|
||||
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
|
||||
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
|
||||
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
|
||||
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
|
||||
for t in cluster}
|
||||
for cluster, rhythm in classified:
|
||||
if rhythm is None:
|
||||
continue
|
||||
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
|
||||
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
|
||||
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
|
||||
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
|
||||
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
|
||||
# veraltet.
|
||||
cluster_sign = cluster[-1].amount > 0
|
||||
volatile = any(
|
||||
(t.amount > 0) == cluster_sign
|
||||
and t.booking_date > cluster[-1].booking_date
|
||||
and t not in qualified_items
|
||||
for t in items_sorted
|
||||
)
|
||||
series_by_account.setdefault(account_id, []).append(
|
||||
_Series(items=cluster, rhythm=rhythm))
|
||||
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
|
||||
|
||||
existing = list(session.execute(select(RecurringItem)).scalars())
|
||||
|
||||
@@ -279,6 +350,9 @@ def suggest_recurring(session: Session, today: date | None = None) -> list[dict]
|
||||
if abs(amount) > abs(previous):
|
||||
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
|
||||
|
||||
if s.volatile:
|
||||
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
|
||||
|
||||
# Schritt 8: Bestandsabgleich.
|
||||
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
|
||||
continue
|
||||
|
||||
@@ -253,6 +253,13 @@ def test_suggest_zwei_vertraege_getrennt(db):
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 2
|
||||
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||||
# Nachtrag 3b: beide Vertraege sind fuer sich genommen stabile,
|
||||
# qualifizierte Serien - die jeweils neuere Buchung des ANDEREN Vertrags
|
||||
# gehoert selbst zu einer qualifizierten Serie und darf deshalb NICHT als
|
||||
# "abgetrennte neueste Buchung" gewertet werden (sonst waere einer der
|
||||
# beiden faelschlich als "volatil" markiert, nur weil der andere Vertrag
|
||||
# einen Tag spaeter faellig ist).
|
||||
assert all(s["hinweis"] == "" for s in out)
|
||||
|
||||
|
||||
def test_try_merge_kombiniert_serien_ueber_gruppengrenzen():
|
||||
@@ -320,3 +327,43 @@ def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db):
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------- Nachtrag 4 (A9-Live-Gate-Fund)
|
||||
# Echter Fall aus dem Live-Gate: Fixposten 44 "Mastercard-Abrechnung Volksbank
|
||||
# (variabel, letzter Wert)" (monthly, due_day 7, -296.07) deckte den Vorschlag
|
||||
# "Volksbank Ulm-Biberach eG" (monthly, due_day 5, -584.43) nicht ab, weil
|
||||
# weder Substring- noch Betrags-Toleranz-Regel griffen. Betraege/Namen hier
|
||||
# synthetisch nachgebildet (keine echten Kontodaten, siehe CLAUDE.md).
|
||||
|
||||
def test_suggest_alias_recurring_item_token_match(db):
|
||||
acc = _acc(db)
|
||||
db.add(RecurringItem(name="Mastercard-Abrechnung Volksbank (variabel, letzter Wert)",
|
||||
amount=Decimal("-296.07"), rhythm="monthly", due_day=7))
|
||||
for d, a in [(date(2026, 4, 5), "-560.00"), (date(2026, 5, 5), "-580.00"),
|
||||
(date(2026, 6, 5), "-590.00"), (date(2026, 7, 5), "-580.00")]:
|
||||
_tx(db, acc, d, a, "Volksbank Ulm-Biberach eG")
|
||||
db.commit()
|
||||
|
||||
# Substring-Match (a) schlaegt fehl (kein Teilstring gemeinsam), Betrags-
|
||||
# Toleranz (b) auch (-580 vs. -296.07, >10%) - erst der Token-Match (c)
|
||||
# ueber das gemeinsame Token "volksbank" (Rhythmus gleich, due_day 5 vs. 7
|
||||
# -> Differenz 2 <= 2) deckt den Vorschlag ab.
|
||||
assert suggest_recurring(db, today=TODAY) == []
|
||||
|
||||
|
||||
def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
|
||||
acc = _acc(db)
|
||||
for d in [date(2026, 1, 5), date(2026, 2, 5), date(2026, 3, 5), date(2026, 4, 5)]:
|
||||
_tx(db, acc, d, "-200.00", "Schwankender Anbieter GmbH")
|
||||
# Neueste Buchung weicht >35% vom Serien-Betrag ab -> eigener Cluster,
|
||||
# klassifiziert selbst nicht (nur 1 Buchung) -> die vorgeschlagene Serie
|
||||
# bleibt die -200.00-Serie, aber mit Volatilitaets-Warnhinweis.
|
||||
_tx(db, acc, date(2026, 5, 5), "-600.00", "Schwankender Anbieter GmbH")
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db, today=date(2026, 5, 20))
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
|
||||
assert "schwanken" in out[0]["hinweis"].lower()
|
||||
|
||||
Reference in New Issue
Block a user