From 6c870584ddf654ad192250fcc01601a8ba1c5446 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 20:29:01 +0200 Subject: [PATCH] Backend: Verbrauchsgegenstand (Product.bulk) - Gegenstand wie ein Lebensmittel Neues Flag bulk auf Product: Gegenstand als Charge mit Menge/Einheit/MHD/ Mindestbestand fuehren (z.B. Sonnencreme in ml), ohne eindeutigen Code. Migration (ADD COLUMN), Schemas und Anlege-Pfad ergaenzt. Kernlogik nutzt foodLike = kein Gegenstand ODER bulk; Bestand/Einkaufsliste unveraendert. Co-Authored-By: Claude Opus 4.8 --- backend/app/main.py | 3 +++ backend/app/models.py | 5 ++++ backend/app/routers/products.py | 1 + backend/app/schemas.py | 4 +++ backend/tests/test_gegenstaende.py | 39 ++++++++++++++++++++++++++++++ 5 files changed, 52 insertions(+) diff --git a/backend/app/main.py b/backend/app/main.py index dcb3c53..efb4592 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -176,6 +176,9 @@ def _ensure_schema() -> None: # Einzelstück-Verwaltung (Items mit UID/QR) je Produkt. "ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN " "NOT NULL DEFAULT FALSE", + # Verbrauchsgegenstand: Gegenstand wie ein Lebensmittel führen (Chargen). + "ALTER TABLE products ADD COLUMN IF NOT EXISTS bulk BOOLEAN " + "NOT NULL DEFAULT FALSE", # Kaufpreis (in Rappen/Cent) und Währung am Einzelstück. "ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER", "ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)", diff --git a/backend/app/models.py b/backend/app/models.py index 88bb734..f95b7a9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -251,6 +251,11 @@ class Product(Base): # 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) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index c0851c5..7f73db9 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -370,6 +370,7 @@ def create_product( shop_id=payload.shop_id, product_url=payload.product_url or None, individual=bool(payload.individual), + bulk=bool(payload.bulk), source="manual", ) db.add(product) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e38079a..7535b7e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -299,6 +299,8 @@ class ProductBase(BaseModel): product_url: str | None = Field(default=None, max_length=1024) # Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen. individual: bool = False + # Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen). + bulk: bool = False # Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}. field_values: dict[int, str | None] | None = None @@ -325,6 +327,7 @@ class ProductUpdate(BaseModel): shop_id: int | None = None product_url: str | None = Field(default=None, max_length=1024) individual: bool | None = None + bulk: bool | None = None field_values: dict[int, str | None] | None = None @@ -350,6 +353,7 @@ class ProductOut(BaseModel): shop_id: int | None = None product_url: str | None = None individual: bool = False + bulk: bool = False created_at: datetime # angereichert: stock: float = 0.0 diff --git a/backend/tests/test_gegenstaende.py b/backend/tests/test_gegenstaende.py index 75f144b..9cf336d 100644 --- a/backend/tests/test_gegenstaende.py +++ b/backend/tests/test_gegenstaende.py @@ -11,6 +11,7 @@ import pytest from app.crud import product_tracking from app.models import ( + BaseUnit, Category, CategoryTracking, FieldDefinition, @@ -275,3 +276,41 @@ def test_mengen_produkt_zaehlt_weiter_lots(db): object_add(db, product, 5, a.id, None) db.commit() assert current_stock(db, product.id) == 5 + + +# ---- Verbrauchsgegenstand (bulk): wie ein Lebensmittel geführt ---- + +def _bulk_product(db, name="Sonnencreme", base_unit=BaseUnit.milliliter, min_stock=500): + """Gegenstand, der als Charge (Menge + Einheit) geführt wird – wie ein + Lebensmittel, aber in der Gegenstände-Welt und ohne eindeutigen Code.""" + cat = Category(name="Pflege", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + product = Product( + name=name, category_id=cat.id, bulk=True, + base_unit=base_unit, min_stock=min_stock, + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def test_verbrauchsgegenstand_zaehlt_ueber_lots(db): + # bulk-Gegenstand (individual=False) zählt über Chargen wie ein Lebensmittel. + product = _bulk_product(db) + assert product.bulk is True and product.individual is False + a = _loc(db, "Bad") + object_add(db, product, 200, a.id, None) + object_add(db, product, 200, a.id, None) + db.commit() + assert current_stock(db, product.id) == 400 + + +def test_verbrauchsgegenstand_unter_mindestbestand(db): + product = _bulk_product(db, min_stock=500) # 500 ml + a = _loc(db, "Bad") + object_add(db, product, 200, a.id, None) + db.commit() + # Unter dem Mindestbestand -> gehört auf die Einkaufsliste (wie ein Lebensmittel). + assert current_stock(db, product.id) < product.min_stock