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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user