Gebinde verwalten - mit Einzahl und Mehrzahl

Bisher war die Auswahl eine fest im Frontend verdrahtete Liste und es gab nur
eine Form: ueberall stand "3 Glas".

Neu unter Verwaltung > Gebinde: anlegen, umbenennen, loeschen, jeweils mit
Einzahl und Mehrzahl. Die eingebauten Gebinde lassen sich in der Schreibweise
aendern, aber nicht loeschen; ein Gebinde, das ein Artikel verwendet, ebenfalls
nicht.

Der Artikel speichert weiterhin nur die Einzahl als Text - so bleiben
vorhandene Artikel, Sicherungen und CSV-Dateien gueltig, und eine unbekannte
Bezeichnung faellt schlicht auf die Einzahl zurueck. Deshalb zieht ein
Umbenennen die Artikel mit; sonst zeigten sie auf eine Bezeichnung, die es
nicht mehr gibt.

Die Mehrzahl greift jetzt in Artikelliste, Artikelseite (Chargen und Gebinde-
Auswahl), Auslagern und in allen Ablauf- und Einkaufslisten samt Startseite.
Einheiten wie Gramm oder Liter bleiben unveraendert - die haben im Deutschen
keine Mehrzahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 14:24:42 +02:00
parent 0ba968985f
commit fd9045226b
14 changed files with 511 additions and 22 deletions

View File

@@ -15,6 +15,7 @@ from .routers import (
groups,
locations,
maintenance,
package_types,
products,
settings as settings_router,
stock,
@@ -23,7 +24,12 @@ from .routers import (
users,
views,
)
from .seed import ensure_builtin_categories, ensure_builtin_units, ensure_first_admin
from .seed import (
ensure_builtin_categories,
ensure_builtin_package_types,
ensure_builtin_units,
ensure_first_admin,
)
from .services.group_codes import backfill as backfill_group_codes
settings = get_settings()
@@ -69,6 +75,7 @@ async def lifespan(app: FastAPI):
db = SessionLocal()
try:
ensure_builtin_units(db)
ensure_builtin_package_types(db)
ensure_builtin_categories(db)
ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen.
@@ -104,6 +111,7 @@ app.include_router(stock.router)
app.include_router(locations.router)
app.include_router(groups.router)
app.include_router(units.router)
app.include_router(package_types.router)
app.include_router(views.router)
app.include_router(transfer.router)
app.include_router(api_tokens.router)

View File

@@ -277,6 +277,23 @@ class Movement(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PackageType(Base):
"""Bezeichnung eines Gebindes in Einzahl und Mehrzahl.
Der Artikel speichert weiterhin nur die **Einzahl** als Text
(``Product.package_label``) diese Tabelle liefert dazu die Mehrzahl. So
bleiben vorhandene Artikel, Sicherungen und CSV-Dateien gültig; eine
unbekannte Bezeichnung fällt schlicht auf die Einzahl zurück.
"""
__tablename__ = "package_types"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
singular: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
plural: Mapped[str] = mapped_column(String(32), nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class ProductImage(Base):
"""Lokale Kopie des Artikelbilds.

View File

@@ -0,0 +1,102 @@
"""Gebinde-Bezeichnungen mit Einzahl und Mehrzahl.
Der Artikel speichert nur die Einzahl als Text; hier steht die passende
Mehrzahl. Deshalb zieht ein Umbenennen die Artikel mit: Wird "Glas" zu
"Konservenglas", laufen die Artikel sonst auf eine Bezeichnung, die es nicht
mehr gibt, und fielen stillschweigend auf die Einzahl zurück.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import PackageType, Product, User
from ..schemas import PackageTypeCreate, PackageTypeOut, PackageTypeUpdate
router = APIRouter(prefix="/package-types", tags=["package-types"])
def _doppelt(db: Session, singular: str, ausser_id: int | None = None) -> bool:
query = db.query(PackageType).filter(func.lower(PackageType.singular) == singular.lower())
if ausser_id is not None:
query = query.filter(PackageType.id != ausser_id)
return query.first() is not None
@router.get("", response_model=list[PackageTypeOut])
def list_package_types(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[PackageType]:
return db.query(PackageType).order_by(PackageType.singular).all()
@router.post("", response_model=PackageTypeOut, status_code=status.HTTP_201_CREATED)
def create_package_type(
payload: PackageTypeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> PackageType:
singular = payload.singular.strip()
if _doppelt(db, singular):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieses Gebinde gibt es bereits")
eintrag = PackageType(
singular=singular, plural=payload.plural.strip() or singular, is_builtin=False
)
db.add(eintrag)
db.commit()
db.refresh(eintrag)
return eintrag
@router.patch("/{type_id}", response_model=PackageTypeOut)
def update_package_type(
type_id: int,
payload: PackageTypeUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> PackageType:
eintrag = db.get(PackageType, type_id)
if eintrag is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gebinde nicht gefunden")
daten = payload.model_dump(exclude_unset=True)
neue_einzahl = (daten.get("singular") or "").strip()
if neue_einzahl and neue_einzahl != eintrag.singular:
if _doppelt(db, neue_einzahl, type_id):
raise HTTPException(status.HTTP_409_CONFLICT, "Dieses Gebinde gibt es bereits")
# Artikel mitziehen - sie verweisen ueber den Text, nicht ueber eine ID.
db.query(Product).filter(Product.package_label == eintrag.singular).update(
{Product.package_label: neue_einzahl}, synchronize_session=False
)
eintrag.singular = neue_einzahl
if daten.get("plural"):
eintrag.plural = daten["plural"].strip()
db.commit()
db.refresh(eintrag)
return eintrag
@router.delete("/{type_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_package_type(
type_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
eintrag = db.get(PackageType, type_id)
if eintrag is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gebinde nicht gefunden")
if eintrag.is_builtin:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Eingebaute Gebinde können nicht gelöscht werden"
)
benutzt = db.query(Product).filter(Product.package_label == eintrag.singular).first()
if benutzt:
raise HTTPException(
status.HTTP_409_CONFLICT, "Dieses Gebinde wird noch von Artikeln verwendet"
)
db.delete(eintrag)
db.commit()

View File

@@ -156,6 +156,25 @@ class LocationCreate(BaseModel):
parent_id: int | None = None
# ---- Gebinde (Packung, Glas, …) ----
class PackageTypeCreate(BaseModel):
singular: str = Field(min_length=1, max_length=32)
plural: str = Field(min_length=1, max_length=32)
class PackageTypeUpdate(BaseModel):
singular: str | None = Field(default=None, min_length=1, max_length=32)
plural: str | None = Field(default=None, min_length=1, max_length=32)
class PackageTypeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
singular: str
plural: str
is_builtin: bool = False
# ---- Products ----
class ProductBase(BaseModel):
barcode: str | None = None

View File

@@ -3,7 +3,7 @@
from sqlalchemy.orm import Session
from .config import get_settings
from .models import Category, Role, Unit, UnitKind, User
from .models import Category, PackageType, Role, Unit, UnitKind, User
from .security import hash_password
# (Name, Art, Faktor zur kanonischen Basiseinheit)
@@ -16,6 +16,39 @@ BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
]
# Gebinde in Einzahl und Mehrzahl. Bewusst nur die gaengigen - eigene legt man
# unter Verwaltung > Gebinde an. Wo Einzahl und Mehrzahl gleich lauten
# ("2 Becher"), steht das hier ausdruecklich, damit es niemand fuer vergessen
# haelt und "Bechers" daraus macht.
BUILTIN_PACKAGE_TYPES: list[tuple[str, str]] = [
("Packung", "Packungen"),
("Glas", "Gläser"),
("Flasche", "Flaschen"),
("Dose", "Dosen"),
("Tüte", "Tüten"),
("Beutel", "Beutel"),
("Becher", "Becher"),
("Tube", "Tuben"),
("Karton", "Kartons"),
("Riegel", "Riegel"),
("Rolle", "Rollen"),
("Sack", "Säcke"),
("Kiste", "Kisten"),
("Bund", "Bund"),
("Stück", "Stück"),
]
def ensure_builtin_package_types(db: Session) -> None:
changed = False
for singular, plural in BUILTIN_PACKAGE_TYPES:
if not db.query(PackageType).filter(PackageType.singular == singular).first():
db.add(PackageType(singular=singular, plural=plural, is_builtin=True))
changed = True
if changed:
db.commit()
def ensure_builtin_units(db: Session) -> None:
changed = False
for name, kind, factor in BUILTIN_UNITS:

View File

@@ -0,0 +1,79 @@
import pytest
from fastapi import HTTPException
from app.models import BaseUnit, PackageType, Product, Role, User
from app.routers import package_types
from app.schemas import PackageTypeCreate, PackageTypeUpdate
from app.seed import ensure_builtin_package_types
@pytest.fixture()
def admin(db):
person = User(username="chef", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def test_eingebaute_gebinde_werden_angelegt(db):
ensure_builtin_package_types(db)
glas = db.query(PackageType).filter(PackageType.singular == "Glas").one()
assert glas.plural == "Gläser"
assert glas.is_builtin is True
# Zweiter Durchlauf darf nichts verdoppeln.
vorher = db.query(PackageType).count()
ensure_builtin_package_types(db)
assert db.query(PackageType).count() == vorher
def test_umbenennen_zieht_die_artikel_mit(db, admin):
"""Artikel verweisen ueber den Text, nicht ueber eine ID.
Ohne das Mitziehen zeigte ein Artikel nach dem Umbenennen auf eine
Bezeichnung, die es nicht mehr gibt - und verloere still seine Mehrzahl.
"""
art = package_types.create_package_type(
PackageTypeCreate(singular="Kanister", plural="Kanister"), db=db, _=admin
)
pesto = Product(name="Öl", base_unit=BaseUnit.milliliter, package_label="Kanister")
db.add(pesto)
db.commit()
package_types.update_package_type(
art.id, PackageTypeUpdate(singular="Kanne", plural="Kannen"), db=db, _=admin
)
db.refresh(pesto)
assert pesto.package_label == "Kanne"
def test_benutztes_gebinde_laesst_sich_nicht_loeschen(db, admin):
art = package_types.create_package_type(
PackageTypeCreate(singular="Kanister", plural="Kanister"), db=db, _=admin
)
db.add(Product(name="Öl", base_unit=BaseUnit.milliliter, package_label="Kanister"))
db.commit()
with pytest.raises(HTTPException) as fehler:
package_types.delete_package_type(art.id, db=db, _=admin)
assert fehler.value.status_code == 409
def test_eingebaute_gebinde_sind_nicht_loeschbar(db, admin):
ensure_builtin_package_types(db)
glas = db.query(PackageType).filter(PackageType.singular == "Glas").one()
with pytest.raises(HTTPException) as fehler:
package_types.delete_package_type(glas.id, db=db, _=admin)
assert fehler.value.status_code == 400
def test_doppelte_bezeichnung_wird_abgelehnt(db, admin):
package_types.create_package_type(
PackageTypeCreate(singular="Kanister", plural="Kanister"), db=db, _=admin
)
with pytest.raises(HTTPException) as fehler:
package_types.create_package_type(
PackageTypeCreate(singular="kanister", plural="Kanister"), db=db, _=admin
)
assert fehler.value.status_code == 409