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