Modulare Startseite: Kartenraster, eigene Anordnung je Benutzer, Auswertungen

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>
This commit is contained in:
Scarriffle
2026-07-23 09:48:16 +02:00
parent 4448718d8d
commit 84ce596446
17 changed files with 1940 additions and 115 deletions

View File

@@ -0,0 +1,187 @@
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