from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.orm import Session from typing import List, Optional from pydantic import BaseModel from io import BytesIO from datetime import date import re import difflib import datetime as dt try: import pdfplumber # type: ignore except Exception: # pragma: no cover pdfplumber = None from app.schemas.delivery import DeliveryCreate, DeliveryOut from app.models.delivery import Delivery from app.models.product import Product from app.core.database import SessionLocal from app.core.auth import get_current_user, requires_role from app.models.user import User router = APIRouter(prefix="/deliveries", tags=["deliveries"]) def get_db(): db = SessionLocal() try: yield db finally: db.close() @router.post("/", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))]) def create_delivery(delivery: DeliveryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): # Session.get statt .query().get (SQLAlchemy 2.x) product = db.get(Product, delivery.product_id) if not product: raise HTTPException(status_code=404, detail="Product not found") if delivery.amount < 1: raise HTTPException(status_code=400, detail="Amount must be at least 1") # Pydantic v2: model_dump() db_delivery = Delivery(**delivery.model_dump()) db.add(db_delivery) db.commit() db.refresh(db_delivery) return db_delivery @router.get("/", response_model=list[DeliveryOut], dependencies=[Depends(requires_role("manager", "admin"))]) def list_deliveries(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return db.query(Delivery).all() @router.get("/{delivery_id}", response_model=DeliveryOut, dependencies=[Depends(requires_role("manager", "admin"))]) def get_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): delivery = db.get(Delivery, delivery_id) if not delivery: raise HTTPException(status_code=404, detail="Delivery not found") return delivery @router.delete("/{delivery_id}", status_code=204, dependencies=[Depends(requires_role("manager", "admin"))]) def delete_delivery(delivery_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): delivery = db.get(Delivery, delivery_id) if not delivery: raise HTTPException(status_code=404, detail="Delivery not found") db.delete(delivery) db.commit() # ---- Draft-Schemata für den PDF-Import (Antwort an das Frontend) ----------------------- class DeliveryItemDraft(BaseModel): product_id: Optional[int] = None product_hint: Optional[str] = None # originaler Text aus der PDF quantity_units: int # Anzahl (Einheiten/KA) unit_cost_cents: int # E-Preis je Einheit in Cent class DeliveryDraft(BaseModel): supplier: Optional[str] = None date: Optional[str] = None # ISO yyyy-mm-dd invoice_no: Optional[str] = None note: Optional[str] = None deposit_return_cents: int = 0 # Netto-Pfand (Cent, positiv) items: List[DeliveryItemDraft] = [] # ---- Hilfsfunktionen ------------------------------------------------------------------- def _parse_decimal_de(s: str) -> float: """'1.234,56' -> 1234.56""" t = s.strip().replace(".", "").replace("’", "").replace(",", ".") try: return float(t) except ValueError: return 0.0 def _to_cents(v: float) -> int: return int(round(v * 100)) def _date_de_to_iso(s: str) -> Optional[str]: try: d = dt.datetime.strptime(s.strip(), "%d.%m.%Y").date() return d.isoformat() except Exception: return None _pack_pat = re.compile(r"(\d+)\s*/\s*(\d+[.,]?\d*)") # z.B. 24/0,33 oder 12/1,0 def _extract_pack_size(text: str) -> Optional[int]: m = _pack_pat.search(text) if not m: return None try: return int(m.group(1)) except Exception: return None def _norm(s: str) -> str: return re.sub(r"[^0-9a-z]+", " ", s.lower()).strip() def _best_product_match(desc: str, products: List[Product]) -> Optional[int]: """Einfache Fuzzy-Suche: Textähnlichkeit + Bonus bei passender Packgröße.""" if not products: return None cand_name = _norm(desc) want_pack = _extract_pack_size(desc) best_id, best_score = None, 0.0 for p in products: name = getattr(p, "name", "") or "" score = difflib.SequenceMatcher(a=cand_name, b=_norm(name)).ratio() pack = getattr(p, "pack_size", None) if want_pack and pack and int(pack) == int(want_pack): score += 0.08 # leichter Bonus if score > best_score: best_score, best_id = score, int(getattr(p, "id")) return best_id if best_score >= 0.75 else None # ---- Kern: PDF parsen (ein Supplier, textbasiert) -------------------------------------- def _parse_invoice_pdf(data: bytes, all_products: List[Product]) -> DeliveryDraft: if pdfplumber is None: raise HTTPException( status_code=501, detail="pdfplumber ist nicht installiert. Bitte `pip install pdfplumber` ausführen." ) text_pages: List[str] = [] with pdfplumber.open(BytesIO(data)) as pdf: for page in pdf.pages: # Text mit Zeilenumbrüchen; für dieses Layout reicht Text-Parsing text_pages.append(page.extract_text() or "") full = "\n".join(text_pages) # Header-Felder inv = None m = re.search(r"Rechnungs[-\s]?Nr\.?\s*:\s*([A-Za-z0-9\-\/]+)", full, re.I) if m: inv = m.group(1).strip() date_iso = None m = re.search(r"Datum\s*:\s*(\d{2}\.\d{2}\.\d{4})", full, re.I) if m: date_iso = _date_de_to_iso(m.group(1)) deposit_cents = 0 # 1) Bevorzugt die Steuerzeile netto Summe Pfand m = re.search(r"(?mi)^\s*summe\s+pfand\b.*?([0-9\.,]+)\s*$", full) if not m: # 2) Fallback m = re.search(r"(?mi)^\s*pfand\b.*?eur\s*([0-9\.,]+)\s*$", full) if m: deposit_cents = _to_cents(_parse_decimal_de(m.group(1))) # Positionsblock: Zeilen zwischen Kopf "Art-Nr" und "Zwischensumme Warenwert" items: List[DeliveryItemDraft] = [] block = [] in_block = False for line in full.splitlines(): if not in_block and re.search(r"\bArt[-\s]?Nr\b", line): in_block = True continue if in_block and re.search(r"Zwischensumme\s+Warenwert", line): break if in_block: if line.strip(): block.append(line) # Zeilen parsen: " " row_re = re.compile( r"^\s*\d+\s+(?P.+?)\s+(?P\d+)\s+(?P[A-Za-z]+)\s+(?P\d+,\d{2})\s+(?P\d+,\d{2})\s*$" ) for ln in block: m = row_re.match(ln) if not m: continue desc = m.group("desc").strip() qty = int(m.group("qty")) me = m.group("me").upper() eprice = _to_cents(_parse_decimal_de(m.group("eprice"))) # Nur ME = KA (Kästen/Einheit) übernehmen – andere ignorieren if me not in {"KA", "KASTEN", "EINHEIT"}: # zur Not trotzdem übernehmen pass prod_id = _best_product_match(desc, all_products) items.append( DeliveryItemDraft( product_id=prod_id, product_hint=(None if prod_id else desc), quantity_units=qty, unit_cost_cents=eprice, ) ) return DeliveryDraft( supplier=None, # optional – kann bei Bedarf aus Kopf extrahiert werden date=date_iso, invoice_no=inv, note=None, deposit_return_cents=abs(deposit_cents), items=items or [], ) # ---- Endpoint: PDF hochladen & Draft liefern ------------------------------------------- @router.post("/invoice/import", response_model=DeliveryDraft, dependencies=[Depends(requires_role("manager", "admin"))]) def import_invoice_pdf( file: UploadFile = File(...), # ← fix db: Session = Depends(get_db), user: User = Depends(get_current_user), ): if not file.filename.lower().endswith(".pdf"): raise HTTPException(status_code=400, detail="Bitte eine PDF-Datei hochladen.") data = file.file.read() if not data: raise HTTPException(status_code=400, detail="Leere Datei.") # Produkte für Matching laden products = db.query(Product).all() try: draft = _parse_invoice_pdf(data, products) except HTTPException: raise except Exception as e: # robustes Fehlerhandling raise HTTPException(status_code=422, detail=f"PDF konnte nicht geparst werden: {e}") # Optional: minimale Plausibilitätsprüfung if not draft.items: # Kein harter Fehler – Frontend kann manuell ergänzen draft.note = (draft.note or "") return draft # ---- Bulk-Schema ---- class DeliveryItemIn(BaseModel): product_id: int quantity_units: int unit_cost_cents: int units: int | None = None # optional: Stückzahl vom Client class DeliveryBulkIn(BaseModel): supplier: str date: date invoice_no: str | None = None note: str | None = None deposit_return_cents: int = 0 items: list[DeliveryItemIn] @router.post("/bulk") def create_delivery_bulk(body: DeliveryBulkIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): requires_role("manager", "admin")(user) created_ids: list[int] = [] deposit = int(body.deposit_return_cents or 0) for idx, it in enumerate(body.items): prod = db.get(Product, it.product_id) or HTTPException(status_code=400, detail=f"Unknown product_id {it.product_id}") pack = int(getattr(prod, "pack_size") or 1) amount_pieces = int(it.units) if it.units is not None else int(it.quantity_units) * pack row = Delivery( product_id=it.product_id, amount=amount_pieces, # Stück gesamt price_cents=int(it.unit_cost_cents), # Preis pro Stück delivered_at=body.date, supplier=body.supplier, invoice_number=body.invoice_no, note=body.note, deposit_return_cents=(deposit if idx == 0 else 0), # <- hier ) db.add(row); db.flush(); created_ids.append(row.id) db.commit() return {"created": created_ids}