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() {