diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py
index 26faffb..cf04048 100644
--- a/backend/app/routers/views.py
+++ b/backend/app/routers/views.py
@@ -26,7 +26,7 @@ from ..schemas import (
ShoppingItem,
)
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info
-from ..services.stock import current_stock, location_stock_base
+from ..services.stock import current_stock, location_subtree_stock_base
from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"])
@@ -115,7 +115,7 @@ def shopping_list_by_location(
if product is None:
continue
faktor, label = article_unit(product)
- stock = location_stock_base(db, product, e.location_id) / (faktor or 1.0)
+ stock = location_subtree_stock_base(db, product, e.location_id) / (faktor or 1.0)
if stock < e.min_stock:
prod_needs[e.location_id].append(LocationNeedProduct(
product_id=product.id, name=product.name, unit_label=label,
@@ -131,10 +131,10 @@ def shopping_list_by_location(
if unit is not None:
base = BASE_OF_KIND[unit.kind]
matching = [p for p in group.products if p.base_unit == base]
- stock = sum(location_stock_base(db, p, e.location_id) for p in matching) / unit.factor
+ stock = sum(location_subtree_stock_base(db, p, e.location_id) for p in matching) / unit.factor
unit_name = unit.name
else:
- stock = float(sum(location_stock_base(db, p, e.location_id) for p in group.products))
+ stock = float(sum(location_subtree_stock_base(db, p, e.location_id) for p in group.products))
unit_name = ""
if stock < e.min_stock:
group_needs[e.location_id].append(LocationNeedGroup(
diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py
index baedc02..8b9aeba 100644
--- a/backend/app/services/stock.py
+++ b/backend/app/services/stock.py
@@ -7,7 +7,16 @@ from datetime import date
from sqlalchemy import asc
from sqlalchemy.orm import Session
-from ..models import DatePrecision, Item, Lot, Movement, MovementType, Product, User
+from ..models import (
+ DatePrecision,
+ Item,
+ Location,
+ Lot,
+ Movement,
+ MovementType,
+ Product,
+ User,
+)
from .conversion import to_base
from .dates import clean_precision, normalize_best_before
@@ -226,6 +235,29 @@ def location_stock_base(db: Session, product: Product, location_id: int) -> floa
return float(sum(q for (q,) in total))
+def descendant_location_ids(db: Session, location_id: int) -> set[int]:
+ """Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
+ result: set[int] = set()
+ stack = [location_id]
+ while stack:
+ cur = stack.pop()
+ for (lid,) in db.query(Location.id).filter(Location.parent_id == cur).all():
+ if lid not in result:
+ result.add(lid)
+ stack.append(lid)
+ return result
+
+
+def location_subtree_stock_base(db: Session, product: Product, location_id: int) -> float:
+ """Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
+
+ So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
+ irgendwo darunter liegt (z.B. „Hedingen → Keller").
+ """
+ ids = {location_id} | descendant_location_ids(db, location_id)
+ return float(sum(location_stock_base(db, product, lid) for lid in ids))
+
+
def check_in(
db: Session,
product: Product,
diff --git a/backend/tests/test_location_needs.py b/backend/tests/test_location_needs.py
index 5ca0b42..ae53ef5 100644
--- a/backend/tests/test_location_needs.py
+++ b/backend/tests/test_location_needs.py
@@ -60,3 +60,39 @@ def test_gedeckter_ort_erscheint_nicht(db, user):
# Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag.
assert shopping_list_by_location(db=db, _=user) == []
+
+
+def test_bestand_in_unterlagerort_zaehlt_zum_oberort(db, user):
+ p = Product(name="Konserve", base_unit=BaseUnit.gram, package_size=1)
+ db.add(p)
+ db.flush()
+ haus = Location(name="Hedingen")
+ db.add(haus)
+ db.flush()
+ keller = Location(name="Keller", parent_id=haus.id)
+ db.add(keller)
+ db.flush()
+ # Vorrat liegt im Unter-Lagerort; Mindestbestand ist auf dem Ober-Lagerort.
+ db.add(Lot(product_id=p.id, quantity=5, location_id=keller.id))
+ db.add(ProductLocationMinStock(product_id=p.id, location_id=haus.id, min_stock=3))
+ db.commit()
+ # 5 (im Keller) deckt die 3 auf Hedingen -> kein Bedarf.
+ assert shopping_list_by_location(db=db, _=user) == []
+
+
+def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
+ p = Product(name="Konserve", base_unit=BaseUnit.gram, package_size=1)
+ db.add(p)
+ db.flush()
+ haus = Location(name="Hedingen")
+ db.add(haus)
+ db.flush()
+ keller = Location(name="Keller", parent_id=haus.id)
+ db.add(keller)
+ db.flush()
+ db.add(Lot(product_id=p.id, quantity=2, location_id=keller.id))
+ db.add(ProductLocationMinStock(product_id=p.id, location_id=haus.id, min_stock=5))
+ db.commit()
+ needs = shopping_list_by_location(db=db, _=user)
+ assert needs[0].location_name == "Hedingen"
+ assert needs[0].products[0].deficit == 3 # 5 - 2
diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx
index 6b8244d..990f369 100644
--- a/web/src/pages/Locations.jsx
+++ b/web/src/pages/Locations.jsx
@@ -139,14 +139,12 @@ export default function Locations() {