Backend: Preiserkennung präziser (nicht mehr jede Zahl)

Nur noch Beträge, die nach Preis aussehen: direkt hinter einem Summen-Stichwort
oder mit Währung (CHF/EUR/€/…) in der Nähe. Ohne solchen Hinweis kein Vorschlag.
Datumsangaben (31.12.2027) werden per Lookaround nicht mehr als Betrag (31.12)
missgelesen. Kandidatenliste enthaelt entsprechend nur plausible Preise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 07:06:12 +02:00
parent d0d854be5a
commit 67bc2dd87d
2 changed files with 63 additions and 29 deletions

View File

@@ -134,8 +134,13 @@ _TOTAL_KW = re.compile(
r"zu zahlen|amount due|grand total|total due", r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE, re.IGNORECASE,
) )
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90 _CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}") # Beträge mit genau 2 Nachkommastellen, mit/ohne Tausendertrenner
# (1'299.00 / 1.299,00 / 49,90). Die Lookarounds verhindern Treffer mitten in
# längeren Zahlen und in Datumsangaben (z.B. "31.12.2027" liefert kein "31.12").
_AMOUNT_RE = re.compile(
r"(?<![\d.,])(?:\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![.,]?\d)"
)
def _amount_to_cents(raw: str) -> int | None: def _amount_to_cents(raw: str) -> int | None:
@@ -151,51 +156,70 @@ def _amount_to_cents(raw: str) -> int | None:
return int(ganz) * 100 + int(dezimal) return int(ganz) * 100 + int(dezimal)
def _has_currency_near(text: str, start: int, end: int, window: int = 6) -> bool:
return bool(_CURRENCY_RE.search(text[max(0, start - window): end + window]))
def _near_total(text: str, pos: int) -> bool:
"""Steht der Betrag kurz hinter einem Summen-Stichwort?"""
for kw in _TOTAL_KW.finditer(text):
if kw.start() <= pos <= kw.end() + 40:
return True
return False
def _price_hits(text: str) -> list[tuple[int, int, bool]]:
"""(Position, Betrag in Cent, Währung in der Nähe?) für alle Beträge."""
hits: list[tuple[int, int, bool]] = []
for m in _AMOUNT_RE.finditer(text):
c = _amount_to_cents(m.group())
if c:
hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end())))
return hits
def guess_price_cents(text: str) -> int | None: def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``. """Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert). 1. Betrag direkt nach einem Summen-Stichwort (Gesamtbetrag/Total/…).
2. Sonst der größte Betrag MIT Währungszeichen (CHF/EUR/€/…) in der Nähe.
3. Ohne Hinweis (weder Summe noch Währung) lieber kein Vorschlag.
""" """
if not text: if not text:
return None return None
betraege = [ hits = _price_hits(text)
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text) if not hits:
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
return None return None
# Die Summe steht hinter ihrem Label ("Gesamtbetrag: 54.90") deshalb den
# nächsten Betrag *nach* dem Stichwort nehmen, nicht den absolut nächsten
# (sonst gewönne eine davor stehende Zwischenzeile).
best: int | None = None best: int | None = None
best_dist = 10**9 best_dist = 10**9
for kw in _TOTAL_KW.finditer(text): for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege: for pos, c, _cur in hits:
if pos >= kw.start(): if pos >= kw.start():
dist = pos - kw.start() dist = pos - kw.start()
if dist <= 40 and dist < best_dist: if dist <= 40 and dist < best_dist:
best, best_dist = c, dist best, best_dist = c, dist
return best if best is not None else max(c for _, c in betraege) if best is not None:
return best
mit_waehrung = [c for _pos, c, cur in hits if cur]
return max(mit_waehrung) if mit_waehrung else None
def guess_price_candidates(text: str) -> list[int]: def guess_price_candidates(text: str) -> list[int]:
"""Alle plausiblen Beträge aus dem Beleg (in Rappen/Cent), ohne Dubletten. """Plausible Beträge (Rappen/Cent), bester zuerst, ohne Dubletten.
Der beste Tipp (:func:`guess_price_cents`) steht vorne; danach die übrigen Nur Beträge, die nach Preis aussehen d.h. mit Währung in der Nähe oder kurz
Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern,
automatische Tipp danebenliegt. Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar.
""" """
if not text: if not text:
return [] return []
einzigartig = set() plausibel = {
for m in _AMOUNT_RE.finditer(text): c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos)
c = _amount_to_cents(m.group()) }
if c: if not plausibel:
einzigartig.add(c)
if not einzigartig:
return [] return []
kandidaten = sorted(einzigartig, reverse=True) kandidaten = sorted(plausibel, reverse=True)
best = guess_price_cents(text) best = guess_price_cents(text)
if best is not None and best in kandidaten: if best is not None and best in kandidaten:
kandidaten.remove(best) kandidaten.remove(best)

View File

@@ -60,19 +60,29 @@ def test_preis_deutsches_format():
assert guess_price_cents(text) == 129900 assert guess_price_cents(text) == 129900
def test_preis_ohne_stichwort_nimmt_groessten_betrag(): def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none():
text = "Position A 12,90\nPosition B 199,00\nDanke." # Reine Zahlen ohne Waehrung/Summen-Stichwort werden NICHT als Preis geraten.
assert guess_price_cents("Position A 12,90\nPosition B 199,00\nDanke.") is None
def test_preis_mit_waehrung_ohne_stichwort_nimmt_groessten():
text = "Kabel 12.90 CHF\nGeraet 199.00 CHF"
assert guess_price_cents(text) == 19900 assert guess_price_cents(text) == 19900
def test_datum_wird_nicht_als_preis_gelesen():
assert guess_price_cents("Garantie gültig bis 31.12.2027.") is None
assert guess_price_candidates("Kaufdatum 05.06.2024, Garantie bis 31.12.2027") == []
def test_preis_ohne_betrag_ist_none(): def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None assert guess_price_cents("") is None
def test_preis_kandidaten_bester_zuerst_ohne_dubletten(): def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90\nnochmal 49.00" text = "Artikel 49.00 CHF\nVersand 5.90 CHF\nGesamtbetrag: 54.90 CHF\nnochmal 49.00 CHF"
kandidaten = guess_price_candidates(text) kandidaten = guess_price_candidates(text)
assert kandidaten[0] == 5490 # bester Tipp vorne assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == [] assert guess_price_candidates("") == []