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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user