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:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View 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