diff --git a/backend/app/main.py b/backend/app/main.py index 41408bc..697fbe0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -144,6 +144,10 @@ def _ensure_schema() -> None: "REFERENCES units(id) ON DELETE SET NULL", "ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER " "REFERENCES units(id) ON DELETE SET NULL", + "ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_size DOUBLE PRECISION", + "ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)", + "ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN " + "NOT NULL DEFAULT FALSE", "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER " "REFERENCES units(id) ON DELETE SET NULL", "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN " diff --git a/backend/app/models.py b/backend/app/models.py index 9167631..dc154f6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -129,6 +129,17 @@ class Group(Base): min_stock_unit_id: Mapped[int | None] = mapped_column( ForeignKey("units.id", ondelete="SET NULL"), nullable=True ) + # Gruppen-Gebinde als Richtwert: wie viele Basiseinheiten „1 Packung/Glas" + # der Gruppe zählt. Nötig, weil die Produkte einer Gruppe unterschiedlich + # große Packungen haben können (Pesto 99 g vs. 160 g) – die Gruppe legt einen + # gemeinsamen Richtwert fest. NULL = keiner definiert. + package_size: Mapped[float | None] = mapped_column(Float, nullable=True) + package_label: Mapped[str | None] = mapped_column(String(32), nullable=True) + # true = ``min_stock`` (und die je-Ort-Werte) sind in diesem Gebinde erfasst, + # sonst in ``min_stock_unit`` (bzw. Basiseinheit). + min_stock_in_packages: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) products: Mapped[list[Product]] = relationship(back_populates="group") min_stock_unit: Mapped[Unit | None] = relationship() diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 7f6bbd3..d16c9b4 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -44,7 +44,7 @@ from ..schemas import ( FlowPoint, TimelinePoint, ) -from ..services.conversion import article_unit +from ..services.conversion import article_unit, group_min_context from ..services.stock import current_stock from .settings import get_expiry_warning_days @@ -354,12 +354,9 @@ def stats( if current_stock(db, product.id) < product.min_stock: bedarf += 1 for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0): - unit = group.min_stock_unit - produkte = list(group.products) - summe = float(sum(current_stock(db, p.id) for p in produkte)) - if unit is not None: - summe /= unit.factor or 1.0 - if summe < group.min_stock: + ctx = group_min_context(group) + summe_base = float(sum(current_stock(db, p.id) for p in ctx.matching)) + if summe_base < group.min_stock * ctx.divisor: bedarf += 1 return DashboardStats( diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index c908b44..a976f49 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -15,7 +15,7 @@ from ..schemas import ( LocationMinStockOut, ProductBarcodeOut, ) -from ..services.conversion import BASE_OF_KIND +from ..services.conversion import group_min_context from ..services.stock import current_stock, location_subtree_stock_base router = APIRouter(prefix="/groups", tags=["groups"]) @@ -67,23 +67,21 @@ def _group_to_out(db: Session, group: Group) -> GroupOut: ) out.product_barcodes = product_codes + # Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist + # (Gruppen-Gebinde ODER verwaltete Einheit) – so ist beides vergleichbar. + ctx = group_min_context(group) + stock_base = float(sum(current_stock(db, p.id) for p in ctx.matching)) + out.stock = round(stock_base / ctx.divisor, 3) unit = group.min_stock_unit if unit is not None: - # Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt. - base = BASE_OF_KIND[unit.kind] - matching = [p for p in products if p.base_unit == base] - stock_base = float(sum(current_stock(db, p.id) for p in matching)) - out.stock = stock_base / unit.factor out.min_stock_unit_name = unit.name + out.min_stock_unit_factor = unit.factor out.kind = unit.kind.value - else: - matching = list(products) - out.stock = float(sum(current_stock(db, p.id) for p in products)) # Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock. def _loc_stock(loc_id: str) -> float: - total = sum(location_subtree_stock_base(db, p, loc_id) for p in matching) - return total / unit.factor if unit is not None else float(total) + total = sum(location_subtree_stock_base(db, p, loc_id) for p in ctx.matching) + return round(total / ctx.divisor, 3) out.location_min_stocks = [ LocationMinStockOut( @@ -149,6 +147,9 @@ def create_group( name=payload.name, min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, + package_size=payload.package_size, + package_label=payload.package_label, + min_stock_in_packages=payload.min_stock_in_packages, ) db.add(group) db.commit() @@ -175,8 +176,25 @@ def update_group( ) if clash: raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben") + + # Wird nur die Erfassungseinheit umgestellt (Gebinde <-> verwaltete Einheit), + # ohne dass der Aufrufer neue Zahlen mitschickt, rechnen wir die vorhandenen + # Mindestbestände so um, dass der *physische* Bedarf gleich bleibt. + alt = group_min_context(group) + umschaltung = ( + any(k in data for k in ("min_stock_in_packages", "package_size", "package_label")) + and "min_stock" not in data + ) for field, value in data.items(): setattr(group, field, value) + if umschaltung: + neu = group_min_context(group) + if neu.divisor != alt.divisor and neu.divisor: + faktor = alt.divisor / neu.divisor + if group.min_stock is not None: + group.min_stock = round(group.min_stock * faktor, 3) + for e in group.location_min_stocks: + e.min_stock = round(e.min_stock * faktor, 3) db.commit() db.refresh(group) return _group_to_out(db, group) diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 59d5e6c..4a12deb 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -34,7 +34,12 @@ from ..schemas import ( ShoppingListAll, ShoppingNeed, ) -from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info +from ..services.conversion import ( + BASE_OF_KIND, + article_unit, + display_unit_info, + group_min_context, +) from ..services.stock import ( current_stock, descendant_location_ids, @@ -66,22 +71,6 @@ def _package_plural(db: Session) -> dict[str, str]: return {pt.singular: pt.plural for pt in db.query(PackageType).all()} -def _uniform_package(products: list[Product]) -> tuple[float, str] | None: - """Gemeinsames Gebinde einer Produktmenge – nur wenn *alle* dasselbe haben. - - Für eine Gruppe lässt sich „x Gläser" nur dann eindeutig sagen, wenn jedes - passende Produkt dieselbe Packungsgröße und -bezeichnung trägt. Sonst bleibt - es bei der Basiseinheit.""" - if not products: - return None - combos = {(p.package_size, p.package_label or "Packung") for p in products} - if len(combos) == 1: - size, label = next(iter(combos)) - if size and size > 0: - return float(size), label - return None - - def _build_need( *, deficit_base: float, @@ -120,38 +109,6 @@ def _build_need( ) -def _gruppen_bedarf( - *, - deficit_unit: float, - stock_unit: float, - min_unit: float, - divisor: float, - unit_name: str, - base_unit: BaseUnit | None, - products: list[Product], - plural: dict[str, str], -) -> ShoppingNeed: - """Bedarf einer Gruppe. Mengen kommen in der Gruppen-Einheit (``unit_name``); - ``divisor`` rechnet sie in Basiseinheiten zurück. Haben alle passenden - Produkte dasselbe Gebinde, wird als Leitangabe dieses Gebinde (z.B. Gläser) - genutzt, sonst die Gruppen-Einheit selbst.""" - pkg = _uniform_package(products) if base_unit is not None else None - if pkg is not None: - factor, singular, is_package = pkg[0], pkg[1], True - else: - factor, singular, is_package = (divisor or 1.0), unit_name, False - return _build_need( - deficit_base=deficit_unit * divisor, - stock_base=stock_unit * divisor, - min_base=min_unit * divisor, - factor=factor, - singular=singular, - is_package=is_package, - base_unit=base_unit, - plural=plural, - ) - - @router.get("/shopping-list", response_model=list[ShoppingItem]) def shopping_list( db: Session = Depends(get_db), _: User = Depends(get_current_user) @@ -208,39 +165,28 @@ def group_shopping_list( 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] - divisor = unit.factor - stock = float(sum(current_stock(db, p.id) for p in products)) / divisor - unit_name = unit.name - base_unit: BaseUnit | None = base - else: - products = list(group.products) - divisor = 1.0 - stock = float(sum(current_stock(db, p.id) for p in products)) - unit_name = "" - base_unit = None - if stock < group.min_stock: - deficit = group.min_stock - stock + ctx = group_min_context(group) + stock_base = float(sum(current_stock(db, p.id) for p in ctx.matching)) + min_base = group.min_stock * ctx.divisor + if stock_base < min_base: + deficit_base = min_base - stock_base items.append( GroupShoppingItem( group_id=group.id, name=group.name, - stock=stock, + stock=round(stock_base / ctx.divisor, 3), min_stock=group.min_stock, - deficit=deficit, - unit_name=unit_name, - product_count=len(products), - need=_gruppen_bedarf( - deficit_unit=deficit, - stock_unit=stock, - min_unit=group.min_stock, - divisor=divisor, - unit_name=unit_name, - base_unit=base_unit, - products=products, + deficit=round(deficit_base / ctx.divisor, 3), + unit_name=ctx.label, + product_count=len(ctx.matching), + need=_build_need( + deficit_base=deficit_base, + stock_base=stock_base, + min_base=min_base, + factor=ctx.divisor, + singular=ctx.label, + is_package=ctx.is_package, + base_unit=ctx.base_unit, plural=plural, ), ) @@ -324,34 +270,27 @@ def shopping_list_by_location( group = db.get(Group, 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] - divisor, unit_name = unit.factor, unit.name - g_base_unit: BaseUnit | None = base - else: - matching, divisor, unit_name = list(group.products), 1.0, "" - g_base_unit = None + ctx = group_min_context(group) + # Mengen in der Mindestbestand-Einheit (Gebinde ODER verwaltete Einheit). locs_min = {e.location_id: e.min_stock for e in entries} bestand = { - loc: sum(location_subtree_stock_base(db, p, loc) for p in matching) / divisor + loc: sum(location_subtree_stock_base(db, p, loc) for p in ctx.matching) / ctx.divisor for loc in locs_min } for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): if need > 1e-9: group_needs[loc].append(LocationNeedGroup( - group_id=group.id, name=group.name, unit_name=unit_name, + group_id=group.id, name=group.name, unit_name=ctx.label, stock=round(bestand[loc], 3), min_stock=locs_min[loc], deficit=round(need, 3), - need=_gruppen_bedarf( - deficit_unit=need, - stock_unit=bestand[loc], - min_unit=locs_min[loc], - divisor=divisor, - unit_name=unit_name, - base_unit=g_base_unit, - products=matching, + need=_build_need( + deficit_base=need * ctx.divisor, + stock_base=bestand[loc] * ctx.divisor, + min_base=locs_min[loc] * ctx.divisor, + factor=ctx.divisor, + singular=ctx.label, + is_package=ctx.is_package, + base_unit=ctx.base_unit, plural=plural, ), )) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 806b944..8ec7eaf 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -131,12 +131,17 @@ class GroupOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str - min_stock: float | None = None + min_stock: float | None = None # in der aktuellen Erfassungseinheit (s.u.) min_stock_unit_id: int | None = None + # Gruppen-Gebinde (Richtwert) + ob der Mindestbestand darin erfasst ist: + package_size: float | None = None + package_label: str | None = None + min_stock_in_packages: bool = False # angereichert: product_count: int = 0 - stock: float = 0.0 # Bestand in Basiseinheiten - min_stock_unit_name: str | None = None + stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit + min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …) + min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit kind: str | None = None # Art der Mindestbestand-Einheit barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind # EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar. @@ -149,12 +154,18 @@ class GroupCreate(BaseModel): name: str = Field(min_length=1, max_length=120) min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | None = None + package_size: float | None = Field(default=None, ge=0) + package_label: str | None = Field(default=None, max_length=32) + min_stock_in_packages: bool = False class GroupUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | None = None + package_size: float | None = Field(default=None, ge=0) + package_label: str | None = Field(default=None, max_length=32) + min_stock_in_packages: bool | None = None # ---- Categories ---- diff --git a/backend/app/services/conversion.py b/backend/app/services/conversion.py index 62800dc..f59327b 100644 --- a/backend/app/services/conversion.py +++ b/backend/app/services/conversion.py @@ -7,10 +7,12 @@ Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um. from __future__ import annotations +from typing import NamedTuple + from sqlalchemy import func from sqlalchemy.orm import Session -from ..models import BaseUnit, Product, Unit, UnitKind +from ..models import BaseUnit, Group, Product, Unit, UnitKind BASE_OF_KIND: dict[UnitKind, BaseUnit] = { UnitKind.count: BaseUnit.piece, @@ -108,3 +110,37 @@ def article_units(product: Product, quantity_base: float) -> float: """Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um.""" factor, _ = article_unit(product) return quantity_base / factor if factor else quantity_base + + +class GroupMinContext(NamedTuple): + divisor: float # Basiseinheiten je Mindestbestand-Einheit + label: str # Einzahl-Label ("Glas", "Gramm", …) + is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit + base_unit: BaseUnit | None + matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen + + +def group_min_context(group: Group) -> GroupMinContext: + """In welcher Einheit der Mindestbestand einer Gruppe zu lesen ist. + + Eine Gruppe zaehlt in ihrer verwalteten Einheit (``min_stock_unit``) oder – wenn + ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen. Die + Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen + Produkte), weil deren Packungen unterschiedlich gross sein koennen. + """ + 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] + base_unit: BaseUnit | None = base + else: + matching = list(group.products) + base_unit = None + if group.min_stock_in_packages and group.package_size and group.package_size > 0: + return GroupMinContext( + float(group.package_size), group.package_label or "Packung", + True, base_unit, matching, + ) + if unit is not None: + return GroupMinContext(unit.factor or 1.0, unit.name, False, base_unit, matching) + return GroupMinContext(1.0, "", False, None, matching) diff --git a/backend/tests/test_shopping_need.py b/backend/tests/test_shopping_need.py index 08b075e..e460f07 100644 --- a/backend/tests/test_shopping_need.py +++ b/backend/tests/test_shopping_need.py @@ -1,11 +1,17 @@ """Einkaufsliste: Fehlmenge als Gebinde-Leitangabe (auf ganze Packungen -aufgerundet) mit der Basiseinheit als kleinem Hinweis – „3 Gläser (500 g)". +aufgerundet) mit der Basiseinheit als kleinem Hinweis – „3 Gläser (450 g)". + +Produkte tragen ihre eigene Packungsgröße; Gruppen legen ein eigenes +Gruppen-Gebinde als Richtwert fest (weil ihre Produkte unterschiedlich große +Packungen haben können). """ import pytest from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User +from app.routers.groups import update_group from app.routers.views import group_shopping_list, shopping_list +from app.schemas import GroupUpdate @pytest.fixture() @@ -36,27 +42,28 @@ def test_produkt_bedarf_in_ganzen_gebinden(db, user): assert item.need.hint == "500 g" -def test_gruppe_mit_einheitlichem_gebinde(db, user): - """Eine Gruppe, deren Produkte alle dasselbe Gebinde haben, zeigt es auch.""" +def test_gruppe_mit_gruppen_gebinde(db, user): + """Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser.""" db.add(PackageType(singular="Glas", plural="Gläser")) - gruppe = Group(name="Pesto alla Genovese", min_stock=500, min_stock_unit=_gramm(db)) + gruppe = Group(name="Pesto alla Genovese", min_stock=3, min_stock_unit=_gramm(db), + package_size=150, package_label="Glas", min_stock_in_packages=True) db.add(gruppe) - db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=195, - package_label="Glas", group=gruppe)) - db.commit() + db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=99, group=gruppe)) + db.commit() # kein Bestand -> es fehlen 3 Gläser (450 g) (item,) = group_shopping_list(db=db, _=user) assert item.need is not None assert item.need.is_package is True + assert item.need.count == 3 assert item.need.text == "3 Gläser" - assert item.need.hint == "500 g" + assert item.need.hint == "450 g" # 3 * 150 g def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user): - """Ohne (einheitliches) Gebinde bleibt es bei der Basiseinheit – kein Rateraten.""" + """Ohne aktives Gruppen-Gebinde bleibt es bei der verwalteten Einheit.""" gruppe = Group(name="Mehl", min_stock=500, min_stock_unit=_gramm(db)) db.add(gruppe) - db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, package_size=None, group=gruppe)) + db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, group=gruppe)) db.commit() (item,) = group_shopping_list(db=db, _=user) @@ -64,3 +71,20 @@ def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user): assert item.need.is_package is False assert item.need.text == "500 Gramm" assert item.need.hint == "" + + +def test_gruppe_umschalten_haelt_physischen_bedarf(db, user): + """Umschalten g -> Gebinde rechnet den gespeicherten Wert um (500 g / 150 = 3,33 Glas).""" + gruppe = Group(name="Pesto", min_stock=500, min_stock_unit=_gramm(db)) + db.add(gruppe) + db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, group=gruppe)) + db.commit() + + update_group( + group_id=gruppe.id, + payload=GroupUpdate(min_stock_in_packages=True, package_size=150, package_label="Glas"), + db=db, _=user, + ) + db.refresh(gruppe) + assert gruppe.min_stock_in_packages is True + assert gruppe.min_stock == pytest.approx(500 / 150, abs=0.01) # ~3,33 Gläser