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:
Scarriffle
2026-07-27 06:58:08 +02:00
parent 2bc871f229
commit d0d854be5a
8 changed files with 343 additions and 6 deletions

View File

@@ -8,7 +8,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Barcode, Category, CategoryTracking, Lot, Product 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.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock from .services.stock import current_stock
@@ -62,6 +62,15 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
else: else:
out.min_stock_display = product.min_stock / factor out.min_stock_display = product.min_stock / factor
out.min_stock_unit_label = name 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 return out

View File

@@ -131,6 +131,9 @@ class Group(Base):
products: Mapped[list[Product]] = relationship(back_populates="group") products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship() min_stock_unit: Mapped[Unit | None] = relationship()
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class Location(Base): class Location(Base):
@@ -247,6 +250,9 @@ class Product(Base):
field_values: Mapped[list[ProductFieldValue]] = relationship( field_values: Mapped[list[ProductFieldValue]] = relationship(
back_populates="product", cascade="all, delete-orphan" back_populates="product", cascade="all, delete-orphan"
) )
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class ApiToken(Base): class ApiToken(Base):
@@ -562,3 +568,46 @@ class ItemDocument(Base):
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit. # Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True) data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) 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"),)

View File

@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin 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 ( from ..schemas import (
BarcodeCreate, BarcodeCreate,
BarcodeNoteUpdate, BarcodeNoteUpdate,
@@ -11,6 +11,8 @@ from ..schemas import (
GroupCreate, GroupCreate,
GroupOut, GroupOut,
GroupUpdate, GroupUpdate,
LocationMinStockIn,
LocationMinStockOut,
ProductBarcodeOut, ProductBarcodeOut,
) )
from ..services.conversion import BASE_OF_KIND 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 out.kind = unit.kind.value
else: else:
out.stock = float(sum(current_stock(db, p.id) for p in products)) 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 return out
@@ -87,6 +98,38 @@ def list_groups(
return [_group_to_out(db, g) for g in 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) @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
def create_group( def create_group(
payload: GroupCreate, payload: GroupCreate,

View File

@@ -15,6 +15,7 @@ from ..models import (
MovementType, MovementType,
Product, Product,
ProductImage, ProductImage,
ProductLocationMinStock,
RemovalReason, RemovalReason,
Shop, Shop,
User, User,
@@ -22,6 +23,7 @@ from ..models import (
from ..off import lookup_barcode from ..off import lookup_barcode
from ..schemas import ( from ..schemas import (
BarcodeCreate, BarcodeCreate,
LocationMinStockIn,
LookupResult, LookupResult,
ProductCreate, ProductCreate,
ProductOut, ProductOut,
@@ -116,6 +118,38 @@ def get_product(
return product_to_out(db, 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) @router.get("/{product_id}/removals", response_model=RemovalSummary)
def product_removals( def product_removals(
product_id: int, product_id: int,

View File

@@ -1,3 +1,4 @@
from collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -5,10 +6,27 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Group, Lot, Movement, Product, User from ..models import (
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem Group,
from ..services.conversion import BASE_OF_KIND, display_unit_info GroupLocationMinStock,
from ..services.stock import current_stock 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 from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"]) router = APIRouter(tags=["views"])
@@ -83,6 +101,64 @@ def group_shopping_list(
return items 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]) @router.get("/expiring", response_model=list[ExpiringItem])
def expiring( def expiring(
days: int | None = None, days: int | None = None,

View File

@@ -109,6 +109,19 @@ class UserUpdate(BaseModel):
# ---- Groups ---- # ---- 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): class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -123,6 +136,8 @@ class GroupOut(BaseModel):
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar. # EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
product_barcodes: list[ProductBarcodeOut] = [] product_barcodes: list[ProductBarcodeOut] = []
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
location_min_stocks: list[LocationMinStockOut] = []
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
@@ -351,6 +366,8 @@ class ProductOut(BaseModel):
tracking: CategoryTracking = CategoryTracking.food tracking: CategoryTracking = CategoryTracking.food
shop_name: str | None = None shop_name: str | None = None
field_values: dict[int, str | 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") @field_validator("field_values", mode="before")
@classmethod @classmethod
@@ -606,6 +623,33 @@ class GroupShoppingItem(BaseModel):
product_count: int 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): class MovementOut(BaseModel):
id: int id: int
product_id: int product_id: int

View File

@@ -206,6 +206,26 @@ def current_stock(db: Session, product_id: int) -> float:
return float(sum(q for (q,) in total)) 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( def check_in(
db: Session, db: Session,
product: Product, product: Product,

View 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) == []