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
|
||||
|
||||
Reference in New Issue
Block a user