feat(material): Material-Dubletten zusammenführen
CI / backend-tests (push) Failing after 2m6s
CI / frontend-build (push) Successful in 17s

Verwendung je Material einsehbar (Objektposition/Vorlagenposition/
Bestand/Kontrollposition/Fehlbestand/Ausgabe/Nachfüllung/
Materialbewegung), POST /materialien/{id}/ersetzen hängt alle
Referenzen auf ein Ziel-Material um und löscht die Quelle. Bricht bei
Konflikten ab (z.B. dasselbe Objekt hätte danach zwei Positionen für
dasselbe Material) statt Daten stillschweigend zu verlieren. Admin-UI:
"Zusammenführen" je Material zeigt Verwendung + Ziel-Auswahl.

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:23:35 +02:00
co-authored by Claude Sonnet 5
parent 5f568c365c
commit 06baf03708
5 changed files with 306 additions and 13 deletions
@@ -8,6 +8,7 @@ from app.models.auth import RolleTyp
from app.models.objekt import Objekt 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.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
from app.schemas.stammdaten import ( from app.schemas.stammdaten import (
BereichCreate, BereichCreate,
BereichRead, BereichRead,
@@ -23,6 +24,8 @@ from app.schemas.stammdaten import (
MaterialCreate, MaterialCreate,
MaterialRead, MaterialRead,
MaterialUpdate, MaterialUpdate,
MaterialZusammenfuehren,
MaterialZusammenfuehrenErgebnis,
ObjekttypCreate, ObjekttypCreate,
ObjekttypRead, ObjekttypRead,
ObjekttypUpdate, ObjekttypUpdate,
@@ -365,3 +368,44 @@ async def aendere_material(
setattr(material, feld, wert) setattr(material, feld, wert)
await db.flush() await db.flush()
return material return material
@router.get("/materialien/{material_id}/verwendung", response_model=dict[str, int])
async def hole_material_verwendung(
material_id: int,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> dict[str, int]:
if await db.get(Material, material_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Material nicht gefunden")
return await hole_verwendung(db, material_id)
@router.post("/materialien/{material_id}/ersetzen", response_model=MaterialZusammenfuehrenErgebnis)
async def ersetze_material(
material_id: int,
payload: MaterialZusammenfuehren,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> MaterialZusammenfuehrenErgebnis:
"""Führt zwei Material-Datensätze zusammen (z. B. Import-Dubletten):
quelle_id (aus der URL) wird gelöscht, alle Referenzen zeigen danach auf
ziel_material_id. Bricht bei Konflikten ab (z. B. dasselbe Objekt hätte
danach zwei Positionen für dasselbe Material) statt Daten zu verlieren."""
quelle = await db.get(Material, material_id)
if quelle is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Material nicht gefunden")
if payload.ziel_material_id == material_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Quelle und Ziel sind identisch")
ziel = await db.get(Material, payload.ziel_material_id)
if ziel is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ziel-Material nicht gefunden")
konflikte = await finde_konflikte(db, quelle_id=material_id, ziel_id=payload.ziel_material_id)
if konflikte:
return MaterialZusammenfuehrenErgebnis(erfolgreich=False, konflikte=konflikte)
await fuehre_zusammen(db, quelle_id=material_id, ziel_id=payload.ziel_material_id)
await db.delete(quelle)
await db.flush()
return MaterialZusammenfuehrenErgebnis(erfolgreich=True, konflikte=[])
+9
View File
@@ -124,3 +124,12 @@ class MaterialUpdate(BaseModel):
class MaterialRead(MaterialCreate): class MaterialRead(MaterialCreate):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
class MaterialZusammenfuehren(BaseModel):
ziel_material_id: int
class MaterialZusammenfuehrenErgebnis(BaseModel):
erfolgreich: bool
konflikte: list[str]
+63
View File
@@ -0,0 +1,63 @@
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ausgabe import Ausgabe
from app.models.fehlbestand import Fehlbestand
from app.models.kontrolle import Kontrollposition
from app.models.lager import Bestand, Materialbewegung
from app.models.nachfuellung import Nachfuellung
from app.models.objektposition import Objektposition
from app.models.vorlage import Vorlagenposition
# (Modell, Gruppierungs-Spalte für den Konflikt-Check, Anzeige-Name)
_KONFLIKT_TABELLEN = [
(Objektposition, Objektposition.objekt_id, "Objektposition"),
(Vorlagenposition, Vorlagenposition.vorlage_id, "Vorlagenposition"),
(Bestand, Bestand.lagerplatz_id, "Bestand"),
(Kontrollposition, Kontrollposition.kontrolle_id, "Kontrollposition"),
]
# Reine Protokoll-/Bewegungstabellen ohne Eindeutigkeits-Constraint auf
# material_id - können immer gefahrlos umgehängt werden.
_SICHERE_TABELLEN = [Fehlbestand, Ausgabe, Nachfuellung, Materialbewegung]
async def hole_verwendung(db: AsyncSession, material_id: int) -> dict[str, int]:
verwendung = {}
for model, _, name in _KONFLIKT_TABELLEN:
result = await db.execute(select(func.count()).select_from(model).where(model.material_id == material_id))
verwendung[name] = result.scalar_one()
for model in _SICHERE_TABELLEN:
result = await db.execute(select(func.count()).select_from(model).where(model.material_id == material_id))
verwendung[model.__tablename__] = result.scalar_one()
return verwendung
async def finde_konflikte(db: AsyncSession, *, quelle_id: int, ziel_id: int) -> list[str]:
"""Für Tabellen mit UNIQUE(gruppen_spalte, material_id) darf nach dem
Umhängen nicht dieselbe Kombination doppelt entstehen - z. B. wenn ein
Objekt schon eine Position mit dem Ziel-Material hat UND eine mit dem
Quell-Material."""
konflikte: list[str] = []
for model, gruppen_spalte, name in _KONFLIKT_TABELLEN:
quelle_gruppen = (
(await db.execute(select(gruppen_spalte).where(model.material_id == quelle_id))).scalars().all()
)
if not quelle_gruppen:
continue
ziel_gruppen = set(
(await db.execute(select(gruppen_spalte).where(model.material_id == ziel_id))).scalars().all()
)
ueberschneidung = set(quelle_gruppen) & ziel_gruppen
if ueberschneidung:
konflikte.append(f"{name}: {len(ueberschneidung)} Konflikt(e) - beide Materialien dort gleichzeitig vorhanden")
return konflikte
async def fuehre_zusammen(db: AsyncSession, *, quelle_id: int, ziel_id: int) -> None:
"""Voraussetzung: finde_konflikte() wurde vorher aufgerufen und war leer -
der Aufrufer (Endpunkt) verweigert den Merge sonst."""
for model, _, _ in _KONFLIKT_TABELLEN:
await db.execute(update(model).where(model.material_id == quelle_id).values(material_id=ziel_id))
for model in _SICHERE_TABELLEN:
await db.execute(update(model).where(model.material_id == quelle_id).values(material_id=ziel_id))
await db.flush()
+84
View File
@@ -65,6 +65,90 @@ async def test_material_not_found(client, admin_user):
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio
async def test_material_zusammenfuehren_ohne_konflikt(client, db_session, admin_user, objekt_mit_position):
"""Import-Dublette: quelle_id verschwindet, Referenzen zeigen auf ziel_material_id."""
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from datetime import datetime, timezone
objekt, material_ziel = objekt_mit_position
token = await login(client, "admin1")
quelle = await client.post(
"/api/v1/materialien",
json={"name": "Kompresse (Dublette)", "einheit": "Stück", "materialtyp": "standard"},
headers=auth_header(token),
)
quelle_id = quelle.json()["id"]
db_session.add(
Fehlbestand(
erzeugt_von_server_id=objekt.zustaendiger_server_id,
objekt_id=objekt.id,
material_id=quelle_id,
standort_id=objekt.standort_id,
sollmenge=5,
istmenge=0,
fehlmenge=5,
entstanden_am=datetime.now(timezone.utc),
festgestellt_von=admin_user.id,
status=FehlbestandStatus.offen,
)
)
await db_session.flush()
verwendung = await client.get(f"/api/v1/materialien/{quelle_id}/verwendung", headers=auth_header(token))
assert verwendung.status_code == 200
assert verwendung.json()["fehlbestand"] == 1
ergebnis = await client.post(
f"/api/v1/materialien/{quelle_id}/ersetzen",
json={"ziel_material_id": material_ziel.id},
headers=auth_header(token),
)
assert ergebnis.status_code == 200
assert ergebnis.json()["erfolgreich"] is True
assert ergebnis.json()["konflikte"] == []
geloescht = await client.get(f"/api/v1/materialien/{quelle_id}", headers=auth_header(token))
assert geloescht.status_code == 404
fb = await db_session.execute(
select(Fehlbestand).where(Fehlbestand.objekt_id == objekt.id, Fehlbestand.sollmenge == 5)
)
assert fb.scalar_one().material_id == material_ziel.id
@pytest.mark.asyncio
async def test_material_zusammenfuehren_mit_konflikt_wird_abgelehnt(
client, db_session, admin_user, objekt_mit_position, material_factory
):
"""Beide Materialien haben eine Position am selben Objekt - Merge würde die
UNIQUE(objekt_id, material_id)-Regel verletzen, muss also abgelehnt werden."""
from app.models.objektposition import Objektposition
objekt, material_ziel = objekt_mit_position
token = await login(client, "admin1")
material_quelle = await material_factory("Kompresse (Konflikt)")
db_session.add(
Objektposition(objekt_id=objekt.id, material_id=material_quelle.id, sollmenge_vorlage=1, istmenge=0)
)
await db_session.flush()
ergebnis = await client.post(
f"/api/v1/materialien/{material_quelle.id}/ersetzen",
json={"ziel_material_id": material_ziel.id},
headers=auth_header(token),
)
assert ergebnis.status_code == 200
assert ergebnis.json()["erfolgreich"] is False
assert len(ergebnis.json()["konflikte"]) == 1
weiterhin_da = await client.get(f"/api/v1/materialien/{material_quelle.id}", headers=auth_header(token))
assert weiterhin_da.status_code == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_admin_can_update_bereich(client, admin_user): async def test_admin_can_update_bereich(client, admin_user):
token = await login(client, "admin1") token = await login(client, "admin1")
+94 -1
View File
@@ -28,6 +28,48 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
const [wirdGespeichert, setWirdGespeichert] = useState(false); const [wirdGespeichert, setWirdGespeichert] = useState(false);
const [suche, setSuche] = useState(""); const [suche, setSuche] = useState("");
const [mergeQuelleId, setMergeQuelleId] = useState<number | null>(null);
const [mergeVerwendung, setMergeVerwendung] = useState<Record<string, number> | null>(null);
const [mergeZielId, setMergeZielId] = useState<number | "">("");
const [mergeKonflikte, setMergeKonflikte] = useState<string[]>([]);
async function zusammenfuehrenOeffnen(material: Material) {
if (mergeQuelleId === material.id) {
setMergeQuelleId(null);
return;
}
setMergeQuelleId(material.id);
setMergeZielId("");
setMergeKonflikte([]);
setMergeVerwendung(null);
try {
setMergeVerwendung(await apiRequest<Record<string, number>>(`/materialien/${material.id}/verwendung`));
} catch {
onFehler("Verwendung konnte nicht geladen werden.");
}
}
async function zusammenfuehren() {
if (mergeQuelleId === null || mergeZielId === "") {
onFehler("Ziel-Material auswählen.");
return;
}
try {
const ergebnis = await apiRequest<{ erfolgreich: boolean; konflikte: string[] }>(
`/materialien/${mergeQuelleId}/ersetzen`,
{ method: "POST", body: { ziel_material_id: mergeZielId } }
);
if (!ergebnis.erfolgreich) {
setMergeKonflikte(ergebnis.konflikte);
return;
}
setMergeQuelleId(null);
await onGeaendert();
} catch {
onFehler("Zusammenführen fehlgeschlagen.");
}
}
function bearbeiten(material: Material) { function bearbeiten(material: Material) {
setBearbeiteteId(material.id); setBearbeiteteId(material.id);
setFormular({ setFormular({
@@ -149,7 +191,8 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
{gefiltert.length > 0 && ( {gefiltert.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{gefiltert.map((m) => ( {gefiltert.map((m) => (
<li key={m.id} className="card row-between" style={{ padding: "0.6rem 1rem" }}> <li key={m.id} className="card" style={{ padding: "0.6rem 1rem" }}>
<div className="row-between">
<span> <span>
{m.name} <span className="text-muted">({m.einheit})</span> {m.name} <span className="text-muted">({m.einheit})</span>
{m.artikelnummer && <span className="text-muted"> · {m.artikelnummer}</span>} {m.artikelnummer && <span className="text-muted"> · {m.artikelnummer}</span>}
@@ -159,9 +202,59 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
</span> </span>
)} )}
</span> </span>
<span className="row" style={{ gap: "0.4rem" }}>
<button className="btn btn-secondary" onClick={() => bearbeiten(m)}> <button className="btn btn-secondary" onClick={() => bearbeiten(m)}>
Bearbeiten Bearbeiten
</button> </button>
<button className="btn btn-secondary" onClick={() => zusammenfuehrenOeffnen(m)}>
Zusammenführen
</button>
</span>
</div>
{mergeQuelleId === m.id && (
<div className="card" style={{ marginTop: "0.5rem", background: "var(--color-surface-sunken)" }}>
{mergeVerwendung === null ? (
<p className="text-muted">Lade Verwendung</p>
) : (
<ul>
{Object.entries(mergeVerwendung).map(([tabelle, anzahl]) => (
<li key={tabelle}>
{tabelle}: {anzahl}
</li>
))}
</ul>
)}
{mergeKonflikte.length > 0 && (
<div className="alert alert-danger">
Konflikt(e), Zusammenführen abgebrochen:
<ul>
{mergeKonflikte.map((k) => (
<li key={k}>{k}</li>
))}
</ul>
</div>
)}
<div className="row" style={{ marginTop: "0.5rem" }}>
<select
className="input"
value={mergeZielId}
onChange={(e) => setMergeZielId(Number(e.target.value) || "")}
>
<option value="">Ziel-Material wählen</option>
{materialien
.filter((z) => z.id !== m.id)
.map((z) => (
<option key={z.id} value={z.id}>
{z.name}
</option>
))}
</select>
<button className="btn btn-danger" onClick={zusammenfuehren}>
{m.name} löschen, alles auf Ziel umhängen
</button>
</div>
</div>
)}
</li> </li>
))} ))}
</ul> </ul>