- 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>
243 lines
8.7 KiB
Python
243 lines
8.7 KiB
Python
from collections import defaultdict
|
|
from datetime import date, timedelta
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..database import get_db
|
|
from ..deps import get_current_user
|
|
from ..models import (
|
|
Group,
|
|
GroupLocationMinStock,
|
|
Location,
|
|
Lot,
|
|
Movement,
|
|
Product,
|
|
ProductLocationMinStock,
|
|
User,
|
|
)
|
|
from ..schemas import (
|
|
ExpiringItem,
|
|
GroupShoppingItem,
|
|
LocationNeedGroup,
|
|
LocationNeedProduct,
|
|
LocationNeeds,
|
|
MovementOut,
|
|
ShoppingItem,
|
|
)
|
|
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info
|
|
from ..services.stock import current_stock, location_subtree_stock_base
|
|
from .settings import get_expiry_warning_days
|
|
|
|
router = APIRouter(tags=["views"])
|
|
|
|
|
|
@router.get("/shopping-list", response_model=list[ShoppingItem])
|
|
def shopping_list(
|
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
|
) -> list[ShoppingItem]:
|
|
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
|
|
items: list[ShoppingItem] = []
|
|
products = (
|
|
db.query(Product)
|
|
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
|
|
.all()
|
|
)
|
|
for product in products:
|
|
stock = current_stock(db, product.id)
|
|
if stock < product.min_stock:
|
|
items.append(
|
|
ShoppingItem(
|
|
product_id=product.id,
|
|
name=product.name,
|
|
base_unit=product.base_unit,
|
|
package_size=product.package_size,
|
|
stock=stock,
|
|
min_stock=product.min_stock,
|
|
deficit=product.min_stock - stock,
|
|
)
|
|
)
|
|
items.sort(key=lambda i: i.deficit, reverse=True)
|
|
return items
|
|
|
|
|
|
@router.get("/shopping-list/groups", response_model=list[GroupShoppingItem])
|
|
def group_shopping_list(
|
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
|
) -> list[GroupShoppingItem]:
|
|
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt.
|
|
|
|
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
|
|
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
|
|
"""
|
|
items: list[GroupShoppingItem] = []
|
|
groups = (
|
|
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
|
|
)
|
|
for group in groups:
|
|
unit = group.min_stock_unit
|
|
if unit is not None:
|
|
base = BASE_OF_KIND[unit.kind]
|
|
products = [p for p in group.products if p.base_unit == base]
|
|
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
|
|
unit_name = unit.name
|
|
else:
|
|
products = list(group.products)
|
|
stock = float(sum(current_stock(db, p.id) for p in products))
|
|
unit_name = ""
|
|
if stock < group.min_stock:
|
|
items.append(
|
|
GroupShoppingItem(
|
|
group_id=group.id,
|
|
name=group.name,
|
|
stock=stock,
|
|
min_stock=group.min_stock,
|
|
deficit=group.min_stock - stock,
|
|
unit_name=unit_name,
|
|
product_count=len(products),
|
|
)
|
|
)
|
|
items.sort(key=lambda i: i.deficit, reverse=True)
|
|
return items
|
|
|
|
|
|
@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)
|
|
|
|
for e in db.query(ProductLocationMinStock).all():
|
|
product = db.get(Product, e.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),
|
|
))
|
|
|
|
for e in db.query(GroupLocationMinStock).all():
|
|
group = db.get(Group, e.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
|
|
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),
|
|
))
|
|
|
|
loc_ids = set(prod_needs) | set(group_needs)
|
|
namen = {
|
|
loc.id: loc.name
|
|
for loc in db.query(Location).filter(Location.id.in_(loc_ids)).all()
|
|
}
|
|
result: list[LocationNeeds] = []
|
|
for loc_id in sorted(loc_ids, key=lambda i: namen.get(i, "")):
|
|
result.append(LocationNeeds(
|
|
location_id=loc_id,
|
|
location_name=namen.get(loc_id, "?"),
|
|
products=sorted(prod_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
|
|
groups=sorted(group_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
|
|
))
|
|
return result
|
|
|
|
|
|
@router.get("/expiring", response_model=list[ExpiringItem])
|
|
def expiring(
|
|
days: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
) -> list[ExpiringItem]:
|
|
"""Chargen, die innerhalb der Warnfrist ablaufen (oder schon abgelaufen sind)."""
|
|
if days is None:
|
|
days = get_expiry_warning_days(db)
|
|
today = date.today()
|
|
threshold = today + timedelta(days=days)
|
|
|
|
lots = (
|
|
db.query(Lot)
|
|
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
|
|
.order_by(Lot.best_before)
|
|
.all()
|
|
)
|
|
result: list[ExpiringItem] = []
|
|
for lot in lots:
|
|
product = lot.product
|
|
unit_name, unit_factor = display_unit_info(product)
|
|
result.append(
|
|
ExpiringItem(
|
|
lot_id=lot.id,
|
|
product_id=product.id,
|
|
product_name=product.name,
|
|
quantity=lot.quantity,
|
|
base_unit=product.base_unit,
|
|
best_before=lot.best_before,
|
|
best_before_precision=lot.best_before_precision,
|
|
days_left=(lot.best_before - today).days,
|
|
package_size=product.package_size,
|
|
package_label=product.package_label,
|
|
unit_name=unit_name,
|
|
unit_factor=unit_factor,
|
|
)
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/movements", response_model=list[MovementOut])
|
|
def movements(
|
|
limit: int = 100,
|
|
product_id: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
) -> list[MovementOut]:
|
|
"""Bewegungsverlauf (wer hat wann was ein-/ausgelagert), neueste zuerst."""
|
|
limit = max(1, min(limit, 500))
|
|
query = (
|
|
db.query(Movement, Product, User)
|
|
.join(Product, Movement.product_id == Product.id)
|
|
.outerjoin(User, Movement.user_id == User.id)
|
|
)
|
|
if product_id is not None:
|
|
query = query.filter(Movement.product_id == product_id)
|
|
rows = query.order_by(Movement.created_at.desc(), Movement.id.desc()).limit(limit).all()
|
|
|
|
result: list[MovementOut] = []
|
|
for movement, product, user in rows:
|
|
unit_name, unit_factor = display_unit_info(product)
|
|
result.append(
|
|
MovementOut(
|
|
id=movement.id,
|
|
product_id=product.id,
|
|
product_name=product.name,
|
|
type=movement.type.value,
|
|
quantity=movement.quantity,
|
|
base_unit=product.base_unit,
|
|
unit_used=movement.unit_used,
|
|
username=user.username if user else None,
|
|
note=movement.note,
|
|
created_at=movement.created_at,
|
|
package_size=product.package_size,
|
|
package_label=product.package_label,
|
|
unit_name=unit_name,
|
|
unit_factor=unit_factor,
|
|
)
|
|
)
|
|
return result
|