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:
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user