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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
83
web/src/components/SplitLotDialog.jsx
Normal file
83
web/src/components/SplitLotDialog.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user