Files
Vorrania/backend/app/routers/products.py
Scarriffle de6c8632a1 Mehrere EAN-Codes je Produkt und je Gruppe (mit Notiz) + Gruppen umbenennen
Barcodes:
- Neue Tabelle barcodes (code, note, product_id ODER group_id). Damit lassen sich
  einem Produkt mehrere Codes geben und einer Gruppe beliebig viele Marken
  zuordnen (z.B. alle Mehlmarken zu "Mehl"). Je Code eine Notiz wie
  "Mehl bei Aldi".
- Lookup loest jetzt auch Alias-Codes auf; ist ein Code einer Gruppe zugeordnet,
  liefert die API group_id/group_name zurueck. Beim Anlegen aus einem Scan wird
  diese Gruppe automatisch gesetzt (Vorrang vor dem Kategorie-Rateversuch).
- Endpunkte: POST/DELETE /products/{id}/barcodes und /groups/{id}/barcodes.
- Web: wiederverwendbare BarcodeList-Komponente, eingebunden im Produktformular
  und auf der Gruppen-Seite.

Gruppen:
- Namen lassen sich jetzt direkt in der Tabelle aendern (PATCH war vorhanden,
  nur die Oberflaeche fehlte).

UI-Fix: Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der
Zellenbreite (Gruppen-/Benutzer-Dropdowns waren abgeschnitten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:48:14 +02:00

207 lines
7.3 KiB
Python

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, Group, Product, User
from ..off import lookup_barcode
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
from ..services.conversion import ConversionError, resolve_product_unit
router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductOut])
def list_products(
q: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ProductOut]:
query = db.query(Product)
if q:
like = f"%{q}%"
query = query.filter(Product.name.ilike(like))
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)
if suggestion:
return LookupResult(
found=True, suggestion=suggestion, group_id=group_id, group_name=group_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,
group_id=payload.group_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.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
for field, value in data.items():
setattr(product, field, value)
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")
db.delete(product)
db.commit()