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, User,
) )
from . import currency 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 # 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 # 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 return False
def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]: def _kandidaten(db: Session, user: User,
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke.""" 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() verknuepft = set()
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter( for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
TransferLink.owner_id == user.id 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 if t.id not in verknuepft
] ]
# Nach Tag und Betrag bündeln: Nur wer beides teilt, kommt überhaupt infrage. # Nach Betrag bündeln, der Tag wird danach geprüft – nur so lässt sich ein
nach_schluessel: dict[tuple, list[Transaction]] = defaultdict(list) # 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: 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) kandidaten: dict[int, list[Transaction]] = defaultdict(list)
for gruppe in nach_schluessel.values(): for gruppe in nach_betrag.values():
if len(gruppe) < 2: if len(gruppe) < 2:
continue continue
for a in gruppe: gruppe.sort(key=lambda t: (t.date, t.id))
for b in gruppe: for i, a in enumerate(gruppe):
if a.id == b.id or a.account_id == b.account_id: 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 continue
if a.type == b.type: if a.type == b.type:
continue # eine Ausgabe, eine Einnahme 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: if tuple(sorted((a.id, b.id))) in ignoriert:
continue continue
kandidaten[a.id].append(b) kandidaten[a.id].append(b)
kandidaten[b.id].append(a)
# Währungswechsel: gleicher Tag, aber unterschiedliche Beträge. Hier # Währungswechsel: unterschiedliche Beträge. Hier entscheidet der EZB-Kurs,
# entscheidet der EZB-Kurs, ob die beiden zueinander passen können. # ob die beiden zueinander passen können.
kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, 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 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 return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten): def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten,
nach_tag: dict = defaultdict(list) fenster: int = 0):
for t in buchungen: nach_datum = sorted(buchungen, key=lambda t: (t.date, t.id))
nach_tag[t.date].append(t) for i, a in enumerate(nach_datum):
for gruppe in nach_tag.values(): for b in nach_datum[i + 1:]:
for a in gruppe: if (b.date - a.date).days > fenster:
for b in gruppe: break # sortiert: alles Weitere liegt noch ferner
if a.id == b.id or a.account_id == b.account_id or a.type == b.type: if a.account_id == b.account_id or a.type == b.type:
continue continue
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id) va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
if not va or not vb or va == vb: 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 continue # zufällig gleiche Zahl, andere Währung
if tuple(sorted((a.id, b.id))) in ignoriert: if tuple(sorted((a.id, b.id))) in ignoriert:
continue 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): if _passt_ueber_kurs(db, a, b, va, vb):
kandidaten[a.id].append(b) kandidaten[a.id].append(b)
if _passt_ueber_kurs(db, b, a, vb, va):
kandidaten[b.id].append(a)
return kandidaten 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 Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das
ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern. 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) bezeichner = _kontobezeichner(db, user)
typen = _kontotypen(db, user) typen = _kontotypen(db, user)
nach_id = {} nach_id = {}
+45
View File
@@ -309,3 +309,48 @@ def test_vorschlaege_veraendern_nichts(admin):
# Und es ist wirklich keine Verknuepfung entstanden. # Und es ist wirklich keine Verknuepfung entstanden.
links = admin.get("/api/transactions/links").json() links = admin.get("/api/transactions/links").json()
assert str(raus["id"]) not in links and str(rein["id"]) not in links 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