Files
Vorrania/backend/app/models.py
Scarriffle fd9045226b Gebinde verwalten - mit Einzahl und Mehrzahl
Bisher war die Auswahl eine fest im Frontend verdrahtete Liste und es gab nur
eine Form: ueberall stand "3 Glas".

Neu unter Verwaltung > Gebinde: anlegen, umbenennen, loeschen, jeweils mit
Einzahl und Mehrzahl. Die eingebauten Gebinde lassen sich in der Schreibweise
aendern, aber nicht loeschen; ein Gebinde, das ein Artikel verwendet, ebenfalls
nicht.

Der Artikel speichert weiterhin nur die Einzahl als Text - so bleiben
vorhandene Artikel, Sicherungen und CSV-Dateien gueltig, und eine unbekannte
Bezeichnung faellt schlicht auf die Einzahl zurueck. Deshalb zieht ein
Umbenennen die Artikel mit; sonst zeigten sie auf eine Bezeichnung, die es
nicht mehr gibt.

Die Mehrzahl greift jetzt in Artikelliste, Artikelseite (Chargen und Gebinde-
Auswahl), Auslagern und in allen Ablauf- und Einkaufslisten samt Startseite.
Einheiten wie Gramm oder Liter bleiben unveraendert - die haben im Deutschen
keine Mehrzahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:24:42 +02:00

364 lines
14 KiB
Python
Raw 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
from datetime import date, datetime, timezone
from sqlalchemy import (
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Role(str, enum.Enum):
admin = "admin"
user = "user"
class BaseUnit(str, enum.Enum):
piece = "piece"
gram = "gram"
milliliter = "milliliter"
class MovementType(str, enum.Enum):
in_ = "in"
out = "out"
adjust = "adjust"
class DatePrecision(str, enum.Enum):
"""Wie genau ein MHD angegeben wurde.
Bewusst als kurzer String gespeichert und nicht als DB-Enum: So lässt sich
die Spalte auf bestehenden Tabellen per ADD COLUMN nachziehen, ohne vorher
einen neuen Postgres-Typ anlegen zu müssen.
"""
day = "day" # 07.09.2026
month = "month" # 09/2026
class UnitKind(str, enum.Enum):
"""Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung."""
count = "count" # Basis: Stück
weight = "weight" # Basis: Gramm
volume = "volume" # Basis: Milliliter
class Unit(Base):
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
factor = wie viele Basiseinheiten 1 dieser Einheit entsprechen
(z.B. Kilogramm: kind=weight, factor=1000; Liter: kind=volume, factor=1000).
"""
__tablename__ = "units"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
kind: Mapped[UnitKind] = mapped_column(Enum(UnitKind), nullable=False)
factor: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Group(Base):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True)
# Einheit des Gruppen-Mindestbestands (z.B. Kilogramm). NULL = zählt in Basiseinheiten.
min_stock_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
)
products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship()
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
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[int | None] = mapped_column(
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)
class Product(Base):
__tablename__ = "products"
__table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
barcode: Mapped[str | None] = mapped_column(String(64), nullable=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# Kanonische Basiseinheit (piece/gram/milliliter): bestimmt Speicherung + Art (kind).
base_unit: Mapped[BaseUnit] = mapped_column(
Enum(BaseUnit), nullable=False, default=BaseUnit.piece
)
# Bevorzugte Anzeige-/Eingabeeinheit (z.B. Kilogramm). NULL = Basiseinheit selbst.
display_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
)
# Number of base units contained in one package (e.g. 500 g per package).
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
date_precision: Mapped[str] = mapped_column(
String(8), nullable=False, default=DatePrecision.day.value,
server_default=DatePrecision.day.value,
)
# Gruppe: zaehlt Bestaende mehrerer Marken zusammen.
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
)
# Kategorie: reine Ordnungshilfe fuer die Artikelliste, voellig unabhaengig
# von der Gruppe. Ein Artikel kann beides, eines oder keines haben.
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
# In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige).
min_stock_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
)
min_stock_in_packages: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
group: Mapped[Group | None] = relationship(back_populates="products")
category: Mapped[Category | 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"
)
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[int | None] = mapped_column(
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)
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):
"""Anordnung der Karten auf der Startseite.
``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.
"""
__tablename__ = "dashboard_layouts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True
)
# JSON-Text: [{"i": "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)