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, groups,
locations, locations,
maintenance, maintenance,
package_types,
products, products,
settings as settings_router, settings as settings_router,
stock, stock,
@@ -23,7 +24,12 @@ from .routers import (
users, users,
views, 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 from .services.group_codes import backfill as backfill_group_codes
settings = get_settings() settings = get_settings()
@@ -69,6 +75,7 @@ async def lifespan(app: FastAPI):
db = SessionLocal() db = SessionLocal()
try: try:
ensure_builtin_units(db) ensure_builtin_units(db)
ensure_builtin_package_types(db)
ensure_builtin_categories(db) ensure_builtin_categories(db)
ensure_first_admin(db) ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen. # Codes bestehender Gruppen-Zuordnungen nachziehen.
@@ -104,6 +111,7 @@ app.include_router(stock.router)
app.include_router(locations.router) app.include_router(locations.router)
app.include_router(groups.router) app.include_router(groups.router)
app.include_router(units.router) app.include_router(units.router)
app.include_router(package_types.router)
app.include_router(views.router) app.include_router(views.router)
app.include_router(transfer.router) app.include_router(transfer.router)
app.include_router(api_tokens.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) 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): class ProductImage(Base):
"""Lokale Kopie des Artikelbilds. """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 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 ---- # ---- Products ----
class ProductBase(BaseModel): class ProductBase(BaseModel):
barcode: str | None = None barcode: str | None = None

View File

@@ -3,7 +3,7 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import get_settings 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 from .security import hash_password
# (Name, Art, Faktor zur kanonischen Basiseinheit) # (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: def ensure_builtin_units(db: Session) -> None:
changed = False changed = False
for name, kind, factor in BUILTIN_UNITS: 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

View File

@@ -11,6 +11,7 @@ import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups"; import Groups from "./pages/Groups";
import Categories from "./pages/Categories"; import Categories from "./pages/Categories";
import Locations from "./pages/Locations"; import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes";
import Units from "./pages/Units"; import Units from "./pages/Units";
import Users from "./pages/Users"; import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList"; import ShoppingList from "./pages/ShoppingList";
@@ -57,6 +58,7 @@ function Sidebar() {
<div className="nav-section">Verwaltung</div> <div className="nav-section">Verwaltung</div>
<NavItem to="/locations" icon="location" label="Lagerorte" /> <NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/units" icon="box" label="Einheiten" /> <NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" />
<NavItem to="/users" icon="users" label="Benutzer" /> <NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/transfer" icon="history" label="Import / Export" /> <NavItem to="/transfer" icon="history" label="Import / Export" />
<NavItem to="/settings" icon="settings" label="Einstellungen" /> <NavItem to="/settings" icon="settings" label="Einstellungen" />
@@ -112,6 +114,7 @@ export default function App() {
<Route path="/history" element={<Protected><History /></Protected>} /> <Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} /> <Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} /> <Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} /> <Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} /> <Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} /> <Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />

View File

@@ -191,6 +191,12 @@ export const api = {
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), 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"), listGroups: () => request("/groups"),
createGroup: (body) => request("/groups", { method: "POST", body }), createGroup: (body) => request("/groups", { method: "POST", body }),
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),

View File

@@ -4,7 +4,7 @@ import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units"; import { buildUnitOptions, fmt, gebinde, isExpired, unitShort } from "../units";
export default function CheckOut() { export default function CheckOut() {
const { formatBestBefore } = useSettings(); const { formatBestBefore } = useSettings();
@@ -98,7 +98,7 @@ export default function CheckOut() {
// Chargenmenge in der Artikeleinheit (Gebinde), sonst in der Produkteinheit. // Chargenmenge in der Artikeleinheit (Gebinde), sonst in der Produkteinheit.
function lotAmount(q) { 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}`; return `${fmt(q / (product?.unit_factor || 1))} ${product?.unit_name || baseShort}`;
} }

View File

@@ -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 (
<div>
<div className="page-head">
<div>
<h1>Gebinde</h1>
<div className="sub">
Bezeichnung eines Artikelgebindes die Mehrzahl wird überall dort
verwendet, wo mehr als eines gemeint ist.
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Einzahl</th><th>Mehrzahl</th><th>Beispiel</th><th></th></tr>
</thead>
<tbody>
{arten.map((art) => {
const offen = bearbeitung?.id === art.id;
return (
<tr key={art.id}>
<td data-label="Einzahl" className="strong">
{offen ? (
<input value={bearbeitung.singular}
onChange={(e) => setBearbeitung({ ...bearbeitung, singular: e.target.value })} />
) : (
<span className="cell-row">
<span>{art.singular}</span>
{art.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
)}
</td>
<td data-label="Mehrzahl">
{offen ? (
<input value={bearbeitung.plural}
onChange={(e) => setBearbeitung({ ...bearbeitung, plural: e.target.value })} />
) : art.plural}
</td>
<td data-label="Beispiel" className="muted">
1 {art.singular} · 3 {art.plural}
</td>
<td className="num">
{offen ? (
<span className="cell-row">
<button type="button" className="btn sm primary" onClick={speichern}>Speichern</button>
<button type="button" className="btn sm ghost" onClick={() => setBearbeitung(null)}>Abbrechen</button>
</span>
) : (
<span className="cell-row">
<button type="button" className="btn-icon" title="Bearbeiten"
onClick={() => setBearbeitung({ ...art })}>
<Icon name="edit" size={16} />
</button>
{!art.is_builtin && (
<button type="button" className="btn-icon danger" title="Löschen"
onClick={() => loeschen(art)}>
<Icon name="trash" size={16} />
</button>
)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<form className="card" onSubmit={anlegen}>
<div className="card-head"><Icon name="package" /><h2>Neues Gebinde</h2></div>
<label>
Einzahl
<input placeholder="z.B. Kanister" value={form.singular} required
onChange={(e) => setForm({ ...form, singular: e.target.value })} />
</label>
<label>
Mehrzahl
<input placeholder="z.B. Kanister" value={form.plural} required
onChange={(e) => setForm({ ...form, plural: e.target.value })} />
<span className="muted small">
Lauten beide gleich, hier dasselbe Wort eintragen.
</span>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form>
</div>
</div>
);
}

View File

@@ -11,7 +11,8 @@ import { useToast } from "../toast";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
import { import {
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort, daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
toMonthInput, unitShort,
} from "../units"; } from "../units";
const EMPTY = { const EMPTY = {
@@ -20,10 +21,7 @@ const EMPTY = {
group_id: "", category_id: "", group_id: "", category_id: "",
}; };
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert). // (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
const PACKAGE_LABELS = [
"Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton", "Riegel", "Rolle",
];
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand). // Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" }; const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
@@ -46,6 +44,7 @@ export default function ProductForm() {
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [gebindearten, setGebindearten] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
@@ -204,12 +203,13 @@ export default function ProductForm() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const [gs, us, cs] = await Promise.all([ const [gs, us, cs, pts] = await Promise.all([
api.listGroups(), api.listUnits(), api.listCategories(), api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(),
]); ]);
setGroups(gs); setGroups(gs);
setUnits(us); setUnits(us);
setCategories(cs); setCategories(cs);
setGebindearten(pts);
try { try {
const s = await api.listSettings(); const s = await api.listSettings();
const row = s.find((x) => x.key === "expiry_warning_days"); const row = s.find((x) => x.key === "expiry_warning_days");
@@ -415,12 +415,18 @@ export default function ProductForm() {
</div> </div>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
Bezeichnung der Einheit <span className="tip" title="Verwaltet unter Verwaltung → Gebinde, dort auch mit Mehrzahl.">
Gebinde
</span>
<select value={form.package_label} onChange={(e) => set("package_label", e.target.value)} <select value={form.package_label} onChange={(e) => set("package_label", e.target.value)}
disabled={readOnly}> disabled={readOnly}>
<option value="">Packung (Standard)</option> <option value="">Packung (Standard)</option>
{PACKAGE_LABELS.map((l) => <option key={l} value={l}>{l}</option>)} {gebindearten.map((g) => (
{form.package_label && !PACKAGE_LABELS.includes(form.package_label) && ( <option key={g.id} value={g.singular}>{g.singular} / {g.plural}</option>
))}
{/* Ein Artikel kann eine Bezeichnung tragen, die es in der
Verwaltung (noch) nicht gibt etwa aus einem Import. */}
{form.package_label && !gebindearten.some((g) => g.singular === form.package_label) && (
<option value={form.package_label}>{form.package_label}</option> <option value={form.package_label}>{form.package_label}</option>
)} )}
</select> </select>
@@ -555,7 +561,9 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onEr
// Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist // Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist
// sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein. // sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein.
const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor; const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor;
const primaryLabel = pkgSize > 0 ? pkgLabel : unitName; // Mengenabhaengig: "1 Glas", aber "3 Gläser". Einheiten (Gramm, Liter)
// bleiben unveraendert - die haben im Deutschen keine Mehrzahl.
const primaryLabel = (menge) => (pkgSize > 0 ? gebinde(menge, pkgLabel) : unitName);
function secondary(qty) { function secondary(qty) {
if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`; if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`;
@@ -667,7 +675,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onEr
</div> </div>
) : ( ) : (
<> <>
{fmt(l.quantity / primaryFactor)} {primaryLabel} {fmt(l.quantity / primaryFactor)} {primaryLabel(l.quantity / primaryFactor)}
{secondary(l.quantity) && ( {secondary(l.quantity) && (
<div className="muted small">{secondary(l.quantity)}</div> <div className="muted small">{secondary(l.quantity)}</div>
)} )}

View File

@@ -4,16 +4,17 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { fmt, unitShort } from "../units"; import { fmt, gebinde, unitShort } from "../units";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
function unitCount(p) { function unitCount(p) {
return p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1); 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 return p.package_size && p.package_size > 0
? (p.package_label || "Packung") ? gebinde(menge, p.package_label)
: (p.unit_name || unitShort(p.base_unit)); : (p.unit_name || unitShort(p.base_unit));
} }
@@ -124,7 +125,7 @@ export default function Products() {
<span> <span>
{fmt(p.stock / unitCount(p))} {fmt(p.stock / unitCount(p))}
{p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "} {p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "}
{countLabel(p)} {countLabel(p, p.stock / unitCount(p))}
</span> </span>
{low && <span className="badge warn">niedrig</span>} {low && <span className="badge warn">niedrig</span>}
</span> </span>

View File

@@ -1,7 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { api } from "./api"; import { api } from "./api";
import { useAuth } from "./auth"; import { useAuth } from "./auth";
import { formatBestBefore, formatDate } from "./units"; import { formatBestBefore, formatDate, setGebindeFormen } from "./units";
export const DATE_FORMAT_KEY = "date_format"; export const DATE_FORMAT_KEY = "date_format";
const DEFAULT_FORMAT = "de"; const DEFAULT_FORMAT = "de";
@@ -26,6 +26,13 @@ export function SettingsProvider({ children }) {
} catch { } catch {
/* Einstellungen sind optional Standard bleibt bestehen. */ /* 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(() => { useEffect(() => {

View File

@@ -1,5 +1,35 @@
// Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen). // 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 = [ export const BASE_UNITS = [
{ value: "piece", label: "Stück" }, { value: "piece", label: "Stück" },
{ value: "gram", label: "Gramm (g)" }, { 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. // Erwartet ein Objekt mit quantity, package_size, package_label, unit_name, unit_factor, base_unit.
export function articlePrimary(item) { export function articlePrimary(item) {
if (item.package_size > 0) { 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)}`; return `${fmt(item.quantity / (item.unit_factor || 1))} ${item.unit_name || unitShort(item.base_unit)}`;
} }