From fd9045226b89eb36ce3489784a74f9af0945c6d2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 23 Jul 2026 14:24:42 +0200 Subject: [PATCH] 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 --- backend/app/main.py | 10 +- backend/app/models.py | 17 +++ backend/app/routers/package_types.py | 102 ++++++++++++++++ backend/app/schemas.py | 19 +++ backend/app/seed.py | 35 +++++- backend/tests/test_package_types.py | 79 ++++++++++++ web/src/App.jsx | 3 + web/src/api.js | 6 + web/src/pages/CheckOut.jsx | 4 +- web/src/pages/PackageTypes.jsx | 175 +++++++++++++++++++++++++++ web/src/pages/ProductForm.jsx | 32 +++-- web/src/pages/Products.jsx | 9 +- web/src/settings.jsx | 9 +- web/src/units.js | 33 ++++- 14 files changed, 511 insertions(+), 22 deletions(-) create mode 100644 backend/app/routers/package_types.py create mode 100644 backend/tests/test_package_types.py create mode 100644 web/src/pages/PackageTypes.jsx 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() {
Verwaltung
+ @@ -112,6 +114,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index d8b30c5..eee6d81 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -191,6 +191,12 @@ export const api = { updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), + // Gebinde (Einzahl/Mehrzahl): "Glas" -> "Gläser" + listPackageTypes: () => request("/package-types"), + createPackageType: (body) => request("/package-types", { method: "POST", body }), + updatePackageType: (id, body) => request(`/package-types/${id}`, { method: "PATCH", body }), + deletePackageType: (id) => request(`/package-types/${id}`, { method: "DELETE" }), + listGroups: () => request("/groups"), createGroup: (body) => request("/groups", { method: "POST", body }), updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx index 066e7fa..64f428d 100644 --- a/web/src/pages/CheckOut.jsx +++ b/web/src/pages/CheckOut.jsx @@ -4,7 +4,7 @@ import Icon from "../components/Icon"; import { ProduktThumb } from "../components/ProduktBild"; import { useToast } from "../toast"; import { useSettings } from "../settings"; -import { buildUnitOptions, fmt, isExpired, unitShort } from "../units"; +import { buildUnitOptions, fmt, gebinde, isExpired, unitShort } from "../units"; export default function CheckOut() { const { formatBestBefore } = useSettings(); @@ -98,7 +98,7 @@ export default function CheckOut() { // Chargenmenge in der Artikeleinheit (Gebinde), sonst in der Produkteinheit. function lotAmount(q) { - if (pkgSize > 0) return `${fmt(q / pkgSize)} ${pkgLabel}`; + if (pkgSize > 0) return `${fmt(q / pkgSize)} ${gebinde(q / pkgSize, pkgLabel)}`; return `${fmt(q / (product?.unit_factor || 1))} ${product?.unit_name || baseShort}`; } diff --git a/web/src/pages/PackageTypes.jsx b/web/src/pages/PackageTypes.jsx new file mode 100644 index 0000000..0224678 --- /dev/null +++ b/web/src/pages/PackageTypes.jsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { useConfirm } from "../confirm"; +import Icon from "../components/Icon"; +import { useSettings } from "../settings"; + +/** + * Gebinde verwalten: Einzahl und Mehrzahl. + * + * Die Mehrzahl ist der eigentliche Zweck – ohne sie stünde überall „3 Glas“. + * Sie lässt sich auch für eingebaute Gebinde ändern; nur Löschen bleibt den + * selbst angelegten vorbehalten. + */ +export default function PackageTypes() { + const confirm = useConfirm(); + const { reload: reloadSettings } = useSettings(); + const [arten, setArten] = useState([]); + const [form, setForm] = useState({ singular: "", plural: "" }); + const [bearbeitung, setBearbeitung] = useState(null); // { id, singular, plural } + const [error, setError] = useState(null); + + async function load() { + try { + setArten(await api.listPackageTypes()); + // Die Mehrzahlformen stecken in einer Modulvariablen, die beim Login + // gefuellt wird - nach einer Aenderung muss sie neu geladen werden, + // sonst zeigen andere Seiten weiter die alte Form. + reloadSettings(); + } catch (err) { + setError(err.message); + } + } + + useEffect(() => { load(); }, []); + + async function anlegen(e) { + e.preventDefault(); + setError(null); + try { + await api.createPackageType({ + singular: form.singular.trim(), + plural: form.plural.trim() || form.singular.trim(), + }); + setForm({ singular: "", plural: "" }); + load(); + } catch (err) { + setError(err.message); + } + } + + async function speichern() { + setError(null); + try { + await api.updatePackageType(bearbeitung.id, { + singular: bearbeitung.singular.trim(), + plural: bearbeitung.plural.trim(), + }); + setBearbeitung(null); + load(); + } catch (err) { + setError(err.message); + } + } + + async function loeschen(art) { + const ok = await confirm({ + title: `Gebinde „${art.singular}“ löschen?`, + message: "Nur möglich, solange kein Artikel es verwendet.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; + try { + await api.deletePackageType(art.id); + load(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+
+
+

Gebinde

+
+ Bezeichnung eines Artikelgebindes – die Mehrzahl wird überall dort + verwendet, wo mehr als eines gemeint ist. +
+
+
+ {error &&
{error}
} + +
+
+
+ + + + + + {arten.map((art) => { + const offen = bearbeitung?.id === art.id; + return ( + + + + + + + ); + })} + +
EinzahlMehrzahlBeispiel
+ {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 && ( + + )} + + )} +
+
+
+ +
+

Neues Gebinde

+ + + +
+
+
+ ); +} diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 9e443cf..1bebcf4 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -11,7 +11,8 @@ import { useToast } from "../toast"; import { useSettings } from "../settings"; import { asTree } from "../categoryTree"; import { - daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort, + daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, + toMonthInput, unitShort, } from "../units"; const EMPTY = { @@ -20,10 +21,7 @@ const EMPTY = { group_id: "", category_id: "", }; -// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert). -const PACKAGE_LABELS = [ - "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton", "Riegel", "Rolle", -]; +// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes) // Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand). const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" }; @@ -46,6 +44,7 @@ export default function ProductForm() { const [groups, setGroups] = useState([]); const [categories, setCategories] = useState([]); const [units, setUnits] = useState([]); + const [gebindearten, setGebindearten] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. @@ -204,12 +203,13 @@ export default function ProductForm() { useEffect(() => { async function load() { try { - const [gs, us, cs] = await Promise.all([ - api.listGroups(), api.listUnits(), api.listCategories(), + const [gs, us, cs, pts] = await Promise.all([ + api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(), ]); setGroups(gs); setUnits(us); setCategories(cs); + setGebindearten(pts); try { const s = await api.listSettings(); const row = s.find((x) => x.key === "expiry_warning_days"); @@ -415,12 +415,18 @@ export default function ProductForm() {
) : ( <> - {fmt(l.quantity / primaryFactor)} {primaryLabel} + {fmt(l.quantity / primaryFactor)} {primaryLabel(l.quantity / primaryFactor)} {secondary(l.quantity) && (
{secondary(l.quantity)}
)} diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index d51a59c..28784ac 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -4,16 +4,17 @@ import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { ProduktThumb } from "../components/ProduktBild"; -import { fmt, unitShort } from "../units"; +import { fmt, gebinde, unitShort } from "../units"; import { asTree } from "../categoryTree"; // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). function unitCount(p) { return p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1); } -function countLabel(p) { +// Mengenabhaengig, damit "3 Gläser" dasteht und nicht "3 Glas". +function countLabel(p, menge) { return p.package_size && p.package_size > 0 - ? (p.package_label || "Packung") + ? gebinde(menge, p.package_label) : (p.unit_name || unitShort(p.base_unit)); } @@ -124,7 +125,7 @@ export default function Products() { {fmt(p.stock / unitCount(p))} {p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "} - {countLabel(p)} + {countLabel(p, p.stock / unitCount(p))} {low && niedrig} diff --git a/web/src/settings.jsx b/web/src/settings.jsx index c63d434..a9d2f47 100644 --- a/web/src/settings.jsx +++ b/web/src/settings.jsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { api } from "./api"; import { useAuth } from "./auth"; -import { formatBestBefore, formatDate } from "./units"; +import { formatBestBefore, formatDate, setGebindeFormen } from "./units"; export const DATE_FORMAT_KEY = "date_format"; const DEFAULT_FORMAT = "de"; @@ -26,6 +26,13 @@ export function SettingsProvider({ children }) { } catch { /* Einstellungen sind optional – Standard bleibt bestehen. */ } + try { + // Mehrzahlformen der Gebinde. Sie landen in einer Modulvariablen, weil + // auch Nicht-Komponenten (die Formatierer in units.js) sie brauchen. + setGebindeFormen(await api.listPackageTypes()); + } catch { + /* Ohne Liste bleibt die Einzahl stehen – unschoen, aber nicht falsch. */ + } }, []); useEffect(() => { diff --git a/web/src/units.js b/web/src/units.js index 23976f7..b8e722d 100644 --- a/web/src/units.js +++ b/web/src/units.js @@ -1,5 +1,35 @@ // Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen). +// ---- Gebinde: Einzahl und Mehrzahl ---- +// Der Artikel speichert nur die Einzahl ("Glas"); die Mehrzahl steht in der +// Verwaltung (Tabelle package_types) und wird nach dem Login einmal geladen. +// +// Bewusst eine Modulvariable und kein React-Kontext: Die Formatierer hier sind +// gewöhnliche Funktionen, die auch außerhalb von Komponenten aufgerufen werden. +// Sie alle in Hooks zu verwandeln, hieße jede Aufrufstelle umzubauen, um eine +// Liste durchzureichen, die sich praktisch nie ändert. +let GEBINDE_MEHRZAHL = {}; + +export function setGebindeFormen(liste) { + GEBINDE_MEHRZAHL = Object.fromEntries( + (liste || []).map((t) => [t.singular, t.plural || t.singular]), + ); +} + +/** + * Gebinde-Bezeichnung passend zur Menge: "1 Glas", "3 Gläser". + * + * Unbekannte Bezeichnungen bleiben unverändert – lieber eine fehlende Mehrzahl + * als ein erfundenes "Glass". Nicht ganze Mengen zählen als Mehrzahl + * ("0,5 Gläser"), so wie im Deutschen alles außer der Eins. + */ +export function gebinde(menge, einzahl) { + const wort = einzahl || "Packung"; + if (Math.abs(Number(menge)) === 1) return wort; + return GEBINDE_MEHRZAHL[wort] || wort; +} + + export const BASE_UNITS = [ { value: "piece", label: "Stück" }, { value: "gram", label: "Gramm (g)" }, @@ -173,7 +203,8 @@ export function buildUnitOptions(product, units) { // Erwartet ein Objekt mit quantity, package_size, package_label, unit_name, unit_factor, base_unit. export function articlePrimary(item) { if (item.package_size > 0) { - return `${fmt(item.quantity / item.package_size)} ${item.package_label || "Packung"}`; + const anzahl = item.quantity / item.package_size; + return `${fmt(anzahl)} ${gebinde(anzahl, item.package_label)}`; } return `${fmt(item.quantity / (item.unit_factor || 1))} ${item.unit_name || unitShort(item.base_unit)}`; }