Files
Vorrania/backend/app/services/warranty.py
Scarriffle c8c1686395 Amazon-Kaufdatum erkennen; Preis-Vorschlaege beim Anlegen; Lagerort-Dropdowns mit Pfad
1) Beleganalyse erkennt Datumsangaben mit Monatsnamen (DE+EN, z.B. Amazon 'Bestelldatum 29 April 2026'), nicht nur 29.04.2026 - so wird das Kaufdatum solcher Rechnungen gefunden. 2) Beim direkten Anlegen eines Einzelstuecks mit Beleg werden jetzt alle erkannten Preise als Vorschlag angeboten (Dropdown), statt stillschweigend nur den wahrscheinlichsten zu nehmen. 3) Alle Lagerort-Dropdowns zeigen den vollen Pfad (Hedingen -> Keller -> Schublade 1) statt nur den Namen, damit gleichnamige Orte unterscheidbar sind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:24:08 +02:00

362 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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<iy>\d{4})-(?P<im>\d{1,2})-(?P<idd>\d{1,2})"
r"|(?P<d>\d{1,2})[.\/](?P<m>\d{1,2})[.\/](?P<y>\d{2,4})"
r"|(?P<nd>\d{1,2})\.?\s+(?P<nmon>" + _MONTH_ALT + r")\.?\s+(?P<ny>\d{4})"
r"|(?P<emon>" + _MONTH_ALT + r")\s+(?P<ed>\d{1,2})(?:th|st|nd|rd)?,?\s+(?P<ey>\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"(?<![\d.,])(?:\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![.,]?\d)"
)
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 _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]