Files
Vorrania/backend/app/models.py
Scarriffle 9f3225113e Zweiteinheit am Artikel: Bruecke zwischen Stueck, Gramm und Milliliter
Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet
count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor
dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten
beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte
Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der
Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht.

Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert
wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim
naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund:
250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und
liefe damit gegen die Bestandspruefung beim Auslagern.

Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine
Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit
(GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet -
weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit
geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck,
und Runden wuerde stumm etwas anderes buchen als angegeben.

WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in
_gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein
Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren.
Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer
Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in
Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit
verrechnet.

Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den
ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND
Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen
schmalen Vertrag.

18 neue Tests. Dass test_wrong_kind_rejected und
test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist
selbst der Beleg: ohne Bruecke aendert sich nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:38:13 +02:00

742 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
# 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.
"""
__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"),
),
)