Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln

Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -60,6 +60,39 @@ class UnitKind(str, enum.Enum):
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.
@@ -130,6 +163,15 @@ class Category(Base):
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):
@@ -181,15 +223,26 @@ class Product(Base):
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_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"
)
class ApiToken(Base):
@@ -274,6 +327,14 @@ class Movement(Base):
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[int | None] = mapped_column(
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)
@@ -369,3 +430,71 @@ class Setting(Base):
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()