Eine Kategorie mit Artikeln zeigte "0 EANs", und der Versuch, den Code eines eigenen Artikels einzutragen, scheiterte mit "Dieser Code ist bereits vergeben". Beides war fachlich richtig, aber nirgends erklaert. Hintergrund: Die Code-Liste einer Kategorie ist eine Vorratsliste fuer Artikel, die es noch nicht gibt. Sie greift nur, wenn beim Einlagern ein unbekannter Code gescannt wird - dann landet der neu angelegte Artikel in dieser Kategorie (routers/products.lookup). Haengt ein Code bereits an einem Artikel, findet die Suche immer zuerst den Artikel; ein gleichlautender Kategorie-Eintrag koennte nie wirken. Die Spalte zaehlte bisher ausschliesslich diese Vorratscodes, nie die Codes der enthaltenen Artikel - daher die irritierende 0. Die Kategorie liefert jetzt zusaetzlich die Codes ihrer Artikel mit (product_barcodes, rein informativ). Beide Herkuenfte stehen in einer Liste: Artikel-Codes sind als solche gekennzeichnet, nennen den Artikel und lassen sich hier nicht loeschen, weil sie am Artikel haengen. Bewusst ohne Dublette in der Datenbank - ein zweiter Datensatz koennte nie greifen und beim Loeschen des Artikels verwaisen. Die Spalte zaehlt beide Herkuenfte. Die Fehlermeldung sagt jetzt, wem ein Code gehoert: beim Artikel mit Namen und dem Hinweis, dass Artikel-Codes hier nicht eingetragen werden muessen; bei einer anderen Kategorie mit deren Namen. Signal beim Einlagern: Wird ein Code erkannt, der einer Kategorie zugeordnet ist, steht jetzt deutlich sichtbar "Wird automatisch der Kategorie X zugeordnet" samt Begruendung - sowohl im Web als auch in der App, und in beiden Faellen (Open-Food-Facts-Treffer und voellig unbekannter Code). Nach dem Anlegen meldet die Web-Oberflaeche zurueck, welche Kategorie es geworden ist. Vorher stand die Zuordnung nur als Nebensatz in grauer Kleinschrift. Getestet: Gegen die laufende API geprueft, dass eine Kategorie die Codes ihrer Artikel meldet (Haupt-Barcode und zusaetzliche Alias-Codes), dass das Eintragen eines Artikel-Codes und eines fremden Kategorie-Codes mit der jeweils richtigen Begruendung abgewiesen wird und dass ein echter Vorratscode weiterhin angelegt werden kann. iOS-Geraetebuild und Web-Build fehlerfrei, 40 pytest-Tests gruen. Dabei zwei Uebersetzungsfehler durch deutsche Anfuehrungszeichen gefunden: Das schliessende Zeichen war ein gerades ", das den String vorzeitig beendete. Korrigiert und die uebrigen Vorkommen im Projekt gleich mit vereinheitlicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, require_admin
|
||
from ..models import Barcode, Group, Product, User
|
||
from ..schemas import (
|
||
BarcodeCreate,
|
||
BarcodeOut,
|
||
GroupCreate,
|
||
GroupOut,
|
||
GroupUpdate,
|
||
ProductBarcodeOut,
|
||
)
|
||
from ..services.conversion import BASE_OF_KIND
|
||
from ..services.stock import current_stock
|
||
|
||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||
|
||
|
||
def _group_to_out(db: Session, group: Group) -> GroupOut:
|
||
out = GroupOut.model_validate(group)
|
||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||
out.product_count = len(products)
|
||
out.barcodes = [
|
||
BarcodeOut.model_validate(b)
|
||
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all()
|
||
]
|
||
# Codes der Artikel in dieser Gruppe mitliefern. Sie waren bisher nirgends
|
||
# sichtbar, wodurch eine Kategorie mit Artikeln "0 EANs" anzeigte.
|
||
product_codes: list[ProductBarcodeOut] = []
|
||
for product in products:
|
||
if product.barcode:
|
||
product_codes.append(
|
||
ProductBarcodeOut(
|
||
code=product.barcode, product_id=product.id, product_name=product.name
|
||
)
|
||
)
|
||
for alias in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id):
|
||
product_codes.append(
|
||
ProductBarcodeOut(
|
||
code=alias.code, product_id=product.id, product_name=product.name
|
||
)
|
||
)
|
||
out.product_barcodes = product_codes
|
||
|
||
unit = group.min_stock_unit
|
||
if unit is not None:
|
||
# Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt.
|
||
base = BASE_OF_KIND[unit.kind]
|
||
matching = [p for p in products if p.base_unit == base]
|
||
stock_base = float(sum(current_stock(db, p.id) for p in matching))
|
||
out.stock = stock_base / unit.factor
|
||
out.min_stock_unit_name = unit.name
|
||
out.kind = unit.kind.value
|
||
else:
|
||
out.stock = float(sum(current_stock(db, p.id) for p in products))
|
||
return out
|
||
|
||
|
||
@router.get("", response_model=list[GroupOut])
|
||
def list_groups(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> list[GroupOut]:
|
||
groups = db.query(Group).order_by(Group.name).all()
|
||
return [_group_to_out(db, g) for g in groups]
|
||
|
||
|
||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||
def create_group(
|
||
payload: GroupCreate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
if db.query(Group).filter(Group.name == payload.name).first():
|
||
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
||
group = Group(
|
||
name=payload.name,
|
||
min_stock=payload.min_stock,
|
||
min_stock_unit_id=payload.min_stock_unit_id,
|
||
)
|
||
db.add(group)
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
@router.patch("/{group_id}", response_model=GroupOut)
|
||
def update_group(
|
||
group_id: int,
|
||
payload: GroupUpdate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if "name" in data and data["name"]:
|
||
clash = (
|
||
db.query(Group)
|
||
.filter(Group.name == data["name"], Group.id != group_id)
|
||
.first()
|
||
)
|
||
if clash:
|
||
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
|
||
for field, value in data.items():
|
||
setattr(group, field, value)
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
def _describe_conflict(db: Session, code: str) -> str | None:
|
||
"""Sagt, wem ein Code schon gehört – "bereits vergeben" allein half nicht weiter."""
|
||
product = db.query(Product).filter(Product.barcode == code).first()
|
||
existing = db.query(Barcode).filter(Barcode.code == code).first()
|
||
|
||
if existing is not None and existing.product_id:
|
||
product = db.get(Product, existing.product_id) or product
|
||
if product is not None:
|
||
return (
|
||
f"Dieser Code gehört bereits zum Artikel \"{product.name}\". "
|
||
"Artikel-Codes müssen hier nicht eingetragen werden – beim Scannen "
|
||
"wird immer zuerst der Artikel gefunden, und dessen Kategorie zählt."
|
||
)
|
||
if existing is not None and existing.group_id:
|
||
other = db.get(Group, existing.group_id)
|
||
name = other.name if other else "einer anderen Kategorie"
|
||
return f"Dieser Code ist bereits der Kategorie \"{name}\" zugeordnet."
|
||
if existing is not None:
|
||
return "Dieser Code ist bereits vergeben."
|
||
return None
|
||
|
||
|
||
@router.post("/{group_id}/barcodes", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||
def add_group_barcode(
|
||
group_id: int,
|
||
payload: BarcodeCreate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
"""EAN einer Gruppe zuordnen (z.B. alle Mehl-Marken zur Gruppe "Mehl")."""
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
code = payload.code.strip()
|
||
conflict = _describe_conflict(db, code)
|
||
if conflict:
|
||
raise HTTPException(status.HTTP_409_CONFLICT, conflict)
|
||
db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id))
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
@router.delete("/{group_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_group_barcode(
|
||
group_id: int,
|
||
code: str,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> None:
|
||
entry = (
|
||
db.query(Barcode).filter(Barcode.group_id == group_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("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_group(
|
||
group_id: int,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> None:
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
db.delete(group)
|
||
db.commit()
|