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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user