From 512dac2b046bd2743092d5c28cc9d930cf8de1b3 Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 22 Aug 2026 20:36:49 +0200 Subject: [PATCH] fix: Beschriftungen unabhaengig von Umlaut und Zitat erkennen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ohne den Beguenstigten ist ein Eintrag im Eingang wertlos – man sieht einen Betrag und weiss nicht, wofuer. Genau das passierte bei weitergeleiteten Mails: Gmail zitiert den Text mit "> " davor, die Beschriftung stand also nicht mehr am Zeilenanfang, und je nach Kodierung kam "Beguenstigter" statt "Beguenstigter" mit Umlaut an. Beschriftungen werden jetzt normalisiert verglichen (Umlaute verschriftlicht, alles ausser Buchstaben und Ziffern verworfen), und Zitatzeichen fallen vor der Feldsuche weg. Die Suchbegriffe laufen durch dieselbe Normalisierung, damit sie lesbar bleiben statt doppelt gepflegt zu werden. Co-Authored-By: Claude Opus 5 --- backend/app/services/mail_parse.py | 39 +++++++++++++++++++++++------- backend/tests/test_mail_parse.py | 17 +++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/backend/app/services/mail_parse.py b/backend/app/services/mail_parse.py index 4040bd8..ce52122 100644 --- a/backend/app/services/mail_parse.py +++ b/backend/app/services/mail_parse.py @@ -30,10 +30,9 @@ from decimal import Decimal, InvalidOperation # Beschriftungen je Feld, längere zuerst (spezifischer schlägt allgemeiner). LABELS_BETRAG = ("betrag", "gesendet", "empfangen", "erhalten", "amount", "summe") -LABELS_EMPFAENGER = ("begünstigter", "beguenstigter", "empfänger", "empfaenger", - "zahlungsempfänger", "an", "händler", "haendler", "to") -LABELS_DATUM = ("ausführungsdatum", "ausfuehrungsdatum", "valutadatum", "buchungsdatum", - "datum", "am", "date") +LABELS_EMPFAENGER = ("beguenstigter", "zahlungsempfaenger", "empfaenger", "haendler", + "beguenstigte", "an", "to") +LABELS_DATUM = ("ausfuehrungsdatum", "valutadatum", "buchungsdatum", "datum", "am", "date") LABELS_KONTO = ("konto", "account", "iban") LABELS_STATUS = ("neuer status", "status") @@ -100,6 +99,20 @@ def html_zu_text(roh: str) -> str: return "\n".join(z for z in zeilen if z) +def _schluessel(text: str) -> str: + """Beschriftung auf eine vergleichbare Form bringen. + + Umlaute verschriftlichen und alles ausser Buchstaben und Ziffern + wegwerfen. „Begünstigter", „Beguenstigter" und „BEGÜNSTIGTER :" ergeben + damit denselben Schlüssel – sonst haengt die Erkennung daran, wie das + Mailprogramm den Umlaut kodiert hat. + """ + t = text.strip().lower() + for von, nach in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): + t = t.replace(von, nach) + return re.sub(r"[^a-z0-9]", "", t) + + def _zahl_zu_cent(roh: str) -> int | None: s = roh.replace("'", "").replace("’", "").replace(" ", "") if "," in s and "." in s: @@ -134,7 +147,9 @@ def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]: ``Gesendet`` / ``324 €`` den Inhalt der Folgezeile. """ gefunden: dict[str, list[tuple[str, int, str]]] = {} - zeilen = text.splitlines() + # Weitergeleitete Mails kommen oft mit „> " zitiert an; die Beschriftung + # steht dann nicht mehr am Zeilenanfang. + zeilen = [re.sub(r"^[\s>]+", "", z) for z in text.splitlines()] pos = 0 for i, zeile in enumerate(zeilen): start = pos @@ -142,12 +157,12 @@ def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]: if ":" in zeile: links, rechts = zeile.split(":", 1) beschriftung = links.strip() - schl, wert = beschriftung.lower(), rechts.strip() + schl, wert = _schluessel(beschriftung), rechts.strip() if wert: gefunden.setdefault(schl, []).append((wert, start, beschriftung)) continue beschriftung = zeile.strip().rstrip(":") - schl = beschriftung.lower() + schl = _schluessel(beschriftung) if schl and len(schl) <= 30 and i + 1 < len(zeilen): folge = zeilen[i + 1].strip() if folge and ":" not in folge: @@ -156,8 +171,14 @@ def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]: def _erster_treffer(felder: dict, labels: tuple[str, ...]) -> tuple[str, int, str] | None: - """Wert, Stelle und die Beschriftung in Originalschreibweise.""" - for label in labels: + """Wert, Stelle und die Beschriftung in Originalschreibweise. + + Die Suchbegriffe laufen durch dieselbe Normalisierung wie die gefundenen + Beschriftungen – sonst muesste man sie doppelt pflegen, einmal lesbar und + einmal fuer den Vergleich. + """ + for roh in labels: + label = _schluessel(roh) if label in felder: wert, stelle, beschriftung = felder[label][0] return wert, stelle, beschriftung diff --git a/backend/tests/test_mail_parse.py b/backend/tests/test_mail_parse.py index b4c7d6b..760dfec 100644 --- a/backend/tests/test_mail_parse.py +++ b/backend/tests/test_mail_parse.py @@ -103,3 +103,20 @@ def test_blosse_zahlen_sind_keine_betraege(): e = mail_parse.parse("Auftrag 12345", "Referenz 998877\nBestellnummer 4711\n") assert e.betrag is None assert "betrag" in e.unklar + + +def test_beschriftung_wird_unabhaengig_von_umlaut_und_zitat_erkannt(): + """Weitergeleitete Mails kommen zitiert an, und Umlaute schreibt nicht jedes + Programm gleich. „Begünstigter“ muss trotzdem greifen – ohne den + Begünstigten ist ein Eintrag im Eingang wertlos.""" + varianten = [ + " Begünstigter: Sunrise GmbH / Yallo\n Betrag: 14.90 CHF\n", + "> Begünstigter: Sunrise GmbH / Yallo\n> Betrag: 14.90 CHF\n", + ">> BEGÜNSTIGTER : Sunrise GmbH / Yallo\n>> Betrag: 14.90 CHF\n", + " Beguenstigter: Sunrise GmbH / Yallo\n Betrag: 14.90 CHF\n", + ] + for text in varianten: + e = mail_parse.parse("Zahlungsauftrag", text) + assert e.empfaenger is not None, f"kein Empfänger erkannt in: {text!r}" + assert e.empfaenger.wert == "Sunrise GmbH / Yallo" + assert e.betrag.wert == 1490