feat(identity): IDENT-009 QR-/Code-Neuvergabe mit Historie
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:
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -17,5 +17,7 @@ export const EREIGNIS_LABEL: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -25,10 +25,12 @@ const MANGEL_PRIORITAET_LABEL: Record<string, string> = {
|
||||
|
||||
export function AktePage() {
|
||||
const { objektId } = useParams<{ objektId: string }>();
|
||||
const { istVerantwortlich } = useAuth();
|
||||
const { istVerantwortlich, istAdmin } = useAuth();
|
||||
const [akte, setAkte] = useState<Akte | null>(null);
|
||||
const [fehler, setFehler] = useState<string | null>(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() {
|
||||
<div className="akte-stamm card">
|
||||
<h3 style={{ marginTop: 0 }}>Stammdaten</h3>
|
||||
<div className="stack">
|
||||
{istAdmin && (
|
||||
<div>
|
||||
{!codeNeuVergebenOffen ? (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setNeuerCode(objekt.code);
|
||||
setCodeNeuVergebenOffen(true);
|
||||
}}
|
||||
title="Neuen Code vergeben, z.B. bei beschädigtem Etikett - der alte Code bleibt in der Historie sichtbar"
|
||||
>
|
||||
Code neu vergeben
|
||||
</button>
|
||||
) : (
|
||||
<div className="row" style={{ gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<label htmlFor="neuer-code" className="sr-only">
|
||||
Neuer Code
|
||||
</label>
|
||||
<input
|
||||
id="neuer-code"
|
||||
className="input"
|
||||
style={{ maxWidth: "12rem" }}
|
||||
value={neuerCode}
|
||||
onChange={(e) => setNeuerCode(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={codeNeuVergeben}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCodeNeuVergebenOffen(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>Standort: {akte.standort_name}</div>
|
||||
<div>
|
||||
Letzte Kontrolle:{" "}
|
||||
|
||||
Reference in New Issue
Block a user