Backend: Kaufpreis aus Beleg-PDF schätzen

guess_price_cents zieht den Kaufpreis aus dem Belegtext: bevorzugt den Betrag
nach einem Summen-Stichwort (Gesamtbetrag/Total/…), sonst den groessten Betrag.
Versteht Tausendertrenner (1'299.00) und deutsches Format (1.299,00). Der
Upload liefert den Vorschlag als suggested_price_cents mit. 5 neue Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 20:57:30 +02:00
parent 61270e6d00
commit ba65e48d47
4 changed files with 95 additions and 9 deletions

View File

@@ -31,7 +31,11 @@ from ..schemas import (
ItemUpdate, ItemUpdate,
) )
from ..services.items import generate_uid, item_to_out 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"]) router = APIRouter(tags=["items"])
@@ -240,19 +244,20 @@ async def upload_item_document(
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
# Garantieende nur aus PDFs schätzen (Bilder haben keine Textebene). # Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
suggestion = None warranty = price = None
if content_type == "application/pdf": if content_type == "application/pdf":
suggestion = guess_warranty_until( text = extract_pdf_text(data)
extract_pdf_text(data), acquired_on=item.acquired_on warranty = guess_warranty_until(text, acquired_on=item.acquired_on)
) price = guess_price_cents(text)
return ItemDocumentUploadOut( return ItemDocumentUploadOut(
id=doc.id, id=doc.id,
filename=doc.filename, filename=doc.filename,
content_type=doc.content_type, content_type=doc.content_type,
uploaded_at=doc.uploaded_at, uploaded_at=doc.uploaded_at,
suggested_warranty_until=suggestion, suggested_warranty_until=warranty,
suggested_price_cents=price,
) )

View File

@@ -541,8 +541,9 @@ class ItemDocumentOut(BaseModel):
class ItemDocumentUploadOut(ItemDocumentOut): 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_warranty_until: date | None = None
suggested_price_cents: int | None = None
class ItemOut(BaseModel): class ItemOut(BaseModel):

View File

@@ -124,3 +124,56 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
if period and base: if period and base:
return _add_period(base, period[0], period[1]) return _add_period(base, period[0], period[1])
return _find_date_near_keyword(text) 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)

View File

@@ -2,7 +2,7 @@
from datetime import date 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(): def test_zeitraum_plus_kaufdatum_vom_stueck():
@@ -37,3 +37,30 @@ def test_ohne_hinweise_kein_vorschlag():
def test_leerer_text_ist_none(): def test_leerer_text_ist_none():
assert guess_warranty_until("", acquired_on=date(2024, 1, 1)) is 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