From 28785b0a10c27321c85e65425945c52cfd33441d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 7 Aug 2026 18:31:30 +0200 Subject: [PATCH] =?UTF-8?q?Mindestbest=C3=A4nde:=20Einheit=20je=20Produkt?= =?UTF-8?q?=20umschaltbar=20(g/ml=20<->=20Gebinde)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Produkte mit Packungsgröße lassen sich auf der Mindestbestandsseite nun wahlweise in ihrer Gebinde-Einheit (Glas/Dose) statt nur in g/ml erfassen - Auswahl in der Spalte 'Einheit'. Mindestbestand UND Bestand werden dann in Packungen angezeigt; der Hinzufügen-Dialog hat den Umschalter ebenfalls. Gespeicherte Werte bleiben physisch gleich (nur Anzeige/Erfassung wechselt), das Backend-Flag min_stock_in_packages steuert die Umrechnung. Co-Authored-By: Claude Opus 4.8 --- web/src/pages/MinStock.jsx | 114 ++++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/web/src/pages/MinStock.jsx b/web/src/pages/MinStock.jsx index e6d26c3..308f1d6 100644 --- a/web/src/pages/MinStock.jsx +++ b/web/src/pages/MinStock.jsx @@ -7,7 +7,7 @@ import DataTable from "../components/DataTable"; import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; import { categoryInfoMap } from "../categoryPath"; import { locationOptions, locationPathById } from "../locationPath"; -import { fmt } from "../units"; +import { fmt, gebinde } from "../units"; // Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm, // Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine @@ -21,13 +21,21 @@ const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true; // Artikeleinheit (Packung, sonst Anzeigeeinheit): nur noch nötig, um die je-Lagerort- // Mindestbestände umzurechnen, die in Artikeleinheiten gespeichert sind. const articleUnit = (p) => (p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1)); +// Gebinde-Umschaltung: hat das Produkt eine Packungsgröße, lässt sich der +// Mindestbestand wahlweise in Packungen (Glas/Dose) statt in g/ml erfassen. +const hasPkg = (p) => !!(p.package_size && p.package_size > 0); +const pkgMode = (p) => hasPkg(p) && !!p.min_stock_in_packages; +// Basiseinheiten je gewählter Erfassungseinheit (Packung ODER Anzeigeeinheit). +const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p)); +// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser). +const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p)); /** * Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die * Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen- * Formulare um einen Überblick an einem Ort. */ -const EMPTY_DRAFT = { kind: "product", targetId: "", scope: "global", locId: "", menge: "" }; +const EMPTY_DRAFT = { kind: "product", targetId: "", scope: "global", locId: "", menge: "", pkg: false }; export default function MinStock() { const { isAdmin } = useAuth(); @@ -58,24 +66,27 @@ export default function MinStock() { const rows = useMemo(() => { const out = []; for (const p of products.filter(canHaveMin)) { + const gSoll = p.min_stock != null ? p.min_stock / effFactor(p) : null; out.push({ key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p, name: p.name, catId: p.category_id, ort: "(Gesamt)", - // Alles in der Anzeigeeinheit (Gramm/Milliliter/Stück): min_stock ist in - // Basiseinheiten gespeichert. - soll: p.min_stock != null ? p.min_stock / dispFactor(p) : null, - unit: dispLabel(p), - bestand: p.stock / dispFactor(p), bestandUnit: dispLabel(p), + // In der gewählten Einheit: Anzeigeeinheit (g/ml) ODER Packung. min_stock + // ist in Basiseinheiten gespeichert. + soll: gSoll, + unit: effUnit(p, gSoll ?? 1), + bestand: p.stock / effFactor(p), bestandUnit: effUnit(p, p.stock / effFactor(p)), }); for (const l of (p.location_min_stocks || [])) { + // je-Lagerort ist in Artikeleinheiten gespeichert → in die gewählte Einheit. + const lSoll = (l.min_stock * articleUnit(p)) / effFactor(p); out.push({ key: `p:${p.id}:${l.location_id}`, kind: "product", scope: "loc", entity: p, locId: l.location_id, name: p.name, catId: p.category_id, ort: locationPathById(l.location_id, locations) || "?", - // je-Lagerort ist in Artikeleinheiten gespeichert → in Anzeigeeinheit umrechnen. - soll: (l.min_stock * articleUnit(p)) / dispFactor(p), unit: dispLabel(p), - // Bestand kommt in Basiseinheiten (wie der Gesamt-Bestand) → in Anzeigeeinheit. - bestand: l.stock != null ? l.stock / dispFactor(p) : null, bestandUnit: dispLabel(p), + soll: lSoll, unit: effUnit(p, lSoll), + // Bestand kommt in Basiseinheiten (wie der Gesamt-Bestand) → in die Einheit. + bestand: l.stock != null ? l.stock / effFactor(p) : null, + bestandUnit: effUnit(p, l.stock != null ? l.stock / effFactor(p) : 1), }); } } @@ -118,16 +129,16 @@ export default function MinStock() { try { if (row.kind === "product" && row.scope === "global") { const p = row.entity; - // Eingabe in der Anzeigeeinheit → Basiseinheiten speichern. + // Eingabe in der gewählten Einheit (Anzeigeeinheit ODER Packung) → Basiseinheiten. await api.updateProduct(p.id, { - min_stock: num == null ? null : Math.round(num * dispFactor(p)), - min_stock_in_packages: false, - min_stock_unit_id: p.display_unit_id ?? null, + min_stock: num == null ? null : Math.round(num * effFactor(p)), + min_stock_in_packages: pkgMode(p), + min_stock_unit_id: pkgMode(p) ? null : (p.display_unit_id ?? null), }); } else if (row.kind === "product" && row.scope === "loc") { const p = row.entity; - // Eingabe in der Anzeigeeinheit → Artikeleinheiten (so gespeichert). - const artikel = num == null ? null : (num * dispFactor(p)) / articleUnit(p); + // Eingabe in der gewählten Einheit → Artikeleinheiten (so gespeichert). + const artikel = num == null ? null : (num * effFactor(p)) / articleUnit(p); await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, row.locId, artikel)); } else if (row.kind === "group" && row.scope === "global") { await api.updateGroup(row.entity.id, { min_stock: num }); @@ -138,6 +149,18 @@ export default function MinStock() { } catch (err) { setError(err.message); } } + // Einheit des Mindestbestands umschalten (Anzeigeeinheit ↔ Packung). Der + // gespeicherte Wert bleibt physisch gleich (global in Basiseinheiten, je Ort in + // Artikeleinheiten) – es wechselt nur, in welcher Einheit angezeigt/erfasst wird. + async function toggleProductUnit(p, wantPkg) { + if (wantPkg === pkgMode(p)) return; + setError(null); + try { + await api.updateProduct(p.id, { min_stock_in_packages: wantPkg }); + await load(); + } catch (err) { setError(err.message); } + } + // Mindestbestand ganz entfernen: Gesamt-Wert auf NULL, je-Lagerort-Eintrag raus. // Das Feld leeren tut dasselbe – aber ein eigener Knopf macht das Löschen // eindeutig (und bei Gesamt-Werten sichtbar, da die Zeile sonst bestehen bleibt). @@ -153,8 +176,8 @@ export default function MinStock() { try { if (row.kind === "product" && row.scope === "global") { await api.updateProduct(row.entity.id, { - min_stock: null, min_stock_in_packages: false, - min_stock_unit_id: row.entity.display_unit_id ?? null, + min_stock: null, min_stock_in_packages: pkgMode(row.entity), + min_stock_unit_id: pkgMode(row.entity) ? null : (row.entity.display_unit_id ?? null), }); } else if (row.kind === "product" && row.scope === "loc") { await api.setProductLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, null)); @@ -189,16 +212,16 @@ export default function MinStock() { try { if (row.kind === "product") { const p = ent; - const artikel = (row.soll * dispFactor(p)) / articleUnit(p); + const artikel = (row.soll * effFactor(p)) / articleUnit(p); let liste = p.location_min_stocks || []; if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein // Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt). if (row.scope === "global" || !ziel) { await api.updateProduct(p.id, { - min_stock: ziel ? null : Math.round(row.soll * dispFactor(p)), - min_stock_in_packages: false, - min_stock_unit_id: p.display_unit_id ?? null, + min_stock: ziel ? null : Math.round(row.soll * effFactor(p)), + min_stock_in_packages: pkgMode(p), + min_stock_unit_id: pkgMode(p) ? null : (p.display_unit_id ?? null), }); } if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste); @@ -220,8 +243,9 @@ export default function MinStock() { const draftTarget = draft.kind === "product" ? products.find((p) => String(p.id) === String(draft.targetId)) : groups.find((g) => String(g.id) === String(draft.targetId)); + const draftUsePkg = draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg; const draftUnit = draft.kind === "product" - ? (draftTarget ? dispLabel(draftTarget) : "") + ? (draftTarget ? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget)) : "") : (draftTarget ? (draftTarget.min_stock_unit_name || "Stück") : ""); function resetAdd() { @@ -239,16 +263,16 @@ export default function MinStock() { try { if (draft.kind === "product") { const p = draftTarget; + // In der gewählten Einheit erfasst (Anzeigeeinheit ODER Packung). + const f = draftUsePkg ? p.package_size : dispFactor(p); if (draft.scope === "global") { - // Menge in der Anzeigeeinheit erfasst → in Basiseinheiten speichern. await api.updateProduct(p.id, { - min_stock: Math.round(num * dispFactor(p)), - min_stock_in_packages: false, - min_stock_unit_id: p.display_unit_id ?? null, + min_stock: Math.round(num * f), + min_stock_in_packages: draftUsePkg, + min_stock_unit_id: draftUsePkg ? null : (p.display_unit_id ?? null), }); } else { - // Anzeigeeinheit → Artikeleinheiten (so gespeichert). - const artikel = (num * dispFactor(p)) / articleUnit(p); + const artikel = (num * f) / articleUnit(p); await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, draft.locId, artikel)); } } else { @@ -306,8 +330,21 @@ export default function MinStock() { )} ) : (r.soll != null ? fmt(r.soll) : "–")) }, - { key: "unit", header: "Einheit", width: 120, filterText: (r) => r.unit || "", - render: (r) => {r.unit || "–"} }, + { key: "unit", header: "Einheit", width: 140, filterText: (r) => r.unit || "", + render: (r) => { + // Produkte mit Packung: Einheit umschaltbar (g/ml ↔ Glas/Dose). + if (isAdmin && r.kind === "product" && hasPkg(r.entity)) { + return ( + + ); + } + return {r.unit || "–"}; + } }, { key: "bestand", header: "Bestand", width: 120, align: "num", render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : ) }, ]; @@ -360,6 +397,19 @@ export default function MinStock() { + {draft.kind === "product" && draftTarget && hasPkg(draftTarget) && ( +
+ +
+ )}