feat(identity): IDENT-009 QR-/Code-Neuvergabe mit Historie
CI / backend-tests (push) Successful in 2m20s
CI / frontend-build (push) Successful in 27s

POST /objekte/{id}/code-neu-vergeben und /objektpositionen/{id}/code-neu-
vergeben (admin-only) - z.B. bei beschädigtem/verlorenem Etikett. Alter
Code bleibt über die bestehende Audit-Infrastruktur (Historie-Tabelle,
Prompt 13) nachvollziehbar statt eine eigene Versions-Tabelle zu brauchen:
GET /historie?entitaet_typ=objekt&entitaet_id=... zeigt alter_wert/
neuer_wert des Codes. Duplikat-Prüfung explizit vorab (Projekt-Konvention,
siehe geraet_instanz.py::erstelle).

Frontend: "Code neu vergeben"-Aktion in der Objektakte (nur Admin), inline
statt window.prompt (Accessibility-Vorgabe aus dieser Session).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 18:13:53 +02:00
co-authored by Claude Sonnet 5
parent 08ce8efe55
commit b7d9581b90
6 changed files with 262 additions and 2 deletions
+91
View File
@@ -19,6 +19,7 @@ from app.services.label import generiere_code128_label_pdf, generiere_qr_label_p
LabelFormat = Literal["code128", "qr"]
from app.schemas.objekt import (
CodeNeuVergeben,
FahrzeugdetailsRead,
FahrzeugdetailsUpsert,
ObjektCreate,
@@ -209,6 +210,96 @@ async def objektposition_label(
)
@router.post("/objekte/{objekt_id}/code-neu-vergeben", response_model=ObjektRead)
async def objekt_code_neu_vergeben(
objekt_id: int,
payload: CodeNeuVergeben,
db: AsyncSession = Depends(get_db),
current_user=Depends(_admin_only),
) -> ObjektRead:
"""IDENT-009: QR-/Barcode-Neuvergabe, z.B. bei beschädigtem oder verlorenem
Etikett - der alte Code bleibt über die Historie nachvollziehbar (GET
/historie?entitaet_typ=objekt&entitaet_id=...), keine eigene Versions-
Tabelle nötig (bestehende Audit-Infrastruktur, Prompt 13)."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
bestehendes = await db.execute(select(Objekt).where(Objekt.code == payload.neuer_code, Objekt.id != objekt_id))
if bestehendes.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben")
alter_code = objekt.code
objekt.code = payload.neuer_code
await db.flush()
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_code_neu_vergeben",
entitaet_typ="objekt",
entitaet_id=objekt.id,
alter_wert={"code": alter_code},
neuer_wert={"code": payload.neuer_code},
begruendung=payload.begruendung,
)
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.post("/objektpositionen/{position_id}/code-neu-vergeben", response_model=ObjektpositionRead)
async def objektposition_code_neu_vergeben(
position_id: uuid.UUID,
payload: CodeNeuVergeben,
db: AsyncSession = Depends(get_db),
current_user=Depends(_admin_only),
) -> ObjektpositionRead:
"""IDENT-009: wie objekt_code_neu_vergeben, für Geräte-Etiketten auf
Positionsebene."""
position = await db.get(Objektposition, position_id)
if position is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
bestehendes = await db.execute(
select(Objektposition).where(
Objektposition.code == payload.neuer_code, Objektposition.id != position_id
)
)
if bestehendes.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben")
alter_code = position.code
position.code = payload.neuer_code
await db.flush()
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="objektposition_code_neu_vergeben",
entitaet_typ="objektposition",
entitaet_id=position.id,
alter_wert={"code": alter_code},
neuer_wert={"code": payload.neuer_code},
begruendung=payload.begruendung,
)
return ObjektpositionRead(
id=position.id,
objekt_id=position.objekt_id,
material_id=position.material_id,
sollmenge_override=position.sollmenge_override,
sollmenge_effektiv=sollmenge_effektiv(position),
ist_status=position.ist_status,
istmenge=position.istmenge,
seriennummer=position.seriennummer,
ablaufdatum=position.ablaufdatum,
chargennummer=position.chargennummer,
code=position.code,
pruefintervall_monate=position.pruefintervall_monate,
)
@router.get("/objekte/{objekt_id}/positionen", response_model=list[ObjektpositionRead])
async def liste_objektpositionen(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
+5
View File
@@ -35,6 +35,11 @@ class ObjektUpdate(BaseModel):
status: ObjektStatus | None = None
class CodeNeuVergeben(BaseModel):
neuer_code: str
begruendung: str | None = None
class FahrzeugdetailsUpsert(BaseModel):
kennzeichen: str | None = None
funkrufname: str | None = None
+110
View File
@@ -525,3 +525,113 @@ async def test_fahrzeug_zuordnung_lehnt_nicht_zugfahrzeug_typ_ab(
headers=auth_header(token),
)
assert abgelehnt_patch.status_code == 422
@pytest.mark.asyncio
async def test_objekt_code_neu_vergeben_mit_historie(client, objekt_mit_position, admin_user):
"""IDENT-009: neuer Code wird gesetzt, alter Code bleibt über die
Historie nachvollziehbar."""
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
alter_code = objekt.code
neu = await client.post(
f"/api/v1/objekte/{objekt.id}/code-neu-vergeben",
json={"neuer_code": "KT-NEU", "begruendung": "Etikett beschädigt"},
headers=auth_header(token),
)
assert neu.status_code == 200
assert neu.json()["code"] == "KT-NEU"
historie = await client.get(
f"/api/v1/historie?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
)
eintraege = [h for h in historie.json() if h["ereignistyp"] == "objekt_code_neu_vergeben"]
assert len(eintraege) == 1
assert eintraege[0]["alter_wert"] == {"code": alter_code}
assert eintraege[0]["neuer_wert"] == {"code": "KT-NEU"}
assert eintraege[0]["begruendung"] == "Etikett beschädigt"
@pytest.mark.asyncio
async def test_objekt_code_neu_vergeben_duplikat_abgelehnt(
client, db_session, admin_user, standort_factory, objekttyp_factory, hauptserver_id
):
"""Neuer Code darf nicht mit dem Code eines ANDEREN Objekts kollidieren."""
from app.models.objekt import Objekt
objekttyp = await objekttyp_factory("Typ-CodeDupe")
standort = await standort_factory("Standort-CodeDupe")
erstes = Objekt(
code="KT-A",
name="Objekt A",
objekttyp_id=objekttyp.id,
standort_id=standort.id,
zustaendiger_server_id=hauptserver_id,
)
zweites = Objekt(
code="KT-B",
name="Objekt B",
objekttyp_id=objekttyp.id,
standort_id=standort.id,
zustaendiger_server_id=hauptserver_id,
)
db_session.add_all([erstes, zweites])
await db_session.flush()
token = await login(client, "admin1")
konflikt = await client.post(
f"/api/v1/objekte/{zweites.id}/code-neu-vergeben",
json={"neuer_code": "KT-A"},
headers=auth_header(token),
)
assert konflikt.status_code == 409
eigener_code_ist_ok = await client.post(
f"/api/v1/objekte/{zweites.id}/code-neu-vergeben",
json={"neuer_code": "KT-B"},
headers=auth_header(token),
)
assert eigener_code_ist_ok.status_code == 200
@pytest.mark.asyncio
async def test_mitarbeiter_darf_code_nicht_neu_vergeben(client, objekt_mit_position, mitarbeiter_user):
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
response = await client.post(
f"/api/v1/objekte/{objekt.id}/code-neu-vergeben",
json={"neuer_code": "KT-XYZ"},
headers=auth_header(token),
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_objektposition_code_neu_vergeben(client, db_session, objekt_mit_position, admin_user):
objekt, material = objekt_mit_position
token = await login(client, "admin1")
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
position_id = positionen.json()[0]["id"]
await client.patch(
f"/api/v1/objekte/{objekt.id}/positionen/{position_id}",
json={"code": "GR-ALT"},
headers=auth_header(token),
)
neu = await client.post(
f"/api/v1/objektpositionen/{position_id}/code-neu-vergeben",
json={"neuer_code": "GR-NEU", "begruendung": "Etikett verloren"},
headers=auth_header(token),
)
assert neu.status_code == 200
assert neu.json()["code"] == "GR-NEU"
historie = await client.get(
f"/api/v1/historie?entitaet_typ=objektposition&entitaet_id={position_id}", headers=auth_header(token)
)
eintraege = [h for h in historie.json() if h["ereignistyp"] == "objektposition_code_neu_vergeben"]
assert len(eintraege) == 1
assert eintraege[0]["alter_wert"] == {"code": "GR-ALT"}