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