Dashboard zaehlt Einzelstuecke mit; Baum-Dropdown scrollbar

Auswertungen (by_category, expiry_split, stats) iterierten nur Lots. Gegenstaende
mit Menge-je-Lagerort liegen als Lots und tauchten auf – Einzelstuecke liegen
aber als Items und fehlten dadurch komplett (Dashboard zeigte z.B. nur die
Powerbank, nicht Kamera/Kabel). Neuer Helfer _bestand_beitraege deckt beide
Speicherformen ab: jedes Item zaehlt als ein Stueck ohne MHD. Regressionstest
ergaenzt; volle Suite gruen (120).

CategorySelect: Scrollen IM aufklappbaren Menue schloss es, weil jeder Scroll als
Seiten-Scroll gewertet wurde. Jetzt schliesst nur ein Scroll ausserhalb des
Menues; die lange Liste laesst sich scrollen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 12:09:25 +02:00
parent f35126cb7b
commit a70a99b387
3 changed files with 75 additions and 19 deletions

View File

@@ -24,6 +24,7 @@ from ..models import (
Category, Category,
DashboardLayout, DashboardLayout,
Group, Group,
Item,
Lot, Lot,
Movement, Movement,
MovementType, 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) @router.get("/stats", response_model=DashboardStats)
def stats( def stats(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -310,11 +340,9 @@ def stats(
einheiten = 0.0 einheiten = 0.0
bestand_produkte: set[int] = set() bestand_produkte: set[int] = set()
bald = abgelaufen = 0 bald = abgelaufen = 0
for lot, product in _lots_mit_produkt(db): for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
faktor, _label = article_unit(product) einheiten += menge
einheiten += lot.quantity / faktor if faktor else 0.0
bestand_produkte.add(product.id) bestand_produkte.add(product.id)
zustand = _zustand(lot.best_before, heute, warnfrist)
if zustand == "expired": if zustand == "expired":
abgelaufen += 1 abgelaufen += 1
elif zustand == "soon": elif zustand == "soon":
@@ -351,9 +379,8 @@ def expiry_split(
heute = date.today() heute = date.today()
warnfrist = get_expiry_warning_days(db) warnfrist = get_expiry_warning_days(db)
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0} summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
for lot, product in _lots_mit_produkt(db): for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
faktor, _label = article_unit(product) summen[zustand] += menge
summen[_zustand(lot.best_before, heute, warnfrist)] += lot.quantity / (faktor or 1.0)
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()}) 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} 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) eimer: dict[int | None, dict] = defaultdict(leer)
for lot, product in _lots_mit_produkt(db): for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
faktor, _label = article_unit(product)
menge = lot.quantity / (faktor or 1.0)
topf = eimer[product.category_id] topf = eimer[product.category_id]
topf["article_units"] += menge topf["article_units"] += menge
topf[_zustand(lot.best_before, heute, warnfrist)] += menge topf[zustand] += menge
ergebnis = [ ergebnis = [
CategoryShare( CategoryShare(

View File

@@ -2,9 +2,10 @@ from datetime import date, timedelta # noqa: F401 (date fuer die Zustands-Test
import pytest 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.routers import dashboard
from app.services.conversion import article_unit, article_units 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 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 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 # ----------------------------------------------------------------- Verlauf
def test_schrittweite_haengt_am_zeitraum(): def test_schrittweite_haengt_am_zeitraum():
"""Kurze Zeitraeume werden fein abgetastet, lange grob.""" """Kurze Zeitraeume werden fein abgetastet, lange grob."""

View File

@@ -45,18 +45,23 @@ export default function CategorySelect({
if (popRef.current?.contains(e.target)) return; if (popRef.current?.contains(e.target)) return;
setOpen(false); 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); } function onKey(e) { if (e.key === "Escape") setOpen(false); }
document.addEventListener("mousedown", onDoc); document.addEventListener("mousedown", onDoc);
// Beim Scrollen/Größenwechsel würde das fixierte Menü verrutschen lieber window.addEventListener("scroll", onScroll, true);
// schließen. window.addEventListener("resize", onResize);
window.addEventListener("scroll", onAway, true);
window.addEventListener("resize", onAway);
document.addEventListener("keydown", onKey); document.addEventListener("keydown", onKey);
return () => { return () => {
document.removeEventListener("mousedown", onDoc); document.removeEventListener("mousedown", onDoc);
window.removeEventListener("scroll", onAway, true); window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onAway); window.removeEventListener("resize", onResize);
document.removeEventListener("keydown", onKey); document.removeEventListener("keydown", onKey);
}; };
}, [open]); }, [open]);