feat(fach): Duplikate case-insensitiv verhindern, Verwendung anzeigen und ersetzen/zusammenführen
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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ 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:
|
||||||
|
if name.strip().lower() not in vorhanden_klein:
|
||||||
db.add(Fach(objekttyp_id=objekttyp_id, name=name))
|
db.add(Fach(objekttyp_id=objekttyp_id, name=name))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
onClick={() => fachOeffnen(f)}
|
||||||
|
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }}
|
||||||
|
>
|
||||||
|
<span style={{ textDecoration: "underline" }}>{f.name}</span>
|
||||||
{objekttypId === "" && <span className="text-muted"> ({objekttypName(f.objekttyp_id)})</span>}
|
{objekttypId === "" && <span className="text-muted"> ({objekttypName(f.objekttyp_id)})</span>}
|
||||||
</span>
|
</button>
|
||||||
<button className="btn btn-danger" onClick={() => loeschen(f.id)}>
|
<button className="btn btn-danger" onClick={() => loeschen(f.id)}>
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user