diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index ff8f2eb..5819936 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -24,6 +24,7 @@ from ..models import ( Category, DashboardLayout, Group, + Item, Lot, Movement, MovementType, @@ -300,6 +301,35 @@ def _lots_mit_produkt(db: Session) -> list[tuple[Lot, Product]]: ) +def _bestand_beitraege( + db: Session, heute: date, warnfrist: int +) -> list[tuple[Product, float, str]]: + """Alle Bestands-Beiträge als (Produkt, Artikeleinheiten, Ablaufzustand). + + Deckt beide Speicherformen ab, damit die Auswertungen vollständig sind: + - Chargen (Lebensmittel und Mengen-Gegenstände) je Lot mit ihrem MHD-Zustand. + - Einzelstücke: jedes Item zählt als ein Stück ohne MHD ("no_date"). Diese + liegen als Items und nicht als Lots vor – ohne sie fehlten Einzelstück- + Produkte (z.B. eine Kamera) in jeder Kategorie-/Ablauf-Auswertung. + """ + beitraege: list[tuple[Product, float, str]] = [] + for lot, product in _lots_mit_produkt(db): + # Einzelstücke werden über ihre Items gezählt (siehe unten), nie über Lots. + if product.individual: + continue + faktor, _label = article_unit(product) + menge = lot.quantity / (faktor or 1.0) + beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist))) + + for product in db.query(Product).filter(Product.individual.is_(True)).all(): + anzahl = db.query(Item).filter(Item.product_id == product.id).count() + if anzahl <= 0: + continue + faktor, _label = article_unit(product) + beitraege.append((product, anzahl / (faktor or 1.0), "no_date")) + return beitraege + + @router.get("/stats", response_model=DashboardStats) def stats( db: Session = Depends(get_db), _: User = Depends(get_current_user) @@ -310,11 +340,9 @@ def stats( einheiten = 0.0 bestand_produkte: set[int] = set() bald = abgelaufen = 0 - for lot, product in _lots_mit_produkt(db): - faktor, _label = article_unit(product) - einheiten += lot.quantity / faktor if faktor else 0.0 + for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist): + einheiten += menge bestand_produkte.add(product.id) - zustand = _zustand(lot.best_before, heute, warnfrist) if zustand == "expired": abgelaufen += 1 elif zustand == "soon": @@ -351,9 +379,8 @@ def expiry_split( heute = date.today() warnfrist = get_expiry_warning_days(db) summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0} - for lot, product in _lots_mit_produkt(db): - faktor, _label = article_unit(product) - summen[_zustand(lot.best_before, heute, warnfrist)] += lot.quantity / (faktor or 1.0) + for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist): + summen[zustand] += menge return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()}) @@ -368,12 +395,10 @@ def by_category( leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0} eimer: dict[int | None, dict] = defaultdict(leer) - for lot, product in _lots_mit_produkt(db): - faktor, _label = article_unit(product) - menge = lot.quantity / (faktor or 1.0) + for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist): topf = eimer[product.category_id] topf["article_units"] += menge - topf[_zustand(lot.best_before, heute, warnfrist)] += menge + topf[zustand] += menge ergebnis = [ CategoryShare( diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 1e2c006..5774cc6 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -2,9 +2,10 @@ from datetime import date, timedelta # noqa: F401 (date fuer die Zustands-Test import pytest -from app.models import BaseUnit, Category, Product, Role, User +from app.models import BaseUnit, Category, Item, Product, Role, User from app.routers import dashboard from app.services.conversion import article_unit, article_units +from app.services.items import generate_uid from app.services.stock import check_in, check_out @@ -89,6 +90,31 @@ def test_by_category_bündelt_und_sortiert(db, user): assert anteile[1].article_units == 2 +def test_einzelstuecke_zaehlen_in_auswertungen(db, user): + """Regression: Einzelstueck-Produkte liegen als Items, nicht als Lots. Sie + muessen trotzdem in Kategorie-, Ablauf- und Bestandsauswertung erscheinen – + sonst zeigte das Dashboard nur die Mengen-Gegenstaende (Bug: nur Powerbank).""" + kamera_kat = Category(name="Kamera", tracking="object") + db.add(kamera_kat) + db.commit() + kamera = Product(name="Alpha 7 IV", category_id=kamera_kat.id, individual=True) + db.add(kamera) + db.commit() + for _ in range(2): + db.add(Item(uid=generate_uid(db), product_id=kamera.id)) + db.commit() + + anteil = next(a for a in dashboard.by_category(db=db, _=user) if a.name == "Kamera") + assert anteil.article_units == 2 + assert anteil.no_date == 2 # Einzelstuecke haben kein MHD + + assert dashboard.expiry_split(db=db, _=user).no_date == 2 + + st = dashboard.stats(db=db, _=user) + assert st.article_units == 2 + assert st.products_in_stock == 1 + + # ----------------------------------------------------------------- Verlauf def test_schrittweite_haengt_am_zeitraum(): """Kurze Zeitraeume werden fein abgetastet, lange grob.""" diff --git a/web/src/components/CategorySelect.jsx b/web/src/components/CategorySelect.jsx index bc06244..de8aff7 100644 --- a/web/src/components/CategorySelect.jsx +++ b/web/src/components/CategorySelect.jsx @@ -45,18 +45,23 @@ export default function CategorySelect({ if (popRef.current?.contains(e.target)) return; setOpen(false); } - function onAway() { setOpen(false); } + function onScroll(e) { + // Scrollen IM Menü (die Liste ist scrollbar) darf es nicht schließen – + // nur ein Scrollen der Seite dahinter, weil das fixierte Menü sonst + // verrutschen würde. + if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return; + setOpen(false); + } + function onResize() { setOpen(false); } function onKey(e) { if (e.key === "Escape") setOpen(false); } document.addEventListener("mousedown", onDoc); - // Beim Scrollen/Größenwechsel würde das fixierte Menü verrutschen – lieber - // schließen. - window.addEventListener("scroll", onAway, true); - window.addEventListener("resize", onAway); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onResize); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); - window.removeEventListener("scroll", onAway, true); - window.removeEventListener("resize", onAway); + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onResize); document.removeEventListener("keydown", onKey); }; }, [open]);