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",
|
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)
|
||||||
|
|||||||
@@ -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("") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user