From 26f7ed21d8caf9d57bd68a3f10e59d5db8002f71 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 23 Jul 2026 12:45:59 +0200 Subject: [PATCH] Ein- und Auslagerungen als Wasserfall- bzw. Brueckendiagramm Bisher waren es zwei Linien mit der Anzahl der Vorgaenge. Gemeint war eine Bruecke: die Bestandslinie, und schwebende gruene/rote Balken, die jeden ihrer Spruenge erklaeren. Damit Balken und Linie ueberhaupt zusammen in ein Bild duerfen, zaehlen die Balken jetzt Mengen in Artikeleinheiten statt Vorgaengen - beides liegt so auf derselben Achse. Zwei Skalen in einem Diagramm bleiben ausgeschlossen. Neuer Endpunkt GET /dashboard/flow liefert je Abschnitt Anfangsbestand, Zugang und Abgang aus einer Abfrage. Getrennt abgefragt koennten die Abschnitte von timeline und activity um eine Schrittweite auseinanderliegen - dann stuende ein Balken neben dem Sprung, den er erklaert. /dashboard/activity bleibt fuer externe Zugriffe erhalten. Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/dashboard.py | 77 ++++++++++++ backend/app/schemas.py | 14 +++ backend/tests/test_dashboard.py | 38 ++++++ web/src/api.js | 2 + web/src/dashboard/Vorschau.jsx | 14 +++ web/src/dashboard/cards.jsx | 24 ++-- web/src/dashboard/charts/Line.jsx | 23 +--- web/src/dashboard/charts/Wasserfall.jsx | 148 ++++++++++++++++++++++++ web/src/dashboard/charts/useGroesse.js | 25 ++++ web/src/styles.css | 3 + 10 files changed, 336 insertions(+), 32 deletions(-) create mode 100644 web/src/dashboard/charts/Wasserfall.jsx create mode 100644 web/src/dashboard/charts/useGroesse.js diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index ea450ec..120a7a1 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -38,6 +38,7 @@ from ..schemas import ( DashboardLayoutOut, DashboardStats, ExpirySplit, + FlowPoint, TimelinePoint, ) from ..services.conversion import article_unit @@ -388,6 +389,82 @@ def timeline( 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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 130ada7..88e5001 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -441,3 +441,17 @@ class ActivityPoint(BaseModel): at: datetime checked_in: int checked_out: int + + +class FlowPoint(BaseModel): + """Ein Abschnitt eines Wasserfalls: Anfangsbestand, Zu- und Abgang. + + Alle Werte sind Artikeleinheiten und liegen damit auf derselben Skala wie + die Bestandslinie – nur so darf beides in ein Diagramm. + ``opening + checked_in - checked_out`` ergibt den Endbestand. + """ + + at: datetime + opening: float + checked_in: float + checked_out: float diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index f09a004..f8075f8 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -134,6 +134,44 @@ def test_timeline_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user): assert nur_reis[-1].article_units == 2 +def test_flow_liefert_eine_geschlossene_bruecke(db, rice, user): + """Anfangsbestand + Zugang - Abgang muss den Endbestand ergeben.""" + check_in(db, rice, 5, "package", None, None, user) + check_out(db, rice, 2, "package", user) + db.commit() + + abschnitte = dashboard.flow(days=3, product_id=None, db=db, _=user) + juengster = abschnitte[-1] + assert juengster.opening == 0 # vorher war das Lager leer + assert juengster.checked_in == 5 # Artikeleinheiten, nicht Vorgaenge + assert juengster.checked_out == 2 + assert juengster.opening + juengster.checked_in - juengster.checked_out == 3 + + +def test_flow_schliesst_ohne_luecke_an_den_vorherigen_abschnitt_an(db, rice, user): + """Der Endbestand eines Abschnitts ist der Anfangsbestand des naechsten – + sonst haette die Bruecke einen Sprung, den kein Balken erklaert.""" + check_in(db, rice, 4, "package", None, None, user) + db.commit() + + abschnitte = dashboard.flow(days=7, product_id=None, db=db, _=user) + for vorher, nachher in zip(abschnitte, abschnitte[1:]): + schluss = vorher.opening + vorher.checked_in - vorher.checked_out + assert round(schluss, 3) == round(nachher.opening, 3) + + +def test_flow_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() + + nur_reis = dashboard.flow(days=2, product_id=rice.id, db=db, _=user) + assert nur_reis[-1].checked_in == 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) diff --git a/web/src/api.js b/web/src/api.js index 94f1894..851853a 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -154,6 +154,8 @@ export const api = { dashboardTimeline: (days, productId) => request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`), dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`), + dashboardFlow: (days, productId) => + request(`/dashboard/flow?days=${days}${productId ? `&product_id=${productId}` : ""}`), // Stammdaten listLocations: () => request("/locations"), diff --git a/web/src/dashboard/Vorschau.jsx b/web/src/dashboard/Vorschau.jsx index 900169d..cc0cf92 100644 --- a/web/src/dashboard/Vorschau.jsx +++ b/web/src/dashboard/Vorschau.jsx @@ -98,6 +98,20 @@ const SKIZZEN = { ), + wasserfall: ( + + + {/* Schwebende Balken: jeder beginnt dort, wo der vorige endete. */} + + + + + + + ), + linien: ( diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 92aab64..aa8042c 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -9,6 +9,7 @@ import { import Bars from "./charts/Bars"; import Donut from "./charts/Donut"; import Line from "./charts/Line"; +import Wasserfall from "./charts/Wasserfall"; import { istDunkel, mitFarben, tokenFarbe } from "./palette"; /** @@ -369,20 +370,21 @@ function KarteArtikelverlauf({ tage = 90 }) { ); } +/** + * Ein- und Auslagerungen als Brücke: die Bestandslinie plus die Balken, die + * jeden ihrer Sprünge erklären. Die Mengen kommen deshalb in Artikeleinheiten + * und nicht als Anzahl Vorgänge – sonst lägen Balken und Linie auf zwei Skalen. + */ function KarteBewegungsverlauf({ tage = 30 }) { - const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]); + const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]); if (fehler) return
{fehler}
; if (!daten) return
Lädt…
; return ( - zeitLabel(p.at, tage))} - serien={[ - { name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) }, - { name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) }, - ]} - einheit="Vorgänge" + ({ ...p, label: zeitLabel(p.at, tage) }))} + einheit="Einheiten" /> ); } @@ -482,9 +484,9 @@ export const KARTEN = { komponente: KarteArtikelverlauf, }, "activity-timeline": { - titel: "Ein- und Auslagerungen", icon: "history", vorschau: "linien", - beschreibung: "Vorgänge je Tag über 30 Tage", - zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, + titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall", + beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung", + zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 }, komponente: KarteBewegungsverlauf, }, }; diff --git a/web/src/dashboard/charts/Line.jsx b/web/src/dashboard/charts/Line.jsx index 4acd59c..cd7d2a0 100644 --- a/web/src/dashboard/charts/Line.jsx +++ b/web/src/dashboard/charts/Line.jsx @@ -1,26 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { fmt } from "../../units"; import { tokenFarbe } from "../palette"; - -/** Misst die tatsächliche Größe, damit das SVG in echten Pixeln zeichnet. - * Ein festes viewBox mit Skalierung würde Linienstärken verzerren. - * Auch die Höhe wird gemessen: Mit fester Höhe passte das Diagramm nicht mehr - * in die Karte, sobald darüber noch eine Auswahl stand – es gab einen Rollbalken. */ -function useGroesse(ref) { - const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 }); - useEffect(() => { - if (!ref.current) return undefined; - const beobachter = new ResizeObserver(([eintrag]) => { - setGroesse({ - breite: eintrag.contentRect.width, - hoehe: eintrag.contentRect.height, - }); - }); - beobachter.observe(ref.current); - return () => beobachter.disconnect(); - }, [ref]); - return groesse; -} +import useGroesse from "./useGroesse"; /** * Liniendiagramm über die Zeit. diff --git a/web/src/dashboard/charts/Wasserfall.jsx b/web/src/dashboard/charts/Wasserfall.jsx new file mode 100644 index 0000000..c90f1b5 --- /dev/null +++ b/web/src/dashboard/charts/Wasserfall.jsx @@ -0,0 +1,148 @@ +import { useRef, useState } from "react"; +import { fmt } from "../../units"; +import { tokenFarbe } from "../palette"; +import useGroesse from "./useGroesse"; + +/** + * Wasserfall- bzw. Brückendiagramm. + * + * Die Linie zeigt den Bestand, die Balken erklären jeden Sprung darin: grün nach + * oben für Einlagerungen, rot nach unten für Auslagerungen. Ein Balken beginnt + * dort, wo der vorherige Bestand stand – deshalb „Brücke“. + * + * Bewusst nur EINE Werteachse. Balken und Linie sind hier beide Artikeleinheiten, + * nur deshalb dürfen sie überhaupt zusammen in ein Bild. Wären es Vorgänge gegen + * Mengen, gehörten sie in zwei Karten. + * + * abschnitte: [{ label, opening, checked_in, checked_out }] + */ +export default function Wasserfall({ abschnitte = [], einheit = "" }) { + const plot = useRef(null); + const { breite, hoehe } = useGroesse(plot); + const [index, setIndex] = useState(null); + + if (abschnitte.length < 2) { + return
Keine Daten vorhanden.
; + } + + const gruen = tokenFarbe("--ok", "#2e7d55"); + const rot = tokenFarbe("--danger", "#c0392b"); + const linie = tokenFarbe("--accent", "#2a78d6"); + const gitterFarbe = tokenFarbe("--border", "#ddd"); + const textFarbe = tokenFarbe("--muted", "#888"); + const flaeche = tokenFarbe("--surface", "#fff"); + + // Ein Abschnitt läuft von opening über die Zwischenspitze zum Endbestand. + const punkte = abschnitte.map((a) => { + const spitze = a.opening + a.checked_in; + return { ...a, spitze, schluss: spitze - a.checked_out }; + }); + + const rand = { oben: 12, rechts: 12, unten: 24, links: 44 }; + const w = Math.max(breite, 240); + const h = Math.max(hoehe, 120); + const flaecheB = Math.max(w - rand.links - rand.rechts, 10); + const flaecheH = Math.max(h - rand.oben - rand.unten, 10); + + // Von null aus: Bei Mengen ist eine abgeschnittene Achse irreführend. + const maxWert = Math.max(...punkte.map((p) => Math.max(p.opening, p.spitze, p.schluss)), 1); + const skalaMax = maxWert * 1.1; + + const schlitz = flaecheB / punkte.length; + const mitte = (i) => rand.links + schlitz * (i + 0.5); + const y = (wert) => rand.oben + flaecheH - (wert / skalaMax) * flaecheH; + const balkenB = Math.max(Math.min(schlitz - 2, 18), 1); + + const gitter = [0, 0.5, 1].map((teil) => ({ wert: skalaMax * teil, y: y(skalaMax * teil) })); + + const schritt = Math.max(1, Math.round(punkte.length / 5)); + const achse = punkte.map((p, i) => ({ l: p.label, i })).filter(({ i }) => i % schritt === 0); + + /** Segment eines Balkens. Unter zwei Pixeln Höhe bliebe es unsichtbar – + * dann lieber eine dünne Marke als gar nichts. */ + function segment(von, bis, farbe, key) { + const oben = Math.min(y(von), y(bis)); + const hoch = Math.max(Math.abs(y(von) - y(bis)), 2); + return ; + } + + function bewegen(event) { + const box = event.currentTarget.getBoundingClientRect(); + const rel = event.clientX - box.left - rand.links; + setIndex(Math.min(Math.max(Math.floor(rel / schlitz), 0), punkte.length - 1)); + } + + const aktiv = index != null ? punkte[index] : null; + + return ( +
+
    +
  • Eingelagert
  • +
  • Ausgelagert
  • +
  • Bestand
  • +
+ +
+ setIndex(null)}> + {gitter.map((g) => ( + + + {fmt(g.wert)} + + ))} + + {achse.map(({ l, i }) => ( + {l} + ))} + + {punkte.map((p, i) => ( + + {p.checked_in > 0 && segment(p.opening, p.spitze, gruen, "ein")} + {p.checked_out > 0 && segment(p.spitze, p.schluss, rot, "aus")} + + ))} + + {/* Die Bestandslinie liegt über den Balken: Sie ist die Aussage, + die Balken sind die Begründung. */} + `${i === 0 ? "M" : "L"} ${mitte(i)} ${y(p.schluss)}`).join(" ")} /> + + {aktiv && ( + <> + + + + )} + + + {aktiv && ( +
+
{aktiv.label}
+
+ + Eingelagert+{fmt(aktiv.checked_in)} +
+
+ + Ausgelagert−{fmt(aktiv.checked_out)} +
+
+ + Bestand{fmt(aktiv.schluss)} + {einheit && {einheit}} +
+
+ )} +
+
+ ); +} diff --git a/web/src/dashboard/charts/useGroesse.js b/web/src/dashboard/charts/useGroesse.js new file mode 100644 index 0000000..f556d02 --- /dev/null +++ b/web/src/dashboard/charts/useGroesse.js @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; + +/** + * Misst die tatsächliche Größe eines Elements, damit ein SVG in echten Pixeln + * zeichnen kann. Ein festes viewBox mit Skalierung würde Linienstärken und + * Schriftgrößen verzerren. + * + * Auch die Höhe wird gemessen: Mit fester Höhe passt ein Diagramm nicht mehr in + * seine Karte, sobald darüber noch etwas steht – es gäbe einen Rollbalken. + */ +export default function useGroesse(ref) { + const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 }); + useEffect(() => { + if (!ref.current) return undefined; + const beobachter = new ResizeObserver(([eintrag]) => { + setGroesse({ + breite: eintrag.contentRect.width, + hoehe: eintrag.contentRect.height, + }); + }); + beobachter.observe(ref.current); + return () => beobachter.disconnect(); + }, [ref]); + return groesse; +} diff --git a/web/src/styles.css b/web/src/styles.css index 8814d90..e1e075f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -603,6 +603,9 @@ select.zeitraum:hover { color: var(--text); } .chart-legend .legend-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chart-legend .legend-value { color: var(--muted); font-size: 0.78rem; white-space: nowrap; } .swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; } +/* Eine Serie, die als Linie gezeichnet wird, traegt auch in der Legende einen + Strich – sonst sieht sie aus wie eine weitere Flaeche. */ +.swatch.linie { height: 3px; border-radius: 2px; width: 12px; } .chart-bars { display: flex; flex-direction: column; gap: 7px; } .bar-row { display: grid; grid-template-columns: minmax(80px, 26%) 1fr auto; align-items: center; gap: var(--sp-3); }