Preis-Vorschlag: Auswahl aus mehreren erkannten Beträgen

Findet die Beleg-Analyse mehrere Beträge, liefert der Upload jetzt eine
Kandidatenliste (bester Tipp zuerst). Web und iOS bieten dann ein Dropdown zur
Auswahl des richtigen Kaufpreises, statt nur den automatischen Tipp. 1 neuer
Test; Backend-Suite gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 06:39:03 +02:00
parent 761c131286
commit 2bc871f229
7 changed files with 94 additions and 8 deletions

View File

@@ -33,6 +33,7 @@ from ..schemas import (
from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_price_candidates,
guess_price_cents,
guess_warranty_until,
)
@@ -246,10 +247,12 @@ async def upload_item_document(
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
warranty = price = None
candidates: list[int] = []
if content_type == "application/pdf":
text = extract_pdf_text(data)
warranty = guess_warranty_until(text, acquired_on=item.acquired_on)
price = guess_price_cents(text)
candidates = guess_price_candidates(text)
return ItemDocumentUploadOut(
id=doc.id,
@@ -258,6 +261,7 @@ async def upload_item_document(
uploaded_at=doc.uploaded_at,
suggested_warranty_until=warranty,
suggested_price_cents=price,
suggested_price_candidates=candidates,
)

View File

@@ -544,6 +544,8 @@ class ItemDocumentUploadOut(ItemDocumentOut):
"""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
# Alle plausiblen Preise (bester zuerst) zur Auswahl, falls es mehrere gibt.
suggested_price_candidates: list[int] = []
class ItemOut(BaseModel):

View File

@@ -177,3 +177,27 @@ def guess_price_cents(text: str) -> int | None:
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)
def guess_price_candidates(text: str) -> list[int]:
"""Alle plausiblen Beträge aus dem Beleg (in Rappen/Cent), ohne Dubletten.
Der beste Tipp (:func:`guess_price_cents`) steht vorne; danach die übrigen
Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der
automatische Tipp danebenliegt.
"""
if not text:
return []
einzigartig = set()
for m in _AMOUNT_RE.finditer(text):
c = _amount_to_cents(m.group())
if c:
einzigartig.add(c)
if not einzigartig:
return []
kandidaten = sorted(einzigartig, reverse=True)
best = guess_price_cents(text)
if best is not None and best in kandidaten:
kandidaten.remove(best)
kandidaten.insert(0, best)
return kandidaten[:8]

View File

@@ -2,7 +2,11 @@
from datetime import date
from app.services.warranty import guess_price_cents, guess_warranty_until
from app.services.warranty import (
guess_price_candidates,
guess_price_cents,
guess_warranty_until,
)
def test_zeitraum_plus_kaufdatum_vom_stueck():
@@ -64,3 +68,11 @@ def test_preis_ohne_stichwort_nimmt_groessten_betrag():
def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None
def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90\nnochmal 49.00"
kandidaten = guess_price_candidates(text)
assert kandidaten[0] == 5490 # bester Tipp vorne
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == []