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:
@@ -11,6 +11,7 @@ from .routers import (
|
||||
auth,
|
||||
branding,
|
||||
categories,
|
||||
dashboard,
|
||||
groups,
|
||||
locations,
|
||||
maintenance,
|
||||
@@ -110,3 +111,4 @@ app.include_router(settings_router.router)
|
||||
app.include_router(branding.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
@@ -295,6 +295,27 @@ class BrandingAsset(Base):
|
||||
)
|
||||
|
||||
|
||||
class DashboardLayout(Base):
|
||||
"""Anordnung der Karten auf der Startseite.
|
||||
|
||||
``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen
|
||||
Benutzern als Ausgangspunkt und lässt sich – falls in den Einstellungen so
|
||||
gesetzt – auch verbindlich für alle machen.
|
||||
"""
|
||||
|
||||
__tablename__ = "dashboard_layouts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True
|
||||
)
|
||||
# JSON-Text: [{"i": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
|
||||
layout: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
|
||||
374
backend/app/routers/dashboard.py
Normal file
374
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""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 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.
|
||||
BUILTIN_LAYOUT: list[dict] = [
|
||||
{"i": "actions", "x": 0, "y": 0, "w": 4, "h": 1},
|
||||
{"i": "status", "x": 4, "y": 0, "w": 8, "h": 1},
|
||||
{"i": "expiring", "x": 0, "y": 1, "w": 6, "h": 3},
|
||||
{"i": "shopping", "x": 6, "y": 1, "w": 6, "h": 3},
|
||||
{"i": "expiry-donut", "x": 0, "y": 4, "w": 4, "h": 3},
|
||||
{"i": "category-donut", "x": 4, "y": 4, "w": 4, "h": 3},
|
||||
{"i": "stock-timeline", "x": 8, "y": 4, "w": 4, "h": 3},
|
||||
]
|
||||
|
||||
|
||||
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", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reset_layout(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> None:
|
||||
"""Eigene Anordnung verwerfen – danach gilt wieder die Vorgabe."""
|
||||
row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first()
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
@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(2, 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(2, 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)
|
||||
]
|
||||
@@ -386,3 +386,56 @@ class MovementOut(BaseModel):
|
||||
class SettingOut(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
# ---- Startseite ----
|
||||
class DashboardLayoutIn(BaseModel):
|
||||
"""Anordnung der Karten. Die Felder entsprechen react-grid-layout."""
|
||||
layout: list[dict]
|
||||
|
||||
|
||||
class DashboardLayoutOut(BaseModel):
|
||||
layout: list[dict]
|
||||
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
|
||||
source: str
|
||||
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
|
||||
enforced: bool
|
||||
has_default: bool
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
products_in_stock: int # Artikel mit Bestand > 0
|
||||
article_units: float # Summe der Artikeleinheiten
|
||||
expiring_soon: int # Chargen innerhalb der Warnfrist (nicht abgelaufen)
|
||||
expired: int # bereits abgelaufene Chargen
|
||||
shopping_items: int # Produkte + Gruppen unter Mindestbestand
|
||||
products_total: int
|
||||
|
||||
|
||||
class ExpirySplit(BaseModel):
|
||||
"""Artikeleinheiten je Ablaufzustand – Grundlage für Ring und Säulen."""
|
||||
ok: float
|
||||
soon: float
|
||||
expired: float
|
||||
no_date: float # Chargen ohne MHD zählen separat, nicht als "ok"
|
||||
|
||||
|
||||
class CategoryShare(BaseModel):
|
||||
category_id: int | None
|
||||
name: str
|
||||
article_units: float
|
||||
ok: float
|
||||
soon: float
|
||||
expired: float
|
||||
no_date: float
|
||||
|
||||
|
||||
class TimelinePoint(BaseModel):
|
||||
date: date
|
||||
article_units: float
|
||||
|
||||
|
||||
class ActivityPoint(BaseModel):
|
||||
date: date
|
||||
checked_in: int
|
||||
checked_out: int
|
||||
|
||||
@@ -88,3 +88,23 @@ def display_unit_info(product: Product) -> tuple[str, float]:
|
||||
if product.display_unit is not None:
|
||||
return product.display_unit.name, product.display_unit.factor
|
||||
return BASE_LABEL[product.base_unit], 1.0
|
||||
|
||||
|
||||
def article_unit(product: Product) -> tuple[float, str]:
|
||||
"""Faktor und Bezeichnung der *Artikeleinheit* – das zählbare Gebinde.
|
||||
|
||||
Gibt es eine Packungsgröße, ist eine Einheit ein Gebinde (Glas, Packung …),
|
||||
sonst die Anzeigeeinheit des Produkts. Nur diese Größe lässt sich über
|
||||
verschiedene Artikel hinweg sinnvoll addieren: 3 Gläser + 2 Packungen sind
|
||||
5 Einheiten, während 1000 g + 2 Stück keine sinnvolle Summe ergeben.
|
||||
"""
|
||||
if product.package_size and product.package_size > 0:
|
||||
return product.package_size, (product.package_label or "Packung")
|
||||
name, factor = display_unit_info(product)
|
||||
return (factor or 1.0), name
|
||||
|
||||
|
||||
def article_units(product: Product, quantity_base: float) -> float:
|
||||
"""Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
|
||||
factor, _ = article_unit(product)
|
||||
return quantity_base / factor if factor else quantity_base
|
||||
|
||||
Reference in New Issue
Block a user