diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index 68fb0f7..a555a10 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -21,6 +21,7 @@ from ..models import ( ) from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut from ..services.fields import effective_field_definitions +from ..services.vererbung import geerbte_kategorie_frist from .field_definitions import to_out as field_to_out router = APIRouter(prefix="/categories", tags=["categories"]) @@ -54,6 +55,7 @@ def _to_out(db: Session, category: Category) -> CategoryOut: out.product_count = ( db.query(Product).filter(Product.category_id == category.id).count() ) + out.inherited_open_days, out.open_days_source = geerbte_kategorie_frist(db, category) return out diff --git a/backend/app/routers/measures.py b/backend/app/routers/measures.py index 3085bba..b7914c0 100644 --- a/backend/app/routers/measures.py +++ b/backend/app/routers/measures.py @@ -30,7 +30,7 @@ from ..schemas import ( MeasureOut, ) from ..services.conversion import PACKAGE_TOKENS, find_unit -from ..services.vererbung import verfuegbare_masse +from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse router = APIRouter(tags=["measures"]) @@ -224,13 +224,17 @@ def delete_product_measure( @router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut]) def category_measures( category_id: int, + vererbt: bool = False, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[MeasureAmountOut]: - """Die an DIESER Kategorie hinterlegten Mengen (ohne die geerbten). + """Die an DIESER Kategorie hinterlegten Mengen. - Bewusst ohne Vererbung: Diese Liste ist die Bearbeitungsansicht. Was ein - Artikel am Ende wirklich bekommt, zeigt ``/products/{id}/measures``. + Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit + ``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer + Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was + gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende + bekommt, zeigt ``/products/{id}/measures``. """ if db.get(Category, category_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden") @@ -241,13 +245,31 @@ def category_measures( .order_by(Measure.position, Measure.id) .all() ) - return [ + eigene = [ MeasureAmountOut( measure_id=m.id, name=m.name, short=m.short, amount=cm.amount, base=cm.base, herkunft="Kategorie", ) for cm, m in rows ] + if not vererbt: + return eigene + + # Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit + # beides: was hier steht und was gelten wuerde, wenn man es loescht. + category = db.get(Category, category_id) + gesetzt = {e.measure_id for e in eigene} + for measure in db.query(Measure).order_by(Measure.position, Measure.id).all(): + if measure.id in gesetzt: + continue + angabe = geerbtes_mass_kategorie(db, category, measure.id) + if angabe is None: + continue + eigene.append(MeasureAmountOut( + measure_id=measure.id, name=measure.name, short=measure.short, + amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft, + )) + return eigene @router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 417f4de..1e758ce 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -278,6 +278,11 @@ class CategoryOut(BaseModel): # Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der # Oberkategorie, zuletzt vom globalen Wert. open_days: int | None = None + # Was gelten wuerde, wenn ``open_days`` leer bleibt – samt Herkunft + # („Kategorie: Molkerei" / „Einstellungen"). Fuer den Platzhalter in der + # Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein. + inherited_open_days: int = 0 + open_days_source: str = "" class CategoryCreate(BaseModel): diff --git a/backend/app/services/vererbung.py b/backend/app/services/vererbung.py index 14963be..41383ff 100644 --- a/backend/app/services/vererbung.py +++ b/backend/app/services/vererbung.py @@ -104,6 +104,19 @@ def frist_herkunft(db: Session, product: Product) -> tuple[int, str]: return globale_oeffnungsfrist(db), "Einstellungen" +def geerbte_kategorie_frist(db: Session, category) -> tuple[int, str]: + """Die Frist, die fuer diese Kategorie gelten WUERDE, haette sie keine eigene. + + Gefragt wird also nach dem Platzhalter in der Maske: Was passiert, wenn ich + das Feld leer lasse? Deshalb beginnt die Suche beim Elternteil, nicht bei + der Kategorie selbst. + """ + for cat in _vorfahren_abwaerts(db, category.parent_id): + if cat.open_days is not None and cat.open_days > 0: + return cat.open_days, f"Kategorie: {cat.name}" + return globale_oeffnungsfrist(db), "Einstellungen" + + # -------------------------------------------------------------------------- # Hausmaße # -------------------------------------------------------------------------- @@ -147,6 +160,31 @@ def _mass_fuer_artikel(db: Session, product: Product, measure_id: int) -> MassAn return None +def geerbtes_mass_kategorie( + db: Session, category, measure_id: int +) -> MassAngabe | None: + """Was eine Kategorie fuer dieses Mass ERBT, ohne ihren eigenen Eintrag. + + Dasselbe wie bei der Frist: Die Maske soll zeigen, was gilt, wenn das Feld + leer bleibt. Deshalb beginnt die Suche beim Elternteil. + """ + measure = db.get(Measure, measure_id) + if measure is None: + return None + for cat in _vorfahren_abwaerts(db, category.parent_id): + row = ( + db.query(CategoryMeasure) + .filter( + CategoryMeasure.category_id == cat.id, + CategoryMeasure.measure_id == measure_id, + ) + .first() + ) + if row is not None: + return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}") + return None + + def verfuegbare_masse(db: Session, product: Product) -> list[MassAngabe]: """Alle Hausmaße, die für diesen Artikel eine Menge haben. diff --git a/backend/tests/test_hausmasse.py b/backend/tests/test_hausmasse.py index 8ed62ae..f2c56bc 100644 --- a/backend/tests/test_hausmasse.py +++ b/backend/tests/test_hausmasse.py @@ -15,10 +15,15 @@ from app.models import ( Product, ProductMeasure, ) +from app.routers.measures import category_measures from app.seed import ensure_builtin_measures from app.services.conversion import ConversionError, to_base from app.services.stock import check_in, check_out, current_stock -from app.services.vererbung import mass_aufloesen, verfuegbare_masse +from app.services.vererbung import ( + geerbte_kategorie_frist, + mass_aufloesen, + verfuegbare_masse, +) @pytest.fixture() @@ -253,3 +258,54 @@ def test_artikel_ohne_kategorie_hat_keine_masse(db, masse): assert verfuegbare_masse(db, product) == [] assert mass_aufloesen(db, product, "EL") is None + + +# -------------------------------------------------------------------------- +# Bearbeitungsansicht: eigene und geerbte Mengen +# -------------------------------------------------------------------------- +def test_kategorie_sieht_ihre_eigenen_mengen(db, masse): + """Ohne Vererbung ist die Liste die reine Bearbeitungsansicht.""" + ober = _kategorie(db, "Mehl & Zucker") + _mass_an_kategorie(db, masse["Esslöffel"], ober, 10) + + liste = category_measures(ober.id, db=db, _=None) + + assert [(e.name, e.amount, e.herkunft) for e in liste] == [("Esslöffel", 10, "Kategorie")] + + +def test_unterkategorie_zeigt_das_geerbte_als_solches(db, masse): + """Der Platzhalter in der Maske: Was gilt, wenn ich das Feld leer lasse?""" + ober = _kategorie(db, "Mehl & Zucker") + unter = _kategorie(db, "Backzutaten", parent=ober) + _mass_an_kategorie(db, masse["Esslöffel"], ober, 10) + + ohne = category_measures(unter.id, db=db, _=None) + mit = category_measures(unter.id, vererbt=True, db=db, _=None) + + assert ohne == [] + assert [(e.name, e.amount, e.herkunft) for e in mit] == [ + ("Esslöffel", 10, "Kategorie: Mehl & Zucker"), + ] + + +def test_eigener_wert_verdeckt_den_geerbten_in_der_liste(db, masse): + """Sonst stünde dieselbe Zeile zweimal da, mit verschiedenen Zahlen.""" + ober = _kategorie(db, "Mehl & Zucker") + unter = _kategorie(db, "Backzutaten", parent=ober) + _mass_an_kategorie(db, masse["Esslöffel"], ober, 10) + _mass_an_kategorie(db, masse["Esslöffel"], unter, 12) + + liste = category_measures(unter.id, vererbt=True, db=db, _=None) + + assert [(e.amount, e.herkunft) for e in liste] == [(12, "Kategorie")] + + +def test_geerbte_frist_nennt_die_kategorie_die_sie_setzt(db): + ober = _kategorie(db, "Molkerei") + unter = _kategorie(db, "Frischmilch", parent=ober) + ober.open_days = 5 + db.commit() + + assert geerbte_kategorie_frist(db, unter) == (5, "Kategorie: Molkerei") + # Die Kategorie, die den Wert selbst setzt, erbt ihn NICHT von sich. + assert geerbte_kategorie_frist(db, ober)[1] == "Einstellungen" diff --git a/web/src/App.jsx b/web/src/App.jsx index 83fce71..98cd0d4 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -23,6 +23,7 @@ import ItemResolve from "./pages/ItemResolve"; import LocationResolve from "./pages/LocationResolve"; import Locations from "./pages/Locations"; import PackageTypes from "./pages/PackageTypes"; +import Masse from "./pages/Masse"; import Units from "./pages/Units"; import Users from "./pages/Users"; import ShoppingList from "./pages/ShoppingList"; @@ -156,6 +157,7 @@ function Sidebar() { +
System
@@ -258,6 +260,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index cd878bb..a208f53 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -293,6 +293,24 @@ export const api = { // Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker // verschieden viel Gramm). productMeasures: (id) => request(`/products/${id}/measures`), + // Hausmasse: die NAMEN sind global, die MENGEN haengen an Kategorie bzw. + // Artikel - "1 EL" ist bei Mehl und Zucker verschieden schwer. + listMeasures: () => request("/measures"), + createMeasure: (body) => request("/measures", { method: "POST", body }), + updateMeasure: (id, body) => request(`/measures/${id}`, { method: "PATCH", body }), + deleteMeasure: (id) => request(`/measures/${id}`, { method: "DELETE" }), + // `vererbt` holt zusaetzlich, was die Kategorie von oben erbt - die Maske + // zeigt damit auch, was gelten wuerde, wenn man den eigenen Eintrag loescht. + categoryMeasures: (id, vererbt = false) => + request(`/categories/${id}/measures${vererbt ? "?vererbt=true" : ""}`), + setCategoryMeasure: (catId, measureId, body) => + request(`/categories/${catId}/measures/${measureId}`, { method: "PUT", body }), + deleteCategoryMeasure: (catId, measureId) => + request(`/categories/${catId}/measures/${measureId}`, { method: "DELETE" }), + setProductMeasure: (productId, measureId, body) => + request(`/products/${productId}/measures/${measureId}`, { method: "PUT", body }), + deleteProductMeasure: (productId, measureId) => + request(`/products/${productId}/measures/${measureId}`, { method: "DELETE" }), // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste). setProductLocationMinStock: (id, list) => diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index bd6a3ba..cb2a80b 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -24,6 +24,8 @@ export default function Categories() { const [error, setError] = useState(null); // Kategorie, deren Felder gerade verwaltet werden. const [feldKat, setFeldKat] = useState(null); + // Welche Kategorie gerade ihre Masse und ihre Oeffnungsfrist zeigt. + const [massKat, setMassKat] = useState(null); // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. const [typFilter, setTypFilter] = useState(""); const [loading, setLoading] = useState(true); @@ -131,13 +133,17 @@ export default function Categories() { } }, { key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count, render: (c) => {c.product_count} }, - { key: "actions", header: "", label: "Aktionen", fixed: true, width: 96, align: "num", + { key: "actions", header: "", label: "Aktionen", fixed: true, width: 132, align: "num", render: (c) => ( + {isAdmin && ( + + {error &&
{error}
} + + +

+ Gilt für angebrochene Packungen dieser Kategorie und aller darunter. + Leer lassen heißt erben. +

+ +
+

Küchenmaße

+
+ {loading &&

Wird geladen…

} + {!loading && masse.length === 0 && ( +

+ Es gibt noch keine Maße. Angelegt werden sie unter Stammdaten ▸ Maße. +

+ )} + {!loading && masse.map((m) => { + const w = wertFuer(m.id); + return ( + speichern(m, menge, basis)} + onEntfernen={async () => { + const ok = await confirm({ + title: `„${m.name}" hier entfernen?`, + message: "Danach erbt die Kategorie den Wert von oben – oder das Maß " + + "gilt hier gar nicht mehr.", + confirmLabel: "Entfernen", + }); + if (ok) entfernen(m); + }} /> + ); + })} +

+ „1 EL Mehl ≈ 10 g", „1 Tasse Milch ≈ 200 ml". Damit lässt sich beim + Verbrauchen „2 EL" buchen, statt Gramm zu schätzen. Die Art (Gramm, + Milliliter, Stück) gehört zur Menge, nicht zum Namen: Ein Esslöffel Öl + ist Milliliter, einer Mehl ist Gramm. +

+ + ); +} + +/** Eine Maß-Zeile: eigene Menge eintragen, geerbte als Platzhalter zeigen. */ +function MassZeile({ mass, wert, eigen, isAdmin, onSpeichern, onEntfernen }) { + const [menge, setMenge] = useState(eigen && wert ? String(wert.amount) : ""); + const [basis, setBasis] = useState(wert ? wert.base : "gram"); + + // Nach dem Neuladen (Speichern, Entfernen) die Felder nachziehen. + useEffect(() => { + setMenge(eigen && wert ? String(wert.amount) : ""); + setBasis(wert ? wert.base : "gram"); + }, [wert?.amount, wert?.base, eigen]); + + const geerbt = wert && !eigen; + + return ( +
+ + + {geerbt && ( + geerbt + )} + {eigen && isAdmin && ( + + )} +
+ ); +} diff --git a/web/src/pages/Masse.jsx b/web/src/pages/Masse.jsx new file mode 100644 index 0000000..eeb167b --- /dev/null +++ b/web/src/pages/Masse.jsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { useConfirm } from "../confirm"; +import Icon from "../components/Icon"; +import DataTable from "../components/DataTable"; + +/** + * Hausmaße – Stammdaten. + * + * Hier stehen nur die **Namen** („Esslöffel", „Tasse"). Die Mengen gehören + * nicht hierher, sondern an die Kategorie oder den Artikel: Ein Esslöffel Mehl + * sind etwa 10 g, einer Zucker etwa 12 g, und eine Tasse Milch ist Milliliter. + * Ein globaler Wert wäre bei jedem zweiten Artikel falsch. + * + * Deshalb ist diese Seite absichtlich kurz. Die Arbeit passiert unter + * Kategorien ▸ Maße & Frist. + */ +export default function Masse() { + const confirm = useConfirm(); + const [masse, setMasse] = useState([]); + const [form, setForm] = useState({ name: "", short: "" }); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + try { + setMasse(await api.listMeasures()); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + useEffect(() => { load(); }, []); + + async function add(e) { + e.preventDefault(); + setError(null); + try { + await api.createMeasure({ + name: form.name.trim(), + short: form.short.trim() || null, + position: masse.length, + }); + setForm({ name: "", short: "" }); + load(); + } catch (err) { setError(err.message); } + } + + async function patch(mass, body) { + setError(null); + try { + await api.updateMeasure(mass.id, { + name: body.name ?? mass.name, + short: body.short !== undefined ? body.short : mass.short, + position: mass.position, + }); + load(); + } catch (err) { setError(err.message); load(); } + } + + async function remove(mass) { + const ok = await confirm({ + title: `Maß „${mass.name}" löschen?`, + message: "Die bei Kategorien und Artikeln hinterlegten Mengen dieses Maßes " + + "verschwinden mit. Buchungen, die damit gemacht wurden, bleiben.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; + try { + await api.deleteMeasure(mass.id); + load(); + } catch (err) { setError(err.message); } + } + + const columns = [ + { key: "name", header: "Name", grow: true, min: 160, + filterText: (m) => m.name, sortValue: (m) => m.name, + render: (m) => ( + + { + const v = e.target.value.trim(); + if (v && v !== m.name) patch(m, { name: v }); + }} /> + {m.is_builtin && eingebaut} + + ) }, + { key: "short", header: "Kurzform", width: 160, + filterText: (m) => m.short || "", + render: (m) => ( + { + const v = e.target.value.trim(); + if (v !== (m.short || "")) patch(m, { short: v || null }); + }} /> + ) }, + { key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num", + render: (m) => ( + + ) }, + ]; + + return ( +
+
+
+

Maße

+
+ Küchenmaße zum Verbrauchen: „2 EL Mehl", „1 Tasse Milch". + Wie viel das ist, steht je Kategorie bzw. Artikel. +
+
+
+ {error &&
{error}
} + +
+
+ m.id} empty="Noch keine Maße." /> +
+ +
+
+

Neues Maß

+ + + +

+ Ein Name darf nicht mit einer echten Einheit zusammenfallen: Ein + Hausmaß namens „Liter" würde die Einheit verdecken, und ein + geschätzter Liter wäre etwas anderes als ein gemessener. +

+
+ +
+

Wo die Mengen stehen

+

+ Unter Kategorien öffnet der Knopf „Maße & Frist" + je Kategorie die Mengen – dort trägst du ein, dass ein Esslöffel bei + Mehl 10 g sind. Unterkategorien erben das; der nächste Vorfahr mit + einem Eintrag gewinnt. Einzelne Artikel können davon abweichen. +

+
+
+
+
+ ); +}