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>
239 lines
7.0 KiB
Python
239 lines
7.0 KiB
Python
"""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
|