Backend: Beleg-Analyse präziser (Preis, Kaufdatum, Garantieende)

Anhand echter Galaxus-Rechnungen nachgeschärft:
- Preis: MwSt-Satz ("8.10 %") wird als Prozentangabe ignoriert; von den
  Betraegen nahe einem Summen-Stichwort gewinnt der GRÖSSTE (Endbetrag inkl.,
  steht rechts) statt des naechstgelegenen (bisher der Exkl.-Betrag).
- Kaufdatum: eindeutige Stichworte (Rechnungs-/Bestell-/Kaufdatum) haben Vorrang;
  "Fälligkeitsdatum"/Zahlungsziel werden nicht mehr als Kaufdatum gewertet.
- Garantieende: ein ausdruecklich genanntes Enddatum im Garantie-Umfeld
  (z.B. "… - 06.10.2027") hat Vorrang vor dem gerechneten. 4 neue Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 10:40:18 +02:00
parent a38c0651c5
commit 88b9bdcbea
2 changed files with 106 additions and 17 deletions

View File

@@ -77,20 +77,53 @@ def _find_period(text: str) -> tuple[int, str] | None:
return None return None
# Eindeutige Kaufdatum-Stichworte (Vorrang) und Fälligkeits-/Zahlungsziel-Wörter,
# die KEIN Kaufdatum markieren (z.B. "Fälligkeitsdatum" enthält "datum").
_STRONG_DATE_KW = re.compile(
r"rechnungsdatum|bestelldatum|kaufdatum|belegdatum|bestellt am|kaufdatum|"
r"invoice date|order date|purchase date|date of purchase",
re.IGNORECASE,
)
_DUE_KW = re.compile(
r"fällig|faellig|zahlbar|zahlungsziel|valuta|due date|payable", re.IGNORECASE
)
def _closest_date(text: str, dates: list[tuple[int, date]], kw_re, window: int) -> date | None:
best: date | None = None
best_dist = 10**9
for kw in kw_re.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= window and dist < best_dist:
best, best_dist = d, dist
return best
def _near_due(text: str, pos: int) -> bool:
return bool(_DUE_KW.search(text[max(0, pos - 30): pos + 30]))
def _find_purchase_date(text: str) -> date | None: def _find_purchase_date(text: str) -> date | None:
dates = _all_dates(text) dates = _all_dates(text)
if not dates: if not dates:
return None return None
# 1) Eindeutiges Kaufdatum-Stichwort (Rechnungs-/Bestell-/Kaufdatum) gewinnt.
stark = _closest_date(text, dates, _STRONG_DATE_KW, window=60)
if stark is not None:
return stark
# 2) Generisches "datum/rechnung", aber NICHT neben einem Fälligkeits-Wort.
best: date | None = None best: date | None = None
best_dist = 10**9 best_dist = 10**9
for kw in _PURCHASE_KW.finditer(text): for kw in _PURCHASE_KW.finditer(text):
for pos, d in dates: for pos, d in dates:
dist = abs(pos - kw.start()) dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist: if dist <= _WINDOW and dist < best_dist and not _near_due(text, pos):
best, best_dist = d, dist best, best_dist = d, dist
# Kein Kauf-Stichwort getroffen: das früheste Datum liegt am ehesten vor dem if best is not None:
# Garantieende. return best
return best if best is not None else min(d for _, d in dates) # 3) Kein Stichwort getroffen: frühestes Datum (liegt vor dem Garantieende).
return min(d for _, d in dates)
def _find_date_near_keyword(text: str) -> date | None: def _find_date_near_keyword(text: str) -> date | None:
@@ -105,6 +138,18 @@ def _find_date_near_keyword(text: str) -> date | None:
return best return best
def _find_latest_date_near_keyword(text: str) -> date | None:
"""Spätestes Datum nahe einem Garantie-Stichwort das Ende eines ausdrücklich
genannten Zeitraums (z.B. „… - 06.10.2027")."""
dates = _all_dates(text)
latest: date | None = None
for kw in _KEYWORD_RE.finditer(text):
for pos, d in dates:
if abs(pos - kw.start()) <= _WINDOW and (latest is None or d > latest):
latest = d
return latest
def _add_period(base: date, n: int, unit: str) -> date: def _add_period(base: date, n: int, unit: str) -> date:
if unit.startswith(("jahr", "year")): if unit.startswith(("jahr", "year")):
return base + relativedelta(years=n) return base + relativedelta(years=n)
@@ -119,8 +164,13 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
""" """
if not text: if not text:
return None return None
period = _find_period(text)
base = acquired_on or _find_purchase_date(text) base = acquired_on or _find_purchase_date(text)
# Ein ausdrücklich genanntes Enddatum im Garantie-Umfeld hat Vorrang vor dem
# aus Zeitraum + Kaufdatum gerechneten (z.B. Beleg nennt "… - 06.10.2027").
explizit = _find_latest_date_near_keyword(text)
if explizit is not None and (base is None or explizit > base):
return explizit
period = _find_period(text)
if period and base: if period and base:
return _add_period(base, period[0], period[1]) return _add_period(base, period[0], period[1])
return _find_date_near_keyword(text) return _find_date_near_keyword(text)
@@ -220,9 +270,15 @@ def _near_total(text: str, pos: int) -> bool:
def _price_hits(text: str) -> list[tuple[int, int, bool]]: 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.""" """(Position, Betrag in Cent, Währung in der Nähe?) für alle Beträge.
Prozentangaben (MwSt-Satz „8.10 %") werden übersprungen das sind keine
Preise.
"""
hits: list[tuple[int, int, bool]] = [] hits: list[tuple[int, int, bool]] = []
for m in _AMOUNT_RE.finditer(text): for m in _AMOUNT_RE.finditer(text):
if text[m.end(): m.end() + 3].lstrip().startswith("%"):
continue
c = _amount_to_cents(m.group()) c = _amount_to_cents(m.group())
if c: if c:
hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end()))) hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end())))
@@ -233,7 +289,8 @@ 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``.
Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt: Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
1. Betrag direkt nach einem Summen-Stichwort (Gesamtbetrag/Total/…). 1. Von den Beträgen nahe einem Summen-Stichwort (Gesamtbetrag/Total/…) der
GRÖSSTE der Endbetrag inkl. steht rechts und ist meist der höchste.
2. Sonst der größte Betrag MIT Währungszeichen (CHF/EUR/€/…) in der Nähe. 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. 3. Ohne Hinweis (weder Summe noch Währung) lieber kein Vorschlag.
""" """
@@ -242,16 +299,11 @@ def guess_price_cents(text: str) -> int | None:
hits = _price_hits(text) hits = _price_hits(text)
if not hits: if not hits:
return None return None
best: int | None = None total_nah = [
best_dist = 10**9 c for pos, c, _cur in hits if _near_total(text, pos)
for kw in _TOTAL_KW.finditer(text): ]
for pos, c, _cur in hits: if total_nah:
if pos >= kw.start(): return max(total_nah)
dist = pos - kw.start()
if dist <= 40 and dist < best_dist:
best, best_dist = c, dist
if best is not None:
return best
mit_waehrung = [c for _pos, c, cur in hits if cur] mit_waehrung = [c for _pos, c, cur in hits if cur]
return max(mit_waehrung) if mit_waehrung else None return max(mit_waehrung) if mit_waehrung else None

View File

@@ -110,3 +110,40 @@ def test_shop_schlaegt_neuen_namen_vor():
def test_shop_ohne_text_leer(): def test_shop_ohne_text_leer():
assert guess_shop("", [(1, "Digitec")]) == (None, None) assert guess_shop("", [(1, "Digitec")]) == (None, None)
# ---- Realer Galaxus-Beleg (vereinfacht) ----
GALAXUS = """Kaufbeleg / Rechnung Auftrag 161498087
Bestell- / Rechnungsdatum 05.10.2025
Fälligkeitsdatum 26.10.2025
Währung CHF
Delock USB2.0-Ladekabel 10079671 2 8.10% 16.56 17.90 33.12 35.80
24 Monate Garantie (06.10.2025 - 06.10.2027)
Logitech G Pro X 13489068 1 8.10% 102.68 111.00 102.68 111.00
24 Monate Garantie (06.10.2025 - 06.10.2027)
Gesamtbetrag 135.80 146.80
Gesamtbetrag enthält folgende Mehrwertsteuer:
MwSt. 8.10% 11.00
Total aller Lieferungen und Leistungen CHF 146.80
03.11.2025 Zahlung geleistet -146.80
Offener Betrag 0.00
"""
def test_galaxus_kaufdatum_nicht_faelligkeit():
assert guess_acquired_on(GALAXUS) == date(2025, 10, 5)
def test_galaxus_preis_ist_gesamt_inkl():
assert guess_price_cents(GALAXUS) == 14680
def test_galaxus_mwst_satz_ist_kein_preis():
kandidaten = guess_price_candidates(GALAXUS)
assert 810 not in kandidaten # 8.10 % ist der Steuersatz, kein Preis
assert kandidaten[0] == 14680 # bester Tipp: Gesamt inkl.
def test_galaxus_garantieende_aus_zeitraum():
assert guess_warranty_until(GALAXUS, acquired_on=date(2025, 10, 5)) == date(2027, 10, 6)