From 264d3886f39731036b750f7c3272636f1886657c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 15 Oct 2026 20:06:07 +0200 Subject: [PATCH] Artikelsuche findet die Marke - und Loeschen fragt erst nach dem Bestand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Suche verglich bisher einen einzigen Textschnipsel gegen den Namen. Wer "Vogel Jack and Jones" eintippte, bekam nichts: Dieser Ausdruck steht in keinem Artikelnamen, und die Marke wurde ueberhaupt nie durchsucht. Jetzt wird der Suchtext in Woerter zerlegt, jedes muss in Name oder Marke vorkommen, die Reihenfolge ist egal. Gefiltert wird in Python und nicht per ILIKE, weil normalisieren() Umlaute aufloest ("Muehle" findet "Mühle") - auf der Spalte faende ein ILIKE den gespeicherten Wert nicht wieder. Dieselbe Form nutzt recipes.zuordnung_ziele schon laenger. Alle Artikellisten haengen an dieser einen Route, die Verbesserung wirkt also auch bei Gegenstaenden und beim Ein-/Auslagern. Die Artikelzeile in der App zeigt nun die Marke, sonst waere ein Treffer darauf nicht zu erklaeren. Dazu die zweite Haelfte: delete_product hatte keinerlei Schutz und riss Chargen, Einzelstuecke, Barcodes, Bilder und den ganzen Bewegungsverlauf per Kaskade mit. Jetzt lehnt der Server mit 409 ab, solange Einzelstuecke oder Chargen dranhaengen. Bewegungen allein halten bewusst nicht auf - sonst waere jeder je benutzte Artikel fuer immer unloeschbar. Der Bestaetigungstext im Web versprach den Verlust des Bestands und stimmte damit nicht mehr. Co-Authored-By: Claude Opus 5 --- backend/app/routers/products.py | 44 ++++++++++-- backend/tests/test_artikel_loeschen.py | 95 ++++++++++++++++++++++++++ backend/tests/test_artikel_suche.py | 90 ++++++++++++++++++++++++ ios/Sources/ListViews.swift | 5 ++ web/src/pages/ProductForm.jsx | 4 +- 5 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_artikel_loeschen.py create mode 100644 backend/tests/test_artikel_suche.py 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, });