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
# 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:
dates = _all_dates(text)
if not dates:
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_dist = 10**9
for kw in _PURCHASE_KW.finditer(text):
for pos, d in dates:
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
# Kein Kauf-Stichwort getroffen: das früheste Datum liegt am ehesten vor dem
# Garantieende.
return best if best is not None else min(d for _, d in dates)
if best is not None:
return best
# 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:
@@ -105,6 +138,18 @@ def _find_date_near_keyword(text: str) -> date | None:
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:
if unit.startswith(("jahr", "year")):
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:
return None
period = _find_period(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:
return _add_period(base, period[0], period[1])
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]]:
"""(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]] = []
for m in _AMOUNT_RE.finditer(text):
if text[m.end(): m.end() + 3].lstrip().startswith("%"):
continue
c = _amount_to_cents(m.group())
if c:
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``.
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.
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)
if not hits:
return None
best: int | None = None
best_dist = 10**9
for kw in _TOTAL_KW.finditer(text):
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
if best is not None:
return best
total_nah = [
c for pos, c, _cur in hits if _near_total(text, pos)
]
if total_nah:
return max(total_nah)
mit_waehrung = [c for _pos, c, cur in hits if cur]
return max(mit_waehrung) if mit_waehrung else None