Files
bacchus/apps/backend/app/api/deliveries.py
2025-09-28 19:13:01 +02:00

285 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}