diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index 5c752e5..d9c10be 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -280,6 +280,11 @@ def list_all_lots( unit_name=name, unit_factor=factor, best_before=lot.best_before, best_before_precision=lot.best_before_precision, location_id=lot.location_id, created_at=lot.created_at, + # Angebrochen: ohne diese drei Felder saehe die Liste eine offene + # Packung wie jede andere Charge - samt falscher Ablaufwarnung, + # denn gewarnt wird nach dem fruheren der beiden Daten. + opened_on=lot.opened_on, consume_by=lot.consume_by, + opened_quantity=lot.opened_quantity, )) return rows diff --git a/backend/tests/test_anbrechen.py b/backend/tests/test_anbrechen.py index 60c69d6..8cc59d4 100644 --- a/backend/tests/test_anbrechen.py +++ b/backend/tests/test_anbrechen.py @@ -17,7 +17,7 @@ from app.models import ( Setting, User, ) -from app.routers.stock import consume_lot, open_lot +from app.routers.stock import consume_lot, list_all_lots, open_lot from app.schemas import LotConsume, LotOpen from app.services.stock import check_in, current_stock from app.services.vererbung import frist_herkunft, oeffnungsfrist @@ -274,3 +274,26 @@ def test_kaputte_globale_einstellung_faellt_auf_den_standard(db, milch): db.add(Setting(key="open_days", value="viele")) db.commit() assert oeffnungsfrist(db, milch) == 3 + + +def test_die_chargenliste_zeigt_den_offenen_zustand(db, user, milch): + """Ohne diese Felder saehe die Liste eine offene Packung wie jede andere. + + Die Zeile traegt dann auch die falsche Ablaufwarnung, denn gewarnt wird nach + dem fruheren von MHD und Verbrauchsdatum. + """ + check_in(db, milch, 4000, "ml", date.today() + timedelta(days=30), None, None) + db.commit() + [charge] = db.query(Lot).all() + + open_lot(charge.id, LotOpen(quantity=None), db=db, _=user) + + zeilen = {z.quantity: z for z in list_all_lots(db=db, _=user)} + offen = zeilen[1000] + zu = zeilen[3000] + + assert offen.opened_on == date.today() + assert offen.consume_by == date.today() + timedelta(days=3) + assert offen.opened_quantity == 1000 + assert zu.opened_on is None + assert zu.consume_by is None diff --git a/web/src/api.js b/web/src/api.js index cb05de6..e72fe91 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -283,6 +283,16 @@ export const api = { 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 }), + // Anbrechen: spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum. + // Ohne Menge ist es eine Artikeleinheit (bzw. die ganze Charge, wenn kleiner). + openLot: (id, quantity = null) => + request(`/lots/${id}/open`, { method: "POST", body: { quantity } }), + // Teilverbrauch. `auto_open` bricht eine noch geschlossene Charge dabei an. + consumeLot: (id, body) => request(`/lots/${id}/consume`, { method: "POST", body }), + // Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft - Grundlage der + // Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker + // verschieden viel Gramm). + productMeasures: (id) => request(`/products/${id}/measures`), // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste). setProductLocationMinStock: (id, list) => diff --git a/web/src/components/VerbrauchDialog.jsx b/web/src/components/VerbrauchDialog.jsx new file mode 100644 index 0000000..d188b67 --- /dev/null +++ b/web/src/components/VerbrauchDialog.jsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import Icon from "./Icon"; +import { buildUnitOptions, fmt, gebinde, unitShort } from "../units"; + +/** + * Teilmenge aus einer Charge verbrauchen – „ich habe 100 ml von dieser Milch + * genommen", „2 von 6 Brötchen gegessen", „2 EL Mehl". + * + * Eingetippt wird die **verbrauchte** Menge, nicht der Rest: Das ist die Zahl, + * die man kennt. War die Charge noch zu, wird sie dabei angebrochen — eine + * Packung spaltet sich ab, bekommt ihr „zu verbrauchen bis" und der Rest bleibt + * unangetastet mit seinem eigenen MHD. Die Rückfrage dazu steht unten und ist + * abwählbar, falls man nur eine Buchung nachtragen will. + * + * Die Einheitenauswahl holt neben den verwalteten Einheiten auch die Hausmaße + * der Kategorie — „EL" taucht nur auf, wenn dafür eine Menge hinterlegt ist, + * denn ein Esslöffel Mehl und ein Esslöffel Zucker sind verschieden schwer. + */ +export default function VerbrauchDialog({ lot, onClose, onDone, onError }) { + const [produkt, setProdukt] = useState(null); + const [einheiten, setEinheiten] = useState([]); + const [masse, setMasse] = useState([]); + const [menge, setMenge] = useState(""); + const [einheit, setEinheit] = useState(""); + const [notiz, setNotiz] = useState(""); + const [anbrechen, setAnbrechen] = useState(true); + const [busy, setBusy] = useState(false); + const [laedt, setLaedt] = useState(true); + + useEffect(() => { + let abgebrochen = false; + (async () => { + try { + const [p, us, ms] = await Promise.all([ + api.getProduct(lot.product_id), + api.listUnits(), + api.productMeasures(lot.product_id), + ]); + if (abgebrochen) return; + setProdukt(p); + setEinheiten(buildUnitOptions(p, us)); + setMasse(ms); + // Vorbelegung: die Basiseinheit. Beim Verbrauchen ist das die + // naheliegende Angabe („100 ml"), nicht die ganze Packung. + setEinheit(unitStandard(p, us)); + } catch (err) { + onError?.(err.message); + onClose(); + } finally { + if (!abgebrochen) setLaedt(false); + } + })(); + return () => { abgebrochen = true; }; + }, [lot.product_id]); + + const zahl = Number(String(menge).trim().replace(",", ".")); + const gueltig = menge !== "" && Number.isFinite(zahl) && zahl > 0; + const offen = !!lot.opened_on; + + async function submit() { + if (!gueltig || busy || !einheit) return; + setBusy(true); + try { + const antwort = await api.consumeLot(lot.id, { + quantity: zahl, + unit: einheit, + note: notiz.trim() || null, + auto_open: offen ? false : anbrechen, + }); + onClose(); + await onDone(antwort); + } catch (err) { + onError?.(err.message); + } finally { + setBusy(false); + } + } + + const basis = unitShort(lot.base_unit); + const restText = lot.package_size > 0 + ? `${fmt(lot.quantity / lot.package_size)} ${gebinde(lot.quantity / lot.package_size, lot.package_label)} (${fmt(lot.quantity)} ${basis})` + : `${fmt(lot.quantity)} ${basis}`; + + return ( +
+ {lot.product_name}: aktuell {restText} + {offen && lot.opened_quantity + ? ` — angebrochen, ursprünglich ${fmt(lot.opened_quantity)} ${basis}.` + : "."} +
+ + {laedt &&Einheiten werden geladen…
} + + {!laedt && ( ++ Für die Kategorie dieses Artikels sind keine Hausmaße hinterlegt – + „2 EL" lässt sich deshalb noch nicht buchen. Mengen dafür stehen + unter Stammdaten ▸ Maße. +
+ )} + + {!offen && ( + + )} + + +