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",
re.IGNORECASE,
)
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}")
_CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
# 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:
@@ -151,51 +156,70 @@ def _amount_to_cents(raw: str) -> int | None:
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:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert).
Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
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:
return None
betraege = [
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text)
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
hits = _price_hits(text)
if not hits:
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_dist = 10**9
for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege:
for pos, c, _cur in hits:
if pos >= kw.start():
dist = pos - kw.start()
if dist <= 40 and dist < best_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]:
"""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
Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der
automatische Tipp danebenliegt.
Nur Beträge, die nach Preis aussehen d.h. mit Währung in der Nähe oder kurz
hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern,
Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar.
"""
if not text:
return []
einzigartig = set()
for m in _AMOUNT_RE.finditer(text):
c = _amount_to_cents(m.group())
if c:
einzigartig.add(c)
if not einzigartig:
plausibel = {
c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos)
}
if not plausibel:
return []
kandidaten = sorted(einzigartig, reverse=True)
kandidaten = sorted(plausibel, reverse=True)
best = guess_price_cents(text)
if best is not None and best in kandidaten:
kandidaten.remove(best)