From 231d86895f265e192daf7210d0748176c7a53102 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 30 Jul 2026 08:43:16 +0200 Subject: [PATCH] Charge aufteilen: Teilmenge mit gleichem MHD an anderen Lagerort umlagern Bisher liess sich nur die ganze Charge umlagern. Neu: POST /lots/{id}/split zweigt eine Teilmenge (Basiseinheiten) als neue Charge mit gleichem MHD an einen anderen Lagerort ab; der Rest bleibt. Reine Umbuchung ohne Bewegungseintrag, Gesamtbestand unveraendert. Schutz gegen zu grosse Menge und gleichen Zielort. Web: gemeinsamer SplitLotDialog (Menge in Artikeleinheit + Zielort, zeigt Rest), Aufteilen-Knopf auf der Chargen-Seite und in der Chargen-Tabelle der Artikelseite. Neues Split-Icon. Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/stock.py | 45 +++++++++++++++ backend/app/schemas.py | 7 +++ web/src/api.js | 2 + web/src/components/Icon.jsx | 8 +++ web/src/components/SplitLotDialog.jsx | 83 +++++++++++++++++++++++++++ web/src/pages/Charges.jsx | 21 +++++++ web/src/pages/ProductForm.jsx | 18 ++++++ 7 files changed, 184 insertions(+) create mode 100644 web/src/components/SplitLotDialog.jsx diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index 804331f..5f2dee0 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -15,6 +15,7 @@ from ..schemas import ( CheckOutResponse, LotOut, LotRow, + LotSplit, LotUpdate, RelocateRequest, RemoveRequest, @@ -295,6 +296,50 @@ def bulk_lot_location( return n +@router.post("/lots/{lot_id}/split", response_model=LotOut) +def split_lot( + lot_id: int, + payload: LotSplit, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Lot: + """Charge aufteilen: `quantity` (Basiseinheiten) abzweigen und als neue Charge + mit gleichem MHD an einen anderen Lagerort legen. Der Rest bleibt in der alten + Charge. Reine Umbuchung wie das Setzen des Lagerorts – kein Bewegungseintrag, + der Gesamtbestand ändert sich nicht. Gibt die neu entstandene Charge zurück.""" + lot = db.get(Lot, lot_id) + if lot is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") + # Nur echt weniger als die Gesamtmenge lässt sich abteilen – die ganze Menge + # zu verschieben ist Sache des Lagerort-Feldes, nicht des Aufteilens. + if payload.quantity >= lot.quantity - 1e-9: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "So viel enthält die Charge nicht – zum kompletten Umlagern das " + "Lagerort-Feld nutzen.", + ) + if (payload.location_id or None) == (lot.location_id or None): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Bitte einen anderen Lagerort als den aktuellen wählen.", + ) + if payload.location_id is not None and db.get(Location, payload.location_id) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") + + lot.quantity -= payload.quantity + neu = Lot( + product_id=lot.product_id, + quantity=payload.quantity, + best_before=lot.best_before, + best_before_precision=lot.best_before_precision, + location_id=payload.location_id, + ) + db.add(neu) + db.commit() + db.refresh(neu) + return neu + + @router.patch("/lots/{lot_id}", response_model=LotOut) def update_lot( lot_id: int, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 23f112d..94e52ff 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -475,6 +475,13 @@ class BulkLotLocation(BaseModel): location_id: str | None = None +class LotSplit(BaseModel): + """Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und + als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt.""" + quantity: float = Field(gt=0) + location_id: str | None = None + + class CheckInResponse(BaseModel): lot: LotOut product_stock: float diff --git a/web/src/api.js b/web/src/api.js index 4b3970c..41e612d 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -196,6 +196,8 @@ export const api = { bulkLotLocation: (lotIds, locationId) => request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }), deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), + // Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen. + splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }), // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste). setProductLocationMinStock: (id, list) => diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index 1f46cbc..bd6c0e6 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -38,6 +38,14 @@ const PATHS = { ), + split: ( + <> + + + + + + ), tag: ( <> diff --git a/web/src/components/SplitLotDialog.jsx b/web/src/components/SplitLotDialog.jsx new file mode 100644 index 0000000..cf6fb56 --- /dev/null +++ b/web/src/components/SplitLotDialog.jsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { api } from "../api"; +import Icon from "./Icon"; +import { fmt } from "../units"; +import { locationOptions, locationPathById } from "../locationPath"; + +/** + * Charge aufteilen: einen Teil der Menge (in Artikeleinheiten – Packungen/Stück/g) + * mit gleichem MHD an einen anderen Lagerort verschieben. Die Restmenge bleibt in + * der ursprünglichen Charge; der Gesamtbestand ändert sich nicht. + * + * `factor` – Basiseinheiten je Artikeleinheit (z.B. 415 ml pro Packung). + * `labelFor` – (menge) => Einheitentext, mengenabhängig ("1 Packung"/"3 Packungen"). + */ +export default function SplitLotDialog({ lot, factor, labelFor, locations, onClose, onDone, onError }) { + const total = lot.quantity / factor; // Gesamtmenge in Artikeleinheiten + const [menge, setMenge] = useState(""); + const [locId, setLocId] = useState(""); + const [busy, setBusy] = useState(false); + + const num = Number(String(menge).trim().replace(",", ".")); + const gueltig = menge !== "" && !Number.isNaN(num) && num > 0 && num < total; + const rest = gueltig ? total - num : null; + const zielGleich = gueltig && (locId || null) === (lot.location_id || null); + + async function submit() { + if (!gueltig || zielGleich || busy) return; + setBusy(true); + try { + await api.splitLot(lot.id, { quantity: num * factor, location_id: locId || null }); + onClose(); + await onDone(); + } catch (err) { + onError?.(err.message); + } finally { + setBusy(false); + } + } + + return ( +
e.target === e.currentTarget && onClose()}> +
+

Charge aufteilen

+

+ Aktuell {fmt(total)} {labelFor(total)} + {lot.location_id ? ` in ${locationPathById(lot.location_id, locations) || "?"}` : " ohne Lagerort"}. + Ein Teil davon wird mit gleichem MHD an einen anderen Lagerort verschoben. +

+
+ + + {rest != null && !zielGleich && ( +

+ Danach: {fmt(num)} {labelFor(num)} am neuen Ort, {fmt(rest)} {labelFor(rest)} bleiben. +

+ )} + {zielGleich && ( +

+ Bitte einen anderen Lagerort als den aktuellen wählen. +

+ )} +
+
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Charges.jsx b/web/src/pages/Charges.jsx index a251853..2f16e0a 100644 --- a/web/src/pages/Charges.jsx +++ b/web/src/pages/Charges.jsx @@ -4,6 +4,7 @@ import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; +import SplitLotDialog from "../components/SplitLotDialog"; import { ProduktThumb } from "../components/ProduktBild"; import { useSettings } from "../settings"; import { locationOptions, locationPathById } from "../locationPath"; @@ -34,6 +35,7 @@ export default function Charges() { const [bulkLoc, setBulkLoc] = useState(""); const [selected, setSelected] = useState(() => new Set()); const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen + const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird async function load() { setLoading(true); @@ -130,6 +132,11 @@ export default function Charges() { { key: "created", header: "Eingelagert", width: 130, sortValue: (l) => new Date(l.created_at).getTime(), render: (l) => {new Date(l.created_at).toLocaleDateString("de-DE")} }, + { key: "split", header: "", label: "Aufteilen", fixed: true, width: 118, align: "num", + render: (l) => (isAdmin ? ( + + ) : null) }, { key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num", render: (l) => Artikel }, ]; @@ -192,6 +199,20 @@ export default function Charges() { }} getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." /> + + {splitting && ( + (splitting.package_size && splitting.package_size > 0 + ? gebinde(q, splitting.package_label) + : (splitting.unit_name || unitShort(splitting.base_unit)))} + locations={locations} + onClose={() => setSplitting(null)} + onDone={load} + onError={setError} + /> + )} ); } diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index e1602d3..73d3be3 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -15,6 +15,7 @@ import LocationMinStock from "../components/LocationMinStock"; import { DynamicFields, FIELD_TYPES } from "../fields"; import ObjektBestand from "../components/ObjektBestand"; import Einzelstuecke from "../components/Einzelstuecke"; +import SplitLotDialog from "../components/SplitLotDialog"; import { locationOptions, locationPathById } from "../locationPath"; import { daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, @@ -1073,6 +1074,7 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin, const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day", location_id: "" }); // In welcher Einheit die Menge bearbeitet wird: "unit" oder "package". const [editUnit, setEditUnit] = useState("unit"); + const [splitLotItem, setSplitLotItem] = useState(null); // Charge, die aufgeteilt wird const unitFactor = product?.unit_factor || 1; const unitName = product?.unit_name || baseShort; @@ -1248,6 +1250,10 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin, ) : (
+ @@ -1267,6 +1273,18 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin, {pkgSize > 0 && (

1 {pkgLabel} = {fmt(pkgSize)} {baseShort}

)} + + {splitLotItem && ( + setSplitLotItem(null)} + onDone={onChanged} + onError={onError} + /> + )} ); }