Verwaltbare Einheiten mit Umrechnung + Chargen bearbeiten + gezieltes Auslagern
Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
bestehende Installationen ihre Daten behalten.
Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
(wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.
Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
Auswahl-Dropdown in der Auslagern-Seite.
Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitOptions, unitShort } from "../units";
|
||||
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
||||
|
||||
export default function CheckOut() {
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [product, setProduct] = useState(null);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lots, setLots] = useState([]);
|
||||
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [unit, setUnit] = useState("");
|
||||
const [lotId, setLotId] = useState(""); // "" = automatisch (FEFO)
|
||||
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function selectProduct(p) {
|
||||
useEffect(() => {
|
||||
api.listUnits().then(setUnits).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function selectProduct(p) {
|
||||
setProduct(p);
|
||||
setResults([]);
|
||||
setUnit(unitOptions(p)[0].value);
|
||||
setLotId("");
|
||||
setQuantity("");
|
||||
const opts = buildUnitOptions(p, units);
|
||||
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
|
||||
try {
|
||||
setLots(await api.listLots(p.id));
|
||||
} catch { setLots([]); }
|
||||
}
|
||||
|
||||
async function doLookup() {
|
||||
@@ -28,7 +41,7 @@ export default function CheckOut() {
|
||||
try {
|
||||
const res = await api.lookup(barcode.trim());
|
||||
if (res.found && res.existing_product) {
|
||||
selectProduct(res.existing_product);
|
||||
await selectProduct(res.existing_product);
|
||||
} else {
|
||||
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
|
||||
}
|
||||
@@ -54,10 +67,17 @@ export default function CheckOut() {
|
||||
product_id: product.id,
|
||||
quantity: Number(quantity),
|
||||
unit,
|
||||
lot_id: lotId === "" ? null : Number(lotId),
|
||||
});
|
||||
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
||||
setInfo(
|
||||
`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ` +
|
||||
`${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
|
||||
);
|
||||
setQuantity("");
|
||||
setProduct(await api.getProduct(product.id));
|
||||
const fresh = await api.getProduct(product.id);
|
||||
setProduct(fresh);
|
||||
setLots(await api.listLots(fresh.id));
|
||||
setLotId("");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -65,6 +85,10 @@ export default function CheckOut() {
|
||||
}
|
||||
}
|
||||
|
||||
const unitOpts = product ? buildUnitOptions(product, units) : [];
|
||||
const selectedLot = lots.find((l) => String(l.id) === String(lotId)) || null;
|
||||
const baseShort = product ? unitShort(product.base_unit) : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>Auslagern</h1></div>
|
||||
@@ -96,7 +120,7 @@ export default function CheckOut() {
|
||||
{results.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button className="link-btn" onClick={() => selectProduct(p)}>
|
||||
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
|
||||
{p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -113,11 +137,35 @@ export default function CheckOut() {
|
||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||
<div className="info">
|
||||
<div className="title">{product.name}</div>
|
||||
<div className="muted small">Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
|
||||
<div className="muted small">
|
||||
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Charge
|
||||
<select value={lotId} onChange={(e) => setLotId(e.target.value)}>
|
||||
<option value="">Automatisch – zuerst ablaufende zuerst (FEFO)</option>
|
||||
{lots.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.best_before ? `MHD ${l.best_before}` : "ohne MHD"} · {fmt(l.quantity)} {baseShort}
|
||||
{isExpired(l.best_before) ? " · abgelaufen" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedLot && (
|
||||
<div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}>
|
||||
<Icon name="alert" size={16} />
|
||||
Gewählte Charge: {fmt(selectedLot.quantity)} {baseShort}
|
||||
{selectedLot.best_before ? ` · MHD ${selectedLot.best_before}` : " · ohne MHD"}
|
||||
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Menge
|
||||
@@ -127,14 +175,19 @@ export default function CheckOut() {
|
||||
<label className="grow">
|
||||
Einheit
|
||||
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||||
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
{unitOpts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small mt-0">
|
||||
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}><Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}</button>
|
||||
|
||||
{!selectedLot && (
|
||||
<p className="muted small mt-0">
|
||||
Ohne Auswahl wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||
</p>
|
||||
)}
|
||||
<button className="btn primary" disabled={busy}>
|
||||
<Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user