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
+52 -22
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,14 +234,14 @@ 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:
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:
@@ -226,8 +250,12 @@ def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandida
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 = {}
+45
View File
@@ -309,3 +309,48 @@ def test_vorschlaege_veraendern_nichts(admin):
# Und es ist wirklich keine Verknuepfung entstanden.
links = admin.get("/api/transactions/links").json()
assert str(raus["id"]) not in links and str(rein["id"]) not in links
def _konto_chf(admin, name):
import secrets
return admin.post("/api/accounts", json={
"name": f"{name} {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
"""Die Bank bucht die Gegenseite ein bis drei Tage spaeter. Die
Uebertrags-Pruefung sucht sie deshalb ueber ein Fenster und schickt den
Nutzer zum Verbinden – die Vorschlagsliste kannte aber nur den gleichen
Tag und bot das Paar nie an. Dann steht er vor einer Meldung, der er nicht
folgen kann."""
import secrets
quelle = _konto_chf(admin, "Revolut")
pocket = _konto_chf(admin, "Sparen Pocket")
betrag = 62 + secrets.randbelow(900)
text = f"Sparen {secrets.token_hex(4)}"
raus = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-05-27", "account_id": quelle["id"],
"amount_cents": betrag, "payee": text}).json()
rein = admin.post("/api/transactions", json={
"type": "income", "date": "2026-05-29", "account_id": pocket["id"],
"amount_cents": betrag, "payee": text}).json()
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
meins = [p for p in paare
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
assert meins, "zwei Tage Abstand muessen als Vorschlag erscheinen"
# Von SELBST verknuepft wird weiter nur am gleichen Tag: Ein Vorschlag
# fragt, das automatische Verknuepfen entscheidet – und das bleibt eng.
admin.post("/api/uebertraege/suchen")
links = admin.get("/api/uebertraege").json()
assert not [l for l in links
if {l["from_txn_id"], l["to_txn_id"]} == {raus["id"], rein["id"]}], \
"ueber Tage hinweg darf nur vorgeschlagen, nicht selbst verknuepft werden"
# Bestaetigen kann der Nutzer es trotzdem.
assert admin.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200