285 lines
10 KiB
Python
285 lines
10 KiB
Python
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: "<artnr> <bezeichnung…> <anzahl> <ME> <E-Preis> <G-Preis>"
|
||
row_re = re.compile(
|
||
r"^\s*\d+\s+(?P<desc>.+?)\s+(?P<qty>\d+)\s+(?P<me>[A-Za-z]+)\s+(?P<eprice>\d+,\d{2})\s+(?P<gprice>\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}
|
||
|