Web: Zweiteinheit erfassen und anzeigen
Im Artikel-Formular eine Zeile "3 [Stk] ≙ 250 [Gramm]" mit ausgerechneter
Kontrolle darunter ("1 Stk ≈ 83,33 g"). Links steht die Basiseinheit des
Artikels fest, rechts stehen nur die anderen Arten zur Wahl - eine Bruecke auf
die eigene Art haette nichts umzurechnen. Nur vollstaendig ausgefuellt wird sie
gespeichert; Gegenstaende ohne Charge tragen keine.
buildUnitOptions laesst bei hinterlegter Bruecke auch die Einheiten der Gegenart
zu - damit erben Ein- und Auslagern die Faehigkeit ohne eigene Logik. Beim
Auslagern steht der Bestand jetzt in beiden Lesarten ("6 Stueck · 500 g").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import Icon from "../components/Icon";
|
|||||||
import { ProduktThumb } from "../components/ProduktBild";
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort } from "../units";
|
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort, zweitText } from "../units";
|
||||||
|
|
||||||
export default function CheckOut() {
|
export default function CheckOut() {
|
||||||
const { formatBestBefore } = useSettings();
|
const { formatBestBefore } = useSettings();
|
||||||
@@ -149,6 +149,10 @@ export default function CheckOut() {
|
|||||||
<div className="title">{product.name}</div>
|
<div className="title">{product.name}</div>
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
||||||
|
{/* Zweite Lesart, wenn eine Zweiteinheit hinterlegt ist. */}
|
||||||
|
{zweitText(product.stock || 0, product) && (
|
||||||
|
<> · {zweitText(product.stock || 0, product)}</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ import Einzelstuecke from "../components/Einzelstuecke";
|
|||||||
import SplitLotDialog from "../components/SplitLotDialog";
|
import SplitLotDialog from "../components/SplitLotDialog";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
import {
|
import {
|
||||||
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
|
BASE_UNITS, daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired,
|
||||||
toMonthInput, unitShort,
|
relativeExpiry, toMonthInput, unitShort, zweitFaktor,
|
||||||
} from "../units";
|
} from "../units";
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
barcode: "", name: "", brand: "", image_url: "",
|
barcode: "", name: "", brand: "", image_url: "",
|
||||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
||||||
group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, bulk: false,
|
group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, bulk: false,
|
||||||
|
secondary_base: "", secondary_count: "", secondary_amount: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
|
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
|
||||||
@@ -429,6 +430,10 @@ export default function ProductForm() {
|
|||||||
product_url: p.product_url || "",
|
product_url: p.product_url || "",
|
||||||
individual: Boolean(p.individual),
|
individual: Boolean(p.individual),
|
||||||
bulk: Boolean(p.bulk),
|
bulk: Boolean(p.bulk),
|
||||||
|
// Zweiteinheit so, wie sie eingegeben wurde („3 ≙ 250").
|
||||||
|
secondary_base: p.secondary_base || "",
|
||||||
|
secondary_count: p.secondary_count ?? "",
|
||||||
|
secondary_amount: p.secondary_amount ?? "",
|
||||||
});
|
});
|
||||||
setFieldValues(p.field_values || {});
|
setFieldValues(p.field_values || {});
|
||||||
setModus(p.tracking === "object" ? "object" : "food");
|
setModus(p.tracking === "object" ? "object" : "food");
|
||||||
@@ -500,6 +505,14 @@ export default function ProductForm() {
|
|||||||
package_label: form.package_label.trim() || null,
|
package_label: form.package_label.trim() || null,
|
||||||
date_precision: form.date_precision || "day",
|
date_precision: form.date_precision || "day",
|
||||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||||
|
// Zweiteinheit nur vollstaendig – eine halbe Angabe waere keine Bruecke.
|
||||||
|
...(form.secondary_base && form.secondary_count !== "" && form.secondary_amount !== ""
|
||||||
|
? {
|
||||||
|
secondary_base: form.secondary_base,
|
||||||
|
secondary_count: Number(form.secondary_count),
|
||||||
|
secondary_amount: Number(form.secondary_amount),
|
||||||
|
}
|
||||||
|
: { secondary_base: null }),
|
||||||
...minStock,
|
...minStock,
|
||||||
...(isObject ? { field_values: fieldPayload } : {}),
|
...(isObject ? { field_values: fieldPayload } : {}),
|
||||||
};
|
};
|
||||||
@@ -517,6 +530,8 @@ export default function ProductForm() {
|
|||||||
min_stock: null,
|
min_stock: null,
|
||||||
min_stock_unit_id: null,
|
min_stock_unit_id: null,
|
||||||
min_stock_in_packages: false,
|
min_stock_in_packages: false,
|
||||||
|
// Zweiteinheit gibt es nur bei Charge-Artikeln.
|
||||||
|
secondary_base: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +588,20 @@ export default function ProductForm() {
|
|||||||
|
|
||||||
const readOnly = !isAdmin;
|
const readOnly = !isAdmin;
|
||||||
const BASE_SHORT_OF_KIND = { count: "Stk", weight: "g", volume: "ml" };
|
const BASE_SHORT_OF_KIND = { count: "Stk", weight: "g", volume: "ml" };
|
||||||
|
// Basiseinheit je Art – die eigene Art fliegt aus der Zweiteinheit-Auswahl.
|
||||||
|
const BASE_OF_KIND = { count: "piece", weight: "gram", volume: "milliliter" };
|
||||||
|
// Ausgerechneter Wert als Kontrolle: „1 Stk ≈ 83,33 g".
|
||||||
|
const zweitHinweis = (() => {
|
||||||
|
const f = zweitFaktor({
|
||||||
|
secondary_base: form.secondary_base,
|
||||||
|
secondary_count: form.secondary_count,
|
||||||
|
secondary_amount: form.secondary_amount,
|
||||||
|
});
|
||||||
|
if (!f) return "";
|
||||||
|
return `1 ${BASE_SHORT_OF_KIND[selectedUnit?.kind] || "Einheit"} \u2248 `
|
||||||
|
+ `${fmt(f)} ${unitShort(form.secondary_base)}. `
|
||||||
|
+ "Bestände bleiben beim Ändern unverändert – nur ihre Umrechnung verschiebt sich.";
|
||||||
|
})();
|
||||||
const baseShort = selectedUnit
|
const baseShort = selectedUnit
|
||||||
? BASE_SHORT_OF_KIND[selectedUnit.kind]
|
? BASE_SHORT_OF_KIND[selectedUnit.kind]
|
||||||
: product ? unitShort(product.base_unit) : "";
|
: product ? unitShort(product.base_unit) : "";
|
||||||
@@ -819,6 +848,36 @@ export default function ProductForm() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Zweiteinheit: dieselbe Ware in der ANDEREN Art lesbar machen.
|
||||||
|
Links steht die Basiseinheit des Artikels fest, rechts die
|
||||||
|
Gegenart. Ohne Eintrag bleibt alles wie bisher getrennt. */}
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span className="tip" title="Zusätzliche Lesart desselben Artikels. Damit zählt er auch in Gruppen der anderen Art und lässt sich darin ein- und auslagern. Das Gebinde bleibt davon unberührt.">
|
||||||
|
Zweiteinheit (optional)
|
||||||
|
</span>
|
||||||
|
<div className="field-inline">
|
||||||
|
<input type="number" step="any" min="0" style={{ maxWidth: 90 }}
|
||||||
|
placeholder="3" value={form.secondary_count} disabled={readOnly}
|
||||||
|
onChange={(e) => set("secondary_count", e.target.value)} />
|
||||||
|
<span className="muted small" style={{ alignSelf: "center" }}>
|
||||||
|
{baseShort || "Einheiten"} ≙
|
||||||
|
</span>
|
||||||
|
<input type="number" step="any" min="0" style={{ maxWidth: 90 }}
|
||||||
|
placeholder="250" value={form.secondary_amount} disabled={readOnly}
|
||||||
|
onChange={(e) => set("secondary_amount", e.target.value)} />
|
||||||
|
<select value={form.secondary_base} disabled={readOnly}
|
||||||
|
style={{ marginTop: 0, maxWidth: 150 }}
|
||||||
|
onChange={(e) => set("secondary_base", e.target.value)}>
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{BASE_UNITS
|
||||||
|
.filter((b) => !selectedUnit || b.value !== BASE_OF_KIND[selectedUnit.kind])
|
||||||
|
.map((b) => <option key={b.value} value={b.value}>{b.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{zweitHinweis && <p className="muted small mt-0">{zweitHinweis}</p>}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{/* Beim Anlegen gibt es den Artikel noch nicht, also auch keine
|
{/* Beim Anlegen gibt es den Artikel noch nicht, also auch keine
|
||||||
Ort-Liste – hier bleibt das eine Feld, das als „Überall“ landet.
|
Ort-Liste – hier bleibt das eine Feld, das als „Überall“ landet.
|
||||||
|
|||||||
@@ -54,6 +54,37 @@ export function kindShort(kind) {
|
|||||||
return KIND_SHORT[kind] || "";
|
return KIND_SHORT[kind] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einheiten-ART je Basiseinheit – Spiegelbild von conversion.py::KIND_OF_BASE.
|
||||||
|
export const KIND_OF_BASE = { piece: "count", gram: "weight", milliliter: "volume" };
|
||||||
|
|
||||||
|
// ---- Zweiteinheit: die Brücke zwischen den Einheiten-Arten ----
|
||||||
|
// Ein Artikel darf zusätzlich zu seiner Basiseinheit eine Äquivalenz tragen:
|
||||||
|
// „3 Stück ≙ 250 g". Der Server liefert den ausgerechneten Faktor als
|
||||||
|
// `secondary_factor` mit; das Paar dahinter bleibt zur Anzeige erhalten.
|
||||||
|
|
||||||
|
/** Zweit-Basiseinheiten je EINER Basiseinheit. 0 = keine Brücke hinterlegt. */
|
||||||
|
export function zweitFaktor(p) {
|
||||||
|
if (!p?.secondary_base) return 0;
|
||||||
|
const f = Number(p.secondary_factor);
|
||||||
|
if (f > 0) return f;
|
||||||
|
// Rückfall, falls das abgeleitete Feld fehlt (ältere Antwort).
|
||||||
|
const n = Number(p.secondary_count);
|
||||||
|
const m = Number(p.secondary_amount);
|
||||||
|
return n > 0 && m > 0 ? m / n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Menge (Basiseinheiten) in der Zweiteinheit lesen: „500 g". */
|
||||||
|
export function zweitText(mengeBase, p) {
|
||||||
|
const f = zweitFaktor(p);
|
||||||
|
return f ? `${fmt(mengeBase * f)} ${unitShort(p.secondary_base)}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bestand mit beiden Lesarten: „6 Stück · 500 g". */
|
||||||
|
export function stockLabelMitZweit(p) {
|
||||||
|
const zweit = zweitText(p.stock || 0, p);
|
||||||
|
return zweit ? `${stockLabel(p)} · ${zweit}` : stockLabel(p);
|
||||||
|
}
|
||||||
|
|
||||||
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
|
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
|
||||||
export function unitOptions(product) {
|
export function unitOptions(product) {
|
||||||
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
||||||
@@ -194,8 +225,13 @@ export function amountText(qty, packageSize, baseUnit) {
|
|||||||
|
|
||||||
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
|
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
|
||||||
export function buildUnitOptions(product, units) {
|
export function buildUnitOptions(product, units) {
|
||||||
|
// Ohne Zweiteinheit nur die eigene Art. Mit Brücke kommen die Einheiten der
|
||||||
|
// Gegenart dazu – das Backend rechnet sie über die am Artikel hinterlegte
|
||||||
|
// Äquivalenz um (services/conversion.py::to_base).
|
||||||
|
const erlaubt = new Set([product.kind]);
|
||||||
|
if (zweitFaktor(product)) erlaubt.add(KIND_OF_BASE[product.secondary_base]);
|
||||||
const opts = (units || [])
|
const opts = (units || [])
|
||||||
.filter((u) => u.kind === product.kind)
|
.filter((u) => erlaubt.has(u.kind))
|
||||||
.map((u) => ({ value: u.name, label: u.name }));
|
.map((u) => ({ value: u.name, label: u.name }));
|
||||||
if (product.package_size) {
|
if (product.package_size) {
|
||||||
// Bezeichnung des Gebindes je Artikel (Glas, Tüte, …); Wert bleibt "package".
|
// Bezeichnung des Gebindes je Artikel (Glas, Tüte, …); Wert bleibt "package".
|
||||||
|
|||||||
Reference in New Issue
Block a user