fix(historie): Audit-Lücke bei Mangel/Personal/Objekt-Änderungen geschlossen
CI / backend-tests (push) Failing after 1m47s
CI / frontend-build (push) Successful in 17s

Bisher waren nur Kontrolle/Fehlbestand/Nachfüllung/Mindermenge/Geräteinstanz
im append-only Audit-Log (historie.log() war seit Prompt 13 nie flächendeckend
verdrahtet, docstring sagte das bereits so). Drei neue Ereignisse ergänzt:

- mangel_gemeldet / mangel_status_geaendert (Mangel-Modul)
- qualifikation_erfasst (Personal-Modul, sicherheitsrelevant: "wer hat wem
  wann eine Qualifikation bestätigt" hängt fachlich direkt an "wer darf
  fahren")
- objekt_geaendert (Status-/Fahrzeug-Zuordnungsänderungen via PATCH /objekte)

Lagerbewegung bewusst NICHT zusätzlich in historie dupliziert - hat bereits
eigenes vollständiges Audit-Trail (Wer/Wann/Von/Nach/Grund in eigener
Tabelle).

Frontend: Änderungslog-Filter um geraet_instanz/mangel/benutzer_qualifikation/
objekt ergänzt (geraet_instanz fehlte dort zuvor ebenfalls).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 13:50:48 +02:00
co-authored by Claude Sonnet 5
parent 417df2beb6
commit 11ad747136
9 changed files with 201 additions and 16 deletions
+22
View File
@@ -4065,3 +4065,25 @@ Keine Commits in dieser Session.
- frontend/src/pages/MangelListePage.tsx | 4 ++-- - 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 ++++++++++++++++++++++++++++++++++++++++++
---
+25 -8
View File
@@ -7,11 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles from app.api.deps import require_roles
from app.db.session import get_db 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.mangel import Mangel, MangelStatus
from app.models.objekt import Objekt from app.models.objekt import Objekt
from app.schemas.mangel import MangelCreate, MangelRead, MangelStatusUpdate 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() 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]) @router.get("/maengel", response_model=list[MangelRead])
async def liste_maengel( async def liste_maengel(
objekt_id: int | None = None, objekt_id: int | None = None,
@@ -65,6 +71,16 @@ async def melde_mangel(
) )
db.add(mangel) db.add(mangel)
await db.flush() 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 return mangel
@@ -79,9 +95,10 @@ async def aendere_mangel_status(
if mangel is None: if mangel is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mangel nicht gefunden") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mangel nicht gefunden")
if payload.status == MangelStatus.erledigt: return await aendere_status(
return await erledige_mangel(db, mangel=mangel, benutzer_id=current_user.id) db,
mangel=mangel,
mangel.status = payload.status status=payload.status,
await db.flush() benutzer_id=current_user.id,
return mangel zustaendiger_server_id=await _hauptserver_id(db),
)
+22 -1
View File
@@ -13,6 +13,7 @@ from app.models.objekt import Objekt
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.models.stammdaten import Material, Objekttyp from app.models.stammdaten import Material, Objekttyp
from app.models.vorlage import Beladungsvorlage 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.services.label import generiere_code128_label_pdf
from app.schemas.objekt import ( from app.schemas.objekt import (
FahrzeugdetailsRead, FahrzeugdetailsRead,
@@ -256,7 +257,10 @@ async def erstelle_objekt(
@router.patch("/objekte/{objekt_id}", response_model=ObjektRead) @router.patch("/objekte/{objekt_id}", response_model=ObjektRead)
async def aendere_objekt( 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: ) -> ObjektRead:
"""Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu """Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu
jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und 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", detail="Zyklus: Zielobjekt ist bereits diesem Objekt zugeordnet",
) )
alte_werte = {feld: getattr(objekt, feld) for feld in daten}
for feld, wert in daten.items(): for feld, wert in daten.items():
setattr(objekt, feld, wert) setattr(objekt, feld, wert)
try: try:
await db.flush() await db.flush()
except IntegrityError as exc: except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from 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]) letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id)) return _objekt_read(objekt, letzte_map.get(objekt.id))
+25 -2
View File
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.db.session import get_db 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 ( from app.models.personal import (
BenutzerQualifikation, BenutzerQualifikation,
Einheit, Einheit,
@@ -25,10 +25,16 @@ from app.schemas.personal import (
QualifikationstypRead, QualifikationstypRead,
QualifikationstypUpdate, QualifikationstypUpdate,
) )
from app.services import historie as historie_service
from app.services.personal import pruefe_berechtigung from app.services.personal import pruefe_berechtigung
router = APIRouter() 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) _admin_only = require_roles(RolleTyp.administration)
@@ -126,7 +132,7 @@ async def erfasse_benutzer_qualifikation(
benutzer_id: int, benutzer_id: int,
payload: BenutzerQualifikationCreate, payload: BenutzerQualifikationCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(_admin_only), current_user=Depends(_admin_only),
) -> BenutzerQualifikation: ) -> BenutzerQualifikation:
if payload.benutzer_id != benutzer_id: if payload.benutzer_id != benutzer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="benutzer_id im Pfad und Body weichen ab") 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( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Qualifikation mit diesem Erwerbsdatum existiert bereits" status_code=status.HTTP_409_CONFLICT, detail="Qualifikation mit diesem Erwerbsdatum existiert bereits"
) from exc ) 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 return qualifikation
+22 -2
View File
@@ -4,6 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
from app.services import historie as historie_service
OFFENE_MANGEL_STATI = ( OFFENE_MANGEL_STATI = (
MangelStatus.neu, MangelStatus.neu,
@@ -25,9 +26,28 @@ async def objekt_ids_mit_kritischem_offenem_mangel(db: AsyncSession) -> set[int]
return set(result.scalars().all()) return set(result.scalars().all())
async def erledige_mangel(db: AsyncSession, *, mangel: Mangel, benutzer_id: int) -> Mangel: async def aendere_status(
mangel.status = MangelStatus.erledigt 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_von = benutzer_id
mangel.erledigt_am = datetime.now(timezone.utc) mangel.erledigt_am = datetime.now(timezone.utc)
await db.flush() 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 return mangel
+15
View File
@@ -97,3 +97,18 @@ async def test_ueberfaellige_hu_macht_objekt_nicht_einsatzbereit_und_erscheint_i
assert len(body) == 1 assert len(body) == 1
assert body[0]["typ"] == "fahrzeug_hu" assert body[0]["typ"] == "fahrzeug_hu"
assert body[0]["status"] == "abgelaufen" 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"
+26
View File
@@ -143,3 +143,29 @@ async def test_leitungsverantwortlicher_kann_status_aendern(client, objekt_mit_p
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "erledigt" 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
+32
View File
@@ -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)) response = await client.get("/api/v1/dashboard/qualifikationsablaeufe", headers=auth_header(token))
assert response.json() == [] 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"
+10 -1
View File
@@ -7,7 +7,16 @@ interface Props {
onFehler: (text: string) => void; 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<string, unknown> | null): string { function formatWert(wert: Record<string, unknown> | null): string {
if (!wert) return ""; if (!wert) return "";