"""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 ( BaseUnit, Category, CategoryTracking, FieldDefinition, Item, Location, Lot, Movement, Product, ProductFieldValue, RemovalReason, ) from app.services.items import generate_uid 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_verbrauchsgegenstand_ohne_kategorie_ist_object(db): # Nach dem Umstellen Lebensmittel -> Verbrauchsgegenstand hat der Artikel oft # keine (Gegenstands-)Kategorie mehr. Das Flag muss ihn trotzdem als # Gegenstand führen, sonst rutscht er zurück in die Lebensmittelliste. product = Product(name="Sonnencreme", bulk=True) db.add(product) db.commit() assert product_tracking(db, product) == "object" def test_einzelstueck_ohne_kategorie_ist_object(db): product = Product(name="Powerbank", individual=True) db.add(product) db.commit() 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 # ---- Einzelstücke (Items mit UID/QR) ---- def _individual_product(db, name="Powerbank"): cat = Category(name="Elektronik", tracking=CategoryTracking.object.value) db.add(cat) db.flush() product = Product(name=name, category_id=cat.id, individual=True) db.add(product) db.commit() db.refresh(product) return product def test_uid_ist_eindeutig(db): codes = {generate_uid(db) for _ in range(50)} assert len(codes) == 50 # Keine verwechselbaren Zeichen (0/O/1/I/L) in der UID. assert all(ch not in "01OIL" for code in codes for ch in code) def test_einzelstueck_bestand_zaehlt_items(db): product = _individual_product(db) for _ in range(3): db.add(Item(uid=generate_uid(db), product_id=product.id)) db.commit() assert current_stock(db, product.id) == 3 def test_mengen_produkt_zaehlt_weiter_lots(db): # Ein Nicht-Einzelstück-Produkt zählt weiterhin über Lots, nicht über Items. product, _ = _object_product(db, name="Unterhose") a = _loc(db, "Schrank A") 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 # ---- Zuletzt geändert ---- def test_updated_at_wird_bei_aenderung_nachgezogen(db): import time product = Product(name="Alt") db.add(product) db.commit() db.refresh(product) erst = product.updated_at assert erst is not None time.sleep(0.01) product.name = "Neu" db.commit() db.refresh(product) assert product.updated_at > erst