Zwei zusammenhaengende Umbauten, weil sie dieselben Stellen betreffen.
Obergruppen: Gruppen bilden jetzt einen gerichteten azyklischen Graphen statt
einer flachen Liste. Eine Gruppe darf unter MEHREREN Obergruppen haengen -
"Grillwurst" unter "Wurst" UND unter "Grillgut"; mit einem einzelnen parent_id
waere genau das nicht abbildbar. Bestand und Mindestbestand einer Gruppe zaehlen
den gesamten Untergraphen, wobei eine ueber zwei Wege erreichbare Untergruppe
nur einmal zaehlt (services/gruppen.py arbeitet durchgaengig mit Mengen).
Product.group_id bleibt unveraendert - ein Artikel haengt weiter an genau einer
Gruppe.
Mindestbestaende: der separate Gesamt-Mindestbestand entfaellt. Er wird zur
Zeile mit location_id NULL ("Ueberall") und ist damit die Wurzel ueber allen
Lagerorten - dieselbe Verrechnung wie bei verschachtelten Orten greift jetzt
auch zwischen Ueberall und Kueche, wodurch derselbe Artikel nicht mehr doppelt
in der Einkaufsliste steht. Alle Werte liegen einheitlich in Basiseinheiten
statt in drei verschiedenen Einheiten nebeneinander; das Umrechnen beim
Umschalten der Erfassungseinheit entfaellt dadurch ersatzlos.
_netted_topups nimmt die Hierarchie jetzt als Parameter und faltet damit
Lagerort-Baum und Gruppen-Graph. Verrechnet wird zwischen zwei Gruppen nur,
wenn die zaehlenden Artikel der Untergruppe eine Teilmenge der Obergruppe sind -
zaehlt die Obergruppe in Kilogramm und die Untergruppe in Stueck, kommt ein Kauf
dort oben nicht an.
Die vierfach kopierte Bestandssumme wandert in Sammelabfragen
(summe_bestand_base), sonst vervielfacht der transitive Teilgraph die Abfragen.
Einmalige Datenwanderung beim Start (Merker in den Einstellungen), 18 neue
Tests - darunter Doppelzaehlung ueber zwei Wege, Ringschutz und die bewusst
offene Grenze bei zwei Obergruppen mit gemeinsamer Untergruppe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
641 lines
23 KiB
Python
641 lines
23 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,
|
||
Item,
|
||
Lot,
|
||
Movement,
|
||
MovementType,
|
||
Product,
|
||
Setting,
|
||
User,
|
||
)
|
||
from ..schemas import (
|
||
ActivityPoint,
|
||
CategoryShare,
|
||
DashboardCreate,
|
||
DashboardListOut,
|
||
DashboardOut,
|
||
DashboardStats,
|
||
DashboardUpdate,
|
||
ExpirySplit,
|
||
FlowPoint,
|
||
TimelinePoint,
|
||
)
|
||
from ..services.conversion import article_unit
|
||
from ..services.stock import current_stock
|
||
from .settings import get_expiry_warning_days
|
||
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
||
|
||
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", "type": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||
{"i": "status", "type": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||
# Bewusst die kombinierte Karte: "expiring" zeigt nur noch Laufendes, in der
|
||
# Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein.
|
||
{"i": "expiry-all", "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 _normalize(layout: list[dict]) -> list[dict]:
|
||
"""Karten auf die Instanz-Form bringen.
|
||
|
||
Frueher war ``i`` zugleich die Kartenart – dadurch ging jede Art genau
|
||
einmal. Jetzt ist ``i`` die Kennung dieser einen Karte und ``type`` ihre
|
||
Art. Aeltere gespeicherte Anordnungen haben kein ``type``; fuer sie ist die
|
||
alte Kennung die Art.
|
||
"""
|
||
result: list[dict] = []
|
||
for eintrag in layout:
|
||
if not isinstance(eintrag, dict) or "i" not in eintrag:
|
||
continue
|
||
karte = dict(eintrag)
|
||
karte.setdefault("type", karte["i"])
|
||
karte.setdefault("props", {})
|
||
result.append(karte)
|
||
return result
|
||
|
||
|
||
def _parse(row: DashboardLayout) -> list[dict]:
|
||
try:
|
||
data = json.loads(row.layout)
|
||
except ValueError:
|
||
return []
|
||
return _normalize(data) if isinstance(data, list) else []
|
||
|
||
|
||
def _rows(db: Session, user_id: int | None) -> list[DashboardLayout]:
|
||
query = db.query(DashboardLayout)
|
||
query = query.filter(
|
||
DashboardLayout.user_id.is_(None)
|
||
if user_id is None
|
||
else DashboardLayout.user_id == user_id
|
||
)
|
||
return query.order_by(DashboardLayout.position, DashboardLayout.id).all()
|
||
|
||
|
||
def _as_out(rows: list[DashboardLayout]) -> list[DashboardOut]:
|
||
return [
|
||
DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||
for row in rows
|
||
]
|
||
|
||
|
||
def _owned(db: Session, dashboard_id: int, user: User) -> DashboardLayout:
|
||
row = db.get(DashboardLayout, dashboard_id)
|
||
if row is None or row.user_id != user.id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dashboard nicht gefunden")
|
||
return row
|
||
|
||
|
||
def _guard_enforced(db: Session) -> None:
|
||
if _enforced(db) and _rows(db, None):
|
||
raise HTTPException(
|
||
status.HTTP_403_FORBIDDEN,
|
||
"Die Startseite ist vom Administrator fest vorgegeben.",
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Anordnung
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/layouts", response_model=DashboardListOut)
|
||
def list_dashboards(
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> DashboardListOut:
|
||
"""Eigene Dashboards, sonst die Vorgabe, sonst das eingebaute."""
|
||
erzwungen = _enforced(db)
|
||
vorgabe = _rows(db, None)
|
||
|
||
if erzwungen and vorgabe:
|
||
return DashboardListOut(
|
||
dashboards=_as_out(vorgabe), source="default", enforced=True, has_default=True
|
||
)
|
||
|
||
eigene = _rows(db, user.id)
|
||
if eigene:
|
||
return DashboardListOut(
|
||
dashboards=_as_out(eigene), source="user",
|
||
enforced=erzwungen, has_default=bool(vorgabe),
|
||
)
|
||
if vorgabe:
|
||
return DashboardListOut(
|
||
dashboards=_as_out(vorgabe), source="default",
|
||
enforced=erzwungen, has_default=True,
|
||
)
|
||
# Ohne alles das eingebaute Dashboard - mit id 0, weil es keine Zeile hat.
|
||
return DashboardListOut(
|
||
dashboards=[DashboardOut(id=0, name="Übersicht", position=0,
|
||
layout=_normalize(BUILTIN_LAYOUT))],
|
||
source="builtin", enforced=erzwungen, has_default=False,
|
||
)
|
||
|
||
|
||
@router.post("/layouts", response_model=DashboardOut, status_code=status.HTTP_201_CREATED)
|
||
def create_dashboard(
|
||
payload: DashboardCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> DashboardOut:
|
||
_guard_enforced(db)
|
||
bestehende = _rows(db, user.id)
|
||
# Legt der Benutzer sein erstes eigenes Dashboard an, waehrend er noch auf
|
||
# der Vorgabe sitzt, wuerde diese sonst schlagartig verschwinden. Deshalb
|
||
# wird sie vorher als eigene Dashboards uebernommen.
|
||
if not bestehende:
|
||
for row in _rows(db, None):
|
||
db.add(DashboardLayout(user_id=user.id, name=row.name,
|
||
position=row.position, layout=row.layout))
|
||
db.flush()
|
||
bestehende = _rows(db, user.id)
|
||
|
||
position = max((row.position for row in bestehende), default=-1) + 1
|
||
neu = DashboardLayout(
|
||
user_id=user.id, name=payload.name.strip(), position=position,
|
||
layout=json.dumps(_normalize(payload.layout), ensure_ascii=False),
|
||
)
|
||
db.add(neu)
|
||
db.commit()
|
||
db.refresh(neu)
|
||
return DashboardOut(id=neu.id, name=neu.name, position=neu.position, layout=_parse(neu))
|
||
|
||
|
||
@router.patch("/layouts/{dashboard_id}", response_model=DashboardOut)
|
||
def update_dashboard(
|
||
dashboard_id: int,
|
||
payload: DashboardUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> DashboardOut:
|
||
_guard_enforced(db)
|
||
row = _owned(db, dashboard_id, user)
|
||
|
||
daten = payload.model_dump(exclude_unset=True)
|
||
if daten.get("name"):
|
||
row.name = daten["name"].strip()
|
||
if daten.get("layout") is not None:
|
||
row.layout = json.dumps(_normalize(daten["layout"]), ensure_ascii=False)
|
||
if daten.get("position") is not None:
|
||
row.position = daten["position"]
|
||
db.commit()
|
||
db.refresh(row)
|
||
return DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||
|
||
|
||
@router.delete("/layouts/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_dashboard(
|
||
dashboard_id: int,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> Response:
|
||
_guard_enforced(db)
|
||
row = _owned(db, dashboard_id, user)
|
||
db.delete(row)
|
||
db.commit()
|
||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
@router.delete("/layouts")
|
||
def reset_dashboards(
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> Response:
|
||
"""Alle eigenen Dashboards 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.
|
||
"""
|
||
for row in _rows(db, user.id):
|
||
db.delete(row)
|
||
db.commit()
|
||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
@router.put("/layouts/default", response_model=DashboardListOut)
|
||
def put_default_dashboards(
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(require_admin),
|
||
) -> DashboardListOut:
|
||
"""Die eigenen Dashboards zur Vorgabe für alle machen."""
|
||
for row in _rows(db, None):
|
||
db.delete(row)
|
||
for row in _rows(db, admin.id):
|
||
db.add(DashboardLayout(user_id=None, name=row.name,
|
||
position=row.position, layout=row.layout))
|
||
db.commit()
|
||
return list_dashboards(db=db, user=admin)
|
||
|
||
|
||
@router.put("/layout/enforced", response_model=DashboardListOut)
|
||
def set_enforced(
|
||
value: bool,
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(require_admin),
|
||
) -> DashboardListOut:
|
||
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 list_dashboards(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()
|
||
)
|
||
|
||
|
||
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)
|
||
) -> DashboardStats:
|
||
heute = date.today()
|
||
warnfrist = get_expiry_warning_days(db)
|
||
|
||
einheiten = 0.0
|
||
bestand_produkte: set[int] = set()
|
||
bald = abgelaufen = 0
|
||
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||
einheiten += menge
|
||
bestand_produkte.add(product.id)
|
||
if zustand == "expired":
|
||
abgelaufen += 1
|
||
elif zustand == "soon":
|
||
bald += 1
|
||
|
||
# Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen –
|
||
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
|
||
# gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
|
||
bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db))
|
||
|
||
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 _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||
summen[zustand] += menge
|
||
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
||
|
||
|
||
@router.get("/by-category", response_model=list[CategoryShare])
|
||
def by_category(
|
||
depth: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[CategoryShare]:
|
||
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt.
|
||
|
||
``depth`` fasst tiefe Unterkategorien zu ihrer Oberkategorie auf der
|
||
gewünschten Stufe zusammen (1 = oberste Ebene). Ohne ``depth`` zählt jede
|
||
Kategorie so, wie sie dem Artikel zugeordnet ist (feinste Auflösung).
|
||
"""
|
||
heute = date.today()
|
||
warnfrist = get_expiry_warning_days(db)
|
||
kategorien = db.query(Category).all()
|
||
namen = {c.id: c.name for c in kategorien}
|
||
eltern = {c.id: c.parent_id for c in kategorien}
|
||
|
||
# Kategorie auf die gewünschte Tiefe hochrollen (Vorfahr auf Stufe ``depth``).
|
||
rollup_cache: dict[int | None, int | None] = {}
|
||
|
||
def rollup(cid: int | None) -> int | None:
|
||
if depth is None or cid is None:
|
||
return cid
|
||
if cid in rollup_cache:
|
||
return rollup_cache[cid]
|
||
pfad: list[int] = []
|
||
cur, gesehen = cid, set()
|
||
while cur is not None and cur not in gesehen:
|
||
pfad.append(cur)
|
||
gesehen.add(cur)
|
||
cur = eltern.get(cur)
|
||
pfad.reverse() # Wurzel zuerst
|
||
ziel = pfad[min(max(depth, 1) - 1, len(pfad) - 1)]
|
||
rollup_cache[cid] = ziel
|
||
return ziel
|
||
|
||
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 product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||
topf = eimer[rollup(product.category_id)]
|
||
topf["article_units"] += menge
|
||
topf[zustand] += 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
|
||
|
||
|
||
def _utc(zeitpunkt: datetime) -> datetime:
|
||
"""SQLite gibt Zeitstempel ohne Zeitzone zurück – hier vereinheitlichen."""
|
||
return zeitpunkt if zeitpunkt.tzinfo else zeitpunkt.replace(tzinfo=timezone.utc)
|
||
|
||
|
||
def _schrittweite(days: int) -> timedelta:
|
||
"""Abtastrate passend zum Zeitraum.
|
||
|
||
Ein Punkt je Tag verbirgt, wann am Tag etwas passiert ist; ein Punkt je
|
||
Stunde über ein Jahr wären knapp 9000 Punkte. Deshalb gestaffelt – die
|
||
Anzahl der Punkte bleibt so immer im Bereich von etwa 25 bis 170.
|
||
"""
|
||
if days <= 2:
|
||
return timedelta(hours=1)
|
||
if days <= 14:
|
||
return timedelta(hours=6)
|
||
if days <= 120:
|
||
return timedelta(days=1)
|
||
return timedelta(days=7)
|
||
|
||
|
||
def _eimer(days: int) -> tuple[datetime, timedelta, int]:
|
||
"""Endzeitpunkt, Schrittweite und Anzahl der Abschnitte."""
|
||
schritt = _schrittweite(days)
|
||
jetzt = datetime.now(timezone.utc)
|
||
anzahl = max(1, int(timedelta(days=days) / schritt))
|
||
return jetzt, schritt, anzahl
|
||
|
||
|
||
@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))
|
||
jetzt, schritt, anzahl = _eimer(days)
|
||
|
||
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 = jetzt - schritt * anzahl
|
||
bewegungen = db.query(Movement).filter(Movement.created_at >= beginn)
|
||
if product_id is not None:
|
||
bewegungen = bewegungen.filter(Movement.product_id == product_id)
|
||
|
||
# Abschnitt 0 ist der jüngste (von jetzt rückwärts eine Schrittweite).
|
||
sekunden = schritt.total_seconds()
|
||
delta: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
|
||
for m in bewegungen.all():
|
||
if m.product_id not in faktoren:
|
||
continue
|
||
index = int((jetzt - _utc(m.created_at)).total_seconds() // sekunden)
|
||
if 0 <= index < anzahl:
|
||
delta[index][m.product_id] += _signiert(m)
|
||
|
||
def summe() -> float:
|
||
return round(sum(bestand[pid] / faktoren[pid] for pid in bestand), 3)
|
||
|
||
punkte = [TimelinePoint(at=jetzt, article_units=summe())]
|
||
for i in range(anzahl):
|
||
for pid, wert in delta.get(i, {}).items():
|
||
bestand[pid] = bestand.get(pid, 0.0) - wert
|
||
punkte.append(TimelinePoint(at=jetzt - schritt * (i + 1), article_units=summe()))
|
||
|
||
punkte.reverse()
|
||
return punkte
|
||
|
||
|
||
@router.get("/flow", response_model=list[FlowPoint])
|
||
def flow(
|
||
days: int = 30,
|
||
product_id: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[FlowPoint]:
|
||
"""Wasserfall: Anfangsbestand je Abschnitt plus Zu- und Abgang.
|
||
|
||
Balken und Bestandslinie kommen bewusst aus *einer* Abfrage. Getrennt
|
||
abgefragt könnten die Abschnitte der beiden Antworten um eine Schrittweite
|
||
auseinanderliegen – dann stünde ein Balken neben dem Sprung, den er erklärt.
|
||
|
||
Anders als :func:`activity` zählt das hier keine Vorgänge, sondern Mengen in
|
||
Artikeleinheiten: Nur so liegen Balken und Linie auf derselben Achse.
|
||
"""
|
||
days = max(1, min(days, 730))
|
||
jetzt, schritt, anzahl = _eimer(days)
|
||
|
||
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 = jetzt - schritt * anzahl
|
||
bewegungen = db.query(Movement).filter(Movement.created_at >= beginn)
|
||
if product_id is not None:
|
||
bewegungen = bewegungen.filter(Movement.product_id == product_id)
|
||
|
||
sekunden = schritt.total_seconds()
|
||
delta: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
|
||
ein: dict[int, float] = defaultdict(float)
|
||
aus: dict[int, float] = defaultdict(float)
|
||
for m in bewegungen.all():
|
||
if m.product_id not in faktoren:
|
||
continue
|
||
index = int((jetzt - _utc(m.created_at)).total_seconds() // sekunden)
|
||
if not 0 <= index < anzahl:
|
||
continue
|
||
menge = _signiert(m)
|
||
delta[index][m.product_id] += menge
|
||
# Korrekturen tragen ihr Vorzeichen bereits – deshalb nach Vorzeichen
|
||
# einsortieren und nicht nach Bewegungsart.
|
||
einheiten = menge / faktoren[m.product_id]
|
||
if einheiten >= 0:
|
||
ein[index] += einheiten
|
||
else:
|
||
aus[index] -= einheiten
|
||
|
||
def summe() -> float:
|
||
return round(sum(bestand[pid] / faktoren[pid] for pid in bestand), 3)
|
||
|
||
# Rückwärts durch die Abschnitte: Der Endbestand des Abschnitts ist bekannt,
|
||
# der Anfangsbestand ergibt sich daraus, dass die Bewegungen herausfallen.
|
||
punkte: list[FlowPoint] = []
|
||
for i in range(anzahl):
|
||
for pid, wert in delta.get(i, {}).items():
|
||
bestand[pid] = bestand.get(pid, 0.0) - wert
|
||
punkte.append(
|
||
FlowPoint(
|
||
at=jetzt - schritt * i,
|
||
opening=summe(),
|
||
checked_in=round(ein.get(i, 0.0), 3),
|
||
checked_out=round(aus.get(i, 0.0), 3),
|
||
)
|
||
)
|
||
|
||
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 Abschnitt.
|
||
|
||
Die Abschnittslänge richtet sich nach dem Zeitraum: bei ein bis zwei Tagen
|
||
stündlich, damit erkennbar wird, zu welcher Tageszeit gelagert wird.
|
||
"""
|
||
days = max(1, min(days, 365))
|
||
jetzt, schritt, anzahl = _eimer(days)
|
||
beginn = jetzt - schritt * anzahl
|
||
sekunden = schritt.total_seconds()
|
||
|
||
ein: dict[int, int] = defaultdict(int)
|
||
aus: dict[int, int] = defaultdict(int)
|
||
for m in db.query(Movement).filter(Movement.created_at >= beginn).all():
|
||
index = int((jetzt - _utc(m.created_at)).total_seconds() // sekunden)
|
||
if not 0 <= index < anzahl:
|
||
continue
|
||
if m.type == MovementType.in_:
|
||
ein[index] += 1
|
||
elif m.type == MovementType.out:
|
||
aus[index] += 1
|
||
|
||
# Von alt nach neu ausgeben; Abschnitt 0 ist der jüngste.
|
||
return [
|
||
ActivityPoint(
|
||
at=jetzt - schritt * i,
|
||
checked_in=ein.get(i, 0),
|
||
checked_out=aus.get(i, 0),
|
||
)
|
||
for i in reversed(range(anzahl))
|
||
]
|