"""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, ) # Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90 _AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}") def _amount_to_cents(raw: str) -> 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 guess_price_cents(text: str) -> int | None: """Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``. Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag (die Gesamtsumme ist auf Rechnungen meist der höchste Wert). """ if not text: return None betraege = [ (m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text) ] betraege = [(pos, c) for pos, c in betraege if c] if not betraege: return None # Die Summe steht hinter ihrem Label ("Gesamtbetrag: 54.90") – deshalb den # nächsten Betrag *nach* dem Stichwort nehmen, nicht den absolut nächsten # (sonst gewönne eine davor stehende Zwischenzeile). best: int | None = None best_dist = 10**9 for kw in _TOTAL_KW.finditer(text): for pos, c in betraege: if pos >= kw.start(): dist = pos - kw.start() if dist <= 40 and dist < best_dist: best, best_dist = c, dist return best if best is not None else max(c for _, c in betraege) def guess_price_candidates(text: str) -> list[int]: """Alle plausiblen Beträge aus dem Beleg (in Rappen/Cent), ohne Dubletten. Der beste Tipp (:func:`guess_price_cents`) steht vorne; danach die übrigen Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der automatische Tipp danebenliegt. """ if not text: return [] einzigartig = set() for m in _AMOUNT_RE.finditer(text): c = _amount_to_cents(m.group()) if c: einzigartig.add(c) if not einzigartig: return [] kandidaten = sorted(einzigartig, 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]