diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index d7f7b7c..a2d2c3f 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -23,7 +23,9 @@ from ..models import ( Category, CategoryTracking, Group, + Item, Location, + Lot, Movement, MovementType, Product, @@ -113,7 +115,12 @@ def list_products( db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[ProductOut]: - """Artikel auflisten, optional nach Name und Kategorie eingegrenzt. + """Artikel auflisten, optional nach Suchtext und Kategorie eingegrenzt. + + ``q`` wird in Wörter zerlegt; jedes muss in Name **oder** Marke vorkommen, + die Reihenfolge ist egal. "Vogel Jack and Jones" findet damit dasselbe + Shirt wie "Jack and Jones Vogel" – vorher suchte ein einzelnes ILIKE nach + dem ganzen Ausdruck und fand gar nichts. ``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will @@ -129,14 +136,24 @@ def list_products( selectinload(Product.field_values), selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location), ) - if q: - like = f"%{q}%" - query = query.filter(Product.name.ilike(like)) if category_id == 0: query = query.filter(Product.category_id.is_(None)) elif category_id is not None: query = query.filter(Product.category_id.in_(descendant_ids(db, category_id))) products = query.order_by(Product.name).all() + + # Gleiche Form wie recipes.zuordnung_ziele. Bewusst in Python und nicht in + # SQL: normalisieren() loest Umlaute auf ("Mühle" -> "muehle"), ein ILIKE + # auf der Spalte faende den gespeicherten Wert damit nicht wieder. Die + # Route laedt bei der normalen Artikelliste ohnehin alle Zeilen, der + # gefilterte Weg kostet also nie mehr als der ungefilterte. + teile = normalisieren(q or "").split() + if teile: + def passt(text: str) -> bool: + ziel = normalisieren(text) + return all(t in ziel for t in teile) + + products = [p for p in products if passt(f"{p.name} {p.brand or ''}")] return products_to_out_bulk(db, products) @@ -652,9 +669,28 @@ def delete_product( db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: + """Artikel löschen – nur, solange nichts mehr daran hängt. + + Ohne diese Sperre reisst ein Klick Chargen, Einzelstücke, Barcodes, Bilder + und den gesamten Bewegungsverlauf per Datenbank-Kaskade mit, ohne zu + fragen. Bewegungen allein halten aber nicht auf: Sonst waere jeder je + benutzte Artikel fuer immer unloeschbar. Geprueft wird der *heutige* + Bestand, und der steht in Einzelstuecken und Chargen – auch bei + Gegenstaenden, deren Menge je Lagerort ebenfalls als Charge liegt. + """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + if db.query(Item).filter(Item.product_id == product_id).first() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "Der Artikel hat noch Einzelstücke. Erst entfernen, dann löschen.", + ) + if db.query(Lot).filter(Lot.product_id == product_id).first() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "Der Artikel hat noch Bestand. Erst auslagern, dann löschen.", + ) detach_group_code(db, product) db.delete(product) db.commit() diff --git a/backend/tests/test_artikel_loeschen.py b/backend/tests/test_artikel_loeschen.py new file mode 100644 index 0000000..d1fbf34 --- /dev/null +++ b/backend/tests/test_artikel_loeschen.py @@ -0,0 +1,95 @@ +"""Artikel löschen – und wann der Server sich weigert. + +Ohne Sperre reisst ein Klick Chargen, Einzelstücke, Barcodes, Bilder und den +gesamten Bewegungsverlauf per Kaskade mit. Geprüft wird deshalb der heutige +Bestand. Bewegungen allein halten bewusst NICHT auf, sonst waere jeder je +benutzte Artikel fuer immer unloeschbar. + +Geprüft wird nur die Sperre selbst – die Kaskade nicht: Die Tests laufen auf +SQLite ohne ``PRAGMA foreign_keys=ON``, eine Kaskaden-Zusicherung waere dort +ohne Aussagekraft. +""" + +import pytest +from fastapi import HTTPException + +from app.models import BaseUnit, Item, Lot, Product, Role, User +from app.routers.products import delete_product + + +@pytest.fixture() +def admin(db): + person = User(username="chef", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def _artikel(db, name="Hammer"): + product = Product(name=name, base_unit=BaseUnit.piece, package_size=1, + source="manual") + db.add(product) + db.commit() + db.refresh(product) + return product + + +def test_loeschen_ohne_bestand_geht_durch(db, admin): + product = _artikel(db) + + delete_product(product_id=product.id, db=db, _=admin) + + assert db.get(Product, product.id) is None + + +def test_loeschen_mit_einzelstuecken_wird_abgelehnt(db, admin): + product = _artikel(db) + db.add(Item(uid="ABC12345", product_id=product.id)) + db.commit() + + with pytest.raises(HTTPException) as fehler: + delete_product(product_id=product.id, db=db, _=admin) + + assert fehler.value.status_code == 409 + assert "Einzelstücke" in fehler.value.detail + assert db.get(Product, product.id) is not None + + +def test_loeschen_mit_chargen_wird_abgelehnt(db, admin): + product = _artikel(db) + db.add(Lot(product_id=product.id, quantity=5)) + db.commit() + + with pytest.raises(HTTPException) as fehler: + delete_product(product_id=product.id, db=db, _=admin) + + assert fehler.value.status_code == 409 + assert "Bestand" in fehler.value.detail + assert db.get(Product, product.id) is not None + + +def test_loeschen_nach_leergeraeumter_charge_geht_durch(db, admin): + """Wer alles auslagert, muss den Artikel loswerden können. + + Haelt fest, dass die Sperre am heutigen Bestand haengt und nicht zu einer + Verlaufspruefung ausgebaut wird – sonst waere ein einmal benutzter Artikel + nie wieder loeschbar. + """ + product = _artikel(db) + lot = Lot(product_id=product.id, quantity=5) + db.add(lot) + db.commit() + db.delete(lot) # wie beim vollstaendigen Auslagern + db.commit() + + delete_product(product_id=product.id, db=db, _=admin) + + assert db.get(Product, product.id) is None + + +def test_unbekannter_artikel_bleibt_ein_404(db, admin): + with pytest.raises(HTTPException) as fehler: + delete_product(product_id=9999, db=db, _=admin) + + assert fehler.value.status_code == 404 diff --git a/backend/tests/test_artikel_suche.py b/backend/tests/test_artikel_suche.py new file mode 100644 index 0000000..356170a --- /dev/null +++ b/backend/tests/test_artikel_suche.py @@ -0,0 +1,90 @@ +"""Artikelsuche über Name UND Marke, wortweise. + +Vorher verglich ein einzelnes ILIKE den ganzen Suchtext gegen den Namen. Wer +"Vogel Jack and Jones" eintippte, bekam nichts – dieser Ausdruck steht in +keinem Artikelnamen. Gesucht wird jetzt Wort für Wort über Name und Marke +zusammen, in beliebiger Reihenfolge. +""" + +import pytest + +from app.models import BaseUnit, Category, CategoryTracking, Product, Role, User +from app.routers.products import list_products + + +@pytest.fixture() +def nutzer(db): + person = User(username="chef", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def _kategorie(db, name, tracking=CategoryTracking.object): + cat = Category(name=name, tracking=tracking.value) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +def _artikel(db, name, cat=None, **extra): + product = Product(name=name, base_unit=BaseUnit.piece, package_size=1, + category_id=(cat.id if cat else None), source="manual", **extra) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _namen(db, nutzer, **kwargs): + return [p.name for p in list_products(db=db, _=nutzer, **kwargs)] + + +def test_suche_findet_ueber_marke_und_name_in_beliebiger_reihenfolge(db, nutzer): + """Der Fall, der vorher gar nichts lieferte.""" + _artikel(db, "Vogel-Shirt", brand="Jack and Jones") + _artikel(db, "Hose", brand="Levis") + + assert _namen(db, nutzer, q="vogel jack and jones") == ["Vogel-Shirt"] + # Dieselben Wörter andersherum – die Reihenfolge darf nichts ändern. + assert _namen(db, nutzer, q="jack and jones vogel") == ["Vogel-Shirt"] + # Nur die Marke, ohne ein Wort aus dem Namen. + assert _namen(db, nutzer, q="jones") == ["Vogel-Shirt"] + + +def test_suche_loest_umlaute_auf(db, nutzer): + """Der Grund, warum gefiltert wird und nicht per ILIKE gesucht.""" + _artikel(db, "Weissmehl Typ 550", brand="Mühle Nord") + _artikel(db, "Zucker") + + assert _namen(db, nutzer, q="muehle") == ["Weissmehl Typ 550"] + assert _namen(db, nutzer, q="MÜHLE") == ["Weissmehl Typ 550"] + + +def test_suche_grenzt_weiterhin_auf_kategorie_ein(db, nutzer): + """Der Kategoriefilter bleibt in SQL – der Umbau darf ihn nicht verlieren.""" + kleidung = _kategorie(db, "Kleidung") + werkzeug = _kategorie(db, "Werkzeug") + _artikel(db, "Vogel-Shirt", kleidung, brand="Jack and Jones") + _artikel(db, "Vogel-Hammer", werkzeug) + + assert _namen(db, nutzer, q="vogel") == ["Vogel-Hammer", "Vogel-Shirt"] + assert _namen(db, nutzer, q="vogel", category_id=kleidung.id) == ["Vogel-Shirt"] + + +def test_artikel_ohne_marke_bleibt_auffindbar(db, nutzer): + """``brand`` ist nullbar – das darf den Vergleich nicht sprengen.""" + _artikel(db, "Hammer", brand=None) + + assert _namen(db, nutzer, q="hammer") == ["Hammer"] + + +def test_leere_suche_liefert_alles(db, nutzer): + """Auch eine Suche aus lauter Leerzeichen: Ohne Wörter wird nicht gefiltert.""" + _artikel(db, "Hammer") + _artikel(db, "Zange") + + assert _namen(db, nutzer) == ["Hammer", "Zange"] + assert _namen(db, nutzer, q=" ") == ["Hammer", "Zange"] diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 37e5f4f..5668a8c 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -632,6 +632,11 @@ struct ProductRow: View { if let size = product.packageSize, size > 0 { text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))" } + // Marke voran: Die Suche findet Artikel ueber sie, ohne sie in der + // Zeile bliebe ein Treffer auf "Jack and Jones" unerklaerlich. + if let marke = product.brand, !marke.isEmpty { + text = "\(marke) · \(text)" + } return text } diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 36449cb..7a93334 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -574,7 +574,9 @@ export default function ProductForm() { async function remove() { const ok = await confirm({ title: "Produkt löschen?", - message: "Der gesamte Bestand dieses Produkts geht dabei verloren.", + // Bestand haelt der Server inzwischen auf (409). Was hier wirklich + // verloren geht, ist alles Uebrige am Artikel. + message: "Barcodes, Bild und der Bewegungsverlauf gehen dabei verloren.", confirmLabel: "Löschen", danger: true, });