Die Uebersicht war fest verdrahtet und fuer alle gleich. Jetzt besteht sie aus Karten in einem 12-Spalten-Raster, die sich ziehen, in der Groesse aendern und einrasten lassen (react-grid-layout). "Anordnen" schaltet das frei, gespeichert wird ausdruecklich - Probieren bleibt folgenlos. Anordnung: - Neue Tabelle dashboard_layouts; user_id = NULL ist die Admin-Vorgabe. - Jeder Benutzer hat seine eigene Anordnung. Admins speichern eine Vorgabe fuer alle und koennen sie ueber die Einstellung dashboard_enforced verbindlich machen; dann lehnt das Backend das Speichern eigener Anordnungen ab. - "Auf Vorgabe zuruecksetzen" verwirft die persoenliche Anordnung. 15 Karten (Verzeichnis web/src/dashboard/cards.jsx, neue Karte = ein Eintrag): Schnellzugriff, Status, Artikelzahl, Artikeleinheiten, Bald ablaufend, Abgelaufen, Einkaufsliste, letzte Bewegungen sowie sechs Auswertungen (Ablauf-Ring, Kategorien-Ring, Kategorien nach Zustand, Bestandsverlauf, Verlauf eines Artikels, Ein-/Auslagerungen). Diagramme als eigene SVG-Komponenten statt Diagramm-Paket: - Zustandsfarben (ok/bald/abgelaufen) sind Statusangaben aus den Tokens und stehen nie ohne Beschriftung; Kategorien nutzen eine gepruefte Farbreihe in fester Ordnung, ab sieben Kategorien wird zu "Andere" gebuendelt. - Eine Werteachse, duenne Marken, zurueckhaltendes Gitter, Fadenkreuz mit Kurzinfo. Hell und Dunkel haben eigene Farbstufen. Mengen durchgehend in Artikeleinheiten (Glaeser, Packungen, Stueck): Gramm und Stueck lassen sich nicht addieren, Gebinde schon. Der bisher dreifach vorhandene Helfer liegt jetzt einmal in services/conversion.py::article_unit. Der Verlauf wird rueckwaerts vom heutigen Bestand aus den Bewegungen rekonstruiert. Bekannte Ungenauigkeit, im Code und in der Roadmap vermerkt: Die Umrechnung nutzt die heutige Packungsgroesse. Geprueft: "npm run build" laeuft durch. Die neuen pytest-Tests (backend/tests/test_dashboard.py) konnten hier nicht laufen - auf diesem Rechner ist kein Python installiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
from datetime import date, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.models import BaseUnit, Category, Product, Role, User
|
|
from app.routers import dashboard
|
|
from app.services.conversion import article_unit, article_units
|
|
from app.services.stock import check_in, check_out
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ---------------------------------------------------------------- Einheiten
|
|
def test_artikeleinheit_ist_das_gebinde(rice):
|
|
# rice: 500 g je Packung -> eine Artikeleinheit sind 500 g
|
|
faktor, label = article_unit(rice)
|
|
assert faktor == 500
|
|
assert label == "Packung"
|
|
assert article_units(rice, 1500) == 3
|
|
|
|
|
|
def test_artikeleinheit_ohne_gebinde_ist_die_produkteinheit(db):
|
|
eier = Product(name="Eier", base_unit=BaseUnit.piece)
|
|
db.add(eier)
|
|
db.commit()
|
|
faktor, _ = article_unit(eier)
|
|
assert faktor == 1
|
|
assert article_units(eier, 6) == 6
|
|
|
|
|
|
def test_gebinde_bezeichnung_wird_uebernommen(db):
|
|
pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
|
|
package_label="Glas")
|
|
db.add(pesto)
|
|
db.commit()
|
|
assert article_unit(pesto) == (195, "Glas")
|
|
|
|
|
|
# ------------------------------------------------------------ Ablaufzustand
|
|
def test_zustand_unterscheidet_die_vier_faelle():
|
|
heute = date(2026, 7, 1)
|
|
assert dashboard._zustand(None, heute, 7) == "no_date"
|
|
assert dashboard._zustand(heute - timedelta(days=1), heute, 7) == "expired"
|
|
assert dashboard._zustand(heute + timedelta(days=3), heute, 7) == "soon"
|
|
assert dashboard._zustand(heute, heute, 7) == "soon" # heute ist noch nicht abgelaufen
|
|
assert dashboard._zustand(heute + timedelta(days=30), heute, 7) == "ok"
|
|
|
|
|
|
def test_expiry_split_zaehlt_in_artikeleinheiten(db, rice, user):
|
|
heute = date.today()
|
|
check_in(db, rice, 2, "package", heute + timedelta(days=60), None, user) # ok
|
|
check_in(db, rice, 1, "package", heute + timedelta(days=3), None, user) # bald
|
|
check_in(db, rice, 3, "package", heute - timedelta(days=2), None, user) # abgelaufen
|
|
check_in(db, rice, 4, "package", None, None, user) # ohne MHD
|
|
db.commit()
|
|
|
|
split = dashboard.expiry_split(db=db, _=user)
|
|
assert split.ok == 2
|
|
assert split.soon == 1
|
|
assert split.expired == 3
|
|
assert split.no_date == 4
|
|
|
|
|
|
def test_by_category_bündelt_und_sortiert(db, user):
|
|
suess = Category(name="Süßwaren")
|
|
db.add(suess)
|
|
db.commit()
|
|
|
|
chips = Product(name="Chips", base_unit=BaseUnit.gram, package_size=175,
|
|
package_label="Tüte", category_id=suess.id)
|
|
ohne = Product(name="Salz", base_unit=BaseUnit.gram, package_size=500)
|
|
db.add_all([chips, ohne])
|
|
db.commit()
|
|
|
|
check_in(db, chips, 5, "package", None, None, user)
|
|
check_in(db, ohne, 2, "package", None, None, user)
|
|
db.commit()
|
|
|
|
anteile = dashboard.by_category(db=db, _=user)
|
|
assert [a.name for a in anteile] == ["Süßwaren", "Ohne Kategorie"]
|
|
assert anteile[0].article_units == 5
|
|
assert anteile[1].article_units == 2
|
|
|
|
|
|
# ----------------------------------------------------------------- Verlauf
|
|
def test_timeline_rechnet_bewegungen_zurueck(db, rice, user):
|
|
"""Der Verlauf entsteht rueckwaerts aus dem heutigen Bestand."""
|
|
check_in(db, rice, 4, "package", None, None, user) # +4 Einheiten heute
|
|
check_out(db, rice, 1, "package", user) # -1 Einheit heute
|
|
db.commit()
|
|
|
|
punkte = dashboard.timeline(days=3, product_id=None, db=db, _=user)
|
|
assert len(punkte) == 3
|
|
# Heute: 3 Einheiten; die Tage davor lag nichts im Lager.
|
|
assert punkte[-1].date == date.today()
|
|
assert punkte[-1].article_units == 3
|
|
assert punkte[0].article_units == 0
|
|
assert punkte[1].article_units == 0
|
|
|
|
|
|
def test_timeline_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user):
|
|
anderes = Product(name="Zucker", base_unit=BaseUnit.gram, package_size=1000)
|
|
db.add(anderes)
|
|
db.commit()
|
|
check_in(db, rice, 2, "package", None, None, user)
|
|
check_in(db, anderes, 5, "package", None, None, user)
|
|
db.commit()
|
|
|
|
gesamt = dashboard.timeline(days=2, product_id=None, db=db, _=user)
|
|
nur_reis = dashboard.timeline(days=2, product_id=rice.id, db=db, _=user)
|
|
assert gesamt[-1].article_units == 7
|
|
assert nur_reis[-1].article_units == 2
|
|
|
|
|
|
def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
|
|
check_in(db, rice, 2, "package", None, None, user)
|
|
check_in(db, rice, 1, "package", None, None, user)
|
|
check_out(db, rice, 1, "package", user)
|
|
db.commit()
|
|
|
|
tage = dashboard.activity(days=3, db=db, _=user)
|
|
assert len(tage) == 3
|
|
heute = tage[-1]
|
|
assert heute.date == date.today()
|
|
assert heute.checked_in == 2
|
|
assert heute.checked_out == 1
|
|
|
|
|
|
# ---------------------------------------------------------------- Anordnung
|
|
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
|
|
antwort = dashboard.get_layout(db=db, user=user)
|
|
assert antwort.source == "builtin"
|
|
assert antwort.enforced is False
|
|
assert antwort.has_default is False
|
|
assert any(k["i"] == "expiring" for k in antwort.layout)
|
|
|
|
|
|
def test_eigene_anordnung_wird_gespeichert(db, user):
|
|
from app.schemas import DashboardLayoutIn
|
|
|
|
eigen = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
|
|
dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user)
|
|
|
|
antwort = dashboard.get_layout(db=db, user=user)
|
|
assert antwort.source == "user"
|
|
assert antwort.layout == eigen
|
|
|
|
|
|
def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
|
|
from fastapi import HTTPException
|
|
|
|
from app.schemas import DashboardLayoutIn
|
|
|
|
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
|
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
|
dashboard.set_enforced(True, db=db, admin=user)
|
|
|
|
antwort = dashboard.get_layout(db=db, user=user)
|
|
assert antwort.source == "default"
|
|
assert antwort.enforced is True
|
|
assert antwort.layout == vorgabe
|
|
|
|
with pytest.raises(HTTPException) as fehler:
|
|
dashboard.put_layout(DashboardLayoutIn(layout=[]), db=db, user=user)
|
|
assert fehler.value.status_code == 403
|
|
|
|
|
|
def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user):
|
|
from app.schemas import DashboardLayoutIn
|
|
|
|
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
|
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
|
dashboard.put_layout(DashboardLayoutIn(layout=[{"i": "shopping", "x": 0, "y": 0, "w": 4, "h": 3}]),
|
|
db=db, user=user)
|
|
assert dashboard.get_layout(db=db, user=user).source == "user"
|
|
|
|
dashboard.reset_layout(db=db, user=user)
|
|
antwort = dashboard.get_layout(db=db, user=user)
|
|
assert antwort.source == "default"
|
|
assert antwort.layout == vorgabe
|