Files
Vorrania/backend/tests/test_gegenstaende.py
Scarriffle 5ce4411d1e Backend: Einzelstücke (Items) mit UID je Gegenstand
Gegenstands-Produkte koennen als Einzelstuecke gefuehrt werden (Product.individual):
jedes physische Stueck ist ein Item mit eigener kurzer UID (fuer QR), eigenem
Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz. So laesst sich dasselbe
Modell mehrfach getrennt fuehren (Powerbank 2024 + 2025).

Neu: models.Item + Product.individual (+Migration), Schemas, services/items.py
(UID-Erzeugung, Anreicherung), routers/items.py (CRUD, /items/by-uid/{uid} zur
QR-Aufloesung, /items/{id}/remove mit Grund als Bewegung). current_stock zaehlt
bei Einzelstueck-Produkten die Items. Tests + HTTP-Smoke gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:26:15 +02:00

278 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
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_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