From 06baf03708c1093a5275139ca6bea245920a2b13 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 7 Sep 2026 00:23:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(material):=20Material-Dubletten=20zusammen?= =?UTF-8?q?f=C3=BChren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- backend/app/api/v1/endpoints/stammdaten.py | 44 +++++++ backend/app/schemas/stammdaten.py | 9 ++ backend/app/services/material_merge.py | 63 ++++++++++ backend/tests/test_stammdaten.py | 84 +++++++++++++ frontend/src/pages/admin/MaterialSection.tsx | 119 +++++++++++++++++-- 5 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 backend/app/services/material_merge.py diff --git a/backend/app/api/v1/endpoints/stammdaten.py b/backend/app/api/v1/endpoints/stammdaten.py index 1921624..2f8000d 100644 --- a/backend/app/api/v1/endpoints/stammdaten.py +++ b/backend/app/api/v1/endpoints/stammdaten.py @@ -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=[]) diff --git a/backend/app/schemas/stammdaten.py b/backend/app/schemas/stammdaten.py index f51af43..6bd974c 100644 --- a/backend/app/schemas/stammdaten.py +++ b/backend/app/schemas/stammdaten.py @@ -124,3 +124,12 @@ class MaterialUpdate(BaseModel): class MaterialRead(MaterialCreate): model_config = ConfigDict(from_attributes=True) id: int + + +class MaterialZusammenfuehren(BaseModel): + ziel_material_id: int + + +class MaterialZusammenfuehrenErgebnis(BaseModel): + erfolgreich: bool + konflikte: list[str] diff --git a/backend/app/services/material_merge.py b/backend/app/services/material_merge.py new file mode 100644 index 0000000..804a922 --- /dev/null +++ b/backend/app/services/material_merge.py @@ -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() diff --git a/backend/tests/test_stammdaten.py b/backend/tests/test_stammdaten.py index 5daf6c8..d326f83 100644 --- a/backend/tests/test_stammdaten.py +++ b/backend/tests/test_stammdaten.py @@ -65,6 +65,90 @@ async def test_material_not_found(client, admin_user): 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 async def test_admin_can_update_bereich(client, admin_user): token = await login(client, "admin1") diff --git a/frontend/src/pages/admin/MaterialSection.tsx b/frontend/src/pages/admin/MaterialSection.tsx index e87fef3..094c5ee 100644 --- a/frontend/src/pages/admin/MaterialSection.tsx +++ b/frontend/src/pages/admin/MaterialSection.tsx @@ -28,6 +28,48 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) { const [wirdGespeichert, setWirdGespeichert] = useState(false); const [suche, setSuche] = useState(""); + const [mergeQuelleId, setMergeQuelleId] = useState(null); + const [mergeVerwendung, setMergeVerwendung] = useState | null>(null); + const [mergeZielId, setMergeZielId] = useState(""); + const [mergeKonflikte, setMergeKonflikte] = useState([]); + + async function zusammenfuehrenOeffnen(material: Material) { + if (mergeQuelleId === material.id) { + setMergeQuelleId(null); + return; + } + setMergeQuelleId(material.id); + setMergeZielId(""); + setMergeKonflikte([]); + setMergeVerwendung(null); + try { + setMergeVerwendung(await apiRequest>(`/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) { setBearbeiteteId(material.id); setFormular({ @@ -149,19 +191,70 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) { {gefiltert.length > 0 && (
    {gefiltert.map((m) => ( -
  • - - {m.name} ({m.einheit}) - {m.artikelnummer && · {m.artikelnummer}} - {m.materialtyp !== "standard" && ( - - {m.materialtyp} - - )} - - +
  • +
    + + {m.name} ({m.einheit}) + {m.artikelnummer && · {m.artikelnummer}} + {m.materialtyp !== "standard" && ( + + {m.materialtyp} + + )} + + + + + +
    + {mergeQuelleId === m.id && ( +
    + {mergeVerwendung === null ? ( +

    Lade Verwendung…

    + ) : ( +
      + {Object.entries(mergeVerwendung).map(([tabelle, anzahl]) => ( +
    • + {tabelle}: {anzahl} +
    • + ))} +
    + )} + {mergeKonflikte.length > 0 && ( +
    + Konflikt(e), Zusammenführen abgebrochen: +
      + {mergeKonflikte.map((k) => ( +
    • {k}
    • + ))} +
    +
    + )} +
    + + +
    +
    + )}
  • ))}