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:
238
backend/tests/test_gegenstaende.py
Normal file
238
backend/tests/test_gegenstaende.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Gegenstände (Non-Food): Menge je Lagerort, Umlagern, Entfernen mit Grund,
|
||||
Kategorie-Modus und selbst definierte Felder.
|
||||
|
||||
Die Lebensmittel-Logik (Chargen/MHD/FEFO) bleibt davon unberührt – dafür sorgt
|
||||
test_fefo.py weiterhin.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crud import product_tracking
|
||||
from app.models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
FieldDefinition,
|
||||
Location,
|
||||
Lot,
|
||||
Movement,
|
||||
Product,
|
||||
ProductFieldValue,
|
||||
RemovalReason,
|
||||
)
|
||||
from app.routers.categories import create_category
|
||||
from app.schemas import CategoryCreate
|
||||
from app.services.fields import (
|
||||
FieldError,
|
||||
apply_field_values,
|
||||
coerce_value,
|
||||
effective_field_definitions,
|
||||
)
|
||||
from app.services.stock import (
|
||||
StockError,
|
||||
current_stock,
|
||||
object_add,
|
||||
object_relocate,
|
||||
object_remove,
|
||||
removal_stats,
|
||||
)
|
||||
|
||||
|
||||
def _object_product(db, name="Unterhose"):
|
||||
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(name=name, category_id=cat.id)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product, cat
|
||||
|
||||
|
||||
def _loc(db, name):
|
||||
loc = Location(name=name)
|
||||
db.add(loc)
|
||||
db.commit()
|
||||
db.refresh(loc)
|
||||
return loc
|
||||
|
||||
|
||||
# ---- Modus je Kategorie ----
|
||||
|
||||
def test_ohne_kategorie_bleibt_food(db):
|
||||
product = Product(name="Altbestand")
|
||||
db.add(product)
|
||||
db.commit()
|
||||
assert product_tracking(db, product) == "food"
|
||||
|
||||
|
||||
def test_gegenstandskategorie_macht_object(db):
|
||||
product, _ = _object_product(db)
|
||||
assert product_tracking(db, product) == "object"
|
||||
|
||||
|
||||
def test_neue_oberkategorie_ist_gegenstand(db):
|
||||
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
|
||||
assert out.tracking == CategoryTracking.object
|
||||
|
||||
|
||||
def test_unterkategorie_erbt_modus_vom_elternteil(db):
|
||||
ober = create_category(
|
||||
CategoryCreate(name="Lebensmittel", tracking=CategoryTracking.food), db, None
|
||||
)
|
||||
unter = create_category(CategoryCreate(name="Käse", parent_id=ober.id), db, None)
|
||||
assert unter.tracking == CategoryTracking.food
|
||||
|
||||
|
||||
# ---- Menge je Lagerort ----
|
||||
|
||||
def test_add_summiert_je_ort(db):
|
||||
product, _ = _object_product(db)
|
||||
a, b = _loc(db, "Schrank A"), _loc(db, "Schrank B")
|
||||
object_add(db, product, 5, a.id, None)
|
||||
object_add(db, product, 3, b.id, None)
|
||||
db.commit()
|
||||
assert current_stock(db, product.id) == 8
|
||||
verteilung = {
|
||||
lot.location_id: lot.quantity
|
||||
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
}
|
||||
assert verteilung == {a.id: 5, b.id: 3}
|
||||
|
||||
|
||||
def test_add_am_gleichen_ort_erhoeht_dieselbe_zeile(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "Schrank A")
|
||||
object_add(db, product, 2, a.id, None)
|
||||
object_add(db, product, 3, a.id, None)
|
||||
db.commit()
|
||||
lots = db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
assert len(lots) == 1 and lots[0].quantity == 5
|
||||
|
||||
|
||||
# ---- Umlagern (ohne Grund) ----
|
||||
|
||||
def test_umlagern_verschiebt_menge(db):
|
||||
product, _ = _object_product(db)
|
||||
a, b = _loc(db, "A"), _loc(db, "B")
|
||||
object_add(db, product, 5, a.id, None)
|
||||
db.commit()
|
||||
object_relocate(db, product, 2, a.id, b.id, None)
|
||||
db.commit()
|
||||
verteilung = {
|
||||
lot.location_id: lot.quantity
|
||||
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
}
|
||||
assert verteilung == {a.id: 3, b.id: 2}
|
||||
assert current_stock(db, product.id) == 5
|
||||
# Umlagern erzeugt keine Entnahme mit Grund.
|
||||
assert db.query(Movement).filter(Movement.reason.isnot(None)).count() == 0
|
||||
|
||||
|
||||
def test_umlagern_zu_wenig_bestand(db):
|
||||
product, _ = _object_product(db)
|
||||
a, b = _loc(db, "A"), _loc(db, "B")
|
||||
object_add(db, product, 1, a.id, None)
|
||||
db.commit()
|
||||
with pytest.raises(StockError):
|
||||
object_relocate(db, product, 5, a.id, b.id, None)
|
||||
|
||||
|
||||
def test_umlagern_gleicher_ort_fehler(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "A")
|
||||
object_add(db, product, 3, a.id, None)
|
||||
db.commit()
|
||||
with pytest.raises(StockError):
|
||||
object_relocate(db, product, 1, a.id, a.id, None)
|
||||
|
||||
|
||||
# ---- Entfernen mit Grund + Statistik ----
|
||||
|
||||
def test_entfernen_mit_grund_und_statistik(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "A")
|
||||
object_add(db, product, 5, a.id, None)
|
||||
db.commit()
|
||||
object_remove(db, product, 2, a.id, RemovalReason.broken.value, None)
|
||||
object_remove(db, product, 1, a.id, RemovalReason.broken.value, None)
|
||||
object_remove(db, product, 1, a.id, RemovalReason.lost.value, None)
|
||||
db.commit()
|
||||
assert current_stock(db, product.id) == 1
|
||||
stats = removal_stats(db, product.id)
|
||||
assert stats["broken"] == {"quantity": 3, "count": 2}
|
||||
assert stats["lost"] == {"quantity": 1, "count": 1}
|
||||
|
||||
|
||||
def test_entfernen_zu_viel_bestand(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "A")
|
||||
object_add(db, product, 1, a.id, None)
|
||||
db.commit()
|
||||
with pytest.raises(StockError):
|
||||
object_remove(db, product, 5, a.id, RemovalReason.lost.value, None)
|
||||
|
||||
|
||||
# ---- Selbst definierte Felder ----
|
||||
|
||||
def test_felder_werden_vererbt_oberkategorie_zuerst(db):
|
||||
ober = Category(name="Elektronik", tracking="object")
|
||||
db.add(ober)
|
||||
db.flush()
|
||||
unter = Category(name="Powerbank", parent_id=ober.id, tracking="object")
|
||||
db.add(unter)
|
||||
db.commit()
|
||||
db.add(FieldDefinition(category_id=ober.id, label="Kaufdatum", key="kaufdatum", field_type="date"))
|
||||
db.add(
|
||||
FieldDefinition(
|
||||
category_id=unter.id, label="Kapazität", key="kapazitaet",
|
||||
field_type="number", unit="mAh",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
eff = effective_field_definitions(db, unter.id)
|
||||
assert [fd.label for fd, _ in eff] == ["Kaufdatum", "Kapazität"]
|
||||
geerbt = {fd.label: inh for fd, inh in eff}
|
||||
assert geerbt["Kaufdatum"] is True and geerbt["Kapazität"] is False
|
||||
|
||||
|
||||
def test_zahlfeld_prueft_typ(db):
|
||||
fd = FieldDefinition(category_id=1, label="Kapazität", key="kap", field_type="number")
|
||||
assert coerce_value(fd, "20000") == "20000"
|
||||
assert coerce_value(fd, "3,5") == "3,5"
|
||||
with pytest.raises(FieldError):
|
||||
coerce_value(fd, "abc")
|
||||
|
||||
|
||||
def test_auswahlfeld_prueft_optionen(db):
|
||||
fd = FieldDefinition(
|
||||
category_id=1, label="Größe", key="groesse", field_type="select",
|
||||
options=json.dumps(["S", "M", "L"]),
|
||||
)
|
||||
assert coerce_value(fd, "M") == "M"
|
||||
with pytest.raises(FieldError):
|
||||
coerce_value(fd, "XXL")
|
||||
|
||||
|
||||
def test_feldwert_setzen_und_leeren(db):
|
||||
cat = Category(name="Elektronik", tracking="object")
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
fd = FieldDefinition(category_id=cat.id, label="Notiz", key="notiz", field_type="text")
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
product = Product(name="Gerät", category_id=cat.id)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
apply_field_values(db, product, {fd.id: "wichtig"})
|
||||
db.commit()
|
||||
assert (
|
||||
db.query(ProductFieldValue).filter_by(product_id=product.id).one().value
|
||||
== "wichtig"
|
||||
)
|
||||
# Leerer Wert löscht den Eintrag wieder.
|
||||
apply_field_values(db, product, {fd.id: ""})
|
||||
db.commit()
|
||||
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
|
||||
Reference in New Issue
Block a user