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

+ )}