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 .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

View File

@@ -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"),)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,