Chargen: mehrere auf einmal ausbuchen, teilweise abbuchen oder loeschen

Die Mehrfachauswahl gab es schon, sie konnte aber nur den Lagerort setzen.
Jetzt kommen drei Aktionen dazu: komplett ausbuchen, von jeder Charge
dieselbe Teilmenge abbuchen, und loeschen als Korrektur.

Anders als bulk-location kein Sammel-UPDATE: Je Charge muss eine Bewegung
entstehen, sonst fehlte der Vorgang im Verlauf und in der
Verbrauchsstatistik. Schlaegt eine Charge fehl, weil sie zu wenig enthaelt,
wird alles zurueckgerollt - ein halb gebuchter Stand waere schlimmer als
gar keiner, dieselbe Haltung wie beim Stapel-Einlagern.

Die gemeinsame Teilmenge gibt es nur, wenn alle ausgewaehlten Chargen zum
selben Artikel gehoeren. "1 Packung" bedeutet bei Milch und Broetchen
Verschiedenes; der Server lehnt es ab, und die Oberflaeche sperrt das Feld
gleich mit einem Hinweis, statt den Fehler erst nach dem Klick zu zeigen.

Loeschen schreibt wie das einzelne Loeschen eine Korrektur-Bewegung
(adjust, negative Menge) statt einer Entnahme - es ist eine Fehleingabe,
kein Verbrauch, und darf die Verbrauchsstatistik nicht verfaelschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 12:41:23 +02:00
co-authored by Claude Opus 5
parent a182037153
commit c66bd0b09e
5 changed files with 408 additions and 4 deletions
+87
View File
@@ -8,6 +8,8 @@ from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Pr
from ..schemas import ( from ..schemas import (
BatchCheckInRequest, BatchCheckInRequest,
BatchCheckInResponse, BatchCheckInResponse,
BulkLotCheckout,
BulkLotDelete,
BulkLotLocation, BulkLotLocation,
CheckInRequest, CheckInRequest,
CheckInResponse, CheckInResponse,
@@ -313,6 +315,91 @@ def bulk_lot_location(
return n return n
@router.post("/lots/bulk-checkout", response_model=int)
def bulk_lot_checkout(
payload: BulkLotCheckout,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> int:
"""Mehrere Chargen ausbuchen – komplett oder je Charge dieselbe Teilmenge.
Anders als ``bulk-location`` **kein** Sammel-UPDATE: Je Charge muss eine
Bewegung entstehen, sonst fehlte der Vorgang im Verlauf und in der
Verbrauchsstatistik. Schlägt eine Charge fehl (zu wenig Bestand), wird
alles zurückgerollt – ein halb gebuchter Stand wäre schlimmer als gar
keiner; dieselbe Haltung wie beim Stapel-Einlagern.
Gibt die Anzahl der betroffenen Chargen zurück.
"""
if not payload.lot_ids:
return 0
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
if not lots:
return 0
if payload.quantity is not None:
# Eine gemeinsame Menge setzt eine gemeinsame Einheit voraus: „1 Packung"
# bei Milch und Broetchen sind verschiedene Dinge.
artikel = {lot.product_id for lot in lots}
if len(artikel) > 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Eine gemeinsame Menge geht nur, wenn alle ausgewählten Chargen "
"zum selben Artikel gehören. Sonst bitte komplett ausbuchen.",
)
betroffen = 0
try:
for lot in lots:
product = db.get(Product, lot.product_id)
if product is None:
continue
menge = payload.quantity if payload.quantity is not None else lot.quantity
einheit = payload.unit if payload.quantity is not None else product.base_unit.value
check_out_lot(db, product, lot, menge, einheit, user, payload.note)
betroffen += 1
except ConversionError as fehler:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
except StockError as fehler:
db.rollback()
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
db.commit()
return betroffen
@router.post("/lots/bulk-delete", response_model=int)
def bulk_lot_delete(
payload: BulkLotDelete,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> int:
"""Mehrere Chargen als Korrektur entfernen – nicht als Verbrauch.
Je Charge eine Korrektur-Bewegung wie beim einzelnen Löschen, damit der
Bestandsverlauf stimmt. Unbekannte IDs werden still übergangen, wie bei
``bulk-location``.
"""
if not payload.lot_ids:
return 0
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
for lot in lots:
db.add(
Movement(
product_id=lot.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.adjust,
quantity=-lot.quantity,
unit_used="base",
note="Charge gelöscht",
)
)
db.delete(lot)
db.commit()
return len(lots)
@router.post("/lots/{lot_id}/split", response_model=LotOut) @router.post("/lots/{lot_id}/split", response_model=LotOut)
def split_lot( def split_lot(
lot_id: int, lot_id: int,
+19
View File
@@ -636,6 +636,25 @@ class BulkLotLocation(BaseModel):
location_id: str | None = None location_id: str | None = None
class BulkLotCheckout(BaseModel):
"""Mehrere Chargen auf einmal ausbuchen.
``quantity`` leer = jede Charge komplett. Mit Menge wird je Charge
dieselbe Menge abgebucht – das ergibt nur Sinn, wenn alle zum selben
Artikel gehören, sonst wäre die Einheit mehrdeutig (der Server lehnt es
dann ab).
"""
lot_ids: list[int]
quantity: float | None = Field(default=None, gt=0)
unit: str | None = None
note: str | None = Field(default=None, max_length=255)
class BulkLotDelete(BaseModel):
"""Mehrere Chargen als Korrektur entfernen (ohne sie als Verbrauch zu buchen)."""
lot_ids: list[int]
class LotSplit(BaseModel): class LotSplit(BaseModel):
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und """Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt.""" als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
+199
View File
@@ -0,0 +1,199 @@
"""Mehrere Chargen auf einmal: komplett ausbuchen, Teilmenge, löschen."""
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from app.models import BaseUnit, Lot, Movement, MovementType, Product, Role, User
from app.routers.stock import bulk_lot_checkout, bulk_lot_delete
from app.schemas import BulkLotCheckout, BulkLotDelete
from app.services.stock import current_stock
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def milch(db):
product = Product(
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _lot(db, product, menge, tage=None):
lot = Lot(
product_id=product.id, quantity=menge,
best_before=(date.today() + timedelta(days=tage)) if tage is not None else None,
)
db.add(lot)
db.commit()
db.refresh(lot)
return lot
# --------------------------------------------------------------------------
# Komplett ausbuchen
# --------------------------------------------------------------------------
def test_mehrere_chargen_komplett_ausbuchen(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
anzahl = bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, b.id]), db=db, user=user)
assert anzahl == 2
assert db.query(Lot).count() == 0
assert current_stock(db, milch.id) == 0
def test_je_charge_eine_bewegung(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, b.id]), db=db, user=user)
bewegungen = db.query(Movement).all()
assert len(bewegungen) == 2
assert {m.quantity for m in bewegungen} == {1000, 2000}
assert all(m.type == MovementType.out for m in bewegungen)
def test_notiz_landet_an_der_bewegung(db, user, milch):
a = _lot(db, milch, 1000, 5)
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id], note="Grossputz"), db=db, user=user
)
assert db.query(Movement).one().note == "Grossputz"
def test_leere_auswahl_tut_nichts(db, user, milch):
_lot(db, milch, 1000, 5)
assert bulk_lot_checkout(BulkLotCheckout(lot_ids=[]), db=db, user=user) == 0
assert current_stock(db, milch.id) == 1000
def test_unbekannte_ids_werden_uebergangen(db, user, milch):
a = _lot(db, milch, 1000, 5)
anzahl = bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, 999]), db=db, user=user)
assert anzahl == 1
assert current_stock(db, milch.id) == 0
# --------------------------------------------------------------------------
# Teilmenge je Charge
# --------------------------------------------------------------------------
def test_teilmenge_je_charge(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id, b.id], quantity=200, unit="ml"), db=db, user=user
)
assert db.get(Lot, a.id).quantity == 800
assert db.get(Lot, b.id).quantity == 1800
def test_teilmenge_bei_gemischten_artikeln_wird_abgelehnt(db, user, milch):
"""„1 Packung" bei Milch und Brötchen sind verschiedene Dinge."""
broetchen = Product(name="Brötchen", base_unit=BaseUnit.piece, package_size=6,
source="manual")
db.add(broetchen)
db.commit()
a = _lot(db, milch, 1000, 5)
b = _lot(db, broetchen, 6, 2)
with pytest.raises(HTTPException) as fehler:
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id, b.id], quantity=1, unit="package"),
db=db, user=user,
)
assert fehler.value.status_code == 400
assert db.get(Lot, a.id).quantity == 1000 # nichts gebucht
assert db.get(Lot, b.id).quantity == 6
def test_zu_wenig_bestand_rollt_alles_zurueck(db, user, milch):
"""Ein halb gebuchter Stand wäre schlimmer als gar keiner."""
viel = _lot(db, milch, 1000, 5)
wenig = _lot(db, milch, 100, 9)
with pytest.raises(HTTPException) as fehler:
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[viel.id, wenig.id], quantity=500, unit="ml"),
db=db, user=user,
)
assert fehler.value.status_code == 409
assert current_stock(db, milch.id) == 1100 # unverändert
assert db.query(Movement).count() == 0
def test_unbekannte_einheit_bucht_nichts(db, user, milch):
a = _lot(db, milch, 1000, 5)
with pytest.raises(HTTPException) as fehler:
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id], quantity=1, unit="furlong"), db=db, user=user
)
assert fehler.value.status_code == 400
assert current_stock(db, milch.id) == 1000
def test_teilmenge_leert_und_entfernt_die_charge(db, user, milch):
a = _lot(db, milch, 500, 5)
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id], quantity=500, unit="ml"), db=db, user=user
)
assert db.get(Lot, a.id) is None
# --------------------------------------------------------------------------
# Löschen als Korrektur
# --------------------------------------------------------------------------
def test_mehrere_chargen_loeschen(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
anzahl = bulk_lot_delete(BulkLotDelete(lot_ids=[a.id, b.id]), db=db, user=user)
assert anzahl == 2
assert db.query(Lot).count() == 0
def test_loeschen_schreibt_korrektur_nicht_verbrauch(db, user, milch):
a = _lot(db, milch, 1000, 5)
bulk_lot_delete(BulkLotDelete(lot_ids=[a.id]), db=db, user=user)
bewegung = db.query(Movement).one()
assert bewegung.type == MovementType.adjust
assert bewegung.quantity == -1000
assert bewegung.note == "Charge gelöscht"
# Keine Charge mehr, an der die Bewegung haengen koennte.
assert bewegung.lot_id is None
def test_loeschen_mit_leerer_auswahl(db, user, milch):
_lot(db, milch, 1000, 5)
assert bulk_lot_delete(BulkLotDelete(lot_ids=[]), db=db, user=user) == 0
assert current_stock(db, milch.id) == 1000
+10
View File
@@ -270,6 +270,16 @@ export const api = {
// Lagerort mehrerer Chargen auf einmal setzen (null = leeren). // Lagerort mehrerer Chargen auf einmal setzen (null = leeren).
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 } }),
// Mehrere Chargen ausbuchen: ohne Menge komplett, mit Menge je Charge
// dieselbe (nur bei einheitlichem Artikel erlaubt).
bulkLotCheckout: (lotIds, { quantity = null, unit = null, note = null } = {}) =>
request("/lots/bulk-checkout", {
method: "POST",
body: { lot_ids: lotIds, quantity, unit, note },
}),
// Mehrere Chargen als Korrektur entfernen (nicht als Verbrauch buchen).
bulkLotDelete: (lotIds) =>
request("/lots/bulk-delete", { method: "POST", body: { lot_ids: lotIds } }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen. // Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }), splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
+93 -4
View File
@@ -7,6 +7,8 @@ import DataTable from "../components/DataTable";
import SplitLotDialog from "../components/SplitLotDialog"; import SplitLotDialog from "../components/SplitLotDialog";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { expiryRowClass, fmt, gebinde, unitShort } from "../units"; import { expiryRowClass, fmt, gebinde, unitShort } from "../units";
@@ -28,6 +30,8 @@ function mengeText(l) {
export default function Charges() { export default function Charges() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { formatBestBefore } = useSettings(); const { formatBestBefore } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [lots, setLots] = useState([]); const [lots, setLots] = useState([]);
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -36,6 +40,7 @@ export default function Charges() {
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 const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
const [teilmenge, setTeilmenge] = useState(""); // Menge für „von jeder abbuchen"
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend" const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
async function load() { async function load() {
@@ -81,6 +86,64 @@ export default function Charges() {
} }
} }
// Gehören alle ausgewählten Chargen zum selben Artikel? Nur dann ist eine
// gemeinsame Menge eindeutig – „1 Packung" bei Milch und Brötchen sind
// verschiedene Dinge.
const ausgewaehlt = useMemo(
() => lots.filter((l) => selected.has(l.id)), [lots, selected],
);
const einArtikel = useMemo(() => {
const ids = new Set(ausgewaehlt.map((l) => l.product_id));
return ids.size === 1 ? ausgewaehlt[0] : null;
}, [ausgewaehlt]);
async function ausbuchen() {
if (selected.size === 0) return;
const ok = await confirm({
title: `${selected.size} Charge(n) komplett ausbuchen?`,
message: "Der Bestand wird abgebucht und im Verlauf als Entnahme festgehalten.",
confirmLabel: "Ausbuchen",
});
if (!ok) return;
try {
const n = await api.bulkLotCheckout([...selected]);
clearSelection();
await load();
toast(`${n} Charge(n) ausgebucht.`);
} catch (err) { setError(err.message); }
}
async function teilmengeAusbuchen() {
const menge = Number(String(teilmenge).replace(",", "."));
if (!einArtikel || !Number.isFinite(menge) || menge <= 0) return;
try {
const n = await api.bulkLotCheckout([...selected], {
quantity: menge * (einArtikel.package_size || 1),
unit: einArtikel.base_unit,
});
setTeilmenge("");
clearSelection();
await load();
toast(`Von ${n} Charge(n) abgebucht.`);
} catch (err) { setError(err.message); }
}
async function loeschen() {
if (selected.size === 0) return;
const ok = await confirm({
title: `${selected.size} Charge(n) löschen?`,
message: "Als Korrektur – nicht als Verbrauch. Für „aufgebraucht\" bitte ausbuchen.",
confirmLabel: "Löschen", danger: true,
});
if (!ok) return;
try {
const n = await api.bulkLotDelete([...selected]);
clearSelection();
await load();
toast(`${n} Charge(n) gelöscht.`);
} catch (err) { setError(err.message); }
}
// Lagerort für die aktuell ausgewählten Chargen setzen (leer = Ort entfernen). // Lagerort für die aktuell ausgewählten Chargen setzen (leer = Ort entfernen).
async function applyToSelection() { async function applyToSelection() {
if (selected.size === 0) return; if (selected.size === 0) return;
@@ -163,11 +226,11 @@ export default function Charges() {
{isAdmin && ( {isAdmin && (
<div className="card" style={{ marginBottom: "var(--sp-3)" }}> <div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="location" /><h2>Lagerort für ausgewählte Chargen setzen</h2></div> <div className="card-head"><Icon name="check" /><h2>Ausgewählte Chargen</h2></div>
<p className="muted small mt-0"> <p className="muted small mt-0">
{selected.size > 0 {selected.size > 0
? `${selected.size} Charge(n) ausgewählt – diese bekommen den gewählten Lagerort (leer = Ort entfernen).` ? `${selected.size} Charge(n) ausgewählt.`
: "Chargen in der Tabelle anhaken, dann hier den Lagerort setzen. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."} : "Chargen in der Tabelle anhaken, dann hier handeln. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."}
</p> </p>
<div className="field-inline" style={{ flexWrap: "wrap" }}> <div className="field-inline" style={{ flexWrap: "wrap" }}>
<select value={bulkLoc} onChange={(e) => setBulkLoc(e.target.value)} style={{ maxWidth: 360 }} <select value={bulkLoc} onChange={(e) => setBulkLoc(e.target.value)} style={{ maxWidth: 360 }}
@@ -176,9 +239,35 @@ export default function Charges() {
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} {locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select> </select>
<button className="btn primary" disabled={selected.size === 0} onClick={applyToSelection}> <button className="btn primary" disabled={selected.size === 0} onClick={applyToSelection}>
<Icon name="check" size={16} />Auf {selected.size} Auswahl anwenden <Icon name="location" size={16} />Lagerort setzen
</button> </button>
</div> </div>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: "var(--sp-2)" }}>
<button className="btn" disabled={selected.size === 0} onClick={ausbuchen}>
<Icon name="checkout" size={16} />Komplett ausbuchen
</button>
<button className="btn danger" disabled={selected.size === 0} onClick={loeschen}>
<Icon name="trash" size={16} />Löschen (Korrektur)
</button>
</div>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
<label style={{ margin: 0, maxWidth: 220 }}>
Von jeder Charge abbuchen
<input type="number" step="any" min="0" value={teilmenge} disabled={!einArtikel}
placeholder={einArtikel ? `in ${einArtikel.package_label || einArtikel.unit_name || "Einheiten"}` : "–"}
onChange={(e) => setTeilmenge(e.target.value)} />
</label>
<button className="btn" disabled={!einArtikel || !teilmenge} onClick={teilmengeAusbuchen}>
<Icon name="checkout" size={16} />Teilmenge ausbuchen
</button>
</div>
{selected.size > 0 && !einArtikel && (
<p className="muted small mt-0">
Eine gemeinsame Menge geht nur bei einem einzigen Artikel – „1 Packung"
bedeutet bei Milch und Brötchen Verschiedenes. Komplett ausbuchen geht
immer.
</p>
)}
<div className="field-inline" style={{ flexWrap: "wrap", marginTop: "var(--sp-2)", marginBottom: 0 }}> <div className="field-inline" style={{ flexWrap: "wrap", marginTop: "var(--sp-2)", marginBottom: 0 }}>
<button type="button" className="btn sm ghost" onClick={() => selectAll(visible.map((l) => l.id))} <button type="button" className="btn sm ghost" onClick={() => selectAll(visible.map((l) => l.id))}
disabled={visible.length === 0}> disabled={visible.length === 0}>