Mindestbestände: Einheit je Produkt umschaltbar (g/ml <-> Gebinde)

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-07 18:31:30 +02:00
parent f2c657ff31
commit 28785b0a10

View File

@@ -7,7 +7,7 @@ import DataTable from "../components/DataTable";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath"; import { categoryInfoMap } from "../categoryPath";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { fmt } from "../units"; import { fmt, gebinde } from "../units";
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm, // Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
// Milliliter, Stück) NICHT die Packung. „1 Packung" ist nichtssagend, weil eine // 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- // Artikeleinheit (Packung, sonst Anzeigeeinheit): nur noch nötig, um die je-Lagerort-
// Mindestbestände umzurechnen, die in Artikeleinheiten gespeichert sind. // 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)); 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 * Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die
* Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen- * Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen-
* Formulare um einen Überblick an einem Ort. * 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() { export default function MinStock() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@@ -58,24 +66,27 @@ export default function MinStock() {
const rows = useMemo(() => { const rows = useMemo(() => {
const out = []; const out = [];
for (const p of products.filter(canHaveMin)) { for (const p of products.filter(canHaveMin)) {
const gSoll = p.min_stock != null ? p.min_stock / effFactor(p) : null;
out.push({ out.push({
key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p, key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p,
name: p.name, catId: p.category_id, ort: "(Gesamt)", name: p.name, catId: p.category_id, ort: "(Gesamt)",
// Alles in der Anzeigeeinheit (Gramm/Milliliter/Stück): min_stock ist in // In der gewählten Einheit: Anzeigeeinheit (g/ml) ODER Packung. min_stock
// Basiseinheiten gespeichert. // ist in Basiseinheiten gespeichert.
soll: p.min_stock != null ? p.min_stock / dispFactor(p) : null, soll: gSoll,
unit: dispLabel(p), unit: effUnit(p, gSoll ?? 1),
bestand: p.stock / dispFactor(p), bestandUnit: dispLabel(p), bestand: p.stock / effFactor(p), bestandUnit: effUnit(p, p.stock / effFactor(p)),
}); });
for (const l of (p.location_min_stocks || [])) { 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({ out.push({
key: `p:${p.id}:${l.location_id}`, kind: "product", scope: "loc", entity: p, key: `p:${p.id}:${l.location_id}`, kind: "product", scope: "loc", entity: p,
locId: l.location_id, name: p.name, catId: p.category_id, locId: l.location_id, name: p.name, catId: p.category_id,
ort: locationPathById(l.location_id, locations) || "?", ort: locationPathById(l.location_id, locations) || "?",
// je-Lagerort ist in Artikeleinheiten gespeichert → in Anzeigeeinheit umrechnen. soll: lSoll, unit: effUnit(p, lSoll),
soll: (l.min_stock * articleUnit(p)) / dispFactor(p), unit: dispLabel(p), // Bestand kommt in Basiseinheiten (wie der Gesamt-Bestand) → in die Einheit.
// Bestand kommt in Basiseinheiten (wie der Gesamt-Bestand) → in Anzeigeeinheit. bestand: l.stock != null ? l.stock / effFactor(p) : null,
bestand: l.stock != null ? l.stock / dispFactor(p) : null, bestandUnit: dispLabel(p), bestandUnit: effUnit(p, l.stock != null ? l.stock / effFactor(p) : 1),
}); });
} }
} }
@@ -118,16 +129,16 @@ export default function MinStock() {
try { try {
if (row.kind === "product" && row.scope === "global") { if (row.kind === "product" && row.scope === "global") {
const p = row.entity; 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, { await api.updateProduct(p.id, {
min_stock: num == null ? null : Math.round(num * dispFactor(p)), min_stock: num == null ? null : Math.round(num * effFactor(p)),
min_stock_in_packages: false, min_stock_in_packages: pkgMode(p),
min_stock_unit_id: p.display_unit_id ?? null, min_stock_unit_id: pkgMode(p) ? null : (p.display_unit_id ?? null),
}); });
} else if (row.kind === "product" && row.scope === "loc") { } else if (row.kind === "product" && row.scope === "loc") {
const p = row.entity; const p = row.entity;
// Eingabe in der Anzeigeeinheit → Artikeleinheiten (so gespeichert). // Eingabe in der gewählten Einheit → Artikeleinheiten (so gespeichert).
const artikel = num == null ? null : (num * dispFactor(p)) / articleUnit(p); const artikel = num == null ? null : (num * effFactor(p)) / articleUnit(p);
await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, row.locId, artikel)); await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, row.locId, artikel));
} else if (row.kind === "group" && row.scope === "global") { } else if (row.kind === "group" && row.scope === "global") {
await api.updateGroup(row.entity.id, { min_stock: num }); await api.updateGroup(row.entity.id, { min_stock: num });
@@ -138,6 +149,18 @@ export default function MinStock() {
} catch (err) { setError(err.message); } } 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. // Mindestbestand ganz entfernen: Gesamt-Wert auf NULL, je-Lagerort-Eintrag raus.
// Das Feld leeren tut dasselbe aber ein eigener Knopf macht das Löschen // 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). // eindeutig (und bei Gesamt-Werten sichtbar, da die Zeile sonst bestehen bleibt).
@@ -153,8 +176,8 @@ export default function MinStock() {
try { try {
if (row.kind === "product" && row.scope === "global") { if (row.kind === "product" && row.scope === "global") {
await api.updateProduct(row.entity.id, { await api.updateProduct(row.entity.id, {
min_stock: null, min_stock_in_packages: false, min_stock: null, min_stock_in_packages: pkgMode(row.entity),
min_stock_unit_id: row.entity.display_unit_id ?? null, min_stock_unit_id: pkgMode(row.entity) ? null : (row.entity.display_unit_id ?? null),
}); });
} else if (row.kind === "product" && row.scope === "loc") { } else if (row.kind === "product" && row.scope === "loc") {
await api.setProductLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, null)); await api.setProductLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, null));
@@ -189,16 +212,16 @@ export default function MinStock() {
try { try {
if (row.kind === "product") { if (row.kind === "product") {
const p = ent; 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 || []; let liste = p.location_min_stocks || [];
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus
if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein
// Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt). // Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt).
if (row.scope === "global" || !ziel) { if (row.scope === "global" || !ziel) {
await api.updateProduct(p.id, { await api.updateProduct(p.id, {
min_stock: ziel ? null : Math.round(row.soll * dispFactor(p)), min_stock: ziel ? null : Math.round(row.soll * effFactor(p)),
min_stock_in_packages: false, min_stock_in_packages: pkgMode(p),
min_stock_unit_id: p.display_unit_id ?? null, min_stock_unit_id: pkgMode(p) ? null : (p.display_unit_id ?? null),
}); });
} }
if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste); if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste);
@@ -220,8 +243,9 @@ export default function MinStock() {
const draftTarget = draft.kind === "product" const draftTarget = draft.kind === "product"
? products.find((p) => String(p.id) === String(draft.targetId)) ? products.find((p) => String(p.id) === String(draft.targetId))
: groups.find((g) => String(g.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" const draftUnit = draft.kind === "product"
? (draftTarget ? dispLabel(draftTarget) : "") ? (draftTarget ? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget)) : "")
: (draftTarget ? (draftTarget.min_stock_unit_name || "Stück") : ""); : (draftTarget ? (draftTarget.min_stock_unit_name || "Stück") : "");
function resetAdd() { function resetAdd() {
@@ -239,16 +263,16 @@ export default function MinStock() {
try { try {
if (draft.kind === "product") { if (draft.kind === "product") {
const p = draftTarget; const p = draftTarget;
// In der gewählten Einheit erfasst (Anzeigeeinheit ODER Packung).
const f = draftUsePkg ? p.package_size : dispFactor(p);
if (draft.scope === "global") { if (draft.scope === "global") {
// Menge in der Anzeigeeinheit erfasst → in Basiseinheiten speichern.
await api.updateProduct(p.id, { await api.updateProduct(p.id, {
min_stock: Math.round(num * dispFactor(p)), min_stock: Math.round(num * f),
min_stock_in_packages: false, min_stock_in_packages: draftUsePkg,
min_stock_unit_id: p.display_unit_id ?? null, min_stock_unit_id: draftUsePkg ? null : (p.display_unit_id ?? null),
}); });
} else { } else {
// Anzeigeeinheit → Artikeleinheiten (so gespeichert). const artikel = (num * f) / articleUnit(p);
const artikel = (num * dispFactor(p)) / articleUnit(p);
await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, draft.locId, artikel)); await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, draft.locId, artikel));
} }
} else { } else {
@@ -306,8 +330,21 @@ export default function MinStock() {
)} )}
</div> </div>
) : (r.soll != null ? fmt(r.soll) : "")) }, ) : (r.soll != null ? fmt(r.soll) : "")) },
{ key: "unit", header: "Einheit", width: 120, filterText: (r) => r.unit || "", { key: "unit", header: "Einheit", width: 140, filterText: (r) => r.unit || "",
render: (r) => <span className="muted">{r.unit || ""}</span> }, render: (r) => {
// Produkte mit Packung: Einheit umschaltbar (g/ml ↔ Glas/Dose).
if (isAdmin && r.kind === "product" && hasPkg(r.entity)) {
return (
<select value={pkgMode(r.entity) ? "pkg" : "disp"} style={{ marginTop: 0, minWidth: 110 }}
title="Einheit für den Mindestbestand umschalten"
onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}>
<option value="disp">{dispLabel(r.entity)}</option>
<option value="pkg">{r.entity.package_label || "Packung"}</option>
</select>
);
}
return <span className="muted">{r.unit || ""}</span>;
} },
{ key: "bestand", header: "Bestand", width: 120, align: "num", { key: "bestand", header: "Bestand", width: 120, align: "num",
render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : <span className="muted"></span>) }, render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : <span className="muted"></span>) },
]; ];
@@ -360,6 +397,19 @@ export default function MinStock() {
</select> </select>
</label> </label>
</div> </div>
{draft.kind === "product" && draftTarget && hasPkg(draftTarget) && (
<div className="row">
<label className="seg-label">
Einheit
<div className="segmented">
<button type="button" className={!draft.pkg ? "active" : ""}
onClick={() => setDraft({ ...draft, pkg: false })}>{dispLabel(draftTarget)}</button>
<button type="button" className={draft.pkg ? "active" : ""}
onClick={() => setDraft({ ...draft, pkg: true })}>{draftTarget.package_label || "Packung"}</button>
</div>
</label>
</div>
)}
<div className="row"> <div className="row">
<label className="seg-label"> <label className="seg-label">
Geltung Geltung