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