Files
Vorrania/backend/app/services/warranty.py
Scarriffle 61270e6d00 Backend: Kaufpreis + Belege an Einzelstücken, PDF-Garantie-Vorschlag
- Item: price_cents + currency (Kaufpreis rappen-/centgenau). Migration ergaenzt.
- Neue Tabelle item_documents (mehrere Belege je Stueck, PDF oder Bild); Blob
  wird verzoegert geladen, damit Item-Listen leicht bleiben.
- Endpunkte: Upload (POST), Ansehen/Download (GET), Loeschen (DELETE) je Stueck.
  Dateiname beim Download gehaertet (keine Header-Injektion).
- services/warranty.py: schaetzt aus PDF-Text lokal ein Garantieende
  (Zeitraum + Kaufdatum, oder Datum nahe Garantie-Stichwort); pypdf ergaenzt.
  Der Upload liefert den Vorschlag zurueck. 10 neue Tests, Suite 136 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:49:08 +02:00

127 lines
4.1 KiB
Python
Raw 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,
)
# 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)