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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user