from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..crud import product_to_out from ..database import get_db from ..deps import get_current_user, require_admin from ..models import Barcode, BaseUnit, Category, Group, Product, User from ..off import lookup_barcode from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate from ..services.categories import suggest_category from .categories import descendant_ids from ..services.conversion import ConversionError, resolve_product_unit from ..services.group_codes import detach as detach_group_code, sync as sync_group_code router = APIRouter(prefix="/products", tags=["products"]) @router.get("", response_model=list[ProductOut]) def list_products( q: str | None = None, category_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[ProductOut]: """Artikel auflisten, optional nach Name und Kategorie eingegrenzt. ``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will auch "Schokolade" sehen. """ query = db.query(Product) if q: like = f"%{q}%" query = query.filter(Product.name.ilike(like)) if category_id == 0: query = query.filter(Product.category_id.is_(None)) elif category_id is not None: query = query.filter(Product.category_id.in_(descendant_ids(db, category_id))) products = query.order_by(Product.name).all() return [product_to_out(db, p) for p in products] @router.get("/lookup", response_model=LookupResult) def lookup( barcode: str, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> LookupResult: """Barcode auflösen: Produkt (Haupt- oder Alias-Code), Gruppen-Code, sonst OFF.""" code = barcode.strip() existing = db.query(Product).filter(Product.barcode == code).first() if existing is None: alias = db.query(Barcode).filter(Barcode.code == code).first() if alias is not None and alias.product_id: existing = db.get(Product, alias.product_id) if existing: return LookupResult(found=True, existing_product=product_to_out(db, existing)) # Code kann einer Gruppe zugeordnet sein (z.B. alle Mehl-Marken in "Mehl"). group_id = group_name = None alias = db.query(Barcode).filter(Barcode.code == code).first() if alias is not None and alias.group_id: group = db.get(Group, alias.group_id) if group is not None: group_id, group_name = group.id, group.name suggestion = lookup_barcode(code) # Die OFF-Einordnung steuert die KATEGORIE (Ueberblick), nicht die Gruppe. category = suggest_category(db, suggestion) category_id = category.id if category else None category_name = category.name if category else None if suggestion: return LookupResult( found=True, suggestion=suggestion, group_id=group_id, group_name=group_name, category_id=category_id, category_name=category_name, ) return LookupResult(found=False, group_id=group_id, group_name=group_name) @router.get("/{product_id}", response_model=ProductOut) def get_product( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> ProductOut: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") return product_to_out(db, product) @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def create_product( payload: ProductCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: if payload.barcode: exists = db.query(Product).filter(Product.barcode == payload.barcode).first() if exists: raise HTTPException( status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits" ) base_unit = payload.base_unit display_unit_id = None if payload.unit_id is not None: try: base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc product = Product( barcode=payload.barcode or None, name=payload.name, brand=payload.brand, image_url=payload.image_url, base_unit=base_unit, display_unit_id=display_unit_id, package_size=payload.package_size, package_label=payload.package_label or None, date_precision=payload.date_precision.value, group_id=payload.group_id, category_id=payload.category_id, min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, min_stock_in_packages=bool(payload.min_stock_in_packages), source="manual", ) db.add(product) db.flush() # product.id fuer den Gruppen-Code sync_group_code(db, product) db.commit() db.refresh(product) return product_to_out(db, product) @router.patch("/{product_id}", response_model=ProductOut) def update_product( product_id: int, payload: ProductUpdate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") data = payload.model_dump(exclude_unset=True) if "barcode" in data and data["barcode"]: clash = ( db.query(Product) .filter(Product.barcode == data["barcode"], Product.id != product_id) .first() ) if clash: raise HTTPException( status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits" ) # Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit. if "unit_id" in data: unit_id = data.pop("unit_id") data.pop("base_unit", None) # unit_id hat Vorrang if unit_id is None: product.display_unit_id = None else: try: product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc if data.get("min_stock_in_packages") is None: data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL if data.get("date_precision") is None: data.pop("date_precision", None) # Spalte ist NOT NULL else: data["date_precision"] = data["date_precision"].value for field, value in data.items(): setattr(product, field, value) # Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen. sync_group_code(db, product) db.commit() db.refresh(product) return product_to_out(db, product) @router.post("/{product_id}/barcodes", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def add_product_barcode( product_id: int, payload: BarcodeCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Weiteren EAN-Code zu einem Produkt hinzufügen.""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") code = payload.code.strip() if db.query(Barcode).filter(Barcode.code == code).first() or ( db.query(Product).filter(Product.barcode == code).first() ): raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben") db.add(Barcode(code=code, note=(payload.note or None), product_id=product.id)) db.commit() db.refresh(product) return product_to_out(db, product) @router.delete("/{product_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT) def delete_product_barcode( product_id: int, code: str, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: entry = ( db.query(Barcode) .filter(Barcode.product_id == product_id, Barcode.code == code) .first() ) if entry is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden") db.delete(entry) db.commit() @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_product( product_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") detach_group_code(db, product) db.delete(product) db.commit()