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:
co-authored by
Claude Opus 5
parent
94f8ed4db9
commit
264d3886f3
@@ -23,7 +23,9 @@ from ..models import (
|
|||||||
Category,
|
Category,
|
||||||
CategoryTracking,
|
CategoryTracking,
|
||||||
Group,
|
Group,
|
||||||
|
Item,
|
||||||
Location,
|
Location,
|
||||||
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
Product,
|
Product,
|
||||||
@@ -113,7 +115,12 @@ def list_products(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
) -> list[ProductOut]:
|
) -> 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
|
``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten
|
||||||
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
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.field_values),
|
||||||
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
|
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
|
||||||
)
|
)
|
||||||
if q:
|
|
||||||
like = f"%{q}%"
|
|
||||||
query = query.filter(Product.name.ilike(like))
|
|
||||||
if category_id == 0:
|
if category_id == 0:
|
||||||
query = query.filter(Product.category_id.is_(None))
|
query = query.filter(Product.category_id.is_(None))
|
||||||
elif category_id is not None:
|
elif category_id is not None:
|
||||||
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
||||||
products = query.order_by(Product.name).all()
|
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)
|
return products_to_out_bulk(db, products)
|
||||||
|
|
||||||
|
|
||||||
@@ -652,9 +669,28 @@ def delete_product(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> None:
|
) -> 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)
|
product = db.get(Product, product_id)
|
||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
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)
|
detach_group_code(db, product)
|
||||||
db.delete(product)
|
db.delete(product)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -632,6 +632,11 @@ struct ProductRow: View {
|
|||||||
if let size = product.packageSize, size > 0 {
|
if let size = product.packageSize, size > 0 {
|
||||||
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
|
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
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -574,7 +574,9 @@ export default function ProductForm() {
|
|||||||
async function remove() {
|
async function remove() {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Produkt löschen?",
|
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",
|
confirmLabel: "Löschen",
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user