Files
Vorrania/backend/app/routers/groups.py
Scarriffle f3bab15362 Mindestbestaende: Bestand je Lagerort anzeigen (nicht nur Gesamt)
Per-Lagerort-Zeilen (Produkt und Gruppe) zeigten '-' beim Bestand, weil die Seite
nur den Gesamtbestand kannte. Jetzt liefert LocationMinStockOut.stock den Bestand
AN DEM Ort (inkl. Unterorte) mit - bei Produkten in Basiseinheiten, bei Gruppen in
der Gruppen-Einheit (analog zu ProductOut.stock/GroupOut.stock). Berechnet ueber
location_subtree_stock_base; die Weboberflaeche zeigt ihn wie beim Gesamt-Bestand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:57:40 +02:00

302 lines
11 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, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Barcode, Group, GroupLocationMinStock, Location, Product, User
from ..schemas import (
BarcodeCreate,
BarcodeNoteUpdate,
BarcodeOut,
GroupCreate,
GroupOut,
GroupUpdate,
LocationMinStockIn,
LocationMinStockOut,
ProductBarcodeOut,
)
from ..services.conversion import BASE_OF_KIND
from ..services.stock import current_stock, location_subtree_stock_base
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)
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
artikel_zu_code: dict[str, Product] = {}
for product in products:
if product.barcode:
artikel_zu_code[product.barcode] = product
for alias in db.query(Barcode).filter(Barcode.product_id == product.id):
artikel_zu_code[alias.code] = product
out.barcodes = []
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all():
eintrag = BarcodeOut.model_validate(b)
besitzer = artikel_zu_code.get(b.code)
if besitzer is not None:
eintrag.product_name = besitzer.name
eintrag.product_brand = besitzer.brand
out.barcodes.append(eintrag)
# Codes der Artikel in dieser Gruppe mitliefern. Sie waren bisher nirgends
# sichtbar, wodurch eine Gruppe mit Artikeln "0 EANs" anzeigte.
# Was schon als Gruppen-Code gefuehrt wird, hier ueberspringen - sonst
# steht derselbe Code zweimal in der Liste, einmal davon schreibgeschuetzt.
schon_da = {b.code for b in out.barcodes}
product_codes: list[ProductBarcodeOut] = []
for product in products:
if product.barcode and product.barcode not in schon_da:
product_codes.append(
ProductBarcodeOut(
code=product.barcode, product_id=product.id,
product_name=product.name, product_brand=product.brand,
)
)
for alias in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id):
if alias.code in schon_da:
continue
product_codes.append(
ProductBarcodeOut(
code=alias.code, product_id=product.id,
product_name=product.name, product_brand=product.brand,
)
)
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:
matching = list(products)
out.stock = float(sum(current_stock(db, p.id) for p in products))
# Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock.
def _loc_stock(loc_id: str) -> float:
total = sum(location_subtree_stock_base(db, p, loc_id) for p in matching)
return total / unit.factor if unit is not None else float(total)
out.location_min_stocks = [
LocationMinStockOut(
location_id=e.location_id,
location_name=e.location.name if e.location else None,
min_stock=e.min_stock,
stock=_loc_stock(e.location_id),
)
for e in sorted(group.location_min_stocks, key=lambda x: x.id)
]
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.put("/{group_id}/location-min-stock", response_model=GroupOut)
def set_group_location_min_stock(
group_id: int,
payload: list[LocationMinStockIn],
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt)."""
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
db.query(GroupLocationMinStock).filter(
GroupLocationMinStock.group_id == group_id
).delete()
gesehen: set[int] = set()
for eintrag in payload:
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
continue
if db.get(Location, eintrag.location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
gesehen.add(eintrag.location_id)
db.add(GroupLocationMinStock(
group_id=group_id,
location_id=eintrag.location_id,
min_stock=eintrag.min_stock,
))
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@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, group_id: int) -> 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:
if product.group_id == group_id:
# Der Code steht durch den Artikel ohnehin schon in dieser Gruppe.
return (
f"Dieser Code gehört zum Artikel \"{product.name}\" und ist über ihn "
"bereits in dieser Gruppe."
)
if product.group_id is not None:
other = db.get(Group, product.group_id)
name = other.name if other else "einer anderen Gruppe"
return (
f"Dieser Code gehört zum Artikel \"{product.name}\", der in der Gruppe "
f"\"{name}\" liegt. Ändere die Gruppe des Artikels, statt den Code hier "
"einzutragen."
)
return (
f"Dieser Code gehört bereits zum Artikel \"{product.name}\". Ordne den "
"Artikel dieser Gruppe zu sein Code erscheint dann automatisch hier."
)
if existing is not None and existing.group_id == group_id:
return "Dieser Code ist in dieser Gruppe bereits hinterlegt."
if existing is not None and existing.group_id:
other = db.get(Group, existing.group_id)
name = other.name if other else "einer anderen Gruppe"
return f"Dieser Code ist bereits der Gruppe \"{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, group.id)
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.patch("/{group_id}/barcodes/{code}", response_model=GroupOut)
def update_group_barcode_note(
group_id: int,
code: str,
payload: BarcodeNoteUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
"""Notiz zu einem Gruppen-Code setzen.
Betrifft ausdrücklich auch Codes, die beim Zuordnen eines Artikels
automatisch entstanden sind die hatten bisher gar keine Möglichkeit,
eine Notiz zu bekommen.
"""
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
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")
note = (payload.note or "").strip()
entry.note = note or None
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()