From c66bd0b09e0f588fea8c570571f04cca953417e8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 12:41:23 +0200 Subject: [PATCH] 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 --- backend/app/routers/stock.py | 87 ++++++++++++ backend/app/schemas.py | 19 +++ backend/tests/test_massenaktionen.py | 199 +++++++++++++++++++++++++++ web/src/api.js | 10 ++ web/src/pages/Charges.jsx | 97 ++++++++++++- 5 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_massenaktionen.py diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index b081a35..5c752e5 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -8,6 +8,8 @@ from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Pr from ..schemas import ( BatchCheckInRequest, BatchCheckInResponse, + BulkLotCheckout, + BulkLotDelete, BulkLotLocation, CheckInRequest, CheckInResponse, @@ -313,6 +315,91 @@ def bulk_lot_location( 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) def split_lot( lot_id: int, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7f0781a..954d5ab 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -636,6 +636,25 @@ class BulkLotLocation(BaseModel): 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): """Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt.""" diff --git a/backend/tests/test_massenaktionen.py b/backend/tests/test_massenaktionen.py new file mode 100644 index 0000000..b79a5a1 --- /dev/null +++ b/backend/tests/test_massenaktionen.py @@ -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 diff --git a/web/src/api.js b/web/src/api.js index 0131f95..b3e9d44 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -270,6 +270,16 @@ export const api = { // Lagerort mehrerer Chargen auf einmal setzen (null = leeren). bulkLotLocation: (lotIds, locationId) => 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" }), // Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen. splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }), diff --git a/web/src/pages/Charges.jsx b/web/src/pages/Charges.jsx index b184aa4..1ac907d 100644 --- a/web/src/pages/Charges.jsx +++ b/web/src/pages/Charges.jsx @@ -7,6 +7,8 @@ import DataTable from "../components/DataTable"; import SplitLotDialog from "../components/SplitLotDialog"; import { ProduktThumb } from "../components/ProduktBild"; import { useSettings } from "../settings"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; import { locationOptions, locationPathById } from "../locationPath"; import { expiryRowClass, fmt, gebinde, unitShort } from "../units"; @@ -28,6 +30,8 @@ function mengeText(l) { export default function Charges() { const { isAdmin } = useAuth(); const { formatBestBefore } = useSettings(); + const confirm = useConfirm(); + const toast = useToast(); const [lots, setLots] = useState([]); const [locations, setLocations] = useState([]); const [error, setError] = useState(null); @@ -36,6 +40,7 @@ export default function Charges() { const [selected, setSelected] = useState(() => new Set()); const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen 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" 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). async function applyToSelection() { if (selected.size === 0) return; @@ -163,11 +226,11 @@ export default function Charges() { {isAdmin && (
-

Lagerort für ausgewählte Chargen setzen

+

Ausgewählte Chargen

{selected.size > 0 - ? `${selected.size} Charge(n) ausgewählt – diese bekommen den gewählten Lagerort (leer = Ort entfernen).` - : "Chargen in der Tabelle anhaken, dann hier den Lagerort setzen. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."} + ? `${selected.size} Charge(n) ausgewählt.` + : "Chargen in der Tabelle anhaken, dann hier handeln. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."}

+
+ + +
+
+ + +
+ {selected.size > 0 && !einArtikel && ( +

+ Eine gemeinsame Menge geht nur bei einem einzigen Artikel – „1 Packung" + bedeutet bei Milch und Brötchen Verschiedenes. Komplett ausbuchen geht + immer. +

+ )}