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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 08:43:16 +02:00
parent 8cf7cef6e1
commit 231d86895f
7 changed files with 184 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ from ..schemas import (
CheckOutResponse, CheckOutResponse,
LotOut, LotOut,
LotRow, LotRow,
LotSplit,
LotUpdate, LotUpdate,
RelocateRequest, RelocateRequest,
RemoveRequest, RemoveRequest,
@@ -295,6 +296,50 @@ def bulk_lot_location(
return n 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) @router.patch("/lots/{lot_id}", response_model=LotOut)
def update_lot( def update_lot(
lot_id: int, lot_id: int,

View File

@@ -475,6 +475,13 @@ class BulkLotLocation(BaseModel):
location_id: str | None = None 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): class CheckInResponse(BaseModel):
lot: LotOut lot: LotOut
product_stock: float product_stock: float

View File

@@ -196,6 +196,8 @@ export const api = {
bulkLotLocation: (lotIds, locationId) => bulkLotLocation: (lotIds, locationId) =>
request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }), request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), 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). // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
setProductLocationMinStock: (id, list) => setProductLocationMinStock: (id, list) =>

View File

@@ -38,6 +38,14 @@ const PATHS = {
<path d="M12 22V12" /> <path d="M12 22V12" />
</> </>
), ),
split: (
<>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</>
),
tag: ( tag: (
<> <>
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" /> <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />

View File

@@ -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 (
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head"><Icon name="split" size={18} /><h2>Charge aufteilen</h2></div>
<p className="modal-body mt-0">
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.
</p>
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
<label>
Menge abteilen (in {labelFor(2)})
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input type="number" step="any" min="0" max={total} value={menge} autoFocus
onChange={(e) => setMenge(e.target.value)} placeholder={`max. ${fmt(total)}`} />
</label>
<label>
nach Lagerort
<select value={locId} onChange={(e) => setLocId(e.target.value)}>
<option value=""> ohne Lagerort </option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</label>
{rest != null && !zielGleich && (
<p className="muted small mt-0">
Danach: {fmt(num)} {labelFor(num)} am neuen Ort, {fmt(rest)} {labelFor(rest)} bleiben.
</p>
)}
{zielGleich && (
<p className="small mt-0" style={{ color: "var(--danger)" }}>
Bitte einen anderen Lagerort als den aktuellen wählen.
</p>
)}
</div>
<div className="btn-pair">
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
<button type="button" className="btn primary" disabled={!gueltig || zielGleich || busy} onClick={submit}>
<Icon name="split" size={16} />Aufteilen
</button>
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import SplitLotDialog from "../components/SplitLotDialog";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
@@ -34,6 +35,7 @@ export default function Charges() {
const [bulkLoc, setBulkLoc] = useState(""); const [bulkLoc, setBulkLoc] = useState("");
const [selected, setSelected] = useState(() => new Set()); const [selected, setSelected] = useState(() => new Set());
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -130,6 +132,11 @@ export default function Charges() {
{ key: "created", header: "Eingelagert", width: 130, { key: "created", header: "Eingelagert", width: 130,
sortValue: (l) => new Date(l.created_at).getTime(), sortValue: (l) => new Date(l.created_at).getTime(),
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> }, render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
{ key: "split", header: "", label: "Aufteilen", fixed: true, width: 118, align: "num",
render: (l) => (isAdmin ? (
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button>
) : null) },
{ key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num", { key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num",
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> }, render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
]; ];
@@ -192,6 +199,20 @@ export default function Charges() {
}} }}
getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." /> getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." />
</div> </div>
{splitting && (
<SplitLotDialog
lot={splitting}
factor={artFactor(splitting)}
labelFor={(q) => (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}
/>
)}
</div> </div>
); );
} }

View File

@@ -15,6 +15,7 @@ import LocationMinStock from "../components/LocationMinStock";
import { DynamicFields, FIELD_TYPES } from "../fields"; import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand"; import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke"; import Einzelstuecke from "../components/Einzelstuecke";
import SplitLotDialog from "../components/SplitLotDialog";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, 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: "" }); const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day", location_id: "" });
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package". // In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
const [editUnit, setEditUnit] = useState("unit"); const [editUnit, setEditUnit] = useState("unit");
const [splitLotItem, setSplitLotItem] = useState(null); // Charge, die aufgeteilt wird
const unitFactor = product?.unit_factor || 1; const unitFactor = product?.unit_factor || 1;
const unitName = product?.unit_name || baseShort; const unitName = product?.unit_name || baseShort;
@@ -1248,6 +1250,10 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin,
</div> </div>
) : ( ) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}> <div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" onClick={() => setSplitLotItem(l)}
title="Charge aufteilen und Teil umlagern">
<Icon name="split" size={16} />
</button>
<button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten"> <button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten">
<Icon name="edit" size={16} /> <Icon name="edit" size={16} />
</button> </button>
@@ -1267,6 +1273,18 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin,
{pkgSize > 0 && ( {pkgSize > 0 && (
<p className="muted small">1 {pkgLabel} = {fmt(pkgSize)} {baseShort}</p> <p className="muted small">1 {pkgLabel} = {fmt(pkgSize)} {baseShort}</p>
)} )}
{splitLotItem && (
<SplitLotDialog
lot={splitLotItem}
factor={primaryFactor}
labelFor={primaryLabel}
locations={locations}
onClose={() => setSplitLotItem(null)}
onDone={onChanged}
onError={onError}
/>
)}
</section> </section>
); );
} }