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 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,9 @@ def _ensure_schema() -> None:
|
|||||||
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
|
||||||
"NOT NULL DEFAULT FALSE",
|
"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.
|
# 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 price_cents INTEGER",
|
||||||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ class Product(Base):
|
|||||||
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
|
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
|
||||||
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
|
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
|
||||||
individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ def create_product(
|
|||||||
shop_id=payload.shop_id,
|
shop_id=payload.shop_id,
|
||||||
product_url=payload.product_url or None,
|
product_url=payload.product_url or None,
|
||||||
individual=bool(payload.individual),
|
individual=bool(payload.individual),
|
||||||
|
bulk=bool(payload.bulk),
|
||||||
source="manual",
|
source="manual",
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ class ProductBase(BaseModel):
|
|||||||
product_url: str | None = Field(default=None, max_length=1024)
|
product_url: str | None = Field(default=None, max_length=1024)
|
||||||
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
|
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
|
||||||
individual: bool = False
|
individual: bool = False
|
||||||
|
# Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen).
|
||||||
|
bulk: bool = False
|
||||||
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
||||||
field_values: dict[int, str | None] | None = None
|
field_values: dict[int, str | None] | None = None
|
||||||
|
|
||||||
@@ -325,6 +327,7 @@ class ProductUpdate(BaseModel):
|
|||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
product_url: str | None = Field(default=None, max_length=1024)
|
product_url: str | None = Field(default=None, max_length=1024)
|
||||||
individual: bool | None = None
|
individual: bool | None = None
|
||||||
|
bulk: bool | None = None
|
||||||
field_values: dict[int, str | None] | None = None
|
field_values: dict[int, str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -350,6 +353,7 @@ class ProductOut(BaseModel):
|
|||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
product_url: str | None = None
|
product_url: str | None = None
|
||||||
individual: bool = False
|
individual: bool = False
|
||||||
|
bulk: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# angereichert:
|
# angereichert:
|
||||||
stock: float = 0.0
|
stock: float = 0.0
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import pytest
|
|||||||
|
|
||||||
from app.crud import product_tracking
|
from app.crud import product_tracking
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
BaseUnit,
|
||||||
Category,
|
Category,
|
||||||
CategoryTracking,
|
CategoryTracking,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
@@ -275,3 +276,41 @@ def test_mengen_produkt_zaehlt_weiter_lots(db):
|
|||||||
object_add(db, product, 5, a.id, None)
|
object_add(db, product, 5, a.id, None)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert current_stock(db, product.id) == 5
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user