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:
co-authored by
Claude Opus 5
parent
f52113e05d
commit
b1f5eb9a51
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user