Artikelsuche findet die Marke - und Loeschen fragt erst nach dem Bestand

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 20:06:07 +02:00
co-authored by Claude Opus 5
parent 94f8ed4db9
commit 264d3886f3
5 changed files with 233 additions and 5 deletions
+40 -4
View File
@@ -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()