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 { 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() {
)}
</div>
) : (r.soll != null ? fmt(r.soll) : "")) },
{ key: "unit", header: "Einheit", width: 120, filterText: (r) => r.unit || "",
render: (r) => <span className="muted">{r.unit || ""}</span> },
{ 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 (
<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",
render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : <span className="muted"></span>) },
];
@@ -360,6 +397,19 @@ export default function MinStock() {
</select>
</label>
</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">
<label className="seg-label">
Geltung