diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 0c501c5..4ff133a 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -65,6 +65,26 @@ def _client(db: Session, user: User) -> BankClient: MAX_TREFFER = 200 +def _rang(name: str, begriff: str, in_name: bool) -> int: + """Wie gut passt ein Name zum Suchbegriff? Kleiner ist besser. + + Ohne Rangfolge entscheidet das Alphabet, welche Treffer die Obergrenze + überleben – und dann fällt „N26" bei der Suche nach „n" hinter 200 Banken + heraus, deren Name irgendwo ein n enthält. Genau dieser Fehler hat hier + einen Abend gekostet. + """ + klein = name.lower() + if klein == begriff: + return 0 + if klein.startswith(begriff): + return 1 + if any(wort.startswith(begriff) for wort in klein.replace("-", " ").split()): + return 2 + if begriff in klein: + return 3 + return 4 if not in_name else 3 + + def banken(db: Session, user: User, land: str | None = None, suche: str | None = None) -> dict: """Banken suchen – in einem Land oder über alle hinweg. @@ -75,43 +95,42 @@ def banken(db: Session, user: User, land: str | None = None, """ roh = _client(db, user).banken(land) begriff = (suche or "").strip().lower() - treffer = [] + + passend = [] for b in roh: name = b.get("name") if not name: continue - # Auch im BIC und im Gruppennamen suchen: Banken heissen in der Liste + name = str(name) + # Auch BIC und Gruppenname durchsuchen: Banken heissen in der Liste # nicht immer so wie im Alltag. - heu = " ".join(str(x) for x in (name, b.get("bic"), b.get("group")) if x).lower() - if begriff and begriff not in heu: + neben = " ".join(str(x) for x in (b.get("bic"), b.get("group")) if x).lower() + if begriff and begriff not in name.lower() and begriff not in neben: continue - treffer.append({"name": name, "country": b.get("country"), - "logo": b.get("logo"), "psu_types": b.get("psu_types", []), - "beta": bool(b.get("beta"))}) - treffer.sort(key=lambda b: (b["name"].lower(), b.get("country") or "")) - # „gesamt" und eine Kostprobe mitliefern: Sonst sieht „keine Treffer" - # gleich aus, egal ob die Liste leer kam, der Suchbegriff nicht passte – - # oder die Anwendung als Sandbox angelegt wurde und lauter Test-Institute - # liefert. An der Kostprobe sieht man Letzteres sofort. - # Beispiele ueber den ganzen Bereich streuen, nicht nur den Anfang zeigen. - # Endet die Liste alphabetisch bei „B", ist sie abgeschnitten – und man - # sucht sonst ewig nach einer Bank, die schlicht nicht mitgeliefert wurde. + passend.append({ + "name": name, "country": b.get("country"), "logo": b.get("logo"), + "psu_types": b.get("psu_types", []), "beta": bool(b.get("beta")), + "_rang": _rang(name, begriff, True) if begriff else 3, + }) + + # Erst nach Güte, dann alphabetisch – und ERST DANN kürzen. + passend.sort(key=lambda b: (b["_rang"], b["name"].lower(), b.get("country") or "")) + gezeigt = [{k: v for k, v in b.items() if k != "_rang"} for b in passend[:MAX_TREFFER]] + namen = sorted({str(b.get("name")) for b in roh if b.get("name")}) beispiele = (namen[:3] + namen[len(namen) // 2: len(namen) // 2 + 2] + namen[-3:] if len(namen) > 8 else namen) return { "gesamt": len(roh), - "treffer": treffer[:MAX_TREFFER], - "gekuerzt": len(treffer) > MAX_TREFFER, + # Die ECHTE Trefferzahl, nicht die gekürzte. Sonst steht dort immer + # „200" und man haelt die Obergrenze für ein Suchergebnis. + "treffer_gesamt": len(passend), + "treffer": gezeigt, + "gekuerzt": len(passend) > MAX_TREFFER, "beispiele": beispiele, "von": namen[0] if namen else None, "bis": namen[-1] if namen else None, - # Fängt überhaupt eine Bank mit diesen Buchstaben an? Wenn nicht, fehlt - # der Teil des Alphabets, und der Suchbegriff ist unschuldig. "anfangsbuchstaben": sorted({n[:1].upper() for n in namen}), - # Alles mit demselben Anfangsbuchstaben wie der Suchbegriff. Damit - # sieht man, unter welchem Namen die gesuchte Bank dort wirklich steht - # – Raten über Namensvarianten hat lange genug gedauert. "gleicher_buchstabe": ( [n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40] if begriff else [] diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 8adf0c7..4b95a1b 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -432,3 +432,51 @@ def test_leere_bankliste_ist_von_keinem_treffer_unterscheidbar(admin, fake_bank) assert daten["treffer"] == [] finally: bank_api_modul.BankClient._anfrage = urspruenglich + + +def test_treffer_werden_nach_guete_gekuerzt_nicht_alphabetisch(admin, monkeypatch): + """Der Fehler, der einen Abend gekostet hat. + + Die Trefferliste wird bei 200 gekürzt. Wurde vorher alphabetisch sortiert, + fiel „N26" bei der Suche nach „n" hinter zweihundert Banken heraus, die + irgendwo ein n im Namen tragen – die Bank war da, tauchte aber nie auf. + Gekürzt wird deshalb erst NACH der Bewertung. + """ + _app(admin) + + # Dreihundert Banken, die alle ein „n" enthalten und alphabetisch vor + # „N26" liegen – plus die gesuchte Bank. + viele = [{"name": f"Aachener Bank {i:03d}", "country": "DE"} for i in range(300)] + viele.append({"name": "N26", "country": "DE"}) + viele.append({"name": "Revolut", "country": "LT"}) + + def _anfrage(self, methode, pfad, **rest): + if pfad.startswith("/aspsps"): + return {"aspsps": viele} + raise AssertionError(f"unerwartet: {pfad}") + + monkeypatch.setattr(bank_api.BankClient, "_anfrage", _anfrage) + + daten = admin.get("/api/bank/banks?search=n").json() + namen = [b["name"] for b in daten["treffer"]] + assert "N26" in namen, "N26 muss trotz Kürzung sichtbar sein" + assert namen[0] == "N26", "Der beste Treffer gehört nach oben" + # Die echte Trefferzahl, nicht die gekürzte – sonst steht dort immer 200. + # 300 Aachener + N26; "Revolut" enthält kein n und zählt nicht mit. + assert daten["treffer_gesamt"] == 301 + assert len(daten["treffer"]) == 200 + assert daten["gekuerzt"] is True + + +def test_genauer_name_schlaegt_teiltreffer(admin, monkeypatch): + _app(admin) + liste = [{"name": "Revolut Bank UAB", "country": "LT"}, + {"name": "Revolut", "country": "LT"}, + {"name": "Banca Revoluta", "country": "IT"}] + + def _anfrage(self, methode, pfad, **rest): + return {"aspsps": liste} + + monkeypatch.setattr(bank_api.BankClient, "_anfrage", _anfrage) + namen = [b["name"] for b in admin.get("/api/bank/banks?search=revolut").json()["treffer"]] + assert namen == ["Revolut", "Revolut Bank UAB", "Banca Revoluta"] diff --git a/web/src/locales/de.json b/web/src/locales/de.json index ad92781..db10cac 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -609,7 +609,8 @@ "range": "Die Liste reicht von „{{von}}“ bis „{{bis}}“.", "letters": "Vorhandene Anfangsbuchstaben", "truncatedHint": "Fehlen ganze Buchstaben – etwa N oder R –, ist die Liste abgeschnitten und deine Bank wurde gar nicht mitgeliefert. Dann liegt es nicht am Suchbegriff, und ich muss die Abfrage blättern lassen.", - "sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:" + "sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:", + "pickBankMore": "Die besten {{n}} von {{treffer}} Treffern – such genauer, wenn deine Bank nicht dabei ist:" }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 641c5d9..7e3c61e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -609,7 +609,8 @@ "range": "The list runs from “{{von}}” to “{{bis}}”.", "letters": "Initial letters present", "truncatedHint": "If whole letters are missing – N or R, say – the list is truncated and your bank was never delivered. Then the search term is innocent and the query needs paging.", - "sameLetter": "Everything in the list starting with “{{buchstabe}}”:" + "sameLetter": "Everything in the list starting with “{{buchstabe}}”:", + "pickBankMore": "The best {{n}} of {{treffer}} matches – search more precisely if yours is not among them:" }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 1dbe2d4..103746f 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -306,7 +306,11 @@ export default function Bankanbindung() { ) : ( <>
- {t("bank.pickBank", { n: banken.treffer.length, gesamt: banken.gesamt })} + {banken.gekuerzt + ? t("bank.pickBankMore", { n: banken.treffer.length, + treffer: banken.treffer_gesamt }) + : t("bank.pickBank", { n: banken.treffer_gesamt, + gesamt: banken.gesamt })}