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 ProductImage(Base): """Lokale Kopie des Artikelbilds. Eigene Tabelle statt einer Spalte an ``products``: Sonst zöge jede Artikelliste die Bilddaten mit, obwohl sie dort niemand braucht. ``source_url`` merkt sich, woher das Bild kam – daran ist erkennbar, ob eine geänderte ``Product.image_url`` ein neues Bild bedeutet. """ __tablename__ = "product_images" product_id: Mapped[int] = mapped_column( ForeignKey("products.id", ondelete="CASCADE"), primary_key=True ) content_type: Mapped[str] = mapped_column(String(64), nullable=False) data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_now, onupdate=_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)