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,
|
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 = {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user