feat(fach): Duplikate case-insensitiv verhindern, Verwendung anzeigen und ersetzen/zusammenführen
CI / backend-tests (push) Failing after 2m7s
CI / frontend-build (push) Successful in 18s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-07 00:18:04 +02:00
co-authored by Claude Sonnet 5
parent 8e89755385
commit 5f568c365c
5 changed files with 283 additions and 14 deletions
+85 -1
View File
@@ -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,
+10
View File
@@ -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
+4 -3
View File
@@ -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()
+102
View File
@@ -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