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:
Scarriffle
2026-07-27 09:30:35 +02:00
parent 729dfe766b
commit bbf10b36c6
4 changed files with 141 additions and 28 deletions

View File

@@ -24,6 +24,7 @@ from ..models import (
User,
)
from ..schemas import (
DocumentSuggestions,
ItemCreate,
ItemDocumentUploadOut,
ItemOut,
@@ -33,8 +34,10 @@ from ..schemas import (
from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_acquired_on,
guess_price_candidates,
guess_price_cents,
guess_shop,
guess_warranty_until,
)
@@ -208,20 +211,8 @@ def _safe_filename(name: str | None) -> str:
return cleaned[:255] or "beleg"
@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 wird ein Garantieende vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
def _read_document(file: UploadFile, data: bytes) -> str:
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES:
@@ -234,6 +225,43 @@ async def upload_item_document(
raise HTTPException(
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(
item_id=item.id,
@@ -245,26 +273,28 @@ async def upload_item_document(
db.commit()
db.refresh(doc)
# 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)
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
return ItemDocumentUploadOut(
id=doc.id,
filename=doc.filename,
content_type=doc.content_type,
uploaded_at=doc.uploaded_at,
suggested_warranty_until=warranty,
suggested_price_cents=price,
suggested_price_candidates=candidates,
**vorschlag.model_dump(),
)
@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}")
def get_item_document(
item_id: int,