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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
102
backend/app/routers/package_types.py
Normal file
102
backend/app/routers/package_types.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
79
backend/tests/test_package_types.py
Normal file
79
backend/tests/test_package_types.py
Normal 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
|
||||
Reference in New Issue
Block a user