feat: Buchungen lassen sich von Hand einem Abo zuordnen – und das merkt sich
Die Erkennung lief seit Stufe 3, aber nur automatisch: Was sie nicht sicher traf, blieb liegen, und sehen konnte man davon nichts. Die Endpunkte gab es, die Oberflaeche kannte keinen davon. Jetzt steht in der Buchungsliste eine Spalte "Abo" – dieselbe Form wie die Kategorie daneben: Die Auswahl zeigt, zu welchem Vertrag die Zahlung gehoert, aendert es, und leer loest die Zuordnung. Uebertraege bleiben aussen vor; sie verschieben nur Geld. Und sie lernt. Musste man von Hand ran, hat der Erkennungstext nicht gepasst – also wird der Empfaenger dieser Buchung der neue. Beim naechsten Mal faellt dieselbe Zahlung von selbst zu, statt dass man jeden Monat dasselbe klickt. Der Vertragsname bleibt als zweite Chance: _passt_text prueft beide, nicht entweder/oder, sonst verlaere man beim Lernen, was vorher schon getroffen hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
493a98ab6b
commit
0ea2ab1322
@@ -34,6 +34,7 @@ from ..schemas import (
|
||||
SubscriptionUpdate,
|
||||
)
|
||||
from ..services import abo_erkennung, currency, logo as logo_service
|
||||
from ..services.csv_import import normalize_text
|
||||
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||
|
||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
||||
@@ -186,6 +187,16 @@ def zuordnen_von_hand(sub_id: int, txn_id: int, db: Session = Depends(get_db),
|
||||
txn.subscription_id = s.id
|
||||
if txn.date is not None:
|
||||
abo_erkennung._ruecke_faelligkeit(s, txn.date)
|
||||
# Und daraus lernen: Musste der Nutzer von Hand zuordnen, hat der
|
||||
# Erkennungstext nicht gepasst. Der Empfaenger dieser Buchung wird der
|
||||
# neue – beim naechsten Mal faellt dieselbe Zahlung von selbst zu. Der
|
||||
# Name bleibt als zweite Chance (siehe _passt_text).
|
||||
#
|
||||
# shortcut: ersetzt einen frueher gelernten Text, statt mehrere zu fuehren.
|
||||
# Taucht ein Vertrag unter mehreren Empfaengernamen auf, braucht es eine
|
||||
# Liste wie bei den Konto-Kennungen.
|
||||
if txn.payee and not abo_erkennung._passt_text(s, normalize_text(txn.payee)):
|
||||
s.payee_pattern = txn.payee[:255]
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return to_out(db, s, _base(db, user), date.today())
|
||||
|
||||
@@ -77,13 +77,18 @@ def _passt_text(vertrag: Subscription, text: str) -> bool:
|
||||
am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst
|
||||
die halbe Kontoauszug.
|
||||
"""
|
||||
muster = normalize_text(vertrag.payee_pattern or vertrag.name)
|
||||
if not muster:
|
||||
return False
|
||||
if muster in text:
|
||||
return True
|
||||
woerter = [w for w in muster.split() if len(w) >= 4]
|
||||
return bool(woerter) and any(w in text for w in woerter)
|
||||
# Beides pruefen, nicht entweder/oder: Der gelernte Text (payee_pattern)
|
||||
# kommt beim Zuordnen von Hand dazu und soll den Namen nicht verdraengen.
|
||||
for roh in (vertrag.payee_pattern, vertrag.name):
|
||||
muster = normalize_text(roh)
|
||||
if not muster:
|
||||
continue
|
||||
if muster in text:
|
||||
return True
|
||||
woerter = [w for w in muster.split() if len(w) >= 4]
|
||||
if woerter and any(w in text for w in woerter):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _erwartet_in(db: Session, vertrag: Subscription, konto: Account,
|
||||
|
||||
@@ -329,3 +329,56 @@ def test_zuordnung_ueberlebt_sichern_und_einspielen(admin):
|
||||
assert verlauf["summe_cents"] == 1990
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
# --- Zuordnen von Hand lehrt den Vertrag ------------------------------------
|
||||
|
||||
|
||||
def test_haendische_zuordnung_lernt_den_empfaenger(admin):
|
||||
"""Musste man von Hand ran, hat der Erkennungstext nicht gepasst.
|
||||
|
||||
Beim naechsten Mal soll dieselbe Zahlung von selbst fallen – sonst klickt
|
||||
man jeden Monat dasselbe.
|
||||
"""
|
||||
konto = _konto(admin)
|
||||
abo = _abo(admin, name="Fitness " + secrets.token_hex(3), amount_cents=4990,
|
||||
account_id=konto["id"])
|
||||
# Der Auszug nennt den Anbieter ganz anders als der Vertrag heisst.
|
||||
t1 = _buchung(admin, konto, 4990, "2026-06-05", payee="ACTIV GYM ZUERICH 8004")
|
||||
t2 = _buchung(admin, konto, 4990, "2026-07-05", payee="ACTIV GYM ZUERICH 8004")
|
||||
try:
|
||||
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t1['id']}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["payee_pattern"] == "ACTIV GYM ZUERICH 8004"
|
||||
|
||||
_zuordnen(admin)
|
||||
assert _txn(admin, t2["id"])["subscription_id"] == abo["id"]
|
||||
finally:
|
||||
_aufraeumen(admin, abo, (t1["id"], t2["id"]))
|
||||
|
||||
|
||||
def test_lernen_verdraengt_den_namen_nicht(admin):
|
||||
"""Der Name bleibt als zweite Chance – sonst verliert man beim Lernen,
|
||||
was vorher schon getroffen hat."""
|
||||
konto = _konto(admin)
|
||||
abo = _abo(admin, name="Spotify", amount_cents=1295, account_id=konto["id"])
|
||||
fremd = _buchung(admin, konto, 1295, "2026-06-07", payee="DIGITAL RIVER GMBH")
|
||||
klassisch = _buchung(admin, konto, 1295, "2026-07-07", payee="SPOTIFY AB")
|
||||
try:
|
||||
admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{fremd['id']}")
|
||||
_zuordnen(admin)
|
||||
assert _txn(admin, klassisch["id"])["subscription_id"] == abo["id"]
|
||||
finally:
|
||||
_aufraeumen(admin, abo, (fremd["id"], klassisch["id"]))
|
||||
|
||||
|
||||
def test_passende_zuordnung_aendert_den_erkennungstext_nicht(admin):
|
||||
"""Hat der Text schon gepasst, gibt es nichts zu lernen."""
|
||||
konto = _konto(admin)
|
||||
abo = _abo(admin, name="Netflix", amount_cents=1799, account_id=konto["id"])
|
||||
t = _buchung(admin, konto, 1799, "2026-06-09", payee="NETFLIX.COM 4711")
|
||||
try:
|
||||
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t['id']}")
|
||||
assert r.json()["payee_pattern"] == "Netflix"
|
||||
finally:
|
||||
_aufraeumen(admin, abo, (t["id"],))
|
||||
|
||||
Reference in New Issue
Block a user