diff --git a/backend/app/services/warranty.py b/backend/app/services/warranty.py index bab88be..30369c8 100644 --- a/backend/app/services/warranty.py +++ b/backend/app/services/warranty.py @@ -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 diff --git a/backend/tests/test_warranty.py b/backend/tests/test_warranty.py index 281fbd3..6a21e0c 100644 --- a/backend/tests/test_warranty.py +++ b/backend/tests/test_warranty.py @@ -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)