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:
Scarriffle
2026-07-22 12:33:14 +02:00
parent d4e7ff8e3d
commit 0310cdfbd6
26 changed files with 1015 additions and 177 deletions

View File

@@ -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>