From 53a8b9e0b6344773dcd3fcef6580c29e719e8c46 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 14:17:20 +0200 Subject: [PATCH] Anbrechen und Teilverbrauch bekommen Knoepfe Das Backend konnte das seit dem letzten Commit, aber ohne Oberflaeche kam niemand dran. Jetzt traegt jede Charge zwei Knoepfe: "Anbrechen" spaltet eine Packung ab, gibt ihr ein "zu verbrauchen bis" und laesst den Rest ungeoeffnet mit seinem eigenen MHD - mit Rueckfrage, weil sich die abgespaltene Packung danach anders verhaelt. "Verbrauchen" nimmt die VERBRAUCHTE Menge ("100 ml genommen", "2 von 6 Broetchen"), nicht den Rest - das ist die Zahl, die man kennt. War die Charge noch zu, wird sie dabei angebrochen; der Haken dafuer ist abwaehlbar, falls man nur eine Buchung nachtraegt. Die Einheitenauswahl holt die Hausmasse der Kategorie dazu, deshalb laesst sich "2 EL" buchen und landet bei Mehl als 20 g im Bestand. Die Liste zeigt den Zustand jetzt auch: Spalte "Zustand" (angebrochen), Spalte "Verbrauchen bis", und bei einer offenen Packung die Restmenge in Basiseinheiten statt im Gebinde - "980 g von 1.000 g" sagt etwas, "0,98 Packungen" nicht. Gewarnt wird nach dem FRUEHEREN von MHD und Verbrauchsdatum (units.js:: faelligAm, Spiegelbild von Lot.faellig_am): Eine angebrochene Milch ist nicht mehr bis zum aufgedruckten Datum gut. Dafuer ein eigenes Farb-Token --open in ruhigem Blaugrau - angebrochen ist ein Zustand, keine Warnung. Dazu ein Fehler, der die ganze Anzeige wertlos gemacht haette: In list_all_lots fehlten opened_on, consume_by und opened_quantity. Die Liste haette eine offene Packung wie jede andere Charge gezeigt, samt falscher Ablaufwarnung. Test dagegen. Von Hand durchgeklickt: 4x1l Milch anbrechen ergibt 1 l offen mit Verbrauchsdatum 5.10. und 3 l zu mit MHD 15.11.; "2 EL" aus dem Mehl ergibt 980 g offen und 1 Packung zu. Suite bei 495. Co-Authored-By: Claude Opus 5 --- backend/app/routers/stock.py | 5 + backend/tests/test_anbrechen.py | 25 +++- web/src/api.js | 10 ++ web/src/components/VerbrauchDialog.jsx | 177 +++++++++++++++++++++++++ web/src/pages/Charges.jsx | 95 ++++++++++++- web/src/styles.css | 9 ++ web/src/units.js | 9 ++ 7 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 web/src/components/VerbrauchDialog.jsx 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 ? (