From 0d55d1f8d9545aa2cb5a98020dab4ab8865c03be Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 12:54:47 +0200 Subject: [PATCH] Mindestbestand in jeder angelegten Einheit erfassbar (und gemerkt) - Produktformular: Auswahl aller verwalteten Einheiten passender Art (plus Packungen), inkl. Umrechnung beim Wechsel. - Die gewaehlte Erfassungseinheit wird gespeichert (products.min_stock_unit_id / min_stock_in_packages) und exakt so wieder angezeigt - vorher wurde sie aus der Produkteinheit abgeleitet. - ProductOut liefert min_stock_display + min_stock_unit_label; Produkttabelle zeigt den Mindestbestand damit in der erfassten Einheit mit Einheit dahinter. - Migration: ADD COLUMN IF NOT EXISTS fuer die zwei neuen Spalten. Co-Authored-By: Claude Opus 4.8 --- backend/app/crud.py | 12 +++++++++ backend/app/main.py | 4 +++ backend/app/models.py | 10 ++++++- backend/app/routers/products.py | 4 +++ backend/app/schemas.py | 12 ++++++++- web/src/pages/ProductForm.jsx | 48 ++++++++++++++++++++------------- web/src/pages/Products.jsx | 8 +----- 7 files changed, 70 insertions(+), 28 deletions(-) diff --git a/backend/app/crud.py b/backend/app/crud.py index 3abdccf..09cde4c 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -30,6 +30,18 @@ def product_to_out(db: Session, product: Product) -> ProductOut: name, factor = display_unit_info(product) out.unit_name = name out.unit_factor = factor + + # Mindestbestand in der Einheit anzeigen, in der er erfasst wurde. + if product.min_stock is not None: + if product.min_stock_in_packages and product.package_size: + out.min_stock_display = product.min_stock / product.package_size + out.min_stock_unit_label = "Pkg" + elif product.min_stock_unit is not None: + out.min_stock_display = product.min_stock / product.min_stock_unit.factor + out.min_stock_unit_label = product.min_stock_unit.name + else: + out.min_stock_display = product.min_stock / factor + out.min_stock_unit_label = name return out diff --git a/backend/app/main.py b/backend/app/main.py index ccfb498..071804c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,6 +37,10 @@ def _ensure_schema() -> None: "REFERENCES units(id) ON DELETE SET NULL", "ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER " "REFERENCES units(id) ON DELETE SET NULL", + "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER " + "REFERENCES units(id) ON DELETE SET NULL", + "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN " + "NOT NULL DEFAULT FALSE", ] with engine.begin() as conn: for stmt in stmts: diff --git a/backend/app/models.py b/backend/app/models.py index 9a4dbed..255a29c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -124,13 +124,21 @@ class Product(Base): ForeignKey("groups.id", ondelete="SET NULL"), nullable=True ) min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units + # In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige). + min_stock_unit_id: Mapped[int | None] = mapped_column( + ForeignKey("units.id", ondelete="SET NULL"), nullable=True + ) + min_stock_in_packages: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) group: Mapped[Group | None] = relationship(back_populates="products") - display_unit: Mapped[Unit | None] = relationship() + display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id]) + min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id]) lots: Mapped[list[Lot]] = relationship( back_populates="product", cascade="all, delete-orphan" ) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 18ab98a..1cea035 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -84,6 +84,8 @@ def create_product( package_size=payload.package_size, group_id=payload.group_id, min_stock=payload.min_stock, + min_stock_unit_id=payload.min_stock_unit_id, + min_stock_in_packages=bool(payload.min_stock_in_packages), source="manual", ) db.add(product) @@ -125,6 +127,8 @@ def update_product( product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + if data.get("min_stock_in_packages") is None: + data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL for field, value in data.items(): setattr(product, field, value) db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a584669..2e5b92e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -100,7 +100,10 @@ class ProductBase(BaseModel): unit_id: int | None = None package_size: float | None = Field(default=None, gt=0) group_id: int | None = None - min_stock: float | None = Field(default=None, ge=0) + min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten + # Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde. + min_stock_unit_id: int | None = None + min_stock_in_packages: bool = False class ProductCreate(ProductBase): @@ -117,6 +120,8 @@ class ProductUpdate(BaseModel): package_size: float | None = Field(default=None, gt=0) group_id: int | None = None min_stock: float | None = Field(default=None, ge=0) + min_stock_unit_id: int | None = None + min_stock_in_packages: bool | None = None class ProductOut(BaseModel): @@ -131,6 +136,8 @@ class ProductOut(BaseModel): package_size: float | None group_id: int | None min_stock: float | None + min_stock_unit_id: int | None = None + min_stock_in_packages: bool = False source: str created_at: datetime # angereichert: @@ -139,6 +146,9 @@ class ProductOut(BaseModel): kind: str = "" unit_name: str = "" unit_factor: float = 1.0 + # Mindestbestand in der erfassten Einheit (für die Anzeige): + min_stock_display: float | None = None + min_stock_unit_label: str = "" class LookupResult(BaseModel): diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 84b91db..d7797fe 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -29,8 +29,8 @@ export default function ProductForm() { const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); - // Einheit für die Mindestbestand-Eingabe: "unit" (gewählte Einheit) oder "package". - const [minUnit, setMinUnit] = useState("unit"); + // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. + const [minUnit, setMinUnit] = useState(""); const [warnDays, setWarnDays] = useState(7); const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null; @@ -48,19 +48,20 @@ export default function ProductForm() { } // Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird. - function minFactor(mode, pkgSize, uFactor) { - if (mode === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1; - return uFactor || 1; + // sel ist "package" oder die ID einer verwalteten Einheit. + function minFactor(sel, pkgSize, unitList) { + if (sel === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1; + const u = unitList.find((x) => String(x.id) === String(sel)); + return u ? u.factor : 1; } - function changeMinUnit(newUnit) { - if (form.min_stock !== "" && newUnit !== minUnit) { - const oldF = minFactor(minUnit, form.package_size, unitFactor); - const newF = minFactor(newUnit, form.package_size, unitFactor); - const base = Number(form.min_stock) * oldF; - set("min_stock", String(base / newF)); + function changeMinUnit(newSel) { + if (form.min_stock !== "" && newSel !== minUnit) { + const oldF = minFactor(minUnit, form.package_size, units); + const newF = minFactor(newSel, form.package_size, units); + set("min_stock", String((Number(form.min_stock) * oldF) / newF)); } - setMinUnit(newUnit); + setMinUnit(newSel); } function applySuggestion(s, groupsList, unitList) { @@ -120,9 +121,13 @@ export default function ProductForm() { const uid = p.display_unit_id ? String(p.display_unit_id) : canonicalUnitId(us, p.base_unit); - const uf = us.find((u) => String(u.id) === uid)?.factor || 1; - const mode = p.package_size && p.package_size > 0 ? "package" : "unit"; - const f = mode === "package" ? p.package_size : uf; + // Erfassungseinheit des Mindestbestands wiederherstellen. + const mode = p.min_stock_in_packages + ? "package" + : p.min_stock_unit_id + ? String(p.min_stock_unit_id) + : uid; + const f = minFactor(mode, p.package_size, us); setMinUnit(mode); setForm({ barcode: p.barcode || "", name: p.name, brand: p.brand || "", @@ -148,9 +153,10 @@ export default function ProductForm() { }, [id]); function buildPayload() { + const sel = minUnit || form.unit_id; let minBase = null; if (form.min_stock !== "") { - minBase = Number(form.min_stock) * minFactor(minUnit, form.package_size, unitFactor); + minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units); } return { barcode: form.barcode || null, @@ -160,6 +166,8 @@ export default function ProductForm() { unit_id: form.unit_id === "" ? null : Number(form.unit_id), package_size: form.package_size === "" ? null : Number(form.package_size), min_stock: minBase, + min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel), + min_stock_in_packages: sel === "package", group_id: form.group_id === "" ? null : Number(form.group_id), }; } @@ -257,9 +265,11 @@ export default function ProductForm() {
set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" /> - changeMinUnit(e.target.value)} + disabled={readOnly} style={{ maxWidth: 160, marginTop: 0 }}> + {units + .filter((u) => !selectedUnit || u.kind === selectedUnit.kind) + .map((u) => )} {form.package_size && }
diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index 7a9c8ce..ae3133e 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -99,13 +99,7 @@ export default function Products() { {p.min_stock != null ? ( - <> - {fmt(p.min_stock / (p.unit_factor || 1))}{" "} - {p.unit_name || unitShort(p.base_unit)} - {p.package_size ? ( - · {fmt(p.min_stock / p.package_size)} Pkg - ) : null} - + <>{fmt(p.min_stock_display)} {p.min_stock_unit_label} ) : "–"} Details