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

@@ -7,13 +7,15 @@ import { fmt } from "../units";
export default function Groups() {
const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]);
const [name, setName] = useState("");
const [minStock, setMinStock] = useState("");
const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [error, setError] = useState(null);
async function load() {
try {
setGroups(await api.listGroups());
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
setGroups(gs);
setUnits(us);
} catch (err) {
setError(err.message);
}
@@ -25,18 +27,21 @@ export default function Groups() {
e.preventDefault();
setError(null);
try {
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) });
setName("");
setMinStock("");
await api.createGroup({
name: form.name,
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
});
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
load();
} catch (err) {
setError(err.message);
}
}
async function saveMin(group, value) {
async function patch(group, body) {
try {
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) });
await api.updateGroup(group.id, body);
load();
} catch (err) {
setError(err.message);
@@ -58,7 +63,7 @@ export default function Groups() {
<div className="page-head">
<div>
<h1>Gruppen</h1>
<div className="sub">Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand</div>
<div className="sub">Produkte zusammenfassen (z.B. Nudeln) und einen Gruppen-Mindestbestand mit Einheit setzen</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -68,7 +73,14 @@ export default function Groups() {
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Gruppe</th><th className="num">Produkte</th><th className="num">Bestand</th><th>Mindestbestand</th><th></th></tr>
<tr>
<th>Gruppe</th>
<th className="num">Produkte</th>
<th className="num">Bestand</th>
<th>Mindestbestand</th>
<th>Einheit</th>
<th></th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
@@ -80,17 +92,30 @@ export default function Groups() {
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num muted">{g.product_count}</td>
<td className="num">{fmt(g.stock)}</td>
<td className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
<td>
{isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ maxWidth: 110, marginTop: 0 }}
style={{ maxWidth: 100, marginTop: 0 }}
onBlur={(e) => {
const v = e.target.value;
if (v !== String(g.min_stock ?? "")) saveMin(g, v);
if (v !== String(g.min_stock ?? "")) {
patch(g, { min_stock: v === "" ? null : Number(v) });
}
}} />
) : (g.min_stock != null ? fmt(g.min_stock) : "")}
</td>
<td>
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ maxWidth: 140, marginTop: 0 }}
onChange={(e) => patch(g, {
min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value),
})}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")}
</td>
<td className="num">
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
@@ -101,7 +126,7 @@ export default function Groups() {
</tr>
);
})}
{groups.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Gruppen.</td></tr>}
{groups.length === 0 && <tr><td colSpan={6} className="empty">Noch keine Gruppen.</td></tr>}
</tbody>
</table>
</div>
@@ -112,18 +137,27 @@ export default function Groups() {
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>
Mindestbestand (optional)
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)}
placeholder="Gesamtmenge über alle Produkte der Gruppe" />
<input placeholder="z.B. Nudeln" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
Mindestbestand (optional)
<input type="number" step="any" value={form.min_stock}
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
</label>
<label className="grow">
Einheit
<select value={form.min_stock_unit_id}
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu.
Der Gruppen-Bestand ist die Summe der Produktbestände sinnvoll, wenn
die Produkte dieselbe Basiseinheit haben.
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen
(Gewicht/Volumen/Stück). Produkte ordnest du im Produkt-Formular einer Gruppe zu.
</p>
</form>
)}