Files
patrickandClaude Sonnet 5 977d81ddcd
CI / backend-tests (push) Failing after 2m15s
CI / frontend-build (push) Successful in 28s
feat(dokumente,identity): DOC-003 Versionierung + QR-Code-Etiketten
Dokument-Versionierung: vorgaenger_id verkettet Versionen (Migration 0027),
POST /dokumente/{id}/ersetzen legt neue Version an statt zu überschreiben,
alte Fassung bleibt als Historie erhalten. GET /dokumente/{id}/versionen
liefert die Kette (neueste zuerst), respektiert DOC-005-Zugriffsrechte.
Liste zeigt nur die jeweils aktuelle Version. Löschen der aktuellen Version
gibt automatisch den Vorgänger als neue "aktuelle" frei. Frontend:
"Ersetzen"-Button + aufklappbare Versionshistorie im DokumentePanel.

QR-Code-Etiketten: bisher nur Code128-Barcode möglich. generiere_qr_label_pdf
(reportlab-eigenes QR-Widget, keine neue Abhängigkeit) als Alternative,
?format=qr|code128 an /objekte/{id}/label.pdf und /objektpositionen/{id}/
label.pdf. AktePage bekommt "Etikett (Barcode)"/"Etikett (QR)"-Buttons
(vorher gab es dafür noch gar keinen UI-Zugang im Web-Frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-08 11:03:46 +02:00

477 lines
19 KiB
Python

import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
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 KnotenTyp, RolleTyp, Systemknoten
from app.models.fahrzeugdetails import Fahrzeugdetails
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, generiere_qr_label_pdf
LabelFormat = Literal["code128", "qr"]
from app.schemas.objekt import (
FahrzeugdetailsRead,
FahrzeugdetailsUpsert,
ObjektCreate,
ObjektDuplizierenRequest,
ObjektpositionCreate,
ObjektpositionRead,
ObjektpositionUpdate,
ObjektRead,
ObjektUpdate,
)
from app.services.objekte import (
dupliziere_objekt,
letzte_kontrollen_map,
naechster_freier_code,
objekt_aus_vorlage,
sollmenge_effektiv,
)
def _objekt_read(objekt: Objekt, letzte_kontrolle_am=None) -> ObjektRead:
return ObjektRead(
id=objekt.id,
code=objekt.code,
name=objekt.name,
objekttyp_id=objekt.objekttyp_id,
vorlage_id=objekt.vorlage_id,
standort_id=objekt.standort_id,
status=objekt.status,
fahrzeug_id=objekt.fahrzeug_id,
letzte_kontrolle_am=letzte_kontrolle_am,
)
router = APIRouter()
_admin_only = require_roles(RolleTyp.administration)
_materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher)
async def _pruefe_zugfahrzeug(db: AsyncSession, fahrzeug_id: int) -> Objekt:
"""Karte 11 Nachtrag (Option A+C): Fahrzeug-Zuordnung nur auf Objekte, deren
Objekttyp ist_zugfahrzeug=True ist - grenzt Rucksäcke/Geräte serverseitig aus,
unabhängig vom (evtl. umgangenen) Frontend-Dropdown."""
ziel = await db.get(Objekt, fahrzeug_id)
if ziel is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden")
objekttyp = await db.get(Objekttyp, ziel.objekttyp_id)
if objekttyp is None or not objekttyp.ist_zugfahrzeug:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Zielobjekt ist kein Zugfahrzeug (Objekttyp.ist_zugfahrzeug=False)",
)
return ziel
@router.get("/objekte", response_model=list[ObjektRead])
async def liste_objekte(
standort_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[ObjektRead]:
stmt = select(Objekt)
if standort_id is not None:
stmt = stmt.where(Objekt.standort_id == standort_id)
result = await db.execute(stmt)
objekte = list(result.scalars().all())
letzte_map = await letzte_kontrollen_map(db, [o.id for o in objekte])
return [_objekt_read(o, letzte_map.get(o.id)) for o in objekte]
@router.get("/objekte/code/{code}", response_model=ObjektRead)
async def hole_objekt_per_code(
code: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> ObjektRead:
"""Lookup per QR/Barcode-Code (Karte 10). Muss VOR /objekte/{id} registriert
sein, sonst greift FastAPI/Starlette-Routing zuerst die generische ID-Route."""
result = await db.execute(select(Objekt).where(Objekt.code == code))
objekt = result.scalar_one_or_none()
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.get("/objekte/naechster-code")
async def naechster_objekt_code(
praefix: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> dict[str, str]:
"""Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09).
Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE."""
return {"code": await naechster_freier_code(db, Objekt.code, praefix)}
@router.get("/objektpositionen/naechster-code")
async def naechster_objektposition_code(
praefix: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> dict[str, str]:
"""Code-Vorschlag für Geräte-Exemplare (objektposition.code, eigener
Nummernkreis je Präfix, unabhängig von objekt.code)."""
return {"code": await naechster_freier_code(db, Objektposition.code, praefix)}
@router.get("/objektpositionen/code/{code}", response_model=ObjektpositionRead)
async def hole_objektposition_per_code(
code: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> ObjektpositionRead:
"""Geräte-Scan (Konzept-Ergänzung Karte 10/Prompt 09): Code128-Etikett am
Exemplar identifiziert die Objektposition direkt, unabhängig vom Objekt-Code -
löst SN-vorbelegte Kontroll-Erfassung ohne Umweg über Objekt-Scan aus."""
result = await db.execute(select(Objektposition).where(Objektposition.code == code))
position = result.scalar_one_or_none()
if position is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
objekt = await db.get(Objekt, position.objekt_id)
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}", response_model=ObjektRead)
async def hole_objekt(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> ObjektRead:
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
def _erzeuge_label_pdf(code: str, klartext_zusatz: str | None, format: LabelFormat) -> bytes:
if format == "qr":
return generiere_qr_label_pdf(code, klartext_zusatz)
return generiere_code128_label_pdf(code, klartext_zusatz)
@router.get("/objekte/{objekt_id}/label.pdf")
async def objekt_label(
objekt_id: int,
format: LabelFormat = "code128",
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> Response:
"""Druckfertiges Etikett (Karte 10, QR-Variante Nutzer-Nachtrag): Code128
(Standard) oder QR-Code + Objektname als Klartext-Fallback. Vektor-PDF,
skaliert verlustfrei auf jede Labelgröße beim Druck."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
pdf = _erzeuge_label_pdf(objekt.code, objekt.name, format)
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{objekt.code}.pdf"'},
)
@router.get("/objektpositionen/{position_id}/label.pdf")
async def objektposition_label(
position_id: uuid.UUID,
format: LabelFormat = "code128",
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> Response:
"""Geräte-Etikett (Karte 10): eigener Code je Exemplar, Klartext-Zusatz ist
der Materialname (Objektname wäre bei mehreren Geräten am selben Objekt
nicht unterscheidend genug)."""
position = await db.get(Objektposition, position_id)
if position is None or position.code is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Geräte-Code vergeben")
material = await db.get(Material, position.material_id)
pdf = _erzeuge_label_pdf(position.code, material.name if material else None, format)
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{position.code}.pdf"'},
)
@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)
) -> list[ObjektpositionRead]:
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
result = await db.execute(select(Objektposition).where(Objektposition.objekt_id == objekt_id))
positionen = result.scalars().all()
return [
ObjektpositionRead(
id=p.id,
objekt_id=p.objekt_id,
material_id=p.material_id,
sollmenge_override=p.sollmenge_override,
sollmenge_effektiv=sollmenge_effektiv(p),
ist_status=p.ist_status,
istmenge=p.istmenge,
seriennummer=p.seriennummer,
ablaufdatum=p.ablaufdatum,
chargennummer=p.chargennummer,
code=p.code,
pruefintervall_monate=p.pruefintervall_monate,
)
for p in positionen
]
@router.post("/objekte", response_model=ObjektRead, status_code=status.HTTP_201_CREATED)
async def erstelle_objekt(
payload: ObjektCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> ObjektRead:
"""Prompt 09: neues Objekt aus einer Beladungsvorlage, startet "nicht kontrolliert"
(Ist-Menge 0 in allen übernommenen Positionen)."""
vorlage = await db.get(Beladungsvorlage, payload.vorlage_id)
if vorlage is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden")
if payload.fahrzeug_id is not None:
await _pruefe_zugfahrzeug(db, payload.fahrzeug_id)
hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
zustaendiger_server_id = hauptserver.scalar_one()
try:
objekt = await objekt_aus_vorlage(
db,
code=payload.code,
name=payload.name,
standort_id=payload.standort_id,
vorlage=vorlage,
zustaendiger_server_id=zustaendiger_server_id,
fahrzeug_id=payload.fahrzeug_id,
)
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc
return _objekt_read(objekt)
@router.patch("/objekte/{objekt_id}", response_model=ObjektRead)
async def aendere_objekt(
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
direkter Zyklus (A↔B) werden geprüft, mehrstufige Ketten sind fachlich
nicht vorgesehen und bewusst nicht geprüft (Aufwand/Nutzen)."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
daten = payload.model_dump(exclude_unset=True)
ziel_id = daten.get("fahrzeug_id")
if ziel_id is not None:
# Selbstbezug explizit vorab abfangen statt sich auf den DB-CHECK-
# Constraint zu verlassen: ein Flush-Fehlschlag hier hinterlässt die
# Session im Zustand DEACTIVE (SQLAlchemy 2.0), was in einer über
# mehrere Requests geteilten Session (z.B. Testsuite) jeden folgenden
# Request mit PendingRollbackError zum Absturz bringt (Fund 2026-09-05).
if ziel_id == objekt_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Zyklus: Objekt kann nicht sich selbst als Fahrzeug zugeordnet werden",
)
ziel = await _pruefe_zugfahrzeug(db, ziel_id)
if ziel.fahrzeug_id == objekt_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
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))
@router.post(
"/objekte/{objekt_id}/duplizieren", response_model=ObjektRead, status_code=status.HTTP_201_CREATED
)
async def dupliziere(
objekt_id: int,
payload: ObjektDuplizierenRequest,
db: AsyncSession = Depends(get_db),
_=Depends(_admin_only),
) -> ObjektRead:
"""Prompt 09: Struktur/Sollmengen kopiert, Ist-Menge/SN/Ablauf/Charge NICHT,
neuer eindeutiger Code Pflicht."""
quelle = await db.get(Objekt, objekt_id)
if quelle is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quell-Objekt nicht gefunden")
try:
neues_objekt = await dupliziere_objekt(
db,
quelle=quelle,
neuer_code=payload.neuer_code,
neuer_name=payload.neuer_name,
neuer_standort_id=payload.neuer_standort_id,
zustaendiger_server_id=quelle.zustaendiger_server_id,
)
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc
return _objekt_read(neues_objekt)
@router.patch("/objekte/{objekt_id}/positionen/{position_id}", response_model=ObjektpositionRead)
async def aendere_objektposition(
objekt_id: int,
position_id: uuid.UUID,
payload: ObjektpositionUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(_materialverantwortliche),
) -> ObjektpositionRead:
"""Prompt 10: Sollmengen-Override setzen/aufheben, Material als entfernt markieren,
Ist-Werte (Menge/SN/Ablauf/Charge) pflegen."""
position = await db.get(Objektposition, position_id)
if position is None or position.objekt_id != objekt_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(position, feld, wert)
await db.flush()
objekt = await db.get(Objekt, objekt_id)
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.post(
"/objekte/{objekt_id}/positionen",
response_model=ObjektpositionRead,
status_code=status.HTTP_201_CREATED,
)
async def fuege_zusatzposition_hinzu(
objekt_id: int,
payload: ObjektpositionCreate,
db: AsyncSession = Depends(get_db),
_=Depends(_materialverantwortliche),
) -> ObjektpositionRead:
"""Prompt 10 Punkt 2: zusätzliches, nicht in der Vorlage enthaltenes Material."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
position = Objektposition(
objekt_id=objekt_id,
material_id=payload.material_id,
sollmenge_override=payload.sollmenge_override,
)
db.add(position)
try:
await db.flush()
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Material existiert bereits an diesem Objekt"
) from exc
return ObjektpositionRead(
id=position.id,
objekt_id=position.objekt_id,
material_id=position.material_id,
sollmenge_override=position.sollmenge_override,
sollmenge_effektiv=payload.sollmenge_override,
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}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
async def hole_fahrzeugdetails(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Fahrzeugdetails:
"""Roadmap Phase 3 (Modul Fahrzeuge): 404 statt leerem Default, wenn noch
keine Fahrzeugdetails erfasst sind - Frontend zeigt dann ein leeres
Formular statt scheinbar vorhandener Nullwerte."""
details = await db.get(Fahrzeugdetails, objekt_id)
if details is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Fahrzeugdetails erfasst")
return details
@router.put("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
async def setze_fahrzeugdetails(
objekt_id: int,
payload: FahrzeugdetailsUpsert,
db: AsyncSession = Depends(get_db),
_=Depends(_materialverantwortliche),
) -> Fahrzeugdetails:
"""Upsert (PUT statt PATCH): Fahrzeugdetails existieren typischerweise
genau einmal je Objekt, ein Formular ersetzt komplett statt Teil-Update -
einfacher als Anlegen/Ändern zu unterscheiden."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
details = await db.get(Fahrzeugdetails, objekt_id)
if details is None:
details = Fahrzeugdetails(objekt_id=objekt_id)
db.add(details)
for feld, wert in payload.model_dump().items():
setattr(details, feld, wert)
await db.flush()
return details