From b7d9581b9027a48b53efe62618350188d3795277 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 8 Sep 2026 18:13:53 +0200 Subject: [PATCH] feat(identity): IDENT-009 QR-/Code-Neuvergabe mit Historie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/00_index.md | 2 +- backend/app/api/v1/endpoints/objekte.py | 91 +++++++++++++++ backend/app/schemas/objekt.py | 5 + backend/tests/test_objekte.py | 110 ++++++++++++++++++ .../src/components/status/ereignisText.ts | 2 + frontend/src/pages/AktePage.tsx | 54 ++++++++- 6 files changed, 262 insertions(+), 2 deletions(-) diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index e5f98f6..41a7247 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -141,7 +141,7 @@ vergeben statt geraten. | IDENT-006 | Identity | QR-Code-Erzeugung | P0 | S | LOW | IDENT-001 | ✅ (`label.pdf`-Endpunkte für Objekt/Objektposition) | | IDENT-007 | Identity | QR-Code-Druck (Label) | P0 | S | LOW | IDENT-006 | ✅ (`/objekte/{id}/label.pdf`, `/objektpositionen/{id}/label.pdf`) | | IDENT-008 | Identity | QR-Scan → Akte öffnen | P0 | M | MEDIUM | IDENT-006, FILE-006 | ✅ (`BarcodeScanner.tsx`, Akte-Route `/akte/objekt/{id}`) | -| IDENT-009 | Identity | QR-Neuvergabe & -Historie | P2 | S | LOW | IDENT-006 | ⬜ (kein Nachweis für Neuvergabe-Workflow) | +| IDENT-009 | Identity | QR-Neuvergabe & -Historie | P2 | S | LOW | IDENT-006 | ✅ (`/code-neu-vergeben`-Endpunkte für Objekt+Objektposition, Historie über bestehende Audit-Infrastruktur, UI in AktePage) | | IDENT-010 | Identity | Barcode/GTIN | P3 | S | LOW | IDENT-001 | 🔶 (`BarcodeScanner.tsx` existiert, GTIN-Materialabgleich nicht nachweisbar) | | FILE-001 | Digital File | Akte-Grundgerüst | P0 | M | MEDIUM | IDENT-001 | ✅ (`akte.py`, `services/akte.py`, docstring referenziert FILE-001) | | FILE-002 | Digital File | Akte-Stammdatenbereich | P0 | S | LOW | FILE-001 | ✅ (Teil des Akte-Aggregats) | diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 54bf251..5354ad2 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -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) diff --git a/backend/app/schemas/objekt.py b/backend/app/schemas/objekt.py index 2a3814d..f3ccb70 100644 --- a/backend/app/schemas/objekt.py +++ b/backend/app/schemas/objekt.py @@ -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 diff --git a/backend/tests/test_objekte.py b/backend/tests/test_objekte.py index e5702fc..f3be65c 100644 --- a/backend/tests/test_objekte.py +++ b/backend/tests/test_objekte.py @@ -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"} diff --git a/frontend/src/components/status/ereignisText.ts b/frontend/src/components/status/ereignisText.ts index 2cfd0f5..fe111eb 100644 --- a/frontend/src/components/status/ereignisText.ts +++ b/frontend/src/components/status/ereignisText.ts @@ -17,5 +17,7 @@ export const EREIGNIS_LABEL: Record = { mindermenge_genehmigt: "Mindermenge genehmigt", nachfuellung_erfasst: "Nachfüllung erfasst", objekt_geaendert: "Objekt geändert", + objekt_code_neu_vergeben: "Objekt-Code neu vergeben", + objektposition_code_neu_vergeben: "Geräte-Code neu vergeben", qualifikation_erfasst: "Qualifikation erfasst", }; diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index d8af9da..931b541 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -25,10 +25,12 @@ const MANGEL_PRIORITAET_LABEL: Record = { export function AktePage() { const { objektId } = useParams<{ objektId: string }>(); - const { istVerantwortlich } = useAuth(); + const { istVerantwortlich, istAdmin } = useAuth(); const [akte, setAkte] = useState(null); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); + const [codeNeuVergebenOffen, setCodeNeuVergebenOffen] = useState(false); + const [neuerCode, setNeuerCode] = useState(""); function laden() { if (!objektId) return; @@ -50,6 +52,21 @@ export function AktePage() { } } + async function codeNeuVergeben() { + if (!objektId || !neuerCode.trim()) return; + try { + await apiRequest(`/objekte/${objektId}/code-neu-vergeben`, { + method: "POST", + body: { neuer_code: neuerCode.trim(), begruendung: "Etikett neu ausgestellt" }, + }); + setCodeNeuVergebenOffen(false); + setNeuerCode(""); + laden(); + } catch { + setFehler("Code konnte nicht neu vergeben werden (evtl. bereits vergeben)."); + } + } + async function pruefungDurchgefuehrt(geraetId: string) { try { await apiRequest(`/geraete/${geraetId}`, { @@ -110,6 +127,41 @@ export function AktePage() {

Stammdaten

+ {istAdmin && ( +
+ {!codeNeuVergebenOffen ? ( + + ) : ( +
+ + setNeuerCode(e.target.value)} + /> + + +
+ )} +
+ )}
Standort: {akte.standort_name}
Letzte Kontrolle:{" "}