Gruppen-Gebinde: Mindestbestand/Bestand in Packungen (Backend)

Gruppen bekommen ein eigenes Richt-Gebinde (package_size/label) plus
min_stock_in_packages. Weil die Produkte einer Gruppe unterschiedlich
große Packungen haben können (Pesto 99 g vs. 160 g), legt die Gruppe einen
gemeinsamen Richtwert fest (1 Glas ≈ X g).

- Neuer Helfer group_min_context() zentralisiert, in welcher Einheit der
  Gruppen-Mindestbestand zaehlt (Gebinde ODER verwaltete Einheit).
- Einkaufsliste (gesamt + je Ort), Dashboard-Bedarf und GroupOut nutzen ihn;
  need.text kommt so als 'x Glaeser (y g)'.
- update_group rechnet bestehende Werte beim Umschalten der Einheit um, damit
  der physische Bedarf gleich bleibt.
- Migration: groups.package_size/package_label/min_stock_in_packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-07 18:38:12 +02:00
parent 28785b0a10
commit ecc1570100
8 changed files with 168 additions and 128 deletions

View File

@@ -144,6 +144,10 @@ def _ensure_schema() -> None:
"REFERENCES units(id) ON DELETE SET NULL", "REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER " "ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL", "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 " "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL", "REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN " "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "

View File

@@ -129,6 +129,17 @@ class Group(Base):
min_stock_unit_id: Mapped[int | None] = mapped_column( min_stock_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("units.id", ondelete="SET NULL"), nullable=True 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") products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship() min_stock_unit: Mapped[Unit | None] = relationship()

View File

@@ -44,7 +44,7 @@ from ..schemas import (
FlowPoint, FlowPoint,
TimelinePoint, TimelinePoint,
) )
from ..services.conversion import article_unit from ..services.conversion import article_unit, group_min_context
from ..services.stock import current_stock from ..services.stock import current_stock
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
@@ -354,12 +354,9 @@ def stats(
if current_stock(db, product.id) < product.min_stock: if current_stock(db, product.id) < product.min_stock:
bedarf += 1 bedarf += 1
for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0): for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0):
unit = group.min_stock_unit ctx = group_min_context(group)
produkte = list(group.products) summe_base = float(sum(current_stock(db, p.id) for p in ctx.matching))
summe = float(sum(current_stock(db, p.id) for p in produkte)) if summe_base < group.min_stock * ctx.divisor:
if unit is not None:
summe /= unit.factor or 1.0
if summe < group.min_stock:
bedarf += 1 bedarf += 1
return DashboardStats( return DashboardStats(

View File

@@ -15,7 +15,7 @@ from ..schemas import (
LocationMinStockOut, LocationMinStockOut,
ProductBarcodeOut, 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 from ..services.stock import current_stock, location_subtree_stock_base
router = APIRouter(prefix="/groups", tags=["groups"]) router = APIRouter(prefix="/groups", tags=["groups"])
@@ -67,23 +67,21 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
) )
out.product_barcodes = product_codes 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 unit = group.min_stock_unit
if unit is not None: 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_name = unit.name
out.min_stock_unit_factor = unit.factor
out.kind = unit.kind.value 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. # Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock.
def _loc_stock(loc_id: str) -> float: def _loc_stock(loc_id: str) -> float:
total = sum(location_subtree_stock_base(db, p, loc_id) for p in matching) total = sum(location_subtree_stock_base(db, p, loc_id) for p in ctx.matching)
return total / unit.factor if unit is not None else float(total) return round(total / ctx.divisor, 3)
out.location_min_stocks = [ out.location_min_stocks = [
LocationMinStockOut( LocationMinStockOut(
@@ -149,6 +147,9 @@ def create_group(
name=payload.name, name=payload.name,
min_stock=payload.min_stock, min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id, 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.add(group)
db.commit() db.commit()
@@ -175,8 +176,25 @@ def update_group(
) )
if clash: if clash:
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben") 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(): for field, value in data.items():
setattr(group, field, value) 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.commit()
db.refresh(group) db.refresh(group)
return _group_to_out(db, group) return _group_to_out(db, group)

View File

@@ -34,7 +34,12 @@ from ..schemas import (
ShoppingListAll, ShoppingListAll,
ShoppingNeed, 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 ( from ..services.stock import (
current_stock, current_stock,
descendant_location_ids, 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()} 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( def _build_need(
*, *,
deficit_base: float, 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]) @router.get("/shopping-list", response_model=list[ShoppingItem])
def shopping_list( def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user) 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() db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
) )
for group in groups: for group in groups:
unit = group.min_stock_unit ctx = group_min_context(group)
if unit is not None: stock_base = float(sum(current_stock(db, p.id) for p in ctx.matching))
base = BASE_OF_KIND[unit.kind] min_base = group.min_stock * ctx.divisor
products = [p for p in group.products if p.base_unit == base] if stock_base < min_base:
divisor = unit.factor deficit_base = min_base - stock_base
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
items.append( items.append(
GroupShoppingItem( GroupShoppingItem(
group_id=group.id, group_id=group.id,
name=group.name, name=group.name,
stock=stock, stock=round(stock_base / ctx.divisor, 3),
min_stock=group.min_stock, min_stock=group.min_stock,
deficit=deficit, deficit=round(deficit_base / ctx.divisor, 3),
unit_name=unit_name, unit_name=ctx.label,
product_count=len(products), product_count=len(ctx.matching),
need=_gruppen_bedarf( need=_build_need(
deficit_unit=deficit, deficit_base=deficit_base,
stock_unit=stock, stock_base=stock_base,
min_unit=group.min_stock, min_base=min_base,
divisor=divisor, factor=ctx.divisor,
unit_name=unit_name, singular=ctx.label,
base_unit=base_unit, is_package=ctx.is_package,
products=products, base_unit=ctx.base_unit,
plural=plural, plural=plural,
), ),
) )
@@ -324,34 +270,27 @@ def shopping_list_by_location(
group = db.get(Group, group_id) group = db.get(Group, group_id)
if group is None: if group is None:
continue continue
unit = group.min_stock_unit ctx = group_min_context(group)
if unit is not None: # Mengen in der Mindestbestand-Einheit (Gebinde ODER verwaltete Einheit).
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
locs_min = {e.location_id: e.min_stock for e in entries} locs_min = {e.location_id: e.min_stock for e in entries}
bestand = { 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 in locs_min
} }
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items(): for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items():
if need > 1e-9: if need > 1e-9:
group_needs[loc].append(LocationNeedGroup( 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], stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(need, 3), deficit=round(need, 3),
need=_gruppen_bedarf( need=_build_need(
deficit_unit=need, deficit_base=need * ctx.divisor,
stock_unit=bestand[loc], stock_base=bestand[loc] * ctx.divisor,
min_unit=locs_min[loc], min_base=locs_min[loc] * ctx.divisor,
divisor=divisor, factor=ctx.divisor,
unit_name=unit_name, singular=ctx.label,
base_unit=g_base_unit, is_package=ctx.is_package,
products=matching, base_unit=ctx.base_unit,
plural=plural, plural=plural,
), ),
)) ))

View File

@@ -131,12 +131,17 @@ class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str 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 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: # angereichert:
product_count: int = 0 product_count: int = 0
stock: float = 0.0 # Bestand in Basiseinheiten stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit
min_stock_unit_name: str | None = None 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 kind: str | None = None # Art der Mindestbestand-Einheit
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.
@@ -149,12 +154,18 @@ class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None 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): class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None 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 ---- # ---- Categories ----

View File

@@ -7,10 +7,12 @@ Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
from __future__ import annotations from __future__ import annotations
from typing import NamedTuple
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session 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] = { BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
UnitKind.count: BaseUnit.piece, 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.""" """Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
factor, _ = article_unit(product) factor, _ = article_unit(product)
return quantity_base / factor if factor else quantity_base 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)

View File

@@ -1,11 +1,17 @@
"""Einkaufsliste: Fehlmenge als Gebinde-Leitangabe (auf ganze Packungen """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 import pytest
from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User 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.routers.views import group_shopping_list, shopping_list
from app.schemas import GroupUpdate
@pytest.fixture() @pytest.fixture()
@@ -36,27 +42,28 @@ def test_produkt_bedarf_in_ganzen_gebinden(db, user):
assert item.need.hint == "500 g" assert item.need.hint == "500 g"
def test_gruppe_mit_einheitlichem_gebinde(db, user): def test_gruppe_mit_gruppen_gebinde(db, user):
"""Eine Gruppe, deren Produkte alle dasselbe Gebinde haben, zeigt es auch.""" """Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser."""
db.add(PackageType(singular="Glas", plural="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(gruppe)
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=195, db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=99, group=gruppe))
package_label="Glas", group=gruppe)) db.commit() # kein Bestand -> es fehlen 3 Gläser (450 g)
db.commit()
(item,) = group_shopping_list(db=db, _=user) (item,) = group_shopping_list(db=db, _=user)
assert item.need is not None assert item.need is not None
assert item.need.is_package is True assert item.need.is_package is True
assert item.need.count == 3
assert item.need.text == "3 Gläser" 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): 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)) gruppe = Group(name="Mehl", min_stock=500, min_stock_unit=_gramm(db))
db.add(gruppe) 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() db.commit()
(item,) = group_shopping_list(db=db, _=user) (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.is_package is False
assert item.need.text == "500 Gramm" assert item.need.text == "500 Gramm"
assert item.need.hint == "" 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