diff --git a/backend/app/crud.py b/backend/app/crud.py index c902c95..f73eb3c 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -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 diff --git a/backend/app/models.py b/backend/app/models.py index 98b4a17..5958011 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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"),) diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index a934f03..4795226 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -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, diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index ab9b738..c0851c5 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -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, diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 1a9b715..26faffb 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e01fc2c..485285a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 9ebb632..baedc02 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -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, diff --git a/backend/tests/test_location_needs.py b/backend/tests/test_location_needs.py new file mode 100644 index 0000000..5ca0b42 --- /dev/null +++ b/backend/tests/test_location_needs.py @@ -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) == []