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 ( +
e.target === e.currentTarget && onClose()}> +
+

Verbrauchen

+

+ {lot.product_name}: aktuell {restText} + {offen && lot.opened_quantity + ? ` — angebrochen, ursprünglich ${fmt(lot.opened_quantity)} ${basis}.` + : "."} +

+ + {laedt &&

Einheiten werden geladen…

} + + {!laedt && ( +
+
+ + +
+ + {masse.length === 0 && produkt && ( +

+ 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 && ( + + )} + + +
+ )} + +
+ + +
+
+
+ ); +} + +// Basiseinheit des Artikels als Vorauswahl – beim Verbrauchen zählt die kleine +// Menge, nicht das Gebinde. +function unitStandard(produkt, units) { + const basis = (units || []).find( + (u) => u.factor === 1 && u.kind === produkt.kind, + ); + return basis ? basis.name : (produkt.package_size ? "package" : ""); +} diff --git a/web/src/pages/Charges.jsx b/web/src/pages/Charges.jsx index 1ac907d..5d0725e 100644 --- a/web/src/pages/Charges.jsx +++ b/web/src/pages/Charges.jsx @@ -5,12 +5,13 @@ import { useAuth } from "../auth"; import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; import SplitLotDialog from "../components/SplitLotDialog"; +import VerbrauchDialog from "../components/VerbrauchDialog"; 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"; +import { expiryRowClass, faelligAm, fmt, gebinde, unitShort } from "../units"; // Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit). const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1)); @@ -40,6 +41,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 [verbrauchen, setVerbrauchen] = useState(null); // Charge, aus der verbraucht wird const [teilmenge, setTeilmenge] = useState(""); // Menge für „von jeder abbuchen" const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend" @@ -76,6 +78,27 @@ export default function Charges() { function selectAll(ids) { setSelected(new Set(ids)); } function clearSelection() { setSelected(new Set()); } + // Anbrechen spaltet eine Packung ab; der Rest bleibt zu und behaelt sein MHD. + // Mit Rueckfrage, weil die abgespaltene Packung ein Verbrauchsdatum bekommt + // und sich damit anders verhaelt als vorher. + async function anbrechen(lot) { + const ok = await confirm({ + title: "Eine Packung anbrechen?", + message: 'Eine Packung wird als eigene, offene Charge geführt und bekommt ' + + 'ein „zu verbrauchen bis". Der Rest bleibt ungeöffnet mit seinem MHD.', + confirmLabel: "Anbrechen", + }); + if (!ok) return; + setError(null); + try { + const offen = await api.openLot(lot.id); + await load(); + toast(offen.consume_by + ? `Angebrochen – zu verbrauchen bis ${new Date(offen.consume_by).toLocaleDateString("de-DE")}.` + : "Angebrochen."); + } catch (err) { setError(err.message); } + } + async function setLocation(lotId, locId) { setError(null); try { @@ -181,12 +204,41 @@ export default function Charges() { render: (l) => ( {l.tracking === "object" ? "Gegenstand" : "Lebensmittel"} ) }, - { key: "menge", header: "Menge", width: 150, align: "num", - sortValue: (l) => l.quantity / artFactor(l), render: mengeText }, + { key: "menge", header: "Menge", width: 170, align: "num", + sortValue: (l) => l.quantity / artFactor(l), + // Bei einer offenen Packung zaehlt die Restmenge, nicht das Gebinde: + // "980 g von 1.000 g" sagt etwas, "0,98 Packungen" nicht. + render: (l) => ( + + + {l.opened_on + ? `${fmt(l.quantity)} ${unitShort(l.base_unit)}` + : mengeText(l)} + + {l.opened_on && ( + + offen{l.opened_quantity ? ` · von ${fmt(l.opened_quantity)} ${unitShort(l.base_unit)}` : ""} + + )} + + ) }, + { key: "offen", header: "Zustand", width: 120, + filterText: (l) => (l.opened_on ? "angebrochen" : "ungeöffnet"), + filterValues: (l) => [l.opened_on ? "angebrochen" : "ungeöffnet"], + sortValue: (l) => (l.opened_on ? 0 : 1), + render: (l) => (l.opened_on + ? angebrochen + : –) }, { key: "mhd", header: "MHD", width: 150, filterText: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : ""), sortValue: (l) => l.best_before || "9999-99-99", render: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : –) }, + { key: "consume", header: "Verbrauchen bis", width: 150, + filterText: (l) => (l.consume_by ? new Date(l.consume_by).toLocaleDateString("de-DE") : ""), + sortValue: (l) => l.consume_by || "9999-99-99", + render: (l) => (l.consume_by + ? {new Date(l.consume_by).toLocaleDateString("de-DE")} + : –) }, { key: "location", header: "Lagerort", width: 240, filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"), filterValues: (l) => [l.location_id ? (locationPathById(l.location_id, locations) || "?") : "(ohne)"], @@ -202,6 +254,21 @@ export default function Charges() { { key: "created", header: "Eingelagert", width: 130, sortValue: (l) => new Date(l.created_at).getTime(), render: (l) => {new Date(l.created_at).toLocaleDateString("de-DE")} }, + { key: "verbrauch", header: "Verbrauch", label: "Verbrauch", width: 190, align: "num", + render: (l) => ( + + + {!l.opened_on && ( + + )} + + ) }, { key: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num", render: (l) => (isAdmin ? (