diff --git a/backend/app/main.py b/backend/app/main.py index b8829c8..7a8f07c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 01fa653..3040080 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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. diff --git a/backend/app/routers/package_types.py b/backend/app/routers/package_types.py new file mode 100644 index 0000000..c508247 --- /dev/null +++ b/backend/app/routers/package_types.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 88e5001..011f49d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/seed.py b/backend/app/seed.py index 2e27e8b..bdc16be 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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: diff --git a/backend/tests/test_package_types.py b/backend/tests/test_package_types.py new file mode 100644 index 0000000..29f3ad9 --- /dev/null +++ b/backend/tests/test_package_types.py @@ -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 diff --git a/web/src/App.jsx b/web/src/App.jsx index d37e2ac..ef5efae 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -11,6 +11,7 @@ import CheckOut from "./pages/CheckOut"; import Groups from "./pages/Groups"; import Categories from "./pages/Categories"; import Locations from "./pages/Locations"; +import PackageTypes from "./pages/PackageTypes"; import Units from "./pages/Units"; import Users from "./pages/Users"; import ShoppingList from "./pages/ShoppingList"; @@ -57,6 +58,7 @@ function Sidebar() {
| Einzahl | Mehrzahl | Beispiel | |
|---|---|---|---|
| + {offen ? ( + setBearbeitung({ ...bearbeitung, singular: e.target.value })} /> + ) : ( + + {art.singular} + {art.is_builtin && eingebaut} + + )} + | ++ {offen ? ( + setBearbeitung({ ...bearbeitung, plural: e.target.value })} /> + ) : art.plural} + | ++ 1 {art.singular} · 3 {art.plural} + | ++ {offen ? ( + + + + + ) : ( + + + {!art.is_builtin && ( + + )} + + )} + | +