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.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort
from app.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
from app.schemas.stammdaten import (
BereichCreate,
BereichRead,
@@ -23,6 +24,8 @@ from app.schemas.stammdaten import (
MaterialCreate,
MaterialRead,
MaterialUpdate,
MaterialZusammenfuehren,
MaterialZusammenfuehrenErgebnis,
ObjekttypCreate,
ObjekttypRead,
ObjekttypUpdate,
@@ -365,3 +368,44 @@ async def aendere_material(
setattr(material, feld, wert)
await db.flush()
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=[])