Kategorien-Anteile: einstellbare Kategorie-Tiefe (Unterkategorien zusammenfassen)
/dashboard/by-category bekommt depth: rollt jede Artikel-Kategorie auf ihren Vorfahren der gewuenschten Stufe hoch (1 = oberste Ebene), ohne depth zaehlt jede zugeordnete Kategorie einzeln (feinste). Neue Karten-Konfig select Kategorie-Tiefe (Feinste / oberste / bis 2. / bis 3. Stufe). KartenConfig zeigt jetzt auch bei select den Hinweis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -386,17 +386,45 @@ def expiry_split(
|
|||||||
|
|
||||||
@router.get("/by-category", response_model=list[CategoryShare])
|
@router.get("/by-category", response_model=list[CategoryShare])
|
||||||
def by_category(
|
def by_category(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
depth: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
) -> list[CategoryShare]:
|
) -> list[CategoryShare]:
|
||||||
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt."""
|
"""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()
|
heute = date.today()
|
||||||
warnfrist = get_expiry_warning_days(db)
|
warnfrist = get_expiry_warning_days(db)
|
||||||
namen = {c.id: c.name for c in db.query(Category).all()}
|
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}
|
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)
|
eimer: dict[int | None, dict] = defaultdict(leer)
|
||||||
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
topf = eimer[product.category_id]
|
topf = eimer[rollup(product.category_id)]
|
||||||
topf["article_units"] += menge
|
topf["article_units"] += menge
|
||||||
topf[zustand] += menge
|
topf[zustand] += menge
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const api = {
|
|||||||
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
|
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
|
||||||
dashboardStats: () => request("/dashboard/stats"),
|
dashboardStats: () => request("/dashboard/stats"),
|
||||||
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
|
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
|
||||||
dashboardByCategory: () => request("/dashboard/by-category"),
|
dashboardByCategory: (depth) => request(`/dashboard/by-category${depth ? `?depth=${depth}` : ""}`),
|
||||||
dashboardTimeline: (days, productId) =>
|
dashboardTimeline: (days, productId) =>
|
||||||
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
||||||
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function KartenConfig({
|
|||||||
if (f.type === "select") {
|
if (f.type === "select") {
|
||||||
return (
|
return (
|
||||||
<label key={f.key}>
|
<label key={f.key}>
|
||||||
{f.label}
|
{f.label}{f.hint ? <span className="muted small"> ({f.hint})</span> : null}
|
||||||
<select value={karteProps[f.key] ?? f.default ?? ""}
|
<select value={karteProps[f.key] ?? f.default ?? ""}
|
||||||
onChange={(e) => onProps({ [f.key]: e.target.value })}>
|
onChange={(e) => onProps({ [f.key]: e.target.value })}>
|
||||||
{(f.optionen || []).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{(f.optionen || []).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
|||||||
@@ -463,7 +463,9 @@ function KarteAblaufRing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KarteKategorienRing({ props: karteProps }) {
|
function KarteKategorienRing({ props: karteProps }) {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
// Tiefe: bis zu welcher Kategorie-Stufe zusammengefasst wird (leer = feinste).
|
||||||
|
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
@@ -689,7 +691,17 @@ export const KARTEN = {
|
|||||||
titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring",
|
titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring",
|
||||||
beschreibung: "Ring: welche Kategorie wie viel ausmacht",
|
beschreibung: "Ring: welche Kategorie wie viel ausmacht",
|
||||||
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
|
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
|
||||||
config: [{ key: "detail", type: "boolean", label: "Detailansicht (alle Kategorien einzeln, keine Gruppe „Andere“)" }],
|
config: [
|
||||||
|
{ key: "tiefe", type: "select", label: "Kategorie-Tiefe",
|
||||||
|
hint: "wie weit Unterkategorien zusammengefasst werden", default: "",
|
||||||
|
optionen: [
|
||||||
|
{ value: "", label: "Feinste (jede Kategorie einzeln)" },
|
||||||
|
{ value: "1", label: "Nur oberste Stufe" },
|
||||||
|
{ value: "2", label: "Bis 2. Stufe" },
|
||||||
|
{ value: "3", label: "Bis 3. Stufe" },
|
||||||
|
] },
|
||||||
|
{ key: "detail", type: "boolean", label: "Detailansicht (alle Kategorien einzeln, keine Gruppe „Andere“)" },
|
||||||
|
],
|
||||||
komponente: KarteKategorienRing,
|
komponente: KarteKategorienRing,
|
||||||
},
|
},
|
||||||
"category-bars": {
|
"category-bars": {
|
||||||
|
|||||||
Reference in New Issue
Block a user