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:
@@ -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(
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user