Backend: Beleg-Analyse um Kaufdatum + Shop; Analyse-Endpoint
- guess_acquired_on (Kaufdatum) und guess_shop (bekannter Shop erkannt -> id; sonst Kandidatenname zum Anlegen) ergaenzt. - Upload liefert zusaetzlich suggested_acquired_on / shop_id / shop_name. - Neuer POST /items/analyze-document: analysiert einen Beleg OHNE zu speichern (zum Vorbefuellen beim Anlegen). Gemeinsamer Helfer _doc_suggestions. - 6 neue Tests; Suite 150 gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ from ..models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
DocumentSuggestions,
|
||||||
ItemCreate,
|
ItemCreate,
|
||||||
ItemDocumentUploadOut,
|
ItemDocumentUploadOut,
|
||||||
ItemOut,
|
ItemOut,
|
||||||
@@ -33,8 +34,10 @@ from ..schemas import (
|
|||||||
from ..services.items import generate_uid, item_to_out
|
from ..services.items import generate_uid, item_to_out
|
||||||
from ..services.warranty import (
|
from ..services.warranty import (
|
||||||
extract_pdf_text,
|
extract_pdf_text,
|
||||||
|
guess_acquired_on,
|
||||||
guess_price_candidates,
|
guess_price_candidates,
|
||||||
guess_price_cents,
|
guess_price_cents,
|
||||||
|
guess_shop,
|
||||||
guess_warranty_until,
|
guess_warranty_until,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,20 +211,8 @@ def _safe_filename(name: str | None) -> str:
|
|||||||
return cleaned[:255] or "beleg"
|
return cleaned[:255] or "beleg"
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
def _read_document(file: UploadFile, data: bytes) -> str:
|
||||||
"/items/{item_id}/documents",
|
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
|
||||||
response_model=ItemDocumentUploadOut,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def upload_item_document(
|
|
||||||
item_id: int,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: User = Depends(require_admin),
|
|
||||||
) -> ItemDocumentUploadOut:
|
|
||||||
"""Beleg (PDF oder Bild) hochladen. Bei PDF wird ein Garantieende vorgeschlagen."""
|
|
||||||
item = _item_or_404(db, item_id)
|
|
||||||
data = await file.read()
|
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
||||||
if len(data) > DOC_MAX_BYTES:
|
if len(data) > DOC_MAX_BYTES:
|
||||||
@@ -234,6 +225,43 @@ async def upload_item_document(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
|
||||||
)
|
)
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_suggestions(
|
||||||
|
db: Session, data: bytes, content_type: str, item_acquired_on=None
|
||||||
|
) -> DocumentSuggestions:
|
||||||
|
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
|
||||||
|
if content_type != "application/pdf":
|
||||||
|
return DocumentSuggestions()
|
||||||
|
text = extract_pdf_text(data)
|
||||||
|
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
|
||||||
|
return DocumentSuggestions(
|
||||||
|
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
|
||||||
|
suggested_price_cents=guess_price_cents(text),
|
||||||
|
suggested_price_candidates=guess_price_candidates(text),
|
||||||
|
suggested_acquired_on=guess_acquired_on(text),
|
||||||
|
suggested_shop_id=shop_id,
|
||||||
|
suggested_shop_name=shop_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/items/{item_id}/documents",
|
||||||
|
response_model=ItemDocumentUploadOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def upload_item_document(
|
||||||
|
item_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ItemDocumentUploadOut:
|
||||||
|
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
|
||||||
|
und Shop vorgeschlagen."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
data = await file.read()
|
||||||
|
content_type = _read_document(file, data)
|
||||||
|
|
||||||
doc = ItemDocument(
|
doc = ItemDocument(
|
||||||
item_id=item.id,
|
item_id=item.id,
|
||||||
@@ -245,26 +273,28 @@ async def upload_item_document(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
|
|
||||||
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
|
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
|
||||||
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(
|
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=warranty,
|
**vorschlag.model_dump(),
|
||||||
suggested_price_cents=price,
|
|
||||||
suggested_price_candidates=candidates,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
|
||||||
|
async def analyze_item_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> DocumentSuggestions:
|
||||||
|
"""Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen."""
|
||||||
|
data = await file.read()
|
||||||
|
content_type = _read_document(file, data)
|
||||||
|
return _doc_suggestions(db, data, content_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/{item_id}/documents/{doc_id}")
|
@router.get("/items/{item_id}/documents/{doc_id}")
|
||||||
def get_item_document(
|
def get_item_document(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
|
|||||||
@@ -557,12 +557,20 @@ class ItemDocumentOut(BaseModel):
|
|||||||
uploaded_at: datetime
|
uploaded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ItemDocumentUploadOut(ItemDocumentOut):
|
class DocumentSuggestions(BaseModel):
|
||||||
"""Antwort nach dem Upload – mit aus dem PDF geschätztem Garantieende und Preis."""
|
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
|
||||||
suggested_warranty_until: date | None = None
|
suggested_warranty_until: date | None = None
|
||||||
suggested_price_cents: int | None = None
|
suggested_price_cents: int | None = None
|
||||||
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
||||||
suggested_price_candidates: list[int] = []
|
suggested_price_candidates: list[int] = []
|
||||||
|
suggested_acquired_on: date | None = None
|
||||||
|
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
|
||||||
|
suggested_shop_id: int | None = None
|
||||||
|
suggested_shop_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
||||||
|
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||||||
|
|
||||||
|
|
||||||
class ItemOut(BaseModel):
|
class ItemOut(BaseModel):
|
||||||
|
|||||||
@@ -126,6 +126,57 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
|
|||||||
return _find_date_near_keyword(text)
|
return _find_date_near_keyword(text)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_acquired_on(text: str) -> date | None:
|
||||||
|
"""Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort)."""
|
||||||
|
return _find_purchase_date(text) if text else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Shop / Bezugsquelle aus dem Beleg
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_LEGAL_RE = re.compile(
|
||||||
|
r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_candidate(text: str) -> str | None:
|
||||||
|
"""Bester Rate-Name des Händlers – meist im Kopf des Belegs. Best effort."""
|
||||||
|
lines = [z.strip() for z in text.splitlines() if z.strip()]
|
||||||
|
for z in lines[:20]:
|
||||||
|
if _LEGAL_RE.search(z):
|
||||||
|
return z[:60]
|
||||||
|
for z in lines[:8]:
|
||||||
|
buchstaben = sum(c.isalpha() for c in z)
|
||||||
|
if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search(
|
||||||
|
r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell",
|
||||||
|
z, re.IGNORECASE,
|
||||||
|
):
|
||||||
|
return z[:60]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]:
|
||||||
|
"""(shop_id, name).
|
||||||
|
|
||||||
|
Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser
|
||||||
|
vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum
|
||||||
|
Anlegen – oder (None, None), wenn nichts Brauchbares gefunden wird.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return (None, None)
|
||||||
|
low = text.lower()
|
||||||
|
treffer: tuple[int, str] | None = None
|
||||||
|
best_len = 0
|
||||||
|
for sid, name in shops:
|
||||||
|
n = name.strip().lower()
|
||||||
|
if len(n) >= 3 and n in low and len(n) > best_len:
|
||||||
|
treffer = (sid, name)
|
||||||
|
best_len = len(n)
|
||||||
|
if treffer is not None:
|
||||||
|
return treffer
|
||||||
|
return (None, _vendor_candidate(text))
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Kaufpreis aus dem Beleg schätzen
|
# Kaufpreis aus dem Beleg schätzen
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.services.warranty import (
|
from app.services.warranty import (
|
||||||
|
guess_acquired_on,
|
||||||
guess_price_candidates,
|
guess_price_candidates,
|
||||||
guess_price_cents,
|
guess_price_cents,
|
||||||
|
guess_shop,
|
||||||
guess_warranty_until,
|
guess_warranty_until,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,3 +88,25 @@ def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
|
|||||||
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
|
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
|
||||||
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
|
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
|
||||||
assert guess_price_candidates("") == []
|
assert guess_price_candidates("") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Kaufdatum & Shop ----
|
||||||
|
|
||||||
|
def test_kaufdatum_aus_beleg():
|
||||||
|
assert guess_acquired_on("Rechnungsdatum: 05.06.2024") == date(2024, 6, 5)
|
||||||
|
assert guess_acquired_on("nichts hier") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_erkennt_bekannten_namen():
|
||||||
|
shops = [(1, "Digitec"), (2, "Galaxus")]
|
||||||
|
assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_schlaegt_neuen_namen_vor():
|
||||||
|
sid, name = guess_shop("ACME Electronics GmbH\nRechnung\nDatum 01.01.2024", [(1, "Galaxus")])
|
||||||
|
assert sid is None
|
||||||
|
assert name == "ACME Electronics GmbH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_ohne_text_leer():
|
||||||
|
assert guess_shop("", [(1, "Digitec")]) == (None, None)
|
||||||
|
|||||||
Reference in New Issue
Block a user