diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index 84b0586..dc3b59b 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -31,7 +31,11 @@ from ..schemas import ( ItemUpdate, ) from ..services.items import generate_uid, item_to_out -from ..services.warranty import extract_pdf_text, guess_warranty_until +from ..services.warranty import ( + extract_pdf_text, + guess_price_cents, + guess_warranty_until, +) router = APIRouter(tags=["items"]) @@ -240,19 +244,20 @@ async def upload_item_document( db.commit() db.refresh(doc) - # Garantieende nur aus PDFs schätzen (Bilder haben keine Textebene). - suggestion = None + # Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene). + warranty = price = None if content_type == "application/pdf": - suggestion = guess_warranty_until( - extract_pdf_text(data), acquired_on=item.acquired_on - ) + text = extract_pdf_text(data) + warranty = guess_warranty_until(text, acquired_on=item.acquired_on) + price = guess_price_cents(text) return ItemDocumentUploadOut( id=doc.id, filename=doc.filename, content_type=doc.content_type, uploaded_at=doc.uploaded_at, - suggested_warranty_until=suggestion, + suggested_warranty_until=warranty, + suggested_price_cents=price, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e455838..49f297b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -541,8 +541,9 @@ class ItemDocumentOut(BaseModel): class ItemDocumentUploadOut(ItemDocumentOut): - """Antwort nach dem Upload – mit dem aus dem PDF vorgeschlagenen Garantieende.""" + """Antwort nach dem Upload – mit aus dem PDF geschätztem Garantieende und Preis.""" suggested_warranty_until: date | None = None + suggested_price_cents: int | None = None class ItemOut(BaseModel): diff --git a/backend/app/services/warranty.py b/backend/app/services/warranty.py index fcd9867..f8efae8 100644 --- a/backend/app/services/warranty.py +++ b/backend/app/services/warranty.py @@ -124,3 +124,56 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N 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) diff --git a/backend/tests/test_warranty.py b/backend/tests/test_warranty.py index 2b86943..4ac0443 100644 --- a/backend/tests/test_warranty.py +++ b/backend/tests/test_warranty.py @@ -2,7 +2,7 @@ from datetime import date -from app.services.warranty import guess_warranty_until +from app.services.warranty import guess_price_cents, guess_warranty_until def test_zeitraum_plus_kaufdatum_vom_stueck(): @@ -37,3 +37,30 @@ def test_ohne_hinweise_kein_vorschlag(): def test_leerer_text_ist_none(): assert guess_warranty_until("", acquired_on=date(2024, 1, 1)) is None + + +# ---- Preis ---- + +def test_preis_nahe_total_stichwort(): + text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90 CHF" + assert guess_price_cents(text) == 5490 + + +def test_preis_mit_tausendertrenner_apostroph(): + text = "Total CHF 1'299.00" + assert guess_price_cents(text) == 129900 + + +def test_preis_deutsches_format(): + text = "Rechnungsbetrag 1.299,00 EUR" + assert guess_price_cents(text) == 129900 + + +def test_preis_ohne_stichwort_nimmt_groessten_betrag(): + text = "Position A 12,90\nPosition B 199,00\nDanke." + assert guess_price_cents(text) == 19900 + + +def test_preis_ohne_betrag_ist_none(): + assert guess_price_cents("Kein Preis hier.") is None + assert guess_price_cents("") is None