new init
This commit is contained in:
284
apps/backend/app/api/deliveries.py
Normal file
284
apps/backend/app/api/deliveries.py
Normal file
@@ -0,0 +1,284 @@
|
||||
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}
|
||||
|
Reference in New Issue
Block a user