diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index f3ccf4d..f96a966 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -1,5 +1,6 @@ from collections import defaultdict from datetime import date, timedelta +from typing import Callable from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -109,47 +110,87 @@ def group_shopping_list( return items +def _netted_topups( + db: Session, locs_min: dict[str, float], stock_of: Callable[[str], float] +) -> dict[str, float]: + """Bedarf je Ort mit verschachtelten Orten verrechnet: Was in einen Unterort + gekauft wird, liegt auch im Subtree des Oberorts und deckt dessen Bedarf mit. + ``topup(ort)`` ist die je Ort ZUSÄTZLICH nötige Menge – über die Käufe in den + Unterorten hinaus. So kostet „Lemgo braucht 5, Küche braucht 2" bei je 1 fehlend + nur 1 (in die Küche), nicht 2.""" + locs = list(locs_min) + # Nachkommen-Bedarfsorte je Ort (im Lagerort-Baum), memoisiert von unten nach oben. + desc = { + loc: [d for d in locs if d != loc and d in descendant_location_ids(db, loc)] + for loc in locs + } + memo: dict[str, float] = {} + + def topup(loc: str) -> float: + if loc not in memo: + committed = sum(topup(d) for d in desc[loc]) + memo[loc] = max(0.0, locs_min[loc] - (stock_of(loc) + committed)) + return memo[loc] + + return {loc: topup(loc) for loc in locs} + + @router.get("/shopping-list/by-location", response_model=list[LocationNeeds]) def shopping_list_by_location( db: Session = Depends(get_db), _: User = Depends(get_current_user) ) -> list[LocationNeeds]: """Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT unter dem dort hinterlegten Mindestbestand liegt.""" - prod_needs: dict[int, list[LocationNeedProduct]] = defaultdict(list) - group_needs: dict[int, list[LocationNeedGroup]] = defaultdict(list) + prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list) + group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list) + # Je Produkt alle Ort-Mindestbestände sammeln und hierarchisch verrechnen. + prod_by_id: dict[int, list[ProductLocationMinStock]] = defaultdict(list) for e in db.query(ProductLocationMinStock).all(): - product = db.get(Product, e.product_id) + prod_by_id[e.product_id].append(e) + for product_id, entries in prod_by_id.items(): + product = db.get(Product, product_id) if product is None: continue faktor, label = article_unit(product) - 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, - stock=round(stock, 3), min_stock=e.min_stock, - deficit=round(e.min_stock - stock, 3), - )) + faktor = faktor or 1.0 + locs_min = {e.location_id: e.min_stock for e in entries} + bestand = {loc: location_subtree_stock_base(db, product, loc) / faktor for loc in locs_min} + for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): + if need > 1e-9: + prod_needs[loc].append(LocationNeedProduct( + product_id=product.id, name=product.name, unit_label=label, + stock=round(bestand[loc], 3), min_stock=locs_min[loc], + deficit=round(need, 3), + )) + # Je Gruppe genauso – Bestand je Ort ist die Summe der passenden Produkte im Subtree. + group_by_id: dict[int, list[GroupLocationMinStock]] = defaultdict(list) for e in db.query(GroupLocationMinStock).all(): - group = db.get(Group, e.group_id) + group_by_id[e.group_id].append(e) + for group_id, entries in group_by_id.items(): + group = db.get(Group, group_id) if group is None: continue unit = group.min_stock_unit 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_subtree_stock_base(db, p, e.location_id) for p in matching) / unit.factor - unit_name = unit.name + divisor, unit_name = unit.factor, unit.name else: - 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( - group_id=group.id, name=group.name, unit_name=unit_name, - stock=round(stock, 3), min_stock=e.min_stock, - deficit=round(e.min_stock - stock, 3), - )) + matching, divisor, unit_name = list(group.products), 1.0, "" + locs_min = {e.location_id: e.min_stock for e in entries} + bestand = { + loc: sum(location_subtree_stock_base(db, p, loc) for p in matching) / divisor + for loc in locs_min + } + for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): + if need > 1e-9: + group_needs[loc].append(LocationNeedGroup( + group_id=group.id, name=group.name, unit_name=unit_name, + stock=round(bestand[loc], 3), min_stock=locs_min[loc], + deficit=round(need, 3), + )) loc_ids = set(prod_needs) | set(group_needs) namen = { diff --git a/backend/tests/test_location_needs.py b/backend/tests/test_location_needs.py index ae53ef5..03de0c2 100644 --- a/backend/tests/test_location_needs.py +++ b/backend/tests/test_location_needs.py @@ -96,3 +96,51 @@ def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user): needs = shopping_list_by_location(db=db, _=user) assert needs[0].location_name == "Hedingen" assert needs[0].products[0].deficit == 3 # 5 - 2 + + +def test_verschachtelte_orte_werden_verrechnet(db, user): + """Ober- UND Unterort haben einen Mindestbestand. Was in den Unterort gekauft + wird, liegt im Subtree des Oberorts und deckt ihn mit – dann taucht der Oberort + nicht mehr auf (keine Doppelzählung).""" + p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1) + db.add(p) + db.flush() + lemgo = Location(name="Lemgo") + db.add(lemgo) + db.flush() + kueche = Location(name="Kueche", parent_id=lemgo.id) + db.add(kueche) + db.flush() + # 3 direkt in Lemgo, 1 in der Kueche -> Lemgo-Subtree = 4, Kueche = 1. + db.add(Lot(product_id=p.id, quantity=3, location_id=lemgo.id)) + db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id)) + db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5)) + db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2)) + db.commit() + + nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)} + # 1 in die Kueche gekauft (2-1) hebt Lemgo auf 5 -> Lemgo verschwindet. + assert set(nach_ort) == {"Kueche"} + assert nach_ort["Kueche"].products[0].deficit == 1 + + +def test_verschachtelte_orte_restbedarf_am_oberort(db, user): + """Deckt der Unterort-Kauf den Oberort nicht ganz, bleibt der Restbedarf am + Oberort – aber die Unterort-Menge wird nicht doppelt gezaehlt.""" + p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1) + db.add(p) + db.flush() + lemgo = Location(name="Lemgo") + db.add(lemgo) + db.flush() + kueche = Location(name="Kueche", parent_id=lemgo.id) + db.add(kueche) + db.flush() + db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id)) # nur 1 in der Kueche + db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5)) + db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2)) + db.commit() + + nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)} + assert nach_ort["Kueche"].products[0].deficit == 1 # 2 - 1 + assert nach_ort["Lemgo"].products[0].deficit == 3 # 5 - (1 da + 1 aus Kueche-Kauf), nicht 4