Der Lagerort bleibt beim Verleihen stehen - er sagt weiter, wohin das Stueck nach der Rueckgabe zurueckgehoert. Ein Lagerort-Wert "verliehen" haette diese Information vernichtet. Stattdessen eine eigene Tabelle item_loans mit Entleiher und Grund als Freitext, Verleihdatum und einem optionalen Rueckgabedatum: "kurz mal mitgenommen" soll nicht an einem Datumszwang scheitern. Zurueckgegebene Vorgaenge bleiben stehen, damit die Tabelle gleichzeitig die Verleih-Historie je Stueck ist. Ein Teilindex auf die offenen Vorgaenge haelt fest, dass ein Stueck nicht zweimal gleichzeitig verliehen sein kann - wie bei den "Ueberall"-Mindestbestaenden, weil Postgres NULLs in UNIQUE als verschieden zaehlt. Verliehen heisst nicht im Bestand: Ein Stueck, das nicht im Haus ist, zaehlt nirgends mehr mit - weder am Lagerort noch im Gesamtbestand. Entschieden wird das an einer Stelle (services/loans.py::nicht_verliehen), die alle fuenf Zaehlungen benutzen. Sonst behauptete "Was liegt hier?" weiterhin, die Kamera liege im Regal. Folge: Faellt ein verliehenes Stueck unter den Mindestbestand, erscheint der Artikel auf der Einkaufsliste. Verleihen und Zuruecknehmen darf jeder angemeldete Benutzer - es ist eine Buchung wie Ein- und Auslagern, keine Stammdatenaenderung - und wird mit dem Konto protokolliert. Nur das Loeschen eines Vorgangs bleibt Admins vorbehalten, weil es Historie vernichtet. Bewusst keine Movement-Bewegung je Verleih: Der Verlauf rechnet den Bestand rueckwaerts aus vorzeichenbehafteten Bewegungen, ein Abgang ohne Gegenbuchung wuerde diese Kurve verfaelschen. GET /loans?due_in= liefert die faelligen Verleihe und ist damit schon die Datenquelle fuer die spaetere Rueckgabe-Erinnerung der App. Oberflaeche in Web und App fehlt noch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
905 lines
39 KiB
Python
905 lines
39 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)
|
||
# Leer bei Konten, die es nur ueber Single Sign-on gibt. Ein solches Konto ist
|
||
# per Passwort schlicht nicht anmeldbar (siehe routers/auth.py).
|
||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
# Nur fuer die Erstverknuepfung mit einem SSO-Konto. Bewusst nicht Pflicht:
|
||
# bestehende Installationen haben keine Adressen hinterlegt.
|
||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
identities: Mapped[list[UserIdentity]] = relationship(
|
||
back_populates="user", cascade="all, delete-orphan"
|
||
)
|
||
|
||
__table_args__ = (
|
||
# Die Adresse entscheidet, welches Konto ein SSO-Login beim ersten Mal
|
||
# uebernimmt – zwei Konten mit derselben Adresse waeren also mehrdeutig.
|
||
# Gross-/Kleinschreibung darf dabei keinen Unterschied machen, und leere
|
||
# Adressen (der Normalfall bei Passwort-Konten) bleiben aussen vor.
|
||
Index(
|
||
"uq_users_email",
|
||
text("lower(email)"),
|
||
unique=True,
|
||
postgresql_where=text("email IS NOT NULL"),
|
||
sqlite_where=text("email IS NOT NULL"),
|
||
),
|
||
)
|
||
|
||
|
||
class OidcLoginState(Base):
|
||
"""Merkzettel zwischen „zum Anmeldedienst geschickt" und „zurueckgekommen".
|
||
|
||
Bewusst eine Tabelle und kein Sitzungs-Cookie. Dieses Backend kennt sonst
|
||
gar keine Cookies, und Starlettes CORS-Schicht wechselt bei
|
||
``allow_origins=["*"]`` **zusammen mit** ``allow_credentials=True`` auf die
|
||
gespiegelte Herkunft, sobald ein Request ueberhaupt ein Cookie mitbringt
|
||
(starlette/middleware/cors.py). Ohne Cookie entsteht diese Wechselwirkung
|
||
erst gar nicht – und mit der ``db``-Fixture laesst sich der Ablauf pruefen,
|
||
was bei einer Middleware ohne TestClient nicht ginge.
|
||
"""
|
||
|
||
__tablename__ = "oidc_login_states"
|
||
|
||
state: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
|
||
# Gesetzt, wenn der Vorgang eine VERKNUEPFUNG ist statt einer Anmeldung: Der
|
||
# Rueckweg haengt die Identitaet dann an genau dieses (bereits angemeldete)
|
||
# Konto, statt jemanden anzumelden. Die Berechtigung dafuer stammt aus der
|
||
# laufenden Vorrania-Sitzung, nicht aus der E-Mail-Adresse.
|
||
link_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)
|
||
|
||
|
||
class OidcPendingLink(Base):
|
||
"""Eine gepruefte Anmeldedienst-Kennung, die noch keinem Konto gehoert.
|
||
|
||
Entsteht, wenn sich jemand erfolgreich beim Anmeldedienst ausgewiesen hat,
|
||
aber weder eine bekannte Kennung noch eine passende bestaetigte E-Mail
|
||
vorliegt. Statt still ein zweites Konto anzulegen, fragt Vorrania nach:
|
||
neues Konto oder bestehendes verknuepfen.
|
||
|
||
Kurzlebig und einmalig verwendbar. Gespeichert werden nur die geprueften
|
||
Angaben aus dem ID-Token – nie das Token selbst.
|
||
"""
|
||
|
||
__tablename__ = "oidc_pending_links"
|
||
|
||
# Wie bei ApiToken liegt nur der SHA-256-Hash hier; den Klartext kennt allein
|
||
# der Browser, der ihn im Adressfragment mitbekommen hat.
|
||
code_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
email: Mapped[str | None] = mapped_column(String(255))
|
||
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
username_vorschlag: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
|
||
class UserIdentity(Base):
|
||
"""Verknuepfung eines Vorrania-Kontos mit einem Konto beim Anmeldeanbieter.
|
||
|
||
Eigene Tabelle statt zweier Spalten am Benutzer, damit ein Konto spaeter
|
||
Identitaeten bei MEHREREN Anbietern haben kann (Authentik heute, etwas
|
||
anderes morgen) – genau die Erweiterbarkeit, um die es beim OIDC-Umbau geht.
|
||
"""
|
||
|
||
__tablename__ = "user_identities"
|
||
__table_args__ = (UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
# Das Kuerzel aus OIDC_PROVIDERS, z.B. "authentik".
|
||
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
# Die sub-Angabe des Anbieters – stabil, auch wenn sich die E-Mail aendert.
|
||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
|
||
user: Mapped[User] = relationship(back_populates="identities")
|
||
|
||
|
||
# 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)
|
||
# Zweiteinheit: Bruecke zwischen den Einheiten-ARTEN fuer DIESEN Artikel.
|
||
# „3 Stueck ≙ 250 g" – gespeichert als das eingegebene PAAR, damit beim
|
||
# naechsten Oeffnen genau das wieder dasteht und nicht „83,333 g je Stueck".
|
||
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||
# ``secondary_amount`` in ``secondary_base``. NULL = keine Bruecke, dann
|
||
# bleibt es bei der strikten Trennung der Arten (services/conversion.py).
|
||
#
|
||
# String statt Enum wie bei ``Category.tracking``: ein natives Postgres-Enum
|
||
# in der handgeschriebenen Migration nachzuziehen waere unnoetig heikel.
|
||
secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||
secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
secondary_amount: Mapped[float | None] = mapped_column(Float, 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.
|
||
|
||
``thumb`` ist dasselbe Bild auf Briefmarkengröße. Listen zeigen 34x34 Pixel
|
||
je Zeile – ohne diese Spalte lud eine Liste mit 40 Einträgen 40 Fotos in
|
||
voller Auflösung, also Dutzende Megabyte für ein paar Briefmarken. Nullbar,
|
||
weil ältere Zeilen noch keine haben; sie wird beim ersten Abruf nachgezogen.
|
||
"""
|
||
|
||
__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)
|
||
thumb: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||
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"
|
||
)
|
||
# Verleih-Historie, neueste zuerst. Der offene Verleih (returned_on NULL)
|
||
# ist hoechstens einer, siehe ItemLoan.
|
||
loans: Mapped[list["ItemLoan"]] = relationship(
|
||
back_populates="item", cascade="all, delete-orphan",
|
||
order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()",
|
||
)
|
||
|
||
|
||
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 ItemLoan(Base):
|
||
"""Ein Verleih-Vorgang eines Einzelstücks: an wen, warum, bis wann.
|
||
|
||
Bewusst eine eigene Tabelle statt eines Lagerort-Werts „verliehen": Der
|
||
Lagerort bleibt stehen und sagt weiter, wohin das Stück nach der Rückgabe
|
||
zurückgehört. Zurückgegebene Verleihe bleiben als Zeile erhalten – damit ist
|
||
die Tabelle gleichzeitig die Verleih-Historie je Stück.
|
||
|
||
``returned_on`` NULL heißt „noch draußen"; ein Stück kann nur einen solchen
|
||
offenen Verleih haben (Teilindex unten). Verliehene Stücke zählen nirgends
|
||
als Bestand, siehe ``services/loans.py``.
|
||
"""
|
||
|
||
__tablename__ = "item_loans"
|
||
__table_args__ = (
|
||
# Zweimal gleichzeitig verliehen kann ein einzelnes Stück nicht sein.
|
||
# Als Teilindex, weil nur die OFFENEN Verleihe eindeutig sein dürfen:
|
||
# zurückgegebene bleiben ja beliebig oft je Stück stehen.
|
||
Index(
|
||
"uq_item_loan_offen",
|
||
"item_id",
|
||
unique=True,
|
||
postgresql_where=text("returned_on IS NULL"),
|
||
sqlite_where=text("returned_on IS NULL"),
|
||
),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
item_id: Mapped[int] = mapped_column(
|
||
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
# Freitext: Entleiher sind keine Vorrania-Benutzer (Nachbar, Kollege …).
|
||
borrower: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
lent_on: Mapped[date] = mapped_column(Date, nullable=False, default=date.today)
|
||
# Erwartete Rückgabe – bewusst optional: „kurz mal mitgenommen" soll nicht
|
||
# an einem Datumszwang scheitern.
|
||
due_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
returned_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
# Wer herausgegeben hat. SET NULL, damit ein gelöschtes Konto den Vorgang
|
||
# nicht mitnimmt.
|
||
user_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||
|
||
item: Mapped[Item] = relationship(back_populates="loans")
|
||
user: Mapped[User | None] = relationship()
|
||
|
||
|
||
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"),
|
||
),
|
||
)
|