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 && (