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

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

View File

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

View File

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