Einkaufsliste: Fehlmenge als Gebinde (Glaeser/Dosen), Basiseinheit klein
Statt 'fehlt 500 Gramm' jetzt die Produktgroesse als Leitangabe:
'fehlt 3 Glaeser (500 g)'. Auf ganze Gebinde aufgerundet, weil man nur
ganze Glaeser/Dosen kauft; Basiseinheit als kleiner Hinweis.
Backend: neues ShoppingNeed-Objekt an jeder Einkaufslisten-Zeile (Produkte
+ Gruppen, gesamt + je Ort) - damit die Angabe auch direkt ueber die API
kommt ('x Glaeser (y g)'), inkl. Pluralisierung aus der Gebinde-Tabelle.
Gruppen leiten das Gebinde ab, wenn alle passenden Produkte dasselbe haben,
sonst bleibt es bei der Basiseinheit. Bestehende Felder unveraendert.
Frontend: gemeinsame Komponente ShoppingNeedText fuer Karte und volle Seite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Callable
|
||||
@@ -14,11 +15,13 @@ from ..models import (
|
||||
Location,
|
||||
Lot,
|
||||
Movement,
|
||||
PackageType,
|
||||
Product,
|
||||
ProductLocationMinStock,
|
||||
User,
|
||||
)
|
||||
from ..schemas import (
|
||||
BaseUnit,
|
||||
ExpiringItem,
|
||||
GroupShoppingItem,
|
||||
LocationContentEntry,
|
||||
@@ -29,6 +32,7 @@ from ..schemas import (
|
||||
MovementOut,
|
||||
ShoppingItem,
|
||||
ShoppingListAll,
|
||||
ShoppingNeed,
|
||||
)
|
||||
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info
|
||||
from ..services.stock import (
|
||||
@@ -41,11 +45,119 @@ from .settings import get_expiry_warning_days
|
||||
router = APIRouter(tags=["views"])
|
||||
|
||||
|
||||
# ---- Bedarf lesbar aufbereiten (Gebinde als Leitangabe) --------------------
|
||||
_UNIT_SHORT: dict[BaseUnit, str] = {
|
||||
BaseUnit.piece: "Stk",
|
||||
BaseUnit.gram: "g",
|
||||
BaseUnit.milliliter: "ml",
|
||||
}
|
||||
|
||||
|
||||
def _de_num(x: float) -> str:
|
||||
"""Deutsche Kurzzahl ohne unnoetige Nullen: 500, 1, 1,4."""
|
||||
r = round(float(x), 2)
|
||||
if r == int(r):
|
||||
return str(int(r))
|
||||
return f"{r:.2f}".rstrip("0").rstrip(".").replace(".", ",")
|
||||
|
||||
|
||||
def _package_plural(db: Session) -> dict[str, str]:
|
||||
"""Einzahl -> Mehrzahl der Gebinde (Glas -> Gläser), aus der Gebinde-Tabelle."""
|
||||
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,
|
||||
stock_base: float,
|
||||
min_base: float,
|
||||
factor: float,
|
||||
singular: str,
|
||||
is_package: bool,
|
||||
base_unit: BaseUnit | None,
|
||||
plural: dict[str, str],
|
||||
) -> ShoppingNeed:
|
||||
"""Rechnet Basiseinheiten in Gebinde/Artikeleinheiten um und baut die Texte.
|
||||
|
||||
``factor`` = Basiseinheiten je Gebinde (Packungsgröße bzw. Einheitenfaktor).
|
||||
Bei zählbaren Packungen wird die Kaufmenge auf ganze Gebinde aufgerundet."""
|
||||
f = factor or 1.0
|
||||
count = math.ceil(deficit_base / f - 1e-9) if is_package else round(deficit_base / f, 3)
|
||||
label = singular if abs(count) == 1 else plural.get(singular, singular)
|
||||
text = f"{_de_num(count)} {label}".strip()
|
||||
hint = ""
|
||||
base_amount: float | None = None
|
||||
if (is_package or f != 1.0) and base_unit is not None:
|
||||
base_amount = round(deficit_base, 3)
|
||||
hint = f"{_de_num(deficit_base)} {_UNIT_SHORT.get(base_unit, base_unit.value)}"
|
||||
return ShoppingNeed(
|
||||
text=text,
|
||||
hint=hint,
|
||||
count=count,
|
||||
label=label,
|
||||
singular=singular,
|
||||
is_package=is_package,
|
||||
base_amount=base_amount,
|
||||
base_unit=base_unit if base_amount is not None else None,
|
||||
stock=round(stock_base / f, 3),
|
||||
min_stock=round(min_base / f, 3),
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
) -> list[ShoppingItem]:
|
||||
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
|
||||
plural = _package_plural(db)
|
||||
items: list[ShoppingItem] = []
|
||||
products = (
|
||||
db.query(Product)
|
||||
@@ -55,6 +167,7 @@ def shopping_list(
|
||||
for product in products:
|
||||
stock = current_stock(db, product.id)
|
||||
if stock < product.min_stock:
|
||||
factor, singular = article_unit(product)
|
||||
items.append(
|
||||
ShoppingItem(
|
||||
product_id=product.id,
|
||||
@@ -64,6 +177,16 @@ def shopping_list(
|
||||
stock=stock,
|
||||
min_stock=product.min_stock,
|
||||
deficit=product.min_stock - stock,
|
||||
need=_build_need(
|
||||
deficit_base=product.min_stock - stock,
|
||||
stock_base=stock,
|
||||
min_base=product.min_stock,
|
||||
factor=factor,
|
||||
singular=singular,
|
||||
is_package=bool(product.package_size and product.package_size > 0),
|
||||
base_unit=product.base_unit,
|
||||
plural=plural,
|
||||
),
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda i: i.deficit, reverse=True)
|
||||
@@ -79,6 +202,7 @@ def group_shopping_list(
|
||||
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
|
||||
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
|
||||
"""
|
||||
plural = _package_plural(db)
|
||||
items: list[GroupShoppingItem] = []
|
||||
groups = (
|
||||
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
|
||||
@@ -88,22 +212,37 @@ def group_shopping_list(
|
||||
if unit is not None:
|
||||
base = BASE_OF_KIND[unit.kind]
|
||||
products = [p for p in group.products if p.base_unit == base]
|
||||
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
|
||||
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
|
||||
items.append(
|
||||
GroupShoppingItem(
|
||||
group_id=group.id,
|
||||
name=group.name,
|
||||
stock=stock,
|
||||
min_stock=group.min_stock,
|
||||
deficit=group.min_stock - 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,
|
||||
plural=plural,
|
||||
),
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda i: i.deficit, reverse=True)
|
||||
@@ -141,6 +280,7 @@ def shopping_list_by_location(
|
||||
) -> list[LocationNeeds]:
|
||||
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
|
||||
unter dem dort hinterlegten Mindestbestand liegt."""
|
||||
plural = _package_plural(db)
|
||||
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
|
||||
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
||||
|
||||
@@ -154,6 +294,7 @@ def shopping_list_by_location(
|
||||
continue
|
||||
faktor, label = article_unit(product)
|
||||
faktor = faktor or 1.0
|
||||
ist_gebinde = bool(product.package_size and product.package_size > 0)
|
||||
locs_min = {e.location_id: e.min_stock for e in entries}
|
||||
bestand = {loc: location_subtree_stock_base(db, product, loc) / faktor for loc in locs_min}
|
||||
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items():
|
||||
@@ -162,6 +303,17 @@ def shopping_list_by_location(
|
||||
product_id=product.id, name=product.name, unit_label=label,
|
||||
stock=round(bestand[loc], 3), min_stock=locs_min[loc],
|
||||
deficit=round(need, 3),
|
||||
# Mengen liegen hier in Artikeleinheiten -> * faktor = Basiseinheiten.
|
||||
need=_build_need(
|
||||
deficit_base=need * faktor,
|
||||
stock_base=bestand[loc] * faktor,
|
||||
min_base=locs_min[loc] * faktor,
|
||||
factor=faktor,
|
||||
singular=label,
|
||||
is_package=ist_gebinde,
|
||||
base_unit=product.base_unit,
|
||||
plural=plural,
|
||||
),
|
||||
))
|
||||
|
||||
# Je Gruppe genauso – Bestand je Ort ist die Summe der passenden Produkte im Subtree.
|
||||
@@ -177,8 +329,10 @@ def shopping_list_by_location(
|
||||
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}
|
||||
bestand = {
|
||||
loc: sum(location_subtree_stock_base(db, p, loc) for p in matching) / divisor
|
||||
@@ -190,6 +344,16 @@ def shopping_list_by_location(
|
||||
group_id=group.id, name=group.name, unit_name=unit_name,
|
||||
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,
|
||||
plural=plural,
|
||||
),
|
||||
))
|
||||
|
||||
loc_ids = set(prod_needs) | set(group_needs)
|
||||
|
||||
Reference in New Issue
Block a user