diff --git a/DEVLOG.md b/DEVLOG.md index 4f18ecc..30f9951 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -4065,3 +4065,25 @@ Keine Commits in dieser Session. - frontend/src/pages/MangelListePage.tsx | 4 ++-- --- +## 2026-09-05 13:43 – 13:46 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 417df2b feat(lager): Lagerbewegung-Protokoll (Roadmap Phase 4, Modul Lager) + +### Geänderte Dateien +- DEVLOG.md | 14 ++++++++++ +- backend/alembic/versions/0015_lagerbewegung.py | 40 +++++++++++++++++++++++++++ +- backend/app/api/v1/api.py | 2 ++ +- backend/app/api/v1/endpoints/lagerbewegung.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/models/lagerbewegung.py | 24 ++++++++++++++++ +- backend/app/schemas/lagerbewegung.py | 20 ++++++++++++++ +- backend/app/services/lagerbewegung.py | 26 ++++++++++++++++++ +- backend/tests/test_lagerbewegung.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- frontend/src/api/types.ts | 10 +++++++ +- frontend/src/pages/AdminPage.tsx | 12 +++++++- +- frontend/src/pages/admin/LagerbewegungSection.tsx | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- frontend/src/pages/admin/ObjektSection.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/api/v1/endpoints/mangel.py b/backend/app/api/v1/endpoints/mangel.py index 02d0366..555c3c1 100644 --- a/backend/app/api/v1/endpoints/mangel.py +++ b/backend/app/api/v1/endpoints/mangel.py @@ -7,11 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_roles from app.db.session import get_db -from app.models.auth import RolleTyp +from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.mangel import Mangel, MangelStatus from app.models.objekt import Objekt from app.schemas.mangel import MangelCreate, MangelRead, MangelStatusUpdate -from app.services.mangel import erledige_mangel +from app.services import historie as historie_service +from app.services.mangel import aendere_status router = APIRouter() @@ -26,6 +27,11 @@ _materialverantwortliche = require_roles( ) +async def _hauptserver_id(db: AsyncSession) -> int: + result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + return result.scalar_one() + + @router.get("/maengel", response_model=list[MangelRead]) async def liste_maengel( objekt_id: int | None = None, @@ -65,6 +71,16 @@ async def melde_mangel( ) db.add(mangel) await db.flush() + + await historie_service.log( + db, + zustaendiger_server_id=await _hauptserver_id(db), + benutzer_id=current_user.id, + ereignistyp="mangel_gemeldet", + entitaet_typ="mangel", + entitaet_id=mangel.id, + neuer_wert={"beschreibung": mangel.beschreibung, "prioritaet": mangel.prioritaet.value}, + ) return mangel @@ -79,9 +95,10 @@ async def aendere_mangel_status( if mangel is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mangel nicht gefunden") - if payload.status == MangelStatus.erledigt: - return await erledige_mangel(db, mangel=mangel, benutzer_id=current_user.id) - - mangel.status = payload.status - await db.flush() - return mangel + return await aendere_status( + db, + mangel=mangel, + status=payload.status, + benutzer_id=current_user.id, + zustaendiger_server_id=await _hauptserver_id(db), + ) diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index ad05808..9d403d5 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -13,6 +13,7 @@ from app.models.objekt import Objekt from app.models.objektposition import Objektposition from app.models.stammdaten import Material, Objekttyp from app.models.vorlage import Beladungsvorlage +from app.services import historie as historie_service from app.services.label import generiere_code128_label_pdf from app.schemas.objekt import ( FahrzeugdetailsRead, @@ -256,7 +257,10 @@ async def erstelle_objekt( @router.patch("/objekte/{objekt_id}", response_model=ObjektRead) async def aendere_objekt( - objekt_id: int, payload: ObjektUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) + objekt_id: int, + payload: ObjektUpdate, + db: AsyncSession = Depends(get_db), + current_user=Depends(_admin_only), ) -> ObjektRead: """Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und @@ -276,12 +280,29 @@ async def aendere_objekt( detail="Zyklus: Zielobjekt ist bereits diesem Objekt zugeordnet", ) + alte_werte = {feld: getattr(objekt, feld) for feld in daten} for feld, wert in daten.items(): setattr(objekt, feld, wert) try: await db.flush() except IntegrityError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc + + if daten: + # Historie-Lücke (Roadmap-Review): Status-/Fahrzeug-Zuordnungsänderungen + # waren bisher nicht protokolliert, anders als z.B. Geräteinstanz-Status. + hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + await historie_service.log( + db, + zustaendiger_server_id=hauptserver.scalar_one(), + benutzer_id=current_user.id, + ereignistyp="objekt_geaendert", + entitaet_typ="objekt", + entitaet_id=objekt.id, + alter_wert={k: (v.value if hasattr(v, "value") else v) for k, v in alte_werte.items()}, + neuer_wert={k: (v.value if hasattr(v, "value") else v) for k, v in daten.items()}, + ) + letzte_map = await letzte_kontrollen_map(db, [objekt.id]) return _objekt_read(objekt, letzte_map.get(objekt.id)) diff --git a/backend/app/api/v1/endpoints/personal.py b/backend/app/api/v1/endpoints/personal.py index 0feec58..d748ad6 100644 --- a/backend/app/api/v1/endpoints/personal.py +++ b/backend/app/api/v1/endpoints/personal.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, require_roles from app.db.session import get_db -from app.models.auth import RolleTyp +from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.personal import ( BenutzerQualifikation, Einheit, @@ -25,10 +25,16 @@ from app.schemas.personal import ( QualifikationstypRead, QualifikationstypUpdate, ) +from app.services import historie as historie_service from app.services.personal import pruefe_berechtigung router = APIRouter() + +async def _hauptserver_id(db: AsyncSession) -> int: + result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + return result.scalar_one() + _admin_only = require_roles(RolleTyp.administration) @@ -126,7 +132,7 @@ async def erfasse_benutzer_qualifikation( benutzer_id: int, payload: BenutzerQualifikationCreate, db: AsyncSession = Depends(get_db), - _=Depends(_admin_only), + current_user=Depends(_admin_only), ) -> BenutzerQualifikation: if payload.benutzer_id != benutzer_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="benutzer_id im Pfad und Body weichen ab") @@ -138,6 +144,23 @@ async def erfasse_benutzer_qualifikation( raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Qualifikation mit diesem Erwerbsdatum existiert bereits" ) from exc + + # Historie-Lücke (Roadmap-Review): wer hat wem wann eine Qualifikation + # bestätigt, ist sicherheitsrelevant ("wer darf fahren" hängt fachlich + # direkt daran) und war bisher nirgends protokolliert. + await historie_service.log( + db, + zustaendiger_server_id=await _hauptserver_id(db), + benutzer_id=current_user.id, + ereignistyp="qualifikation_erfasst", + entitaet_typ="benutzer_qualifikation", + entitaet_id=qualifikation.id, + neuer_wert={ + "benutzer_id": benutzer_id, + "qualifikationstyp_id": payload.qualifikationstyp_id, + "gueltig_bis": payload.gueltig_bis.isoformat() if payload.gueltig_bis else None, + }, + ) return qualifikation diff --git a/backend/app/services/mangel.py b/backend/app/services/mangel.py index d610526..161bd95 100644 --- a/backend/app/services/mangel.py +++ b/backend/app/services/mangel.py @@ -4,6 +4,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus +from app.services import historie as historie_service OFFENE_MANGEL_STATI = ( MangelStatus.neu, @@ -25,9 +26,28 @@ async def objekt_ids_mit_kritischem_offenem_mangel(db: AsyncSession) -> set[int] return set(result.scalars().all()) -async def erledige_mangel(db: AsyncSession, *, mangel: Mangel, benutzer_id: int) -> Mangel: - mangel.status = MangelStatus.erledigt - mangel.erledigt_von = benutzer_id - mangel.erledigt_am = datetime.now(timezone.utc) +async def aendere_status( + db: AsyncSession, *, mangel: Mangel, status: MangelStatus, benutzer_id: int, zustaendiger_server_id: int +) -> Mangel: + """Historie-Lücke (Roadmap-Review): Status-Änderungen wurden bisher NICHT + protokolliert - Mangel hatte anders als Kontrolle/Fehlbestand/Geräteinstanz + kein Audit-Trail, obwohl "wer hat den Mangel auf erledigt gesetzt" fachlich + genauso relevant ist.""" + alter_status = mangel.status + mangel.status = status + if status == MangelStatus.erledigt: + mangel.erledigt_von = benutzer_id + mangel.erledigt_am = datetime.now(timezone.utc) await db.flush() + + await historie_service.log( + db, + zustaendiger_server_id=zustaendiger_server_id, + benutzer_id=benutzer_id, + ereignistyp="mangel_status_geaendert", + entitaet_typ="mangel", + entitaet_id=mangel.id, + alter_wert={"status": alter_status.value}, + neuer_wert={"status": status.value}, + ) return mangel diff --git a/backend/tests/test_fahrzeugdetails.py b/backend/tests/test_fahrzeugdetails.py index eb07d67..6b54a75 100644 --- a/backend/tests/test_fahrzeugdetails.py +++ b/backend/tests/test_fahrzeugdetails.py @@ -97,3 +97,18 @@ async def test_ueberfaellige_hu_macht_objekt_nicht_einsatzbereit_und_erscheint_i assert len(body) == 1 assert body[0]["typ"] == "fahrzeug_hu" assert body[0]["status"] == "abgelaufen" + + +@pytest.mark.asyncio +async def test_objekt_status_aenderung_steht_in_historie(client, objekt_mit_position, admin_user): + objekt, _material = objekt_mit_position + token = await login(client, "admin1") + await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "in_wartung"}, headers=auth_header(token)) + + historie = await client.get( + f"/api/v1/historie?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token) + ) + body = historie.json() + assert len(body) == 1 + assert body[0]["ereignistyp"] == "objekt_geaendert" + assert body[0]["neuer_wert"]["status"] == "in_wartung" diff --git a/backend/tests/test_mangel.py b/backend/tests/test_mangel.py index d807928..910d25c 100644 --- a/backend/tests/test_mangel.py +++ b/backend/tests/test_mangel.py @@ -143,3 +143,29 @@ async def test_leitungsverantwortlicher_kann_status_aendern(client, objekt_mit_p ) assert response.status_code == 200 assert response.json()["status"] == "erledigt" + + +@pytest.mark.asyncio +async def test_mangel_meldung_und_status_aenderung_stehen_in_historie( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + objekt, _material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + gemeldet = await client.post( + "/api/v1/maengel", + json={"objekt_id": objekt.id, "beschreibung": "Funkgerät ausgefallen"}, + headers=auth_header(mitarbeiter_token), + ) + mangel_id = gemeldet.json()["id"] + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + await client.patch( + f"/api/v1/maengel/{mangel_id}", json={"status": "in_bearbeitung"}, headers=auth_header(verantwortlicher_token) + ) + + historie = await client.get( + f"/api/v1/historie?entitaet_typ=mangel&entitaet_id={mangel_id}", headers=auth_header(verantwortlicher_token) + ) + ereignistypen = [e["ereignistyp"] for e in historie.json()] + assert "mangel_gemeldet" in ereignistypen + assert "mangel_status_geaendert" in ereignistypen diff --git a/backend/tests/test_personal.py b/backend/tests/test_personal.py index 59d8910..b33900a 100644 --- a/backend/tests/test_personal.py +++ b/backend/tests/test_personal.py @@ -196,3 +196,35 @@ async def test_unbefristete_qualifikation_erscheint_nicht_im_dashboard(client, a response = await client.get("/api/v1/dashboard/qualifikationsablaeufe", headers=auth_header(token)) assert response.json() == [] + + +@pytest.mark.asyncio +async def test_qualifikation_erfassen_steht_in_historie(client, admin_user, mitarbeiter_user): + from datetime import date + + token = await login(client, "admin1") + quali = await client.post( + "/api/v1/qualifikationstypen", + json={"name": "Maschinist", "kategorie": "lehrgang"}, + headers=auth_header(token), + ) + quali_id = quali.json()["id"] + + erfasst = await client.post( + f"/api/v1/benutzer/{mitarbeiter_user.id}/qualifikationen", + json={ + "benutzer_id": mitarbeiter_user.id, + "qualifikationstyp_id": quali_id, + "erworben_am": date.today().isoformat(), + }, + headers=auth_header(token), + ) + qualifikation_id = erfasst.json()["id"] + + historie = await client.get( + f"/api/v1/historie?entitaet_typ=benutzer_qualifikation&entitaet_id={qualifikation_id}", + headers=auth_header(token), + ) + body = historie.json() + assert len(body) == 1 + assert body[0]["ereignistyp"] == "qualifikation_erfasst" diff --git a/frontend/src/pages/admin/HistorieSection.tsx b/frontend/src/pages/admin/HistorieSection.tsx index e6841de..cc55d2e 100644 --- a/frontend/src/pages/admin/HistorieSection.tsx +++ b/frontend/src/pages/admin/HistorieSection.tsx @@ -7,7 +7,16 @@ interface Props { onFehler: (text: string) => void; } -const ENTITAET_TYPEN = ["kontrolle", "fehlbestand", "nachfuellung", "mindermengen_genehmigung"] as const; +const ENTITAET_TYPEN = [ + "kontrolle", + "fehlbestand", + "nachfuellung", + "mindermengen_genehmigung", + "geraet_instanz", + "mangel", + "benutzer_qualifikation", + "objekt", +] as const; function formatWert(wert: Record | null): string { if (!wert) return "–";