From f2c657ff3185694e5de59d44f6a4e34b18379c1a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 7 Aug 2026 18:03:37 +0200 Subject: [PATCH] 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 --- backend/app/routers/views.py | 168 +++++++++++++++++++++++- backend/app/schemas.py | 21 +++ backend/tests/test_shopping_need.py | 66 ++++++++++ web/src/components/ShoppingNeedText.jsx | 24 ++++ web/src/dashboard/cards.jsx | 30 ++--- web/src/pages/ShoppingList.jsx | 14 +- 6 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 backend/tests/test_shopping_need.py create mode 100644 web/src/components/ShoppingNeedText.jsx diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index f96a966..59d5e6c 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 90b460b..806b944 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -665,11 +665,29 @@ class ItemOut(BaseModel): # ---- 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): product_id: int name: str base_unit: BaseUnit package_size: float | None = None + need: ShoppingNeed | None = None stock: float min_stock: float deficit: float @@ -699,6 +717,7 @@ class GroupShoppingItem(BaseModel): deficit: float unit_name: str = "" product_count: int + need: ShoppingNeed | None = None class LocationNeedProduct(BaseModel): @@ -709,6 +728,7 @@ class LocationNeedProduct(BaseModel): stock: float min_stock: float deficit: float + need: ShoppingNeed | None = None class LocationNeedGroup(BaseModel): @@ -718,6 +738,7 @@ class LocationNeedGroup(BaseModel): stock: float min_stock: float deficit: float + need: ShoppingNeed | None = None class LocationNeeds(BaseModel): diff --git a/backend/tests/test_shopping_need.py b/backend/tests/test_shopping_need.py new file mode 100644 index 0000000..08b075e --- /dev/null +++ b/backend/tests/test_shopping_need.py @@ -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 == "" diff --git a/web/src/components/ShoppingNeedText.jsx b/web/src/components/ShoppingNeedText.jsx new file mode 100644 index 0000000..fc64f40 --- /dev/null +++ b/web/src/components/ShoppingNeedText.jsx @@ -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 {fmt(stock == null ? 0 : minStock - stock)} {unitName} + ); + } + const einheit = need.singular ? ` ${gebinde(need.min_stock, need.singular)}` : ""; + return ( + <> + fehlt {need.text} + {need.hint ? <> ({need.hint}) : null}{" "} + (Bestand {fmt(need.stock)} / min {fmt(need.min_stock)}{einheit}) + + ); +} diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 10ced55..a0053dd 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -3,9 +3,10 @@ import { Link } from "react-router-dom"; import { api } from "../api"; import Icon from "../components/Icon"; import { CardSkeleton } from "../components/Skeleton"; +import ShoppingNeedText from "../components/ShoppingNeedText"; import { useSettings } from "../settings"; import { - amountText, articlePrimary, articleSecondary, fmt, relativeExpiry, + articlePrimary, articleSecondary, fmt, relativeExpiry, } from "../units"; import Bars from "./charts/Bars"; import Donut from "./charts/Donut"; @@ -332,10 +333,10 @@ function KarteEinkaufsliste({ props: karteProps }) { const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val })); // Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite. - // `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig, `prodInfo` - // formatiert die Produktzeile (Gesamt trägt Gebinde/Basiseinheit, je-Ort schon - // eine fertige Einheit). - const abschnitt = (gruppen, produkte, prefix, prodInfo) => ( + // `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die + // Bedarfszeile kommt vom Server fertig aufbereitet (`need`) und ist für + // Produkte wie Gruppen, gesamt wie je Ort identisch aufgebaut. + const abschnitt = (gruppen, produkte, prefix) => ( ); - const gesamtInfo = (it) => ( - <>fehlt {amountText(it.deficit, it.package_size, it.base_unit)}{" "} - (Bestand {amountText(it.stock, it.package_size, it.base_unit)}) - ); - const ortInfo = (it) => ( - <>fehlt {fmt(it.deficit)} {it.unit_label}{" "} - (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) - ); return (
{!gesamtLeer && (
Gesamt
- {abschnitt(daten.gruppen, daten.produkte, "", gesamtInfo)} + {abschnitt(daten.gruppen, daten.produkte, "")}
)} {orte.map((o) => ((o.products.length || o.groups.length) ? ( @@ -389,7 +383,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
{o.location_name}
- {abschnitt(o.groups, o.products, `l${o.location_id}`, ortInfo)} + {abschnitt(o.groups, o.products, `l${o.location_id}`)}
) : null))} diff --git a/web/src/pages/ShoppingList.jsx b/web/src/pages/ShoppingList.jsx index 895bfbf..b73bbb5 100644 --- a/web/src/pages/ShoppingList.jsx +++ b/web/src/pages/ShoppingList.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; -import { amountText, fmt, unitShort } from "../units"; +import ShoppingNeedText from "../components/ShoppingNeedText"; export default function ShoppingList() { const [items, setItems] = useState([]); @@ -51,8 +51,7 @@ export default function ShoppingList() { {it.name} - fehlt {fmt(it.deficit)} {it.unit_name}{" "} - (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)} {it.unit_name}) + ); @@ -66,8 +65,7 @@ export default function ShoppingList() { {it.name} - fehlt {amountText(it.deficit, it.package_size, it.base_unit)}{" "} - (Bestand {amountText(it.stock, it.package_size, it.base_unit)}) + ); @@ -93,8 +91,7 @@ export default function ShoppingList() { {it.name} - fehlt {fmt(it.deficit)} {it.unit_name}{" "} - (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + ); @@ -108,8 +105,7 @@ export default function ShoppingList() { {it.name} - fehlt {fmt(it.deficit)} {it.unit_label}{" "} - (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + );