From 5f568c365c580fe7590586e30f13491adde876a2 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 7 Sep 2026 00:18:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(fach):=20Duplikate=20case-insensitiv=20ver?= =?UTF-8?q?hindern,=20Verwendung=20anzeigen=20und=20ersetzen/zusammenf?= =?UTF-8?q?=C3=BChren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlegen prüft jetzt case-insensitiv auf Duplikate (409 statt "Fach A" neben "fach a"). Neuer Endpunkt GET /faecher/{id}/objekte zeigt, welche Objekte ein Fach über ihre Vorlage tatsächlich nutzen. POST /faecher/{id}/ersetzen benennt um und zieht alle Vorlagenpositionen mit; landet der neue Name auf einem bereits bestehenden Fach, werden beide zusammengeführt. Frontend: Fach anklicken öffnet Verwendung + Ersetzen-Eingabe direkt in der Fächer-Verwaltung. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- backend/app/api/v1/endpoints/stammdaten.py | 86 ++++++++++++++++- backend/app/schemas/stammdaten.py | 10 ++ backend/app/services/vorlagen.py | 7 +- backend/tests/test_stammdaten.py | 102 +++++++++++++++++++++ frontend/src/pages/admin/FachSection.tsx | 92 +++++++++++++++++-- 5 files changed, 283 insertions(+), 14 deletions(-) diff --git a/backend/app/api/v1/endpoints/stammdaten.py b/backend/app/api/v1/endpoints/stammdaten.py index c042fa5..1921624 100644 --- a/backend/app/api/v1/endpoints/stammdaten.py +++ b/backend/app/api/v1/endpoints/stammdaten.py @@ -1,18 +1,22 @@ from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, require_roles, require_roles_or_permission from app.db.session import get_db from app.models.auth import RolleTyp +from app.models.objekt import Objekt from app.models.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort +from app.models.vorlage import Beladungsvorlage, Vorlagenposition from app.schemas.stammdaten import ( BereichCreate, BereichRead, BereichUpdate, FachCreate, + FachErsetzen, FachRead, FachUpdate, + FachVerwendungObjekt, KategorieCreate, KategorieRead, KategorieUpdate, @@ -198,12 +202,92 @@ async def erstelle_fach( db: AsyncSession = Depends(get_db), _=Depends(require_roles(RolleTyp.administration)), ) -> Fach: + vorhanden = await db.execute( + select(Fach).where( + Fach.objekttyp_id == payload.objekttyp_id, func.lower(Fach.name) == payload.name.strip().lower() + ) + ) + if vorhanden.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Fach existiert für diesen Objekttyp bereits (Groß-/Kleinschreibung ignoriert)", + ) fach = Fach(**payload.model_dump()) db.add(fach) await db.flush() return fach +@router.get("/faecher/{fach_id}/objekte", response_model=list[FachVerwendungObjekt]) +async def hole_fach_verwendung( + fach_id: int, db: AsyncSession = Depends(get_db), _=Depends(require_roles(RolleTyp.administration)) +) -> list[Objekt]: + """Nutzer-Vorgabe: vor dem Umbenennen/Zusammenführen sehen, welche Objekte + ein Fach tatsächlich verwenden (über die Vorlage, aus der sie entstanden sind).""" + fach = await db.get(Fach, fach_id) + if fach is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fach nicht gefunden") + + result = await db.execute( + select(Objekt) + .join(Beladungsvorlage, Objekt.vorlage_id == Beladungsvorlage.id) + .join(Vorlagenposition, Vorlagenposition.vorlage_id == Beladungsvorlage.id) + .where( + Beladungsvorlage.objekttyp_id == fach.objekttyp_id, + func.lower(Vorlagenposition.fach) == fach.name.strip().lower(), + ) + .distinct() + ) + return list(result.scalars().all()) + + +@router.post("/faecher/{fach_id}/ersetzen", response_model=FachRead) +async def ersetze_fach( + fach_id: int, + payload: FachErsetzen, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Fach: + """Benennt ein Fach um und zieht alle Vorlagenpositionen mit - landet der + neue Name auf einem bereits bestehenden Fach desselben Objekttyps, werden + beide zusammengeführt (dieses Fach verschwindet, das Ziel bleibt).""" + fach = await db.get(Fach, fach_id) + if fach is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fach nicht gefunden") + + neuer_name = payload.neuer_name.strip() + if not neuer_name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name darf nicht leer sein") + + positionen = await db.execute( + select(Vorlagenposition) + .join(Beladungsvorlage, Vorlagenposition.vorlage_id == Beladungsvorlage.id) + .where( + Beladungsvorlage.objekttyp_id == fach.objekttyp_id, + func.lower(Vorlagenposition.fach) == fach.name.strip().lower(), + ) + ) + for position in positionen.scalars().all(): + position.fach = neuer_name + + ziel_result = await db.execute( + select(Fach).where( + Fach.objekttyp_id == fach.objekttyp_id, + func.lower(Fach.name) == neuer_name.lower(), + Fach.id != fach.id, + ) + ) + ziel = ziel_result.scalar_one_or_none() + if ziel is not None: + await db.delete(fach) + await db.flush() + return ziel + + fach.name = neuer_name + await db.flush() + return fach + + @router.patch("/faecher/{fach_id}", response_model=FachRead) async def aendere_fach( fach_id: int, diff --git a/backend/app/schemas/stammdaten.py b/backend/app/schemas/stammdaten.py index 2d2d51b..f51af43 100644 --- a/backend/app/schemas/stammdaten.py +++ b/backend/app/schemas/stammdaten.py @@ -85,6 +85,16 @@ class FachUpdate(BaseModel): sortierung: int | None = None +class FachErsetzen(BaseModel): + neuer_name: str + + +class FachVerwendungObjekt(BaseModel): + id: int + name: str + code: str + + class MaterialCreate(BaseModel): name: str artikelnummer: str | None = None diff --git a/backend/app/services/vorlagen.py b/backend/app/services/vorlagen.py index f83eb24..e011206 100644 --- a/backend/app/services/vorlagen.py +++ b/backend/app/services/vorlagen.py @@ -17,9 +17,10 @@ async def _stelle_faecher_sicher( if not benoetigt: return result = await db.execute(select(Fach.name).where(Fach.objekttyp_id == objekttyp_id)) - vorhanden = set(result.scalars().all()) - for name in benoetigt - vorhanden: - db.add(Fach(objekttyp_id=objekttyp_id, name=name)) + vorhanden_klein = {name.strip().lower() for name in result.scalars().all()} + for name in benoetigt: + if name.strip().lower() not in vorhanden_klein: + db.add(Fach(objekttyp_id=objekttyp_id, name=name)) await db.flush() diff --git a/backend/tests/test_stammdaten.py b/backend/tests/test_stammdaten.py index 9e7a951..5daf6c8 100644 --- a/backend/tests/test_stammdaten.py +++ b/backend/tests/test_stammdaten.py @@ -1,5 +1,8 @@ import pytest +from sqlalchemy import select +from app.models.stammdaten import Fach +from app.models.vorlage import Vorlagenposition from tests.conftest import auth_header, login @@ -160,6 +163,105 @@ async def test_fach_lifecycle(client, admin_user): assert not any(f["id"] == fach_id for f in liste_nach_delete.json()) +@pytest.mark.asyncio +async def test_fach_duplikat_case_insensitiv_ist_409(client, admin_user): + token = await login(client, "admin1") + bereich = await client.post("/api/v1/bereiche", json={"name": "B-Dup"}, headers=auth_header(token)) + objekttyp = await client.post( + "/api/v1/objekttypen", + json={"bereich_id": bereich.json()["id"], "name": "Typ-Dup"}, + headers=auth_header(token), + ) + objekttyp_id = objekttyp.json()["id"] + + erster = await client.post( + "/api/v1/faecher", json={"objekttyp_id": objekttyp_id, "name": "Hauptfach"}, headers=auth_header(token) + ) + assert erster.status_code == 201 + + zweiter = await client.post( + "/api/v1/faecher", json={"objekttyp_id": objekttyp_id, "name": "hauptfach"}, headers=auth_header(token) + ) + assert zweiter.status_code == 409 + + +@pytest.mark.asyncio +async def test_fach_verwendung_und_ersetzen_fuehrt_zusammen( + client, db_session, admin_user, hauptserver_id, standort_factory +): + from app.schemas.vorlage import VorlagenpositionCreate + from app.services.objekte import objekt_aus_vorlage + from app.services.vorlagen import erstelle_vorlage + + token = await login(client, "admin1") + bereich = await client.post("/api/v1/bereiche", json={"name": "B-Merge"}, headers=auth_header(token)) + objekttyp = await client.post( + "/api/v1/objekttypen", + json={"bereich_id": bereich.json()["id"], "name": "Typ-Merge"}, + headers=auth_header(token), + ) + objekttyp_id = objekttyp.json()["id"] + material = await client.post( + "/api/v1/materialien", + json={"name": "Material-Merge", "einheit": "Stück", "materialtyp": "standard"}, + headers=auth_header(token), + ) + material_id = material.json()["id"] + + vorlage = await erstelle_vorlage( + db_session, + objekttyp_id=objekttyp_id, + name="Vorlage-Merge", + positionen=[VorlagenpositionCreate(material_id=material_id, sollmenge=1, fach="Reizverschlussfach gross")], + ) + standort = await standort_factory("Standort-Merge") + objekt = await objekt_aus_vorlage( + db_session, + code="MERGE-01", + name="Objekt-Merge", + standort_id=standort.id, + vorlage=vorlage, + zustaendiger_server_id=hauptserver_id, + ) + + ziel_fach = await client.post( + "/api/v1/faecher", + json={"objekttyp_id": objekttyp_id, "name": "Reizverschlussfach Gross"}, + headers=auth_header(token), + ) + quell_fach = await client.post( + "/api/v1/faecher", + json={"objekttyp_id": objekttyp_id, "name": "reizverschlussfach gross tmp"}, + headers=auth_header(token), + ) + # Feld manuell auf den echten (klein geschriebenen) Namen zurücksetzen, um + # die reale Dublette nachzustellen (Anlegen selbst blockt Duplikate ab). + quell = (await db_session.execute(select(Fach).where(Fach.id == quell_fach.json()["id"]))).scalar_one() + quell.name = "reizverschlussfach gross" + await db_session.flush() + + verwendung = await client.get(f"/api/v1/faecher/{quell.id}/objekte", headers=auth_header(token)) + assert verwendung.status_code == 200 + assert any(o["id"] == objekt.id for o in verwendung.json()) + + ersetzt = await client.post( + f"/api/v1/faecher/{quell.id}/ersetzen", + json={"neuer_name": "Reizverschlussfach Gross"}, + headers=auth_header(token), + ) + assert ersetzt.status_code == 200 + assert ersetzt.json()["id"] == ziel_fach.json()["id"] + + liste = await client.get("/api/v1/faecher", headers=auth_header(token)) + verbleibende = [f for f in liste.json() if f["objekttyp_id"] == objekttyp_id] + assert len(verbleibende) == 1 + + position = await db_session.execute( + select(Vorlagenposition).where(Vorlagenposition.vorlage_id == vorlage.id) + ) + assert position.scalar_one().fach == "Reizverschlussfach Gross" + + @pytest.mark.asyncio async def test_mitarbeiter_cannot_create_fach(client, mitarbeiter_user, admin_user, db_session): from app.models.stammdaten import Bereich, Objekttyp diff --git a/frontend/src/pages/admin/FachSection.tsx b/frontend/src/pages/admin/FachSection.tsx index 5d67a3d..85fba29 100644 --- a/frontend/src/pages/admin/FachSection.tsx +++ b/frontend/src/pages/admin/FachSection.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { apiRequest } from "../../api/client"; -import type { Fach, Objekttyp } from "../../api/types"; +import type { Fach, Objekt, Objekttyp } from "../../api/types"; interface Props { objekttypen: Objekttyp[]; @@ -18,6 +18,11 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro const [name, setName] = useState(""); const [wirdAngelegt, setWirdAngelegt] = useState(false); + const [offenesFachId, setOffenesFachId] = useState(null); + const [verwendungObjekte, setVerwendungObjekte] = useState([]); + const [ladeVerwendung, setLadeVerwendung] = useState(false); + const [ersetzenName, setErsetzenName] = useState(""); + async function anlegen() { if (objekttypId === "") { onFehler("Objekttyp auswählen."); @@ -29,7 +34,7 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro setName(""); await onGeaendert(); } catch { - onFehler("Fach konnte nicht angelegt werden."); + onFehler("Fach konnte nicht angelegt werden (evtl. existiert es schon, Groß-/Kleinschreibung egal)."); } finally { setWirdAngelegt(false); } @@ -44,6 +49,40 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro } } + async function fachOeffnen(fach: Fach) { + if (offenesFachId === fach.id) { + setOffenesFachId(null); + return; + } + setOffenesFachId(fach.id); + setErsetzenName(fach.name); + setLadeVerwendung(true); + try { + setVerwendungObjekte(await apiRequest(`/faecher/${fach.id}/objekte`)); + } catch { + onFehler("Verwendung konnte nicht geladen werden."); + } finally { + setLadeVerwendung(false); + } + } + + async function ersetzen(fach: Fach) { + if (!ersetzenName.trim()) { + onFehler("Neuen Namen angeben."); + return; + } + try { + await apiRequest(`/faecher/${fach.id}/ersetzen`, { + method: "POST", + body: { neuer_name: ersetzenName.trim() }, + }); + setOffenesFachId(null); + await onGeaendert(); + } catch { + onFehler("Fach konnte nicht ersetzt werden."); + } + } + function objekttypName(id: number) { return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`; } @@ -84,14 +123,47 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro {angezeigteFaecher.length > 0 && (
    {angezeigteFaecher.map((f) => ( -
  • - - {f.name} - {objekttypId === "" && ({objekttypName(f.objekttyp_id)})} - - +
  • +
    + + +
    + {offenesFachId === f.id && ( +
    + {ladeVerwendung &&

    Lade…

    } + {!ladeVerwendung && verwendungObjekte.length === 0 && ( +

    Kein Objekt verwendet dieses Fach.

    + )} + {!ladeVerwendung && verwendungObjekte.length > 0 && ( +
      + {verwendungObjekte.map((o) => ( +
    • + {o.name} ({o.code}) +
    • + ))} +
    + )} +
    + setErsetzenName(e.target.value)} + placeholder="Neuer Name (bestehender Fach-Name = Zusammenführen)" + /> + +
    +
    + )}
  • ))}