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

@@ -4,7 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup, suggestionToProduct } from "../offUtils";
import { fmt, isExpired, unitOptions, unitShort } from "../units";
import { buildUnitOptions, fmt, isExpired } from "../units";
const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -18,6 +18,7 @@ export default function CheckIn() {
const [search, setSearch] = useState("");
const [locations, setLocations] = useState([]);
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [unit, setUnit] = useState("");
const [locationId, setLocationId] = useState("");
@@ -30,6 +31,7 @@ export default function CheckIn() {
useEffect(() => {
api.listLocations().then(setLocations).catch(() => {});
api.listGroups().then(setGroups).catch(() => {});
api.listUnits().then(setUnits).catch(() => {});
}, []);
function resetLookup() {
@@ -41,7 +43,8 @@ export default function CheckIn() {
setProduct(p);
setResults([]);
resetLookup();
setUnit(unitOptions(p)[0].value);
const opts = buildUnitOptions(p, units);
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
setLines([emptyLine()]);
setLocationId("");
}
@@ -118,7 +121,7 @@ export default function CheckIn() {
const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines });
setInfo(
`${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` +
`${fmt(res.product_stock)} ${unitShort(product.base_unit)}`
`${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
);
setLines([emptyLine()]);
setProduct(await api.getProduct(product.id));
@@ -197,7 +200,7 @@ export default function CheckIn() {
{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>
))}
@@ -214,7 +217,7 @@ export default function CheckIn() {
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info">
<div className="title">{product.name}</div>
<div className="muted small">Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
@@ -223,7 +226,7 @@ export default function CheckIn() {
<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>)}
{buildUnitOptions(product, units).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label className="grow">
@@ -274,7 +277,7 @@ export default function CheckIn() {
</button>
{totalQty > 0 && (
<span className="muted small">Summe: {fmt(totalQty)} {
unitOptions(product).find((o) => o.value === unit)?.label || ""
buildUnitOptions(product, units).find((o) => o.value === unit)?.label || ""
}</span>
)}
</div>