Lagerorte: Baum-Picker beim Anlegen; Unter-Ort-Bestand zählt zum Bedarf

- Web: der übergeordnete Lagerort wird beim Anlegen jetzt über den
  aufklappbaren Baum (CategorySelect) gewählt statt über das flache Dropdown.
- Backend: Mindestbestand je Lagerort wird jetzt gegen den Bestand INKL. aller
  Unter-Lagerorte geprueft. Ein Bedarf auf „Hedingen" gilt also als gedeckt,
  wenn der Vorrat in „Hedingen -> Keller" liegt. Helfer
  location_subtree_stock_base; 2 neue Tests, Suite 157 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 10:50:31 +02:00
parent 4a950acd85
commit 625b6ee263
4 changed files with 79 additions and 13 deletions

View File

@@ -26,7 +26,7 @@ from ..schemas import (
ShoppingItem, ShoppingItem,
) )
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info 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 from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"]) router = APIRouter(tags=["views"])
@@ -115,7 +115,7 @@ def shopping_list_by_location(
if product is None: if product is None:
continue continue
faktor, label = article_unit(product) 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: if stock < e.min_stock:
prod_needs[e.location_id].append(LocationNeedProduct( prod_needs[e.location_id].append(LocationNeedProduct(
product_id=product.id, name=product.name, unit_label=label, product_id=product.id, name=product.name, unit_label=label,
@@ -131,10 +131,10 @@ def shopping_list_by_location(
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_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 unit_name = unit.name
else: 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 = "" unit_name = ""
if stock < e.min_stock: if stock < e.min_stock:
group_needs[e.location_id].append(LocationNeedGroup( group_needs[e.location_id].append(LocationNeedGroup(

View File

@@ -7,7 +7,16 @@ from datetime import date
from sqlalchemy import asc from sqlalchemy import asc
from sqlalchemy.orm import Session 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 .conversion import to_base
from .dates import clean_precision, normalize_best_before 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)) 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( def check_in(
db: Session, db: Session,
product: Product, product: Product,

View File

@@ -60,3 +60,39 @@ def test_gedeckter_ort_erscheint_nicht(db, user):
# Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag. # Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag.
assert shopping_list_by_location(db=db, _=user) == [] 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

View File

@@ -139,14 +139,12 @@ export default function Locations() {
</label> </label>
<label className="grow"> <label className="grow">
Übergeordneter Lagerort (optional) Übergeordneter Lagerort (optional)
<select value={parentId} onChange={(e) => setParentId(e.target.value)}> <CategorySelect
<option value=""> keiner (oberste Ebene) </option> value={parentId === "" ? null : Number(parentId)}
{locations.map((l) => ( nodes={ordered}
<option key={l.id} value={l.id}> rootLabel=" keiner (oberste Ebene) "
{l.parent_id && nameById[l.parent_id] ? `${nameById[l.parent_id]}` : ""}{l.name} onChange={(id) => setParentId(id == null ? "" : String(id))}
</option> />
))}
</select>
</label> </label>
</div> </div>
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button> <button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>