feat(material): Material-Dubletten zusammenführen
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:
@@ -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=[])
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -28,6 +28,48 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
|
||||
const [wirdGespeichert, setWirdGespeichert] = useState(false);
|
||||
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) {
|
||||
setBearbeiteteId(material.id);
|
||||
setFormular({
|
||||
@@ -149,7 +191,8 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
|
||||
{gefiltert.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{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>
|
||||
{m.name} <span className="text-muted">({m.einheit})</span>
|
||||
{m.artikelnummer && <span className="text-muted"> · {m.artikelnummer}</span>}
|
||||
@@ -159,9 +202,59 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="row" style={{ gap: "0.4rem" }}>
|
||||
<button className="btn btn-secondary" onClick={() => bearbeiten(m)}>
|
||||
Bearbeiten
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user