"""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, ) # Monatsnamen (Deutsch + Englisch, mit gängigen Abkürzungen), damit z.B. # Amazon-Rechnungen mit „29 April 2026" erkannt werden – nicht nur 29.04.2026. _MONTHS = { "januar": 1, "jan": 1, "january": 1, "februar": 2, "feb": 2, "february": 2, "märz": 3, "maerz": 3, "mrz": 3, "mär": 3, "mar": 3, "march": 3, "april": 4, "apr": 4, "mai": 5, "may": 5, "juni": 6, "jun": 6, "june": 6, "juli": 7, "jul": 7, "july": 7, "august": 8, "aug": 8, "september": 9, "sept": 9, "sep": 9, "oktober": 10, "okt": 10, "october": 10, "oct": 10, "november": 11, "nov": 11, "dezember": 12, "dez": 12, "december": 12, "dec": 12, } # Längere Namen zuerst, damit „januar" vor „jan" greift. _MONTH_ALT = "|".join(sorted((re.escape(k) for k in _MONTHS), key=len, reverse=True)) # ISO 2024-03-31, numerisch 31.03.2024 / 31/03/24, oder mit Monatsname # ("29 April 2026" bzw. "April 29, 2026"). _DATE_RE = re.compile( r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" r"|(?P\d{1,2})[.\/](?P\d{1,2})[.\/](?P\d{2,4})" r"|(?P\d{1,2})\.?\s+(?P" + _MONTH_ALT + r")\.?\s+(?P\d{4})" r"|(?P" + _MONTH_ALT + r")\s+(?P\d{1,2})(?:th|st|nd|rd)?,?\s+(?P\d{4})", re.IGNORECASE, ) _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.groupdict() try: if g.get("iy"): # ISO yyyy-mm-dd y, mo, d = int(g["iy"]), int(g["im"]), int(g["idd"]) elif g.get("d"): # dd.mm.yyyy / dd/mm/yy d, mo, y = int(g["d"]), int(g["m"]), int(g["y"]) if y < 100: y += 2000 elif g.get("nd"): # 29 April 2026 d, mo, y = int(g["nd"]), _MONTHS[g["nmon"].lower()], int(g["ny"]) elif g.get("emon"): # April 29, 2026 d, mo, y = int(g["ed"]), _MONTHS[g["emon"].lower()], int(g["ey"]) else: return None return date(y, mo, d) except (TypeError, ValueError, KeyError): 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 # 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 and not _near_due(text, pos): best, best_dist = d, dist 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: 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 _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) 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 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) def guess_acquired_on(text: str) -> date | None: """Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort).""" return _find_purchase_date(text) if text else None # -------------------------------------------------------------------------- # Shop / Bezugsquelle aus dem Beleg # -------------------------------------------------------------------------- _LEGAL_RE = re.compile( r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b" ) def _vendor_candidate(text: str) -> str | None: """Bester Rate-Name des Händlers – meist im Kopf des Belegs. Best effort.""" lines = [z.strip() for z in text.splitlines() if z.strip()] for z in lines[:20]: if _LEGAL_RE.search(z): return z[:60] for z in lines[:8]: buchstaben = sum(c.isalpha() for c in z) if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search( r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell", z, re.IGNORECASE, ): return z[:60] return None def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]: """(shop_id, name). Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum Anlegen – oder (None, None), wenn nichts Brauchbares gefunden wird. """ if not text: return (None, None) low = text.lower() treffer: tuple[int, str] | None = None best_len = 0 for sid, name in shops: n = name.strip().lower() if len(n) >= 3 and n in low and len(n) > best_len: treffer = (sid, name) best_len = len(n) if treffer is not None: return treffer return (None, _vendor_candidate(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. 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()))) 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. 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. """ if not text: return None hits = _price_hits(text) if not hits: return None 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 def guess_price_candidates(text: str) -> list[int]: """Alle geldartigen Beträge (Rappen/Cent) zur Auswahl, bester Tipp zuerst. Bei einer Rechnung mit mehreren Artikeln soll man auch den Zeilenpreis des passenden Produkts wählen können – deshalb kommen hier alle Beträge rein (Zeilen- und Summenbeträge). Mengen, Artikelnummern und Prozentsätze fallen schon in :func:`_price_hits` weg. Der automatische Tipp (Gesamtsumme) steht vorne. """ if not text: return [] werte = {c for _pos, c, _cur in _price_hits(text)} werte.discard(0) if not werte: return [] kandidaten = sorted(werte, 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[:12]