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.
+