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 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,8 +17,9 @@ 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:
|
||||
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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [verwendungObjekte, setVerwendungObjekte] = useState<Objekt[]>([]);
|
||||
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<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) {
|
||||
return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
@@ -84,14 +123,47 @@ export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Pro
|
||||
{angezeigteFaecher.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{angezeigteFaecher.map((f) => (
|
||||
<li key={f.id} className="row-between" style={{ padding: "0.4rem 0" }}>
|
||||
<span>
|
||||
{f.name}
|
||||
<li key={f.id} style={{ padding: "0.4rem 0" }}>
|
||||
<div className="row-between">
|
||||
<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>}
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user