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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user