feat(objekt): letzte abgeschlossene Kontrolle je Objekt anzeigen
CI / backend-tests (push) Failing after 1m28s
CI / frontend-build (push) Successful in 17s

Bisher nirgends vermerkt/abrufbar, wann ein Objekt zuletzt komplett
kontrolliert wurde (nur roh in kontrolle.beendet_am ableitbar). Neues Feld
ObjektRead.letzte_kontrolle_am (jüngste Kontrolle mit status=abgeschlossen),
angezeigt in Objektliste und Admin-Objektsektion ("noch nie kontrolliert"
falls keine).

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 11:00:49 +02:00
co-authored by Claude Sonnet 5
parent 69d6956d23
commit c3f141f7b0
10 changed files with 6599 additions and 14 deletions
+34 -12
View File
@@ -24,12 +24,27 @@ from app.schemas.objekt import (
)
from app.services.objekte import (
dupliziere_objekt,
letzte_kontrollen_map,
naechster_freier_code,
objekt_aus_vorlage,
sollmenge_effektiv,
sollmenge_map,
)
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)
@@ -57,25 +72,28 @@ async def liste_objekte(
standort_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[Objekt]:
) -> list[ObjektRead]:
stmt = select(Objekt)
if standort_id is not None:
stmt = stmt.where(Objekt.standort_id == standort_id)
result = await db.execute(stmt)
return list(result.scalars().all())
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)
) -> Objekt:
) -> 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")
return objekt
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.get("/objekte/naechster-code")
@@ -129,11 +147,12 @@ async def hole_objektposition_per_code(
@router.get("/objekte/{objekt_id}", response_model=ObjektRead)
async def hole_objekt(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Objekt:
) -> 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")
return objekt
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.get("/objekte/{objekt_id}/label.pdf")
@@ -205,7 +224,7 @@ async def liste_objektpositionen(
@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)
) -> Objekt:
) -> 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)
@@ -218,7 +237,7 @@ async def erstelle_objekt(
zustaendiger_server_id = hauptserver.scalar_one()
try:
return await objekt_aus_vorlage(
objekt = await objekt_aus_vorlage(
db,
code=payload.code,
name=payload.name,
@@ -229,12 +248,13 @@ async def erstelle_objekt(
)
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), _=Depends(_admin_only)
) -> Objekt:
) -> 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
@@ -259,7 +279,8 @@ async def aendere_objekt(
await db.flush()
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc
return objekt
letzte_map = await letzte_kontrollen_map(db, [objekt.id])
return _objekt_read(objekt, letzte_map.get(objekt.id))
@router.post(
@@ -270,7 +291,7 @@ async def dupliziere(
payload: ObjektDuplizierenRequest,
db: AsyncSession = Depends(get_db),
_=Depends(_admin_only),
) -> Objekt:
) -> ObjektRead:
"""Prompt 09: Struktur/Sollmengen kopiert, Ist-Menge/SN/Ablauf/Charge NICHT,
neuer eindeutiger Code Pflicht."""
quelle = await db.get(Objekt, objekt_id)
@@ -278,7 +299,7 @@ async def dupliziere(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quell-Objekt nicht gefunden")
try:
return await dupliziere_objekt(
neues_objekt = await dupliziere_objekt(
db,
quelle=quelle,
neuer_code=payload.neuer_code,
@@ -288,6 +309,7 @@ async def dupliziere(
)
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)