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
|
||||
|
||||
|
||||
@@ -110,3 +110,40 @@ def test_shop_schlaegt_neuen_namen_vor():
|
||||
|
||||
def test_shop_ohne_text_leer():
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user