Am Gruppen-Code stand der Produktname. Innerhalb einer Gruppe hilft der aber nicht weiter: In der Gruppe "Zucker" heisst jeder Artikel irgendwie "Zucker", in "Mehl" irgendwie "…mehl". Unterscheiden laesst sich das nur ueber die Marke. Der Endpunkt liefert jetzt beides; angezeigt wird die Marke, faellt bei fehlender Marke auf den Namen zurueck und zeigt den vollen Namen im Tooltip. Nebenbei ist die Marke meist deutlich kuerzer als der Name. Abgeschnittene Tabelle: Die Spalte "verwalten" wurde gekappt. Ursache war nicht die Breite an sich, sondern dass Grid-Kinder standardmaessig nicht unter ihre Inhaltsbreite schrumpfen (min-width: auto). Dadurch lief die Tabelle aus ihrer Karte heraus und wurde beschnitten, statt dass das vorhandene overflow-x gegriffen haette. Mit min-width: 0 an den Grid-Kindern scrollt sie jetzt. Zusaetzlich hat die Tabelle eine Mindestbreite von 640px - lieber waagerecht scrollen als Spalten so weit quetschen, bis Text verschwindet. Umbruch statt Quetschen: Die feste Umbruchbreite von 820px passte nicht zur tatsaechlichen Lage - entscheidend ist, wie viel Platz die Spalten wirklich brauchen, nicht wie breit der Bildschirm ist. Jetzt auto-fit mit Mindestbreite je Spalte: Zwei Spalten, solange beide genug Platz haben, sonst automatisch untereinander. Seiten mit inhaltsreichem Seitenblock (EAN-Codes) verlangen mehr und stapeln frueher. Durchgerechnet: Bei 1920 voller Breite zwei Spalten zu je 668px, Tabelle passt. Bei halber Breite (960) eine Spalte zu 676px, Tabelle passt weiterhin. Erst bei sehr schmalen Fenstern (unter etwa 900px Gesamtbreite) scrollt die Tabelle waagerecht. In keinem Fall wird noch etwas abgeschnitten. Getestet: 58 pytest-Tests gruen, Web-Build laeuft durch. Gegen die laufende API geprueft, dass Marke und Name am Code mitkommen und die fehlende Marke sauber auf den Namen zurueckfaellt. Die Breiten sind rechnerisch geprueft, die Darstellung im Browser habe ich nicht selbst angesehen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
9.2 KiB
Python
252 lines
9.2 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,
|
||
BarcodeNoteUpdate,
|
||
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)
|
||
# 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:
|
||
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, 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()
|