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 collections import defaultdict
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -14,11 +15,13 @@ from ..models import (
|
|||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
|
PackageType,
|
||||||
Product,
|
Product,
|
||||||
ProductLocationMinStock,
|
ProductLocationMinStock,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
BaseUnit,
|
||||||
ExpiringItem,
|
ExpiringItem,
|
||||||
GroupShoppingItem,
|
GroupShoppingItem,
|
||||||
LocationContentEntry,
|
LocationContentEntry,
|
||||||
@@ -29,6 +32,7 @@ from ..schemas import (
|
|||||||
MovementOut,
|
MovementOut,
|
||||||
ShoppingItem,
|
ShoppingItem,
|
||||||
ShoppingListAll,
|
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
|
||||||
from ..services.stock import (
|
from ..services.stock import (
|
||||||
@@ -41,11 +45,119 @@ from .settings import get_expiry_warning_days
|
|||||||
router = APIRouter(tags=["views"])
|
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])
|
@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)
|
||||||
) -> list[ShoppingItem]:
|
) -> list[ShoppingItem]:
|
||||||
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
|
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
|
||||||
|
plural = _package_plural(db)
|
||||||
items: list[ShoppingItem] = []
|
items: list[ShoppingItem] = []
|
||||||
products = (
|
products = (
|
||||||
db.query(Product)
|
db.query(Product)
|
||||||
@@ -55,6 +167,7 @@ def shopping_list(
|
|||||||
for product in products:
|
for product in products:
|
||||||
stock = current_stock(db, product.id)
|
stock = current_stock(db, product.id)
|
||||||
if stock < product.min_stock:
|
if stock < product.min_stock:
|
||||||
|
factor, singular = article_unit(product)
|
||||||
items.append(
|
items.append(
|
||||||
ShoppingItem(
|
ShoppingItem(
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
@@ -64,6 +177,16 @@ def shopping_list(
|
|||||||
stock=stock,
|
stock=stock,
|
||||||
min_stock=product.min_stock,
|
min_stock=product.min_stock,
|
||||||
deficit=product.min_stock - 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)
|
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).
|
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
|
||||||
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
|
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
|
||||||
"""
|
"""
|
||||||
|
plural = _package_plural(db)
|
||||||
items: list[GroupShoppingItem] = []
|
items: list[GroupShoppingItem] = []
|
||||||
groups = (
|
groups = (
|
||||||
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()
|
||||||
@@ -88,22 +212,37 @@ def group_shopping_list(
|
|||||||
if unit is not None:
|
if unit is not None:
|
||||||
base = BASE_OF_KIND[unit.kind]
|
base = BASE_OF_KIND[unit.kind]
|
||||||
products = [p for p in group.products if p.base_unit == base]
|
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
|
unit_name = unit.name
|
||||||
|
base_unit: BaseUnit | None = base
|
||||||
else:
|
else:
|
||||||
products = list(group.products)
|
products = list(group.products)
|
||||||
|
divisor = 1.0
|
||||||
stock = float(sum(current_stock(db, p.id) for p in products))
|
stock = float(sum(current_stock(db, p.id) for p in products))
|
||||||
unit_name = ""
|
unit_name = ""
|
||||||
|
base_unit = None
|
||||||
if stock < group.min_stock:
|
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=stock,
|
||||||
min_stock=group.min_stock,
|
min_stock=group.min_stock,
|
||||||
deficit=group.min_stock - stock,
|
deficit=deficit,
|
||||||
unit_name=unit_name,
|
unit_name=unit_name,
|
||||||
product_count=len(products),
|
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)
|
items.sort(key=lambda i: i.deficit, reverse=True)
|
||||||
@@ -141,6 +280,7 @@ def shopping_list_by_location(
|
|||||||
) -> list[LocationNeeds]:
|
) -> list[LocationNeeds]:
|
||||||
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
|
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
|
||||||
unter dem dort hinterlegten Mindestbestand liegt."""
|
unter dem dort hinterlegten Mindestbestand liegt."""
|
||||||
|
plural = _package_plural(db)
|
||||||
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
|
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
|
||||||
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
||||||
|
|
||||||
@@ -154,6 +294,7 @@ def shopping_list_by_location(
|
|||||||
continue
|
continue
|
||||||
faktor, label = article_unit(product)
|
faktor, label = article_unit(product)
|
||||||
faktor = faktor or 1.0
|
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}
|
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}
|
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():
|
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,
|
product_id=product.id, name=product.name, unit_label=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),
|
||||||
|
# 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.
|
# 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]
|
base = BASE_OF_KIND[unit.kind]
|
||||||
matching = [p for p in group.products if p.base_unit == base]
|
matching = [p for p in group.products if p.base_unit == base]
|
||||||
divisor, unit_name = unit.factor, unit.name
|
divisor, unit_name = unit.factor, unit.name
|
||||||
|
g_base_unit: BaseUnit | None = base
|
||||||
else:
|
else:
|
||||||
matching, divisor, unit_name = list(group.products), 1.0, ""
|
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 matching) / divisor
|
||||||
@@ -190,6 +344,16 @@ def shopping_list_by_location(
|
|||||||
group_id=group.id, name=group.name, unit_name=unit_name,
|
group_id=group.id, name=group.name, unit_name=unit_name,
|
||||||
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(
|
||||||
|
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)
|
loc_ids = set(prod_needs) | set(group_needs)
|
||||||
|
|||||||
@@ -665,11 +665,29 @@ class ItemOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---- Views ----
|
# ---- Views ----
|
||||||
|
class ShoppingNeed(BaseModel):
|
||||||
|
"""Fehlmenge als Gebinde-Leitangabe – bei zaehlbaren Packungen (Glas/Dose)
|
||||||
|
auf ganze aufgerundet, weil man nur ganze Gebinde kauft – mit der
|
||||||
|
Basiseinheit als kleinem Hinweis. So bekommen auch API-Konsumenten direkt
|
||||||
|
„1 Glas (500 g)", ohne selbst Packungsgroessen umzurechnen."""
|
||||||
|
text: str # Leitangabe: "1 Glas", "4 Dosen", "500 g"
|
||||||
|
hint: str = "" # Basiseinheit klein: "500 g" / "2 l" – leer wenn identisch
|
||||||
|
count: float # aufgerundete Gebindezahl (zum Weiterrechnen)
|
||||||
|
label: str # zur count passendes, pluralisiertes Label
|
||||||
|
singular: str # Einzahl-Label ("Glas", "Gramm", …)
|
||||||
|
is_package: bool = False # zaehlbares Gebinde (Glas/Dose) vs. Basis-/Anzeigeeinheit
|
||||||
|
base_amount: float | None = None
|
||||||
|
base_unit: BaseUnit | None = None
|
||||||
|
stock: float # Bestand in derselben Leitangabe
|
||||||
|
min_stock: float # Mindestbestand in derselben Leitangabe
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItem(BaseModel):
|
class ShoppingItem(BaseModel):
|
||||||
product_id: int
|
product_id: int
|
||||||
name: str
|
name: str
|
||||||
base_unit: BaseUnit
|
base_unit: BaseUnit
|
||||||
package_size: float | None = None
|
package_size: float | None = None
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
stock: float
|
stock: float
|
||||||
min_stock: float
|
min_stock: float
|
||||||
deficit: float
|
deficit: float
|
||||||
@@ -699,6 +717,7 @@ class GroupShoppingItem(BaseModel):
|
|||||||
deficit: float
|
deficit: float
|
||||||
unit_name: str = ""
|
unit_name: str = ""
|
||||||
product_count: int
|
product_count: int
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationNeedProduct(BaseModel):
|
class LocationNeedProduct(BaseModel):
|
||||||
@@ -709,6 +728,7 @@ class LocationNeedProduct(BaseModel):
|
|||||||
stock: float
|
stock: float
|
||||||
min_stock: float
|
min_stock: float
|
||||||
deficit: float
|
deficit: float
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationNeedGroup(BaseModel):
|
class LocationNeedGroup(BaseModel):
|
||||||
@@ -718,6 +738,7 @@ class LocationNeedGroup(BaseModel):
|
|||||||
stock: float
|
stock: float
|
||||||
min_stock: float
|
min_stock: float
|
||||||
deficit: float
|
deficit: float
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationNeeds(BaseModel):
|
class LocationNeeds(BaseModel):
|
||||||
|
|||||||
66
backend/tests/test_shopping_need.py
Normal file
66
backend/tests/test_shopping_need.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Einkaufsliste: Fehlmenge als Gebinde-Leitangabe (auf ganze Packungen
|
||||||
|
aufgerundet) mit der Basiseinheit als kleinem Hinweis – „3 Gläser (500 g)".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User
|
||||||
|
from app.routers.views import group_shopping_list, shopping_list
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def user(db):
|
||||||
|
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||||
|
db.add(person)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def _gramm(db) -> Unit:
|
||||||
|
return db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
|
||||||
|
|
||||||
|
def test_produkt_bedarf_in_ganzen_gebinden(db, user):
|
||||||
|
"""500 g fehlen bei 195-g-Gläsern -> 3 Gläser (aufgerundet), 500 g als Hinweis."""
|
||||||
|
db.add(PackageType(singular="Glas", plural="Gläser"))
|
||||||
|
db.add(Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
|
||||||
|
package_label="Glas", min_stock=500))
|
||||||
|
db.commit() # kein Bestand -> es fehlt der volle Mindestbestand
|
||||||
|
|
||||||
|
(item,) = shopping_list(db=db, _=user)
|
||||||
|
assert item.need is not None
|
||||||
|
assert item.need.is_package is True
|
||||||
|
assert item.need.count == 3 # ceil(500 / 195)
|
||||||
|
assert item.need.text == "3 Gläser"
|
||||||
|
assert item.need.hint == "500 g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_mit_einheitlichem_gebinde(db, user):
|
||||||
|
"""Eine Gruppe, deren Produkte alle dasselbe Gebinde haben, zeigt es auch."""
|
||||||
|
db.add(PackageType(singular="Glas", plural="Gläser"))
|
||||||
|
gruppe = Group(name="Pesto alla Genovese", min_stock=500, min_stock_unit=_gramm(db))
|
||||||
|
db.add(gruppe)
|
||||||
|
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=195,
|
||||||
|
package_label="Glas", group=gruppe))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
(item,) = group_shopping_list(db=db, _=user)
|
||||||
|
assert item.need is not None
|
||||||
|
assert item.need.is_package is True
|
||||||
|
assert item.need.text == "3 Gläser"
|
||||||
|
assert item.need.hint == "500 g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user):
|
||||||
|
"""Ohne (einheitliches) Gebinde bleibt es bei der Basiseinheit – kein Rateraten."""
|
||||||
|
gruppe = Group(name="Mehl", min_stock=500, min_stock_unit=_gramm(db))
|
||||||
|
db.add(gruppe)
|
||||||
|
db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, package_size=None, group=gruppe))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
(item,) = group_shopping_list(db=db, _=user)
|
||||||
|
assert item.need is not None
|
||||||
|
assert item.need.is_package is False
|
||||||
|
assert item.need.text == "500 Gramm"
|
||||||
|
assert item.need.hint == ""
|
||||||
24
web/src/components/ShoppingNeedText.jsx
Normal file
24
web/src/components/ShoppingNeedText.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { fmt, gebinde } from "../units";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliche Bedarfszeile der Einkaufsliste: die Gebinde-Leitangabe groß
|
||||||
|
* ("3 Gläser"), die Basiseinheit klein daneben ("(500 g)"), dann Bestand/min in
|
||||||
|
* derselben Leitangabe. Die Rechnung (Aufrunden auf ganze Packungen,
|
||||||
|
* Pluralisierung des Leitworts) kommt fertig vom Server als `need`.
|
||||||
|
*/
|
||||||
|
export default function ShoppingNeedText({ need, stock, minStock, unitName }) {
|
||||||
|
// Fallback für den (Übergangs-)Fall ohne `need` vom Server: altes Format.
|
||||||
|
if (!need) {
|
||||||
|
return (
|
||||||
|
<>fehlt <strong>{fmt(stock == null ? 0 : minStock - stock)} {unitName}</strong></>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const einheit = need.singular ? ` ${gebinde(need.min_stock, need.singular)}` : "";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
fehlt <strong>{need.text}</strong>
|
||||||
|
{need.hint ? <> <span className="muted">({need.hint})</span></> : null}{" "}
|
||||||
|
(Bestand {fmt(need.stock)} / min {fmt(need.min_stock)}{einheit})
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { CardSkeleton } from "../components/Skeleton";
|
import { CardSkeleton } from "../components/Skeleton";
|
||||||
|
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import {
|
import {
|
||||||
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
|
articlePrimary, articleSecondary, fmt, relativeExpiry,
|
||||||
} from "../units";
|
} from "../units";
|
||||||
import Bars from "./charts/Bars";
|
import Bars from "./charts/Bars";
|
||||||
import Donut from "./charts/Donut";
|
import Donut from "./charts/Donut";
|
||||||
@@ -332,10 +333,10 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
|||||||
const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val }));
|
const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val }));
|
||||||
|
|
||||||
// Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite.
|
// Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite.
|
||||||
// `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig, `prodInfo`
|
// `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die
|
||||||
// formatiert die Produktzeile (Gesamt trägt Gebinde/Basiseinheit, je-Ort schon
|
// Bedarfszeile kommt vom Server fertig aufbereitet (`need`) und ist für
|
||||||
// eine fertige Einheit).
|
// Produkte wie Gruppen, gesamt wie je Ort identisch aufgebaut.
|
||||||
const abschnitt = (gruppen, produkte, prefix, prodInfo) => (
|
const abschnitt = (gruppen, produkte, prefix) => (
|
||||||
<ul className="checklist">
|
<ul className="checklist">
|
||||||
{gruppen.map((it) => {
|
{gruppen.map((it) => {
|
||||||
const key = `${prefix}g${it.group_id}`;
|
const key = `${prefix}g${it.group_id}`;
|
||||||
@@ -347,8 +348,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)} {it.unit_name})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -361,27 +361,21 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
|||||||
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">{prodInfo(it)}</span>
|
<span className="muted small">
|
||||||
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
const gesamtInfo = (it) => (
|
|
||||||
<>fehlt <strong>{amountText(it.deficit, it.package_size, it.base_unit)}</strong>{" "}
|
|
||||||
(Bestand {amountText(it.stock, it.package_size, it.base_unit)})</>
|
|
||||||
);
|
|
||||||
const ortInfo = (it) => (
|
|
||||||
<>fehlt <strong>{fmt(it.deficit)} {it.unit_label}</strong>{" "}
|
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-stack">
|
<div className="card-stack">
|
||||||
{!gesamtLeer && (
|
{!gesamtLeer && (
|
||||||
<div>
|
<div>
|
||||||
<div className="muted small" style={{ fontWeight: 600 }}>Gesamt</div>
|
<div className="muted small" style={{ fontWeight: 600 }}>Gesamt</div>
|
||||||
{abschnitt(daten.gruppen, daten.produkte, "", gesamtInfo)}
|
{abschnitt(daten.gruppen, daten.produkte, "")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{orte.map((o) => ((o.products.length || o.groups.length) ? (
|
{orte.map((o) => ((o.products.length || o.groups.length) ? (
|
||||||
@@ -389,7 +383,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
|||||||
<div className="muted small" style={{ fontWeight: 600 }}>
|
<div className="muted small" style={{ fontWeight: 600 }}>
|
||||||
<Icon name="location" size={13} /> {o.location_name}
|
<Icon name="location" size={13} /> {o.location_name}
|
||||||
</div>
|
</div>
|
||||||
{abschnitt(o.groups, o.products, `l${o.location_id}`, ortInfo)}
|
{abschnitt(o.groups, o.products, `l${o.location_id}`)}
|
||||||
</div>
|
</div>
|
||||||
) : null))}
|
) : null))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { amountText, fmt, unitShort } from "../units";
|
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||||
|
|
||||||
export default function ShoppingList() {
|
export default function ShoppingList() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@@ -51,8 +51,7 @@ export default function ShoppingList() {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)} {it.unit_name})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -66,8 +65,7 @@ export default function ShoppingList() {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{amountText(it.deficit, it.package_size, it.base_unit)}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.base_unit} />
|
||||||
(Bestand {amountText(it.stock, it.package_size, it.base_unit)})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -93,8 +91,7 @@ export default function ShoppingList() {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -108,8 +105,7 @@ export default function ShoppingList() {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_label}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user