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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles, require_roles_or_permission from app.api.deps import get_current_user, require_roles, require_roles_or_permission
from app.db.session import get_db from app.db.session import get_db
from app.models.auth import RolleTyp 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.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort
from app.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.schemas.stammdaten import ( from app.schemas.stammdaten import (
BereichCreate, BereichCreate,
BereichRead, BereichRead,
BereichUpdate, BereichUpdate,
FachCreate, FachCreate,
FachErsetzen,
FachRead, FachRead,
FachUpdate, FachUpdate,
FachVerwendungObjekt,
KategorieCreate, KategorieCreate,
KategorieRead, KategorieRead,
KategorieUpdate, KategorieUpdate,
@@ -198,12 +202,92 @@ async def erstelle_fach(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)), _=Depends(require_roles(RolleTyp.administration)),
) -> Fach: ) -> 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()) fach = Fach(**payload.model_dump())
db.add(fach) db.add(fach)
await db.flush() await db.flush()
return fach 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) @router.patch("/faecher/{fach_id}", response_model=FachRead)
async def aendere_fach( async def aendere_fach(
fach_id: int, fach_id: int,
+10
View File
@@ -85,6 +85,16 @@ class FachUpdate(BaseModel):
sortierung: int | None = None sortierung: int | None = None
class FachErsetzen(BaseModel):
neuer_name: str
class FachVerwendungObjekt(BaseModel):
id: int
name: str
code: str
class MaterialCreate(BaseModel): class MaterialCreate(BaseModel):
name: str name: str
artikelnummer: str | None = None artikelnummer: str | None = None
+4 -3
View File
@@ -17,9 +17,10 @@ async def _stelle_faecher_sicher(
if not benoetigt: if not benoetigt:
return return
result = await db.execute(select(Fach.name).where(Fach.objekttyp_id == objekttyp_id)) result = await db.execute(select(Fach.name).where(Fach.objekttyp_id == objekttyp_id))
vorhanden = set(result.scalars().all()) vorhanden_klein = {name.strip().lower() for name in result.scalars().all()}
for name in benoetigt - vorhanden: for name in benoetigt:
db.add(Fach(objekttyp_id=objekttyp_id, name=name)) if name.strip().lower() not in vorhanden_klein:
db.add(Fach(objekttyp_id=objekttyp_id, name=name))
await db.flush() await db.flush()
+102
View File
@@ -1,5 +1,8 @@
import pytest 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 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()) 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 @pytest.mark.asyncio
async def test_mitarbeiter_cannot_create_fach(client, mitarbeiter_user, admin_user, db_session): async def test_mitarbeiter_cannot_create_fach(client, mitarbeiter_user, admin_user, db_session):
from app.models.stammdaten import Bereich, Objekttyp from app.models.stammdaten import Bereich, Objekttyp
+82 -10
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { apiRequest } from "../../api/client"; import { apiRequest } from "../../api/client";
import type { Fach, Objekttyp } from "../../api/types"; import type { Fach, Objekt, Objekttyp } from "../../api/types";
interface Props { interface Props {
objekttypen: Objekttyp[]; objekttypen: Objekttyp[];
@@ -18,6 +18,11 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro
const [name, setName] = useState(""); const [name, setName] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false); const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [offenesFachId, setOffenesFachId] = useState<number | null>(null);
const [verwendungObjekte, setVerwendungObjekte] = useState<Objekt[]>([]);
const [ladeVerwendung, setLadeVerwendung] = useState(false);
const [ersetzenName, setErsetzenName] = useState("");
async function anlegen() { async function anlegen() {
if (objekttypId === "") { if (objekttypId === "") {
onFehler("Objekttyp auswählen."); onFehler("Objekttyp auswählen.");
@@ -29,7 +34,7 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro
setName(""); setName("");
await onGeaendert(); await onGeaendert();
} catch { } catch {
onFehler("Fach konnte nicht angelegt werden."); onFehler("Fach konnte nicht angelegt werden (evtl. existiert es schon, Groß-/Kleinschreibung egal).");
} finally { } finally {
setWirdAngelegt(false); 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<Objekt[]>(`/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) { function objekttypName(id: number) {
return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`; 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.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{angezeigteFaecher.map((f) => ( {angezeigteFaecher.map((f) => (
<li key={f.id} className="row-between" style={{ padding: "0.4rem 0" }}> <li key={f.id} style={{ padding: "0.4rem 0" }}>
<span> <div className="row-between">
{f.name} <button
{objekttypId === "" && <span className="text-muted"> ({objekttypName(f.objekttyp_id)})</span>} onClick={() => fachOeffnen(f)}
</span> style={{ background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }}
<button className="btn btn-danger" onClick={() => loeschen(f.id)}> >
Löschen <span style={{ textDecoration: "underline" }}>{f.name}</span>
</button> {objekttypId === "" && <span className="text-muted"> ({objekttypName(f.objekttyp_id)})</span>}
</button>
<button className="btn btn-danger" onClick={() => loeschen(f.id)}>
Löschen
</button>
</div>
{offenesFachId === f.id && (
<div className="card" style={{ marginTop: "0.5rem", background: "var(--color-surface-sunken)" }}>
{ladeVerwendung && <p className="text-muted">Lade</p>}
{!ladeVerwendung && verwendungObjekte.length === 0 && (
<p className="text-muted">Kein Objekt verwendet dieses Fach.</p>
)}
{!ladeVerwendung && verwendungObjekte.length > 0 && (
<ul>
{verwendungObjekte.map((o) => (
<li key={o.id}>
{o.name} ({o.code})
</li>
))}
</ul>
)}
<div className="row" style={{ marginTop: "0.5rem" }}>
<input
className="input"
value={ersetzenName}
onChange={(e) => setErsetzenName(e.target.value)}
placeholder="Neuer Name (bestehender Fach-Name = Zusammenführen)"
/>
<button className="btn btn-secondary" onClick={() => ersetzen(f)}>
Ersetzen
</button>
</div>
</div>
)}
</li> </li>
))} ))}
</ul> </ul>