Neues Flag bulk auf Product: Gegenstand als Charge mit Menge/Einheit/MHD/ Mindestbestand fuehren (z.B. Sonnencreme in ml), ohne eindeutigen Code. Migration (ADD COLUMN), Schemas und Anlege-Pfad ergaenzt. Kernlogik nutzt foodLike = kein Gegenstand ODER bulk; Bestand/Einkaufsliste unveraendert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
634 lines
26 KiB
Python
634 lines
26 KiB
Python
from __future__ import annotations
|
||
|
||
import enum
|
||
import secrets
|
||
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 CategoryTracking(str, enum.Enum):
|
||
"""Wie Artikel einer Kategorie verwaltet werden.
|
||
|
||
food = Lebensmittel: Chargen mit MHD und FEFO (bestehende Logik, unverändert).
|
||
object = Gegenstand: nur Menge pro Lagerort, kein MHD, keine Chargen.
|
||
|
||
Bewusst als kurzer String gespeichert (wie DatePrecision), damit sich die
|
||
Spalte per ADD COLUMN nachziehen lässt, ohne einen Postgres-Enumtyp anzulegen.
|
||
"""
|
||
food = "food"
|
||
object = "object"
|
||
|
||
|
||
class FieldType(str, enum.Enum):
|
||
"""Art eines selbst definierten Feldes an einer Kategorie."""
|
||
text = "text" # einzeilig
|
||
textarea = "textarea" # mehrzeilig
|
||
number = "number" # Zahl, optional mit Einheit
|
||
date = "date" # Datum (z.B. Kaufdatum)
|
||
select = "select" # Auswahlliste (options = JSON-Liste)
|
||
boolean = "boolean" # Ja/Nein
|
||
|
||
|
||
class RemovalReason(str, enum.Enum):
|
||
"""Grund einer Gegenstands-Entnahme aus dem Bestand."""
|
||
lost = "lost" # verloren
|
||
broken = "broken" # kaputt
|
||
given_away = "given_away" # verschenkt
|
||
sold = "sold" # verkauft
|
||
used_up = "used_up" # aufgebraucht
|
||
other = "other" # sonstiges
|
||
|
||
|
||
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()
|
||
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
|
||
cascade="all, delete-orphan"
|
||
)
|
||
|
||
|
||
# Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
|
||
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
|
||
# praktisch nie, und der Code IST zugleich der Inhalt des QR /l/<code>.
|
||
# Alphabet ohne 0/O/1/I/L – wie bei den Einzelstück-UIDs gut ablesbar.
|
||
_LOCATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||
|
||
|
||
def generate_location_code() -> str:
|
||
"""Zufälliger 10-Zeichen-Code für einen Lagerort (Kollision vernachlässigbar)."""
|
||
return "".join(secrets.choice(_LOCATION_CODE_ALPHABET) for _ in range(10))
|
||
|
||
|
||
class Location(Base):
|
||
__tablename__ = "locations"
|
||
|
||
id: Mapped[str] = mapped_column(
|
||
String(10), primary_key=True, default=generate_location_code
|
||
)
|
||
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[str | None] = mapped_column(
|
||
String(10), 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)
|
||
# Verwaltungsart der Artikel dieser Kategorie: "food" (Chargen+MHD) oder
|
||
# "object" (Menge je Lagerort). Neue Kategorien sind Gegenstände; bestehende
|
||
# (reine Lebensmittel-Installationen) werden bei der Migration auf "food"
|
||
# gesetzt. Als kurzer String wie date_precision, damit per ADD COLUMN nachziehbar.
|
||
tracking: Mapped[str] = mapped_column(
|
||
String(16), nullable=False,
|
||
default=CategoryTracking.object.value,
|
||
server_default=CategoryTracking.food.value,
|
||
)
|
||
|
||
|
||
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
|
||
|
||
# Nur für Gegenstände: Bezugsquelle ("gekauft bei") und Link zum Onlineshop.
|
||
shop_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||
# Gegenstände: als Einzelstücke (Items mit eigener UID/QR) statt als Menge je
|
||
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
|
||
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
|
||
individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
# Gegenstände: als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen,
|
||
# Einheit, Packungsgröße, MHD optional, Mindestbestand), aber ohne eindeutigen
|
||
# Code – z.B. Sonnencreme in ml. Schließt sich mit ``individual`` aus. Für die
|
||
# Kernlogik gilt: foodLike = kein Gegenstand ODER bulk.
|
||
bulk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
group: Mapped[Group | None] = relationship(back_populates="products")
|
||
category: Mapped[Category | None] = relationship()
|
||
shop: Mapped[Shop | 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"
|
||
)
|
||
field_values: Mapped[list[ProductFieldValue]] = relationship(
|
||
back_populates="product", cascade="all, delete-orphan"
|
||
)
|
||
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
|
||
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[str | None] = mapped_column(
|
||
String(10), 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)
|
||
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
|
||
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
|
||
location_id: Mapped[str | None] = mapped_column(
|
||
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
|
||
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
|
||
reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class PackageType(Base):
|
||
"""Bezeichnung eines Gebindes in Einzahl und Mehrzahl.
|
||
|
||
Der Artikel speichert weiterhin nur die **Einzahl** als Text
|
||
(``Product.package_label``) – diese Tabelle liefert dazu die Mehrzahl. So
|
||
bleiben vorhandene Artikel, Sicherungen und CSV-Dateien gültig; eine
|
||
unbekannte Bezeichnung fällt schlicht auf die Einzahl zurück.
|
||
"""
|
||
|
||
__tablename__ = "package_types"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
singular: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
|
||
plural: Mapped[str] = mapped_column(String(32), nullable=False)
|
||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
|
||
|
||
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):
|
||
"""Ein Dashboard: benannte Anordnung von Karten.
|
||
|
||
``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.
|
||
|
||
Jeder Benutzer darf **mehrere** Dashboards haben (deshalb steht auf
|
||
``user_id`` kein unique mehr); ``position`` bestimmt ihre Reihenfolge in der
|
||
Seitenleiste.
|
||
"""
|
||
|
||
__tablename__ = "dashboard_layouts"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
user_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
name: Mapped[str] = mapped_column(String(80), nullable=False, default="Übersicht")
|
||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||
# JSON-Text. "i" ist die Kennung *dieser Karte*, "type" ihre Art - erst
|
||
# dadurch laesst sich dieselbe Art mehrfach auf ein Dashboard legen:
|
||
# [{"i": "k3", "type": "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)
|
||
|
||
|
||
class Shop(Base):
|
||
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
|
||
|
||
Vom Admin verwaltbare Liste (wie Lagerorte). Am Artikel optional – "unbekannt"
|
||
oder "mehrere Herkünfte" bleibt einfach leer.
|
||
"""
|
||
|
||
__tablename__ = "shops"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||
website: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||
|
||
|
||
class FieldDefinition(Base):
|
||
"""Selbst definiertes Feld an einer Kategorie (z.B. „Kapazität“ in mAh).
|
||
|
||
Gilt für Artikel dieser Kategorie und – über die Vererbung im Kategorie-Baum –
|
||
auch für deren Unterkategorien. Der konkrete Wert je Artikel steht in
|
||
:class:`ProductFieldValue`.
|
||
"""
|
||
|
||
__tablename__ = "field_definitions"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
category_id: Mapped[int] = mapped_column(
|
||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
label: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
# Maschinenlesbarer Schlüssel (aus dem Label abgeleitet) – für Export/Anzeige.
|
||
key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
field_type: Mapped[str] = mapped_column(
|
||
String(16), nullable=False, default=FieldType.text.value
|
||
)
|
||
# Einheit für Zahlenfelder (z.B. "mAh", "g", "W").
|
||
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||
# JSON-Liste der Auswahlmöglichkeiten für field_type == "select".
|
||
options: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class ProductFieldValue(Base):
|
||
"""Wert eines selbst definierten Feldes für einen konkreten Artikel."""
|
||
|
||
__tablename__ = "product_field_values"
|
||
__table_args__ = (
|
||
UniqueConstraint(
|
||
"product_id", "field_definition_id", name="uq_pfv_product_field"
|
||
),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
field_definition_id: Mapped[int] = mapped_column(
|
||
ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
# Immer als Text gespeichert; typgerecht interpretiert wird beim Lesen/Schreiben.
|
||
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
|
||
product: Mapped[Product] = relationship(back_populates="field_values")
|
||
field_definition: Mapped[FieldDefinition] = relationship()
|
||
|
||
|
||
class Item(Base):
|
||
"""Ein physisches Einzelstück eines Gegenstands (Instanz) mit eigener UID/QR.
|
||
|
||
Nur für Gegenstands-Produkte mit Einzelstück-Verwaltung (``Product.individual``).
|
||
Je Stück ein eigener Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz –
|
||
so lässt sich dasselbe Modell mehrfach getrennt führen (Powerbank 2024 + 2025).
|
||
Der QR-Code trägt die UID; ein Scan öffnet genau dieses Stück.
|
||
"""
|
||
|
||
__tablename__ = "items"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
# Kurzer, gut lesbarer Code für den QR (ohne verwechselbare Zeichen).
|
||
uid: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
location_id: Mapped[str | None] = mapped_column(
|
||
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
shop_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
|
||
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
|
||
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
|
||
# Kaufpreis in kleinster Einheit (Rappen/Cent), damit keine Float-Rundung
|
||
# entsteht; Währung getrennt (z.B. CHF, EUR).
|
||
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
product: Mapped[Product] = relationship()
|
||
location: Mapped[Location | None] = relationship()
|
||
shop: Mapped[Shop | None] = relationship()
|
||
documents: Mapped[list["ItemDocument"]] = relationship(
|
||
cascade="all, delete-orphan", order_by="ItemDocument.id"
|
||
)
|
||
|
||
|
||
class ItemDocument(Base):
|
||
"""Beleg zu einem Einzelstück: Rechnung oder Garantieschein (PDF oder Bild).
|
||
|
||
Eigene Tabelle wie :class:`ProductImage`, damit die Binärdaten nicht bei
|
||
jeder Item-Abfrage mitgezogen werden. Mehrere Belege je Stück sind erlaubt.
|
||
"""
|
||
|
||
__tablename__ = "item_documents"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
item_id: Mapped[int] = mapped_column(
|
||
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
|
||
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
|
||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class ProductLocationMinStock(Base):
|
||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Artikeleinheiten).
|
||
|
||
Zusätzlich zum globalen ``Product.min_stock``: so laesst sich derselbe Artikel
|
||
an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus).
|
||
"""
|
||
|
||
__tablename__ = "product_location_min_stock"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
location_id: Mapped[str] = mapped_column(
|
||
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
|
||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||
|
||
location: Mapped[Location] = relationship()
|
||
|
||
__table_args__ = (UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),)
|
||
|
||
|
||
class GroupLocationMinStock(Base):
|
||
"""Mindestbestand einer Gruppe an EINEM Lagerort (in der Gruppen-Einheit)."""
|
||
|
||
__tablename__ = "group_location_min_stock"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
group_id: Mapped[int] = mapped_column(
|
||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
location_id: Mapped[str] = mapped_column(
|
||
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||
|
||
location: Mapped[Location] = relationship()
|
||
|
||
__table_args__ = (UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),)
|