Backend: verschachtelte Ort-Mindestbestaende hierarchisch verrechnen

shopping_list_by_location zaehlte Ober- und Unterort unabhaengig, obwohl der
Ober-Subtree den Unterort schon enthaelt - Bedarf wurde doppelt gemeldet. Jetzt
werden Bedarfe je Produkt/Gruppe von unten nach oben verrechnet (_netted_topups):
was in einen Unterort gekauft wird, deckt den Oberort mit. Im Mehl-Beispiel (Lemgo
5, Kueche 2, je 1 fehlend) meldet der Server nur noch 1 (Kueche) statt 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 09:32:45 +02:00
parent 39803dfb82
commit f78ae924de
2 changed files with 110 additions and 21 deletions

View File

@@ -1,5 +1,6 @@
from collections import defaultdict from collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
from typing import Callable
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -109,46 +110,86 @@ def group_shopping_list(
return items 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]) @router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
def shopping_list_by_location( def shopping_list_by_location(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[LocationNeeds]: ) -> list[LocationNeeds]:
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT """Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
unter dem dort hinterlegten Mindestbestand liegt.""" unter dem dort hinterlegten Mindestbestand liegt."""
prod_needs: dict[int, list[LocationNeedProduct]] = defaultdict(list) prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[int, list[LocationNeedGroup]] = 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(): 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: if product is None:
continue continue
faktor, label = article_unit(product) faktor, label = article_unit(product)
stock = location_subtree_stock_base(db, product, e.location_id) / (faktor or 1.0) faktor = faktor or 1.0
if stock < e.min_stock: locs_min = {e.location_id: e.min_stock for e in entries}
prod_needs[e.location_id].append(LocationNeedProduct( 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, product_id=product.id, name=product.name, unit_label=label,
stock=round(stock, 3), min_stock=e.min_stock, stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(e.min_stock - stock, 3), 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(): 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: if group is None:
continue continue
unit = group.min_stock_unit unit = group.min_stock_unit
if unit is not None: if unit is not None:
base = BASE_OF_KIND[unit.kind] base = BASE_OF_KIND[unit.kind]
matching = [p for p in group.products if p.base_unit == base] 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 divisor, unit_name = unit.factor, unit.name
unit_name = unit.name
else: else:
stock = float(sum(location_subtree_stock_base(db, p, e.location_id) for p in group.products)) matching, divisor, unit_name = list(group.products), 1.0, ""
unit_name = "" locs_min = {e.location_id: e.min_stock for e in entries}
if stock < e.min_stock: bestand = {
group_needs[e.location_id].append(LocationNeedGroup( 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, group_id=group.id, name=group.name, unit_name=unit_name,
stock=round(stock, 3), min_stock=e.min_stock, stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(e.min_stock - stock, 3), deficit=round(need, 3),
)) ))
loc_ids = set(prod_needs) | set(group_needs) loc_ids = set(prod_needs) | set(group_needs)

View File

@@ -96,3 +96,51 @@ def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
needs = shopping_list_by_location(db=db, _=user) needs = shopping_list_by_location(db=db, _=user)
assert needs[0].location_name == "Hedingen" assert needs[0].location_name == "Hedingen"
assert needs[0].products[0].deficit == 3 # 5 - 2 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