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>
742 lines
31 KiB
Python
742 lines
31 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)
|
||
# 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"),
|
||
),
|
||
)
|