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>
324 lines
12 KiB
Python
324 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import enum
|
||
from datetime import date, datetime, timezone
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
Date,
|
||
DateTime,
|
||
Enum,
|
||
Float,
|
||
ForeignKey,
|
||
Integer,
|
||
LargeBinary,
|
||
String,
|
||
Text,
|
||
UniqueConstraint,
|
||
)
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from .database import Base
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
class Role(str, enum.Enum):
|
||
admin = "admin"
|
||
user = "user"
|
||
|
||
|
||
class BaseUnit(str, enum.Enum):
|
||
piece = "piece"
|
||
gram = "gram"
|
||
milliliter = "milliliter"
|
||
|
||
|
||
class MovementType(str, enum.Enum):
|
||
in_ = "in"
|
||
out = "out"
|
||
adjust = "adjust"
|
||
|
||
|
||
class DatePrecision(str, enum.Enum):
|
||
"""Wie genau ein MHD angegeben wurde.
|
||
|
||
Bewusst als kurzer String gespeichert und nicht als DB-Enum: So lässt sich
|
||
die Spalte auf bestehenden Tabellen per ADD COLUMN nachziehen, ohne vorher
|
||
einen neuen Postgres-Typ anlegen zu müssen.
|
||
"""
|
||
day = "day" # 07.09.2026
|
||
month = "month" # 09/2026
|
||
|
||
|
||
class UnitKind(str, enum.Enum):
|
||
"""Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung."""
|
||
count = "count" # Basis: Stück
|
||
weight = "weight" # Basis: Gramm
|
||
volume = "volume" # Basis: Milliliter
|
||
|
||
|
||
class Unit(Base):
|
||
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
||
|
||
factor = wie viele Basiseinheiten 1 dieser Einheit entsprechen
|
||
(z.B. Kilogramm: kind=weight, factor=1000; Liter: kind=volume, factor=1000).
|
||
"""
|
||
__tablename__ = "units"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||
kind: Mapped[UnitKind] = mapped_column(Enum(UnitKind), nullable=False)
|
||
factor: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
|
||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
|
||
|
||
class User(Base):
|
||
__tablename__ = "users"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class Group(Base):
|
||
__tablename__ = "groups"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
# Einheit des Gruppen-Mindestbestands (z.B. Kilogramm). NULL = zählt in Basiseinheiten.
|
||
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
|
||
products: Mapped[list[Product]] = relationship(back_populates="group")
|
||
min_stock_unit: Mapped[Unit | None] = relationship()
|
||
|
||
|
||
class Location(Base):
|
||
__tablename__ = "locations"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
|
||
parent_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
|
||
|
||
class Category(Base):
|
||
"""Einordnung eines Artikels **allein für den Überblick**.
|
||
|
||
Nicht zu verwechseln mit :class:`Group`: Eine Gruppe fasst Bestände mehrerer
|
||
Marken zusammen (5 kg Mehl, egal von wem) und trägt Mindestbestand und
|
||
EAN-Codes. Eine Kategorie tut nichts dergleichen – sie hilft nur, in einer
|
||
langen Artikelliste "zeig mir alle Süßwaren" sagen zu können.
|
||
|
||
Beliebig tief verschachtelbar (Süßwaren → Schokolade), wie :class:`Location`.
|
||
"""
|
||
|
||
__tablename__ = "categories"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
parent_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
|
||
|
||
class Product(Base):
|
||
__tablename__ = "products"
|
||
__table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
barcode: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||
|
||
# Kanonische Basiseinheit (piece/gram/milliliter): bestimmt Speicherung + Art (kind).
|
||
base_unit: Mapped[BaseUnit] = mapped_column(
|
||
Enum(BaseUnit), nullable=False, default=BaseUnit.piece
|
||
)
|
||
# Bevorzugte Anzeige-/Eingabeeinheit (z.B. Kilogramm). NULL = Basiseinheit selbst.
|
||
display_unit_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
# Number of base units contained in one package (e.g. 500 g per package).
|
||
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
|
||
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
|
||
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
|
||
date_precision: Mapped[str] = mapped_column(
|
||
String(8), nullable=False, default=DatePrecision.day.value,
|
||
server_default=DatePrecision.day.value,
|
||
)
|
||
|
||
# Gruppe: zaehlt Bestaende mehrerer Marken zusammen.
|
||
group_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
# Kategorie: reine Ordnungshilfe fuer die Artikelliste, voellig unabhaengig
|
||
# von der Gruppe. Ein Artikel kann beides, eines oder keines haben.
|
||
category_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
|
||
# In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige).
|
||
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
min_stock_in_packages: Mapped[bool] = mapped_column(
|
||
Boolean, nullable=False, default=False
|
||
)
|
||
|
||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
group: Mapped[Group | None] = relationship(back_populates="products")
|
||
category: Mapped[Category | None] = relationship()
|
||
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
|
||
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
|
||
lots: Mapped[list[Lot]] = relationship(
|
||
back_populates="product", cascade="all, delete-orphan"
|
||
)
|
||
|
||
|
||
class ApiToken(Base):
|
||
"""Langlebiges Token für externe Zugriffe (z.B. Home Assistant).
|
||
|
||
Gespeichert wird nur der SHA-256-Hash; der Klartext wird einmalig beim
|
||
Anlegen angezeigt.
|
||
"""
|
||
|
||
__tablename__ = "api_tokens"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||
user_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
||
|
||
class Barcode(Base):
|
||
"""Zusätzliche EAN-Codes für ein Produkt ODER eine Gruppe.
|
||
|
||
Beispiel Gruppe "Mehl": alle Mehl-Marken einscannen; ein Scan ordnet das
|
||
Produkt dann automatisch dieser Gruppe zu.
|
||
"""
|
||
|
||
__tablename__ = "barcodes"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||
# Freitext zur Einordnung, z.B. "Mehl bei Aldi"
|
||
note: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||
product_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("products.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
group_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class Lot(Base):
|
||
__tablename__ = "lots"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
||
# Immer ein echtes Datum, damit FEFO und Ablauf-Abfragen unverändert bleiben.
|
||
# Bei Monatsangaben steht hier der Monatsletzte (siehe services/dates.py).
|
||
best_before: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
# Wie genau die Angabe ursprünglich war – entscheidet nur über die Anzeige.
|
||
best_before_precision: Mapped[str] = mapped_column(
|
||
String(8), nullable=False, default=DatePrecision.day.value,
|
||
server_default=DatePrecision.day.value,
|
||
)
|
||
location_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
product: Mapped[Product] = relationship(back_populates="lots")
|
||
|
||
|
||
class Movement(Base):
|
||
__tablename__ = "movements"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
lot_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("lots.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
user_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
type: Mapped[MovementType] = mapped_column(Enum(MovementType), nullable=False)
|
||
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
||
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
|
||
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class BrandingAsset(Base):
|
||
"""Eigenes Logo bzw. Favicon der Installation.
|
||
|
||
Bewusst in der Datenbank und nicht im Dateisystem: So landet das Bild
|
||
automatisch im Backup und das Deployment braucht kein zusätzliches Volume.
|
||
Es geht um wenige Kilobyte, die Größe ist beim Upload begrenzt.
|
||
"""
|
||
|
||
__tablename__ = "branding_assets"
|
||
|
||
kind: Mapped[str] = mapped_column(String(16), primary_key=True) # "logo" | "favicon"
|
||
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), default=_now, onupdate=_now
|
||
)
|
||
|
||
|
||
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"
|
||
|
||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|