diff --git a/backend/alembic/versions/0025_material_suchindex.py b/backend/alembic/versions/0025_material_suchindex.py new file mode 100644 index 0000000..4ad3e46 --- /dev/null +++ b/backend/alembic/versions/0025_material_suchindex.py @@ -0,0 +1,23 @@ +"""SEARCH-002: GIN-Trigram-Index auf Material.name für Suche über Materialnamen. + +Revision ID: 0025_material_suchindex +Revises: 0024_dokument_hash +Create Date: 2026-09-08 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0025_material_suchindex" +down_revision: Union[str, None] = "0024_dokument_hash" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # pg_trgm-Extension existiert bereits seit Migration 0023. + op.execute("CREATE INDEX ix_material_name_trgm ON material USING GIN (name gin_trgm_ops)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_material_name_trgm") diff --git a/backend/app/api/v1/endpoints/suche.py b/backend/app/api/v1/endpoints/suche.py index 03a76df..63e9099 100644 --- a/backend/app/api/v1/endpoints/suche.py +++ b/backend/app/api/v1/endpoints/suche.py @@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.db.session import get_db from app.models.objekt import Objekt +from app.models.objektposition import Objektposition, ObjektpositionStatus +from app.models.stammdaten import Material from app.schemas.suche import SuchTreffer router = APIRouter() @@ -18,21 +20,58 @@ async def suche( db: AsyncSession = Depends(get_db), _=Depends(get_current_user), ) -> list[SuchTreffer]: - """Globale Suche (Command Palette) über Objektname/-code, per pg_trgm- - Index statt vollständig geladener Objektliste + Client-Filter (SEARCH-001). - Kurze Eingaben (<2 Zeichen) liefern bewusst nichts - sonst zu viele - Zufallstreffer über den Ähnlichkeits-Schwellenwert von pg_trgm.""" + """Globale Suche (Command Palette) über Objektname/-code UND Material- + Namen (SEARCH-002: wer "Kompressen" sucht, will Objekte finden, die + welche führen) - per pg_trgm-Index statt vollständig geladener + Objektliste + Client-Filter (SEARCH-001). Kurze Eingaben (<2 Zeichen) + liefern bewusst nichts - sonst zu viele Zufallstreffer über den + Ähnlichkeits-Schwellenwert von pg_trgm.""" q = q.strip() if len(q) < 2: return [] muster = f"%{q}%" - aehnlichkeit = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q)) - stmt = ( - select(Objekt.id, Objekt.code, Objekt.name) - .where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit > 0.2)) - .order_by(aehnlichkeit.desc()) + + aehnlichkeit_objekt = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q)) + direkt_stmt = ( + select(Objekt.id, Objekt.code, Objekt.name, aehnlichkeit_objekt.label("score")) + .where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit_objekt > 0.2)) + .order_by(aehnlichkeit_objekt.desc()) .limit(_TREFFER_LIMIT) ) - result = await db.execute(stmt) - return [SuchTreffer(id=r.id, code=r.code, name=r.name) for r in result.all()] + direkt_ergebnis = (await db.execute(direkt_stmt)).all() + + aehnlichkeit_material = func.similarity(Material.name, q) + material_stmt = ( + select(Objekt.id, Objekt.code, Objekt.name, Material.name.label("material_name"), aehnlichkeit_material.label("score")) + .join(Objektposition, Objektposition.objekt_id == Objekt.id) + .join(Material, Material.id == Objektposition.material_id) + .where( + Objektposition.status == ObjektpositionStatus.aktiv, + or_(Material.name.ilike(muster), aehnlichkeit_material > 0.2), + ) + .order_by(aehnlichkeit_material.desc()) + .limit(_TREFFER_LIMIT) + ) + material_ergebnis = (await db.execute(material_stmt)).all() + + # Zusammenführen: direkter Objekt-Treffer schlägt Material-Treffer für + # dasselbe Objekt (kein "über Material XY" anzeigen, wenn der Objektname + # selbst schon passt), sonst höherer Score gewinnt bei Mehrfachtreffer. + kandidaten: dict[int, SuchTreffer] = {} + scores: dict[int, float] = {} + for row in direkt_ergebnis: + kandidaten[row.id] = SuchTreffer(id=row.id, code=row.code, name=row.name) + scores[row.id] = float(row.score or 0) + for row in material_ergebnis: + score = float(row.score or 0) + if row.id in kandidaten: + continue + if row.id not in scores or score > scores[row.id]: + kandidaten[row.id] = SuchTreffer( + id=row.id, code=row.code, name=row.name, ueber_material=row.material_name + ) + scores[row.id] = score + + treffer = sorted(kandidaten.values(), key=lambda t: scores[t.id], reverse=True) + return treffer[:_TREFFER_LIMIT] diff --git a/backend/app/schemas/suche.py b/backend/app/schemas/suche.py index b54835c..8236031 100644 --- a/backend/app/schemas/suche.py +++ b/backend/app/schemas/suche.py @@ -5,3 +5,6 @@ class SuchTreffer(BaseModel): id: int code: str name: str + # Gesetzt, wenn das Objekt NICHT über eigenen Name/Code getroffen wurde, + # sondern weil es (aktiv) ein Material mit passendem Namen führt. + ueber_material: str | None = None diff --git a/backend/tests/test_suche.py b/backend/tests/test_suche.py index 6d558fd..5689fec 100644 --- a/backend/tests/test_suche.py +++ b/backend/tests/test_suche.py @@ -30,3 +30,30 @@ async def test_suche_zu_kurze_eingabe_liefert_leer(client, objekt_mit_position, async def test_suche_ohne_login_abgelehnt(client, objekt_mit_position): antwort = await client.get("/api/v1/suche?q=Kontroll") assert antwort.status_code == 401 + + +@pytest.mark.asyncio +async def test_suche_findet_objekt_ueber_materialname(client, objekt_mit_position, mitarbeiter_user): + """SEARCH-002: Objekt hat keine "Kompresse" im eigenen Namen/Code, führt + das Material aber - muss trotzdem auffindbar sein, mit ueber_material-Hinweis.""" + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + + antwort = await client.get("/api/v1/suche?q=Kompresse", headers=auth_header(token)) + assert antwort.status_code == 200 + treffer = [t for t in antwort.json() if t["id"] == objekt.id] + assert len(treffer) == 1 + assert treffer[0]["ueber_material"] == "Kompresse" + + +@pytest.mark.asyncio +async def test_suche_direkter_objekt_treffer_hat_vorrang_vor_material(client, objekt_mit_position, mitarbeiter_user): + """Wenn Objektname/-code selbst passt, kein ueber_material-Hinweis, auch + wenn zufällig auch ein Material ähnlich heißen würde.""" + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + + antwort = await client.get("/api/v1/suche?q=Kontroll-Test", headers=auth_header(token)) + treffer = [t for t in antwort.json() if t["id"] == objekt.id] + assert len(treffer) == 1 + assert treffer[0]["ueber_material"] is None diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index ef5507e..9e7579b 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -8,6 +8,7 @@ interface SuchTreffer { id: number; code: string; name: string; + ueber_material?: string | null; } interface Eintrag { @@ -114,7 +115,7 @@ export function CommandPalette() { objektTreffer.map((o) => ({ id: `objekt-${o.id}`, label: o.name, - hinweis: o.code, + hinweis: o.ueber_material ? `${o.code} · führt „${o.ueber_material}“` : o.code, gehe: () => navigate(`/akte/objekt/${o.id}`), })), [objektTreffer, navigate]