fix: Eine Gegenseite, die Tage spaeter gebucht wurde, wird jetzt zum Verbinden vorgeschlagen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-16 08:30:40 +02:00
co-authored by Claude Opus 5
parent f52113e05d
commit b1f5eb9a51
2 changed files with 107 additions and 32 deletions
+62 -32
View File
@@ -41,7 +41,7 @@ from ..models import (
User,
)
from . import currency
from .csv_import import normalize_text
from .csv_import import INBOX_TAGE, normalize_text
# Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer
# allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage
@@ -146,8 +146,19 @@ def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]
return False
def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]:
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke."""
def _kandidaten(db: Session, user: User,
fenster: int = 0) -> dict[int, list[Transaction]]:
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke.
``fenster`` ist der erlaubte Abstand in Tagen. 0 heisst derselbe Tag – so
eng muss es bleiben, wo von selbst verknüpft wird.
Für reine VORSCHLÄGE darf es weiter sein, denn dort entscheidet der Nutzer.
Sonst klafft eine Lücke: Die Übertrags-Prüfung sucht die Gegenseite über
±INBOX_TAGE und schickt den Nutzer zum Verbinden – die Vorschlagsliste aber
kannte nur den gleichen Tag und bot dieses Paar nie an. Die Bank bucht die
Gegenseite regelmässig ein bis drei Tage später.
"""
verknuepft = set()
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
TransferLink.owner_id == user.id
@@ -169,18 +180,23 @@ def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]:
if t.id not in verknuepft
]
# Nach Tag und Betrag bündeln: Nur wer beides teilt, kommt überhaupt infrage.
nach_schluessel: dict[tuple, list[Transaction]] = defaultdict(list)
# Nach Betrag bündeln, der Tag wird danach geprüft – nur so lässt sich ein
# Fenster zulassen. Innerhalb eines Bündels nach Datum sortiert, damit die
# Suche abbrechen kann, sobald der Abstand zu gross wird.
nach_betrag: dict[int, list[Transaction]] = defaultdict(list)
for t in buchungen:
nach_schluessel[(t.date, t.amount_cents)].append(t)
nach_betrag[t.amount_cents].append(t)
kandidaten: dict[int, list[Transaction]] = defaultdict(list)
for gruppe in nach_schluessel.values():
for gruppe in nach_betrag.values():
if len(gruppe) < 2:
continue
for a in gruppe:
for b in gruppe:
if a.id == b.id or a.account_id == b.account_id:
gruppe.sort(key=lambda t: (t.date, t.id))
for i, a in enumerate(gruppe):
for b in gruppe[i + 1:]:
if (b.date - a.date).days > fenster:
break # sortiert: alles Weitere liegt noch ferner
if a.account_id == b.account_id:
continue
if a.type == b.type:
continue # eine Ausgabe, eine Einnahme
@@ -190,11 +206,19 @@ def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]:
if tuple(sorted((a.id, b.id))) in ignoriert:
continue
kandidaten[a.id].append(b)
kandidaten[b.id].append(a)
# Währungswechsel: gleicher Tag, aber unterschiedliche Beträge. Hier
# entscheidet der EZB-Kurs, ob die beiden zueinander passen können.
# Währungswechsel: unterschiedliche Beträge. Hier entscheidet der EZB-Kurs,
# ob die beiden zueinander passen können.
kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung,
ignoriert, kandidaten)
ignoriert, kandidaten, fenster)
# Das nächstliegende Datum zuerst: Wo mehrere passen, ist der gleiche Tag
# das wahrscheinlichere Paar als der drei Tage später.
datum_je_id = {t.id: t.date for t in buchungen}
for a_id, liste in kandidaten.items():
a_datum = datum_je_id[a_id]
liste.sort(key=lambda b, d=a_datum: (abs((b.date - d).days), b.id))
return kandidaten
@@ -210,24 +234,28 @@ def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction,
return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten):
nach_tag: dict = defaultdict(list)
for t in buchungen:
nach_tag[t.date].append(t)
for gruppe in nach_tag.values():
for a in gruppe:
for b in gruppe:
if a.id == b.id or a.account_id == b.account_id or a.type == b.type:
continue
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
if not va or not vb or va == vb:
continue # gleiche Währung ist oben schon erledigt
if a.amount_cents == b.amount_cents:
continue # zufällig gleiche Zahl, andere Währung
if tuple(sorted((a.id, b.id))) in ignoriert:
continue
if _passt_ueber_kurs(db, a, b, va, vb):
kandidaten[a.id].append(b)
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten,
fenster: int = 0):
nach_datum = sorted(buchungen, key=lambda t: (t.date, t.id))
for i, a in enumerate(nach_datum):
for b in nach_datum[i + 1:]:
if (b.date - a.date).days > fenster:
break # sortiert: alles Weitere liegt noch ferner
if a.account_id == b.account_id or a.type == b.type:
continue
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
if not va or not vb or va == vb:
continue # gleiche Währung ist oben schon erledigt
if a.amount_cents == b.amount_cents:
continue # zufällig gleiche Zahl, andere Währung
if tuple(sorted((a.id, b.id))) in ignoriert:
continue
# Die Umrechnung hat eine Richtung – beide prüfen, sonst hinge es
# davon ab, welche der beiden zufaellig zuerst einsortiert war.
if _passt_ueber_kurs(db, a, b, va, vb):
kandidaten[a.id].append(b)
if _passt_ueber_kurs(db, b, a, vb, va):
kandidaten[b.id].append(a)
return kandidaten
@@ -267,7 +295,9 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das
ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern.
"""
kandidaten = _kandidaten(db, user)
# Von selbst verknüpft wird nur am gleichen Tag. Vorschläge dürfen weiter
# greifen – sie ändern nichts, bis der Nutzer zustimmt.
kandidaten = _kandidaten(db, user, INBOX_TAGE if nur_vorschlaege else 0)
bezeichner = _kontobezeichner(db, user)
typen = _kontotypen(db, user)
nach_id = {}