Meldungen: "Gespeichert", "Anordnung gespeichert" und die uebrigen Erfolgsmeldungen standen als Leiste mitten in der Seite - beim Speichern sprang dadurch das ganze Layout ein Stueck nach unten. Sie erscheinen jetzt als Overlay am unteren Rand und verschwinden nach fuenf Sekunden von selbst; ein Kreuz blendet sie sofort aus. Umgestellt in Uebersicht, Einstellungen, Gefahrenbereich, Benutzer, Produktformular sowie Ein- und Auslagern. Bewusst NICHT umgestellt: Fehlermeldungen. Ein Fehler, der nach fuenf Sekunden verschwindet, ist genau dann weg, wenn man ihn noch braucht - der bleibt an seiner Stelle stehen. Rollbalken beim Liniendiagramm: Den hatte ich mir mit der Zeitraum-Auswahl selbst eingebaut. Das Diagramm hatte eine feste Hoehe von 200 px; zusammen mit der Auswahl darueber passte das nicht mehr in die Karte. Jetzt wird auch die Hoehe gemessen, das Diagramm fuellt den verbleibenden Platz. Zeitraum "1 Tag": Ein einzelner Tag ergibt genau einen Messpunkt - eine Linie aus einem Punkt waere unsinnig. Das Diagramm zeigt in dem Fall die blanke Zahl. Die Backend-Begrenzung laesst jetzt einen Tag zu. Geprueft: "npm run build" laeuft durch; kein Verweis mehr auf die entfernten Meldungszustaende. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
385 lines
13 KiB
Python
385 lines
13 KiB
Python
"""Startseite: gespeicherte Kartenanordnung und die Auswertungen dahinter.
|
||
|
||
Die Auswertungen laufen bewusst hier und nicht in der Oberfläche: Sonst müsste
|
||
der Browser alle Chargen und Bewegungen laden, nur um ein paar Summen zu bilden.
|
||
|
||
Alle Mengenangaben sind **Artikeleinheiten** (Gläser, Packungen, Stück) – siehe
|
||
:func:`app.services.conversion.article_unit`. Nur die lassen sich über
|
||
verschiedene Artikel hinweg addieren.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
from datetime import date, datetime, timedelta, timezone
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.responses import Response
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, require_admin
|
||
from ..models import (
|
||
Category,
|
||
DashboardLayout,
|
||
Group,
|
||
Lot,
|
||
Movement,
|
||
MovementType,
|
||
Product,
|
||
Setting,
|
||
User,
|
||
)
|
||
from ..schemas import (
|
||
ActivityPoint,
|
||
CategoryShare,
|
||
DashboardLayoutIn,
|
||
DashboardLayoutOut,
|
||
DashboardStats,
|
||
ExpirySplit,
|
||
TimelinePoint,
|
||
)
|
||
from ..services.conversion import article_unit
|
||
from ..services.stock import current_stock
|
||
from .settings import get_expiry_warning_days
|
||
|
||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||
|
||
ENFORCED_KEY = "dashboard_enforced"
|
||
|
||
# Eingebaute Anordnung – wird benutzt, solange weder eigene noch Vorgabe existiert.
|
||
# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
|
||
# zusätzlich auf ihre Mindesthöhe an.
|
||
BUILTIN_LAYOUT: list[dict] = [
|
||
{"i": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||
{"i": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||
{"i": "expiring", "x": 0, "y": 3, "w": 6, "h": 6},
|
||
{"i": "shopping", "x": 6, "y": 3, "w": 6, "h": 6},
|
||
{"i": "expiry-donut", "x": 0, "y": 9, "w": 4, "h": 6},
|
||
{"i": "category-donut", "x": 4, "y": 9, "w": 4, "h": 6},
|
||
{"i": "stock-timeline", "x": 8, "y": 9, "w": 4, "h": 6},
|
||
]
|
||
|
||
|
||
def _enforced(db: Session) -> bool:
|
||
row = db.get(Setting, ENFORCED_KEY)
|
||
return bool(row and row.value == "1")
|
||
|
||
|
||
def _load(db: Session, user_id: int | None) -> list[dict] | None:
|
||
row = (
|
||
db.query(DashboardLayout)
|
||
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
|
||
.first()
|
||
)
|
||
if row is None:
|
||
return None
|
||
try:
|
||
data = json.loads(row.layout)
|
||
except ValueError:
|
||
return None
|
||
return data if isinstance(data, list) else None
|
||
|
||
|
||
def _store(db: Session, user_id: int | None, layout: list[dict]) -> None:
|
||
row = (
|
||
db.query(DashboardLayout)
|
||
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
|
||
.first()
|
||
)
|
||
text = json.dumps(layout, ensure_ascii=False)
|
||
if row is None:
|
||
db.add(DashboardLayout(user_id=user_id, layout=text))
|
||
else:
|
||
row.layout = text
|
||
db.commit()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Anordnung
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/layout", response_model=DashboardLayoutOut)
|
||
def get_layout(
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> DashboardLayoutOut:
|
||
"""Eigene Anordnung, sonst die Vorgabe, sonst die eingebaute."""
|
||
erzwungen = _enforced(db)
|
||
vorgabe = _load(db, None)
|
||
|
||
if erzwungen and vorgabe is not None:
|
||
return DashboardLayoutOut(layout=vorgabe, source="default", enforced=True, has_default=True)
|
||
|
||
eigene = _load(db, user.id)
|
||
if eigene is not None:
|
||
return DashboardLayoutOut(
|
||
layout=eigene, source="user", enforced=erzwungen, has_default=vorgabe is not None
|
||
)
|
||
if vorgabe is not None:
|
||
return DashboardLayoutOut(
|
||
layout=vorgabe, source="default", enforced=erzwungen, has_default=True
|
||
)
|
||
return DashboardLayoutOut(
|
||
layout=BUILTIN_LAYOUT, source="builtin", enforced=erzwungen, has_default=False
|
||
)
|
||
|
||
|
||
@router.put("/layout", response_model=DashboardLayoutOut)
|
||
def put_layout(
|
||
payload: DashboardLayoutIn,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> DashboardLayoutOut:
|
||
if _enforced(db) and _load(db, None) is not None:
|
||
raise HTTPException(
|
||
status.HTTP_403_FORBIDDEN,
|
||
"Die Startseite ist vom Administrator fest vorgegeben.",
|
||
)
|
||
_store(db, user.id, payload.layout)
|
||
return get_layout(db=db, user=user)
|
||
|
||
|
||
@router.delete("/layout")
|
||
def reset_layout(
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> Response:
|
||
"""Eigene Anordnung verwerfen – danach gilt wieder die Vorgabe.
|
||
|
||
Der Statuscode steht bewusst am Response und nicht im Dekorator: Durch
|
||
``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette,
|
||
die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet – zusammen
|
||
mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab.
|
||
"""
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first()
|
||
if row is not None:
|
||
db.delete(row)
|
||
db.commit()
|
||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
@router.put("/layout/default", response_model=DashboardLayoutOut)
|
||
def put_default_layout(
|
||
payload: DashboardLayoutIn,
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(require_admin),
|
||
) -> DashboardLayoutOut:
|
||
_store(db, None, payload.layout)
|
||
return get_layout(db=db, user=admin)
|
||
|
||
|
||
@router.put("/layout/enforced", response_model=DashboardLayoutOut)
|
||
def set_enforced(
|
||
value: bool,
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(require_admin),
|
||
) -> DashboardLayoutOut:
|
||
row = db.get(Setting, ENFORCED_KEY)
|
||
if row is None:
|
||
db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0"))
|
||
else:
|
||
row.value = "1" if value else "0"
|
||
db.commit()
|
||
return get_layout(db=db, user=admin)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Auswertungen
|
||
# --------------------------------------------------------------------------
|
||
def _zustand(best_before: date | None, heute: date, warnfrist: int) -> str:
|
||
"""Ablaufzustand einer Charge: ohne MHD / abgelaufen / bald / in Ordnung."""
|
||
if best_before is None:
|
||
return "no_date"
|
||
if best_before < heute:
|
||
return "expired"
|
||
if (best_before - heute).days <= warnfrist:
|
||
return "soon"
|
||
return "ok"
|
||
|
||
|
||
def _lots_mit_produkt(db: Session) -> list[tuple[Lot, Product]]:
|
||
return (
|
||
db.query(Lot, Product)
|
||
.join(Product, Lot.product_id == Product.id)
|
||
.filter(Lot.quantity > 0)
|
||
.all()
|
||
)
|
||
|
||
|
||
@router.get("/stats", response_model=DashboardStats)
|
||
def stats(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> DashboardStats:
|
||
heute = date.today()
|
||
warnfrist = get_expiry_warning_days(db)
|
||
|
||
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
|
||
bestand_produkte.add(product.id)
|
||
zustand = _zustand(lot.best_before, heute, warnfrist)
|
||
if zustand == "expired":
|
||
abgelaufen += 1
|
||
elif zustand == "soon":
|
||
bald += 1
|
||
|
||
# Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand.
|
||
bedarf = 0
|
||
for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0):
|
||
if current_stock(db, product.id) < product.min_stock:
|
||
bedarf += 1
|
||
for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0):
|
||
unit = group.min_stock_unit
|
||
produkte = list(group.products)
|
||
summe = float(sum(current_stock(db, p.id) for p in produkte))
|
||
if unit is not None:
|
||
summe /= unit.factor or 1.0
|
||
if summe < group.min_stock:
|
||
bedarf += 1
|
||
|
||
return DashboardStats(
|
||
products_in_stock=len(bestand_produkte),
|
||
article_units=round(einheiten, 3),
|
||
expiring_soon=bald,
|
||
expired=abgelaufen,
|
||
shopping_items=bedarf,
|
||
products_total=db.query(Product).count(),
|
||
)
|
||
|
||
|
||
@router.get("/expiry-split", response_model=ExpirySplit)
|
||
def expiry_split(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> ExpirySplit:
|
||
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)
|
||
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
||
|
||
|
||
@router.get("/by-category", response_model=list[CategoryShare])
|
||
def by_category(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> list[CategoryShare]:
|
||
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt."""
|
||
heute = date.today()
|
||
warnfrist = get_expiry_warning_days(db)
|
||
namen = {c.id: c.name for c in db.query(Category).all()}
|
||
|
||
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)
|
||
topf = eimer[product.category_id]
|
||
topf["article_units"] += menge
|
||
topf[_zustand(lot.best_before, heute, warnfrist)] += menge
|
||
|
||
ergebnis = [
|
||
CategoryShare(
|
||
category_id=cid,
|
||
name=namen.get(cid, "Ohne Kategorie") if cid is not None else "Ohne Kategorie",
|
||
**{k: round(v, 3) for k, v in werte.items()},
|
||
)
|
||
for cid, werte in eimer.items()
|
||
]
|
||
ergebnis.sort(key=lambda c: c.article_units, reverse=True)
|
||
return ergebnis
|
||
|
||
|
||
def _signiert(movement: Movement) -> float:
|
||
"""Bewegung als vorzeichenbehaftete Änderung des Bestands."""
|
||
if movement.type == MovementType.in_:
|
||
return movement.quantity
|
||
if movement.type == MovementType.out:
|
||
return -movement.quantity
|
||
return movement.quantity # Korrekturen sind bereits vorzeichenbehaftet
|
||
|
||
|
||
@router.get("/timeline", response_model=list[TimelinePoint])
|
||
def timeline(
|
||
days: int = 90,
|
||
product_id: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[TimelinePoint]:
|
||
"""Artikeleinheiten im Bestand je Tag – rückwärts aus den Bewegungen.
|
||
|
||
Ausgangspunkt ist der heutige Bestand; für jeden Tag rückwärts wird die
|
||
Netto-Bewegung dieses Tages wieder herausgerechnet.
|
||
|
||
Bekannte Ungenauigkeit: Die Umrechnung in Artikeleinheiten nutzt die *heutige*
|
||
Packungsgröße. Wird sie später geändert, verschiebt sich auch die Historie.
|
||
"""
|
||
days = max(1, min(days, 730))
|
||
heute = date.today()
|
||
|
||
produkte = db.query(Product)
|
||
if product_id is not None:
|
||
produkte = produkte.filter(Product.id == product_id)
|
||
produkte = produkte.all()
|
||
if not produkte:
|
||
return []
|
||
|
||
faktoren = {p.id: (article_unit(p)[0] or 1.0) for p in produkte}
|
||
bestand = {p.id: current_stock(db, p.id) for p in produkte}
|
||
|
||
beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc)
|
||
bewegungen = db.query(Movement).filter(Movement.created_at >= beginn)
|
||
if product_id is not None:
|
||
bewegungen = bewegungen.filter(Movement.product_id == product_id)
|
||
|
||
delta: dict[date, dict[int, float]] = defaultdict(lambda: defaultdict(float))
|
||
for m in bewegungen.all():
|
||
if m.product_id in faktoren:
|
||
delta[m.created_at.date()][m.product_id] += _signiert(m)
|
||
|
||
def summe() -> float:
|
||
return round(sum(bestand[pid] / faktoren[pid] for pid in bestand), 3)
|
||
|
||
punkte = [TimelinePoint(date=heute, article_units=summe())]
|
||
tag = heute
|
||
for _ in range(days - 1):
|
||
for pid, wert in delta.get(tag, {}).items():
|
||
bestand[pid] = bestand.get(pid, 0.0) - wert
|
||
tag = tag - timedelta(days=1)
|
||
punkte.append(TimelinePoint(date=tag, article_units=summe()))
|
||
|
||
punkte.reverse()
|
||
return punkte
|
||
|
||
|
||
@router.get("/activity", response_model=list[ActivityPoint])
|
||
def activity(
|
||
days: int = 30,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[ActivityPoint]:
|
||
"""Anzahl der Ein- und Auslagerungen je Tag."""
|
||
days = max(1, min(days, 365))
|
||
heute = date.today()
|
||
beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc)
|
||
|
||
ein: dict[date, int] = defaultdict(int)
|
||
aus: dict[date, int] = defaultdict(int)
|
||
for m in db.query(Movement).filter(Movement.created_at >= beginn).all():
|
||
tag = m.created_at.date()
|
||
if m.type == MovementType.in_:
|
||
ein[tag] += 1
|
||
elif m.type == MovementType.out:
|
||
aus[tag] += 1
|
||
|
||
return [
|
||
ActivityPoint(
|
||
date=heute - timedelta(days=days - 1 - i),
|
||
checked_in=ein.get(heute - timedelta(days=days - 1 - i), 0),
|
||
checked_out=aus.get(heute - timedelta(days=days - 1 - i), 0),
|
||
)
|
||
for i in range(days)
|
||
]
|