Zwei zusammenhaengende Umbauten, weil sie dieselben Stellen betreffen.
Obergruppen: Gruppen bilden jetzt einen gerichteten azyklischen Graphen statt
einer flachen Liste. Eine Gruppe darf unter MEHREREN Obergruppen haengen -
"Grillwurst" unter "Wurst" UND unter "Grillgut"; mit einem einzelnen parent_id
waere genau das nicht abbildbar. Bestand und Mindestbestand einer Gruppe zaehlen
den gesamten Untergraphen, wobei eine ueber zwei Wege erreichbare Untergruppe
nur einmal zaehlt (services/gruppen.py arbeitet durchgaengig mit Mengen).
Product.group_id bleibt unveraendert - ein Artikel haengt weiter an genau einer
Gruppe.
Mindestbestaende: der separate Gesamt-Mindestbestand entfaellt. Er wird zur
Zeile mit location_id NULL ("Ueberall") und ist damit die Wurzel ueber allen
Lagerorten - dieselbe Verrechnung wie bei verschachtelten Orten greift jetzt
auch zwischen Ueberall und Kueche, wodurch derselbe Artikel nicht mehr doppelt
in der Einkaufsliste steht. Alle Werte liegen einheitlich in Basiseinheiten
statt in drei verschiedenen Einheiten nebeneinander; das Umrechnen beim
Umschalten der Erfassungseinheit entfaellt dadurch ersatzlos.
_netted_topups nimmt die Hierarchie jetzt als Parameter und faltet damit
Lagerort-Baum und Gruppen-Graph. Verrechnet wird zwischen zwei Gruppen nur,
wenn die zaehlenden Artikel der Untergruppe eine Teilmenge der Obergruppe sind -
zaehlt die Obergruppe in Kilogramm und die Untergruppe in Stueck, kommt ein Kauf
dort oben nicht an.
Die vierfach kopierte Bestandssumme wandert in Sammelabfragen
(summe_bestand_base), sonst vervielfacht der transitive Teilgraph die Abfragen.
Einmalige Datenwanderung beim Start (Merker in den Einstellungen), 18 neue
Tests - darunter Doppelzaehlung ueber zwei Wege, Ringschutz und die bewusst
offene Grenze bei zwei Obergruppen mit gemeinsamer Untergruppe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
730 lines
30 KiB
Python
730 lines
30 KiB
Python
from __future__ import annotations
|
||
|
||
import enum
|
||
import secrets
|
||
from datetime import date, datetime, timezone
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
Column,
|
||
Date,
|
||
DateTime,
|
||
Enum,
|
||
Float,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
LargeBinary,
|
||
String,
|
||
Table,
|
||
Text,
|
||
UniqueConstraint,
|
||
text,
|
||
)
|
||
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)
|
||
|
||
|
||
# Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht
|
||
# nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit
|
||
# einem Baum (ein Elternteil je Gruppe) waere genau das nicht abbildbar. Der
|
||
# Graph darf deshalb Rauten haben, aber keine Ringe (Pruefung in routers/groups).
|
||
group_parents = Table(
|
||
"group_parents",
|
||
Base.metadata,
|
||
Column(
|
||
"child_id", ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||
),
|
||
# Eigener Index: der zusammengesetzte Primaerschluessel deckt nur die Suche
|
||
# nach child_id ab, gefragt wird aber genauso oft „wer haengt unter X?".
|
||
Column(
|
||
"parent_id",
|
||
ForeignKey("groups.id", ondelete="CASCADE"),
|
||
primary_key=True,
|
||
index=True,
|
||
),
|
||
)
|
||
|
||
|
||
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
|
||
)
|
||
# Gruppen-Gebinde als Richtwert: wie viele Basiseinheiten „1 Packung/Glas"
|
||
# der Gruppe zählt. Nötig, weil die Produkte einer Gruppe unterschiedlich
|
||
# große Packungen haben können (Pesto 99 g vs. 160 g) – die Gruppe legt einen
|
||
# gemeinsamen Richtwert fest. NULL = keiner definiert.
|
||
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||
# true = ``min_stock`` (und die je-Ort-Werte) sind in diesem Gebinde erfasst,
|
||
# sonst in ``min_stock_unit`` (bzw. Basiseinheit).
|
||
min_stock_in_packages: Mapped[bool] = mapped_column(
|
||
Boolean, nullable=False, default=False
|
||
)
|
||
|
||
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"
|
||
)
|
||
|
||
# Obergruppen dieser Gruppe (mehrere moeglich) bzw. die Gruppen, die
|
||
# umgekehrt unter dieser haengen. Bestand und Mindestbestand einer Gruppe
|
||
# zaehlen immer den ganzen Untergraphen mit – siehe services/groups.py.
|
||
# ``lazy="selectin"``: /groups laedt alle Gruppen auf einmal – so faellt je
|
||
# Ebene EINE Nachladeabfrage an statt einer je Gruppe.
|
||
parents: Mapped[list["Group"]] = relationship(
|
||
"Group",
|
||
secondary=group_parents,
|
||
primaryjoin=lambda: Group.id == group_parents.c.child_id,
|
||
secondaryjoin=lambda: Group.id == group_parents.c.parent_id,
|
||
back_populates="children",
|
||
lazy="selectin",
|
||
)
|
||
children: Mapped[list["Group"]] = relationship(
|
||
"Group",
|
||
secondary=group_parents,
|
||
primaryjoin=lambda: Group.id == group_parents.c.parent_id,
|
||
secondaryjoin=lambda: Group.id == group_parents.c.child_id,
|
||
back_populates="parents",
|
||
lazy="selectin",
|
||
)
|
||
|
||
|
||
# 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)
|
||
# Zeitpunkt der letzten Änderung an den Stammdaten des Artikels (nicht am
|
||
# Bestand). Wird bei jedem ORM-UPDATE der Zeile automatisch nachgezogen.
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), default=_now, onupdate=_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 Basiseinheiten).
|
||
|
||
Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id``
|
||
NULL heisst „Ueberall" – egal wo, Hauptsache die Menge ist im Haus; das war
|
||
frueher das separate Feld ``Product.min_stock``. Damit ist „Ueberall" die
|
||
Wurzel ueber allen Lagerorten und wird von derselben Verrechnung erfasst wie
|
||
verschachtelte Orte (siehe routers/views.py).
|
||
|
||
Mengen stehen in BASISEINHEITEN (g/ml/Stueck), nicht in Artikeleinheiten:
|
||
sonst bedeutet ein gespeicherter Wert etwas anderes, sobald sich die
|
||
Packungsgroesse aendert. In welcher Einheit er angezeigt und eingetippt
|
||
wird, sagen ``Product.min_stock_unit_id`` und ``min_stock_in_packages``.
|
||
"""
|
||
|
||
__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 | None] = mapped_column(
|
||
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||
|
||
location: Mapped[Location | None] = relationship()
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),
|
||
# Postgres zaehlt NULLs in UNIQUE als verschieden – der Constraint oben
|
||
# verhindert also KEINE zwei „Ueberall"-Zeilen. Dafuer ein Teilindex.
|
||
Index(
|
||
"uq_prod_ueberall_min",
|
||
"product_id",
|
||
unique=True,
|
||
postgresql_where=text("location_id IS NULL"),
|
||
sqlite_where=text("location_id IS NULL"),
|
||
),
|
||
)
|
||
|
||
|
||
class GroupLocationMinStock(Base):
|
||
"""Mindestbestand einer Gruppe an EINEM Lagerort (in Basiseinheiten).
|
||
|
||
``location_id`` NULL = „Ueberall", genau wie bei
|
||
:class:`ProductLocationMinStock`. Auch hier Basiseinheiten – nur so lassen
|
||
sich Ober- und Untergruppe gegeneinander verrechnen, wenn die eine in
|
||
Kilogramm und die andere in Glaesern erfasst ist.
|
||
"""
|
||
|
||
__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 | None] = mapped_column(
|
||
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||
|
||
location: Mapped[Location | None] = relationship()
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),
|
||
Index(
|
||
"uq_group_ueberall_min",
|
||
"group_id",
|
||
unique=True,
|
||
postgresql_where=text("location_id IS NULL"),
|
||
sqlite_where=text("location_id IS NULL"),
|
||
),
|
||
)
|