"""Aus einem Beleg-PDF ein mögliches Garantieende schätzen – lokal, ohne KI. Zwei Wege, in dieser Reihenfolge: 1. Garantiezeitraum ("24 Monate", "2 Jahre") + Kaufdatum (aus dem Beleg oder vom Einzelstück) → Enddatum ausrechnen. 2. Ein Datum in der Nähe eines Garantie-Stichworts. Bewusst konservativ: lieber nichts vorschlagen als etwas Falsches – das Ergebnis ist nur ein Vorschlag, den der Nutzer bestätigt. Reine Scan-PDFs ohne Textebene liefern keinen Text und damit keinen Vorschlag (dafür bräuchte es OCR). """ from __future__ import annotations import io import re from datetime import date from dateutil.relativedelta import relativedelta _KEYWORD_RE = re.compile(r"garantie|gew[äa]hrleistung|warranty", re.IGNORECASE) _PERIOD_RE = re.compile(r"(\d{1,3})\s*(jahr|monat|year|month)", re.IGNORECASE) _PURCHASE_KW = re.compile( r"rechnungsdatum|rechnung|kaufdatum|bestelldatum|bestellt|belegdatum|" r"datum|invoice|order date|purchase|receipt", re.IGNORECASE, ) # 2024-03-31 oder 31.03.2024 / 31/03/24 _DATE_RE = re.compile( r"(\d{4})-(\d{1,2})-(\d{1,2})|(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})" ) _WINDOW = 60 # Zeichen um ein Stichwort, in denen Zahl/Datum als zugehörig gelten def extract_pdf_text(data: bytes) -> str: """Text aus einem PDF ziehen. Schlägt es fehl (kaputt, reiner Scan), "".""" try: from pypdf import PdfReader reader = PdfReader(io.BytesIO(data)) return "\n".join((page.extract_text() or "") for page in reader.pages) except Exception: return "" def _match_to_date(m: re.Match) -> date | None: g = m.groups() try: if g[0] is not None: # ISO yyyy-mm-dd y, mo, d = int(g[0]), int(g[1]), int(g[2]) else: # dd.mm.yyyy / dd/mm/yy d, mo, y = int(g[3]), int(g[4]), int(g[5]) if y < 100: y += 2000 return date(y, mo, d) except (TypeError, ValueError): return None def _all_dates(text: str) -> list[tuple[int, date]]: out: list[tuple[int, date]] = [] for m in _DATE_RE.finditer(text): d = _match_to_date(m) if d is not None: out.append((m.start(), d)) return out def _find_period(text: str) -> tuple[int, str] | None: """(Anzahl, Einheit) in der Nähe eines Garantie-Stichworts – beide Reihenfolgen.""" for kw in _KEYWORD_RE.finditer(text): fenster = text[max(0, kw.start() - _WINDOW): kw.end() + _WINDOW] p = _PERIOD_RE.search(fenster) if p: return int(p.group(1)), p.group(2).lower() return None def _find_purchase_date(text: str) -> date | None: dates = _all_dates(text) if not dates: return None 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: 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) def _find_date_near_keyword(text: str) -> date | None: dates = _all_dates(text) best: date | None = None best_dist = 10**9 for kw in _KEYWORD_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 _add_period(base: date, n: int, unit: str) -> date: if unit.startswith(("jahr", "year")): return base + relativedelta(years=n) return base + relativedelta(months=n) def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | None: """Bestes rät fürs Garantieende oder ``None``. ``acquired_on`` ist das am Einzelstück hinterlegte Kaufdatum – es hat Vorrang vor einem im Beleg gefundenen Datum. """ if not text: return None period = _find_period(text) base = acquired_on or _find_purchase_date(text) if period and base: return _add_period(base, period[0], period[1]) return _find_date_near_keyword(text) # -------------------------------------------------------------------------- # Kaufpreis aus dem Beleg schätzen # -------------------------------------------------------------------------- _TOTAL_KW = re.compile( r"gesamtbetrag|gesamtsumme|rechnungsbetrag|endbetrag|gesamt|summe|total|" r"zu zahlen|amount due|grand total|total due", re.IGNORECASE, ) _CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE) # Beträge mit genau 2 Nachkommastellen, mit/ohne Tausendertrenner # (1'299.00 / 1.299,00 / 49,90). Die Lookarounds verhindern Treffer mitten in # längeren Zahlen und in Datumsangaben (z.B. "31.12.2027" liefert kein "31.12"). _AMOUNT_RE = re.compile( r"(? int | None: """"1'299.00" / "1.299,00" / "49,90" -> Rappen/Cent.""" s = raw.replace(" ", "").replace("'", "") m = re.search(r"[.,](\d{2})$", s) # letzter Trenner ist der Dezimalpunkt if not m: return None dezimal = m.group(1) ganz = re.sub(r"[.,]", "", s[: m.start()]) # Tausendertrenner entfernen if not ganz.isdigit(): return None return int(ganz) * 100 + int(dezimal) def _has_currency_near(text: str, start: int, end: int, window: int = 6) -> bool: return bool(_CURRENCY_RE.search(text[max(0, start - window): end + window])) def _near_total(text: str, pos: int) -> bool: """Steht der Betrag kurz hinter einem Summen-Stichwort?""" for kw in _TOTAL_KW.finditer(text): if kw.start() <= pos <= kw.end() + 40: return True return False 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.""" hits: list[tuple[int, int, bool]] = [] for m in _AMOUNT_RE.finditer(text): c = _amount_to_cents(m.group()) if c: hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end()))) return hits 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/…). 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. """ if not text: return 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 mit_waehrung = [c for _pos, c, cur in hits if cur] return max(mit_waehrung) if mit_waehrung else None def guess_price_candidates(text: str) -> list[int]: """Plausible Beträge (Rappen/Cent), bester zuerst, ohne Dubletten. Nur Beträge, die nach Preis aussehen – d.h. mit Währung in der Nähe oder kurz hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern, Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar. """ if not text: return [] plausibel = { c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos) } if not plausibel: return [] kandidaten = sorted(plausibel, reverse=True) best = guess_price_cents(text) if best is not None and best in kandidaten: kandidaten.remove(best) kandidaten.insert(0, best) return kandidaten[:8]