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,
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(

View File

@@ -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."""