Backend: Mindestbestand je Lagerort für Produkte und Gruppen
Zusaetzlich zum globalen Mindestbestand: je Produkt und je Gruppe laesst sich pro
Lagerort ein Mindestbestand (in Artikeleinheiten) hinterlegen.
- Neue Tabellen product_location_min_stock / group_location_min_stock.
- PUT /products/{id}/location-min-stock und /groups/{id}/location-min-stock
ersetzen die Eintraege; Produkt-/Gruppen-Ausgabe liefert sie mit.
- Neue Einkaufsliste GET /shopping-list/by-location: Bedarfe je Ort (Produkte +
Gruppen), Bestand-am-Ort gegen Mindestbestand-am-Ort.
- Helfer location_stock_base (Bestand je Ort). 4 neue Tests, Suite 144 gruen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Barcode, Category, CategoryTracking, Lot, Product
|
||||
from .schemas import BarcodeOut, ProductOut
|
||||
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
|
||||
from .services.conversion import KIND_OF_BASE, display_unit_info
|
||||
from .services.stock import current_stock
|
||||
|
||||
@@ -62,6 +62,15 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
||||
else:
|
||||
out.min_stock_display = product.min_stock / factor
|
||||
out.min_stock_unit_label = name
|
||||
|
||||
out.location_min_stocks = [
|
||||
LocationMinStockOut(
|
||||
location_id=e.location_id,
|
||||
location_name=e.location.name if e.location else None,
|
||||
min_stock=e.min_stock,
|
||||
)
|
||||
for e in sorted(product.location_min_stocks, key=lambda x: x.id)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@ class Group(Base):
|
||||
|
||||
products: Mapped[list[Product]] = relationship(back_populates="group")
|
||||
min_stock_unit: Mapped[Unit | None] = relationship()
|
||||
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Location(Base):
|
||||
@@ -247,6 +250,9 @@ class Product(Base):
|
||||
field_values: Mapped[list[ProductFieldValue]] = relationship(
|
||||
back_populates="product", cascade="all, delete-orphan"
|
||||
)
|
||||
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
@@ -562,3 +568,46 @@ class ItemDocument(Base):
|
||||
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class ProductLocationMinStock(Base):
|
||||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Artikeleinheiten).
|
||||
|
||||
Zusätzlich zum globalen ``Product.min_stock``: so laesst sich derselbe Artikel
|
||||
an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus).
|
||||
"""
|
||||
|
||||
__tablename__ = "product_location_min_stock"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
location_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
|
||||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
location: Mapped[Location] = relationship()
|
||||
|
||||
__table_args__ = (UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),)
|
||||
|
||||
|
||||
class GroupLocationMinStock(Base):
|
||||
"""Mindestbestand einer Gruppe an EINEM Lagerort (in der Gruppen-Einheit)."""
|
||||
|
||||
__tablename__ = "group_location_min_stock"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
location_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
location: Mapped[Location] = relationship()
|
||||
|
||||
__table_args__ = (UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),)
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Barcode, Group, Product, User
|
||||
from ..models import Barcode, Group, GroupLocationMinStock, Location, Product, User
|
||||
from ..schemas import (
|
||||
BarcodeCreate,
|
||||
BarcodeNoteUpdate,
|
||||
@@ -11,6 +11,8 @@ from ..schemas import (
|
||||
GroupCreate,
|
||||
GroupOut,
|
||||
GroupUpdate,
|
||||
LocationMinStockIn,
|
||||
LocationMinStockOut,
|
||||
ProductBarcodeOut,
|
||||
)
|
||||
from ..services.conversion import BASE_OF_KIND
|
||||
@@ -76,6 +78,15 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
|
||||
out.kind = unit.kind.value
|
||||
else:
|
||||
out.stock = float(sum(current_stock(db, p.id) for p in products))
|
||||
|
||||
out.location_min_stocks = [
|
||||
LocationMinStockOut(
|
||||
location_id=e.location_id,
|
||||
location_name=e.location.name if e.location else None,
|
||||
min_stock=e.min_stock,
|
||||
)
|
||||
for e in sorted(group.location_min_stocks, key=lambda x: x.id)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
@@ -87,6 +98,38 @@ def list_groups(
|
||||
return [_group_to_out(db, g) for g in groups]
|
||||
|
||||
|
||||
@router.put("/{group_id}/location-min-stock", response_model=GroupOut)
|
||||
def set_group_location_min_stock(
|
||||
group_id: int,
|
||||
payload: list[LocationMinStockIn],
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> GroupOut:
|
||||
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt)."""
|
||||
group = db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||
|
||||
db.query(GroupLocationMinStock).filter(
|
||||
GroupLocationMinStock.group_id == group_id
|
||||
).delete()
|
||||
gesehen: set[int] = set()
|
||||
for eintrag in payload:
|
||||
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
||||
continue
|
||||
if db.get(Location, eintrag.location_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||
gesehen.add(eintrag.location_id)
|
||||
db.add(GroupLocationMinStock(
|
||||
group_id=group_id,
|
||||
location_id=eintrag.location_id,
|
||||
min_stock=eintrag.min_stock,
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return _group_to_out(db, group)
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(
|
||||
payload: GroupCreate,
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..models import (
|
||||
MovementType,
|
||||
Product,
|
||||
ProductImage,
|
||||
ProductLocationMinStock,
|
||||
RemovalReason,
|
||||
Shop,
|
||||
User,
|
||||
@@ -22,6 +23,7 @@ from ..models import (
|
||||
from ..off import lookup_barcode
|
||||
from ..schemas import (
|
||||
BarcodeCreate,
|
||||
LocationMinStockIn,
|
||||
LookupResult,
|
||||
ProductCreate,
|
||||
ProductOut,
|
||||
@@ -116,6 +118,38 @@ def get_product(
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.put("/{product_id}/location-min-stock", response_model=ProductOut)
|
||||
def set_product_location_min_stock(
|
||||
product_id: int,
|
||||
payload: list[LocationMinStockIn],
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ProductOut:
|
||||
"""Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt)."""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
db.query(ProductLocationMinStock).filter(
|
||||
ProductLocationMinStock.product_id == product_id
|
||||
).delete()
|
||||
gesehen: set[int] = set()
|
||||
for eintrag in payload:
|
||||
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
||||
continue
|
||||
if db.get(Location, eintrag.location_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||
gesehen.add(eintrag.location_id)
|
||||
db.add(ProductLocationMinStock(
|
||||
product_id=product_id,
|
||||
location_id=eintrag.location_id,
|
||||
min_stock=eintrag.min_stock,
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.get("/{product_id}/removals", response_model=RemovalSummary)
|
||||
def product_removals(
|
||||
product_id: int,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -5,10 +6,27 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Group, Lot, Movement, Product, User
|
||||
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
|
||||
from ..services.conversion import BASE_OF_KIND, display_unit_info
|
||||
from ..services.stock import current_stock
|
||||
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_stock_base
|
||||
from .settings import get_expiry_warning_days
|
||||
|
||||
router = APIRouter(tags=["views"])
|
||||
@@ -83,6 +101,64 @@ def group_shopping_list(
|
||||
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_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_stock_base(db, p, e.location_id) for p in matching) / unit.factor
|
||||
unit_name = unit.name
|
||||
else:
|
||||
stock = float(sum(location_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,
|
||||
|
||||
@@ -109,6 +109,19 @@ class UserUpdate(BaseModel):
|
||||
|
||||
|
||||
# ---- Groups ----
|
||||
class LocationMinStockIn(BaseModel):
|
||||
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
|
||||
location_id: int
|
||||
min_stock: float = Field(ge=0)
|
||||
|
||||
|
||||
class LocationMinStockOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
location_id: int
|
||||
location_name: str | None = None
|
||||
min_stock: float
|
||||
|
||||
|
||||
class GroupOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -123,6 +136,8 @@ class GroupOut(BaseModel):
|
||||
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
|
||||
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
|
||||
product_barcodes: list[ProductBarcodeOut] = []
|
||||
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
|
||||
location_min_stocks: list[LocationMinStockOut] = []
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
@@ -351,6 +366,8 @@ class ProductOut(BaseModel):
|
||||
tracking: CategoryTracking = CategoryTracking.food
|
||||
shop_name: str | None = None
|
||||
field_values: dict[int, str | None] = {}
|
||||
# Mindestbestand je Lagerort (in Artikeleinheiten), zusaetzlich zum globalen.
|
||||
location_min_stocks: list[LocationMinStockOut] = []
|
||||
|
||||
@field_validator("field_values", mode="before")
|
||||
@classmethod
|
||||
@@ -606,6 +623,33 @@ class GroupShoppingItem(BaseModel):
|
||||
product_count: int
|
||||
|
||||
|
||||
class LocationNeedProduct(BaseModel):
|
||||
"""Ein Produkt-Bedarf an einem Lagerort (Mengen in Artikeleinheiten)."""
|
||||
product_id: int
|
||||
name: str
|
||||
unit_label: str = ""
|
||||
stock: float
|
||||
min_stock: float
|
||||
deficit: float
|
||||
|
||||
|
||||
class LocationNeedGroup(BaseModel):
|
||||
group_id: int
|
||||
name: str
|
||||
unit_name: str = ""
|
||||
stock: float
|
||||
min_stock: float
|
||||
deficit: float
|
||||
|
||||
|
||||
class LocationNeeds(BaseModel):
|
||||
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
|
||||
location_id: int
|
||||
location_name: str
|
||||
products: list[LocationNeedProduct] = []
|
||||
groups: list[LocationNeedGroup] = []
|
||||
|
||||
|
||||
class MovementOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
|
||||
@@ -206,6 +206,26 @@ def current_stock(db: Session, product_id: int) -> float:
|
||||
return float(sum(q for (q,) in total))
|
||||
|
||||
|
||||
def location_stock_base(db: Session, product: Product, location_id: int) -> float:
|
||||
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||
|
||||
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
||||
Ortes summiert.
|
||||
"""
|
||||
if product.individual:
|
||||
return float(
|
||||
db.query(Item)
|
||||
.filter(Item.product_id == product.id, Item.location_id == location_id)
|
||||
.count()
|
||||
)
|
||||
total = (
|
||||
db.query(Lot.quantity)
|
||||
.filter(Lot.product_id == product.id, Lot.location_id == location_id)
|
||||
.all()
|
||||
)
|
||||
return float(sum(q for (q,) in total))
|
||||
|
||||
|
||||
def check_in(
|
||||
db: Session,
|
||||
product: Product,
|
||||
|
||||
62
backend/tests/test_location_needs.py
Normal file
62
backend/tests/test_location_needs.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Bedarfe (Mindestbestände) je Lagerort und die Einkaufsliste je Ort."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Location,
|
||||
Lot,
|
||||
Product,
|
||||
ProductLocationMinStock,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.routers.views import shopping_list_by_location
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def test_bedarf_je_lagerort(db, user):
|
||||
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar.
|
||||
p = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
zuhause = Location(name="Zuhause")
|
||||
ferien = Location(name="Ferienhaus")
|
||||
db.add_all([zuhause, ferien])
|
||||
db.flush()
|
||||
|
||||
# 1 Stück zuhause, nichts im Ferienhaus.
|
||||
db.add(Lot(product_id=p.id, quantity=1, location_id=zuhause.id))
|
||||
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
|
||||
db.add(ProductLocationMinStock(product_id=p.id, location_id=ferien.id, min_stock=2))
|
||||
db.commit()
|
||||
|
||||
needs = shopping_list_by_location(db=db, _=user)
|
||||
nach_ort = {n.location_name: n for n in needs}
|
||||
|
||||
assert set(nach_ort) == {"Zuhause", "Ferienhaus"}
|
||||
assert nach_ort["Zuhause"].products[0].deficit == 2 # 3 - 1
|
||||
assert nach_ort["Ferienhaus"].products[0].deficit == 2 # 2 - 0
|
||||
|
||||
|
||||
def test_gedeckter_ort_erscheint_nicht(db, user):
|
||||
p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
zuhause = Location(name="Zuhause")
|
||||
db.add(zuhause)
|
||||
db.flush()
|
||||
db.add(Lot(product_id=p.id, quantity=5, location_id=zuhause.id))
|
||||
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
|
||||
db.commit()
|
||||
|
||||
# Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag.
|
||||
assert shopping_list_by_location(db=db, _=user) == []
|
||||
Reference in New Issue
Block a user