feat(objekt): Zugfahrzeug-Flag am Objekttyp + Leitungs-Dashboard-Kacheln
CI / backend-tests (push) Failing after 1m26s
CI / frontend-build (push) Successful in 24s

Objekttyp.ist_zugfahrzeug (Migration 0011) grenzt die Fahrzeug-Zuordnung
serverseitig auf tatsächliche Zugfahrzeuge ein (422 bei Verstoß), Frontend
filtert die Fahrzeug-Dropdowns entsprechend.

Neue Dashboard-Endpunkte /dashboard/einsatzbereitschaft und
/dashboard/prueftermine plus DashboardPage mit drei Kacheln
(Einsatzbereitschaft, Prüftermine, Ablaufdaten). Aktive
Mindermengen-Genehmigung zählt als "eingeschränkt einsatzbereit", nicht als
voll einsatzbereit (U2/Karte 08).

Neue FehlbestandListePage: Fehlbestände eigenständig nachfüllen/Mindermenge
genehmigen, ohne komplette Kontrolle des Objekts zu starten (Backend
existierte bereits, nur das Frontend fehlte).

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 10:33:00 +02:00
co-authored by Claude Sonnet 5
parent 38b8ce1795
commit 54fc29690a
20 changed files with 894 additions and 19 deletions
+31 -2
View File
@@ -4,8 +4,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import RolleTyp
from app.schemas.dashboard import AblaufdatumRead, KennzahlenRead
from app.services.dashboard import bevorstehende_ablaufdaten, kennzahlen
from app.schemas.dashboard import (
AblaufdatumRead,
EinsatzbereitschaftRead,
KennzahlenRead,
PrueftermineintragRead,
)
from app.services.dashboard import (
bevorstehende_ablaufdaten,
bevorstehende_prueftermine,
einsatzbereitschaft,
kennzahlen,
)
router = APIRouter()
@@ -25,3 +35,22 @@ async def hole_kennzahlen(db: AsyncSession = Depends(get_db), _=Depends(_verantw
@router.get("/dashboard/ablaufdaten", response_model=list[AblaufdatumRead])
async def hole_ablaufdaten(db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)) -> list[AblaufdatumRead]:
return [AblaufdatumRead(**eintrag) for eintrag in await bevorstehende_ablaufdaten(db)]
@router.get("/dashboard/einsatzbereitschaft", response_model=EinsatzbereitschaftRead)
async def hole_einsatzbereitschaft(
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
) -> EinsatzbereitschaftRead:
"""Kachel 1 Leitungs-Dashboard: einsatzbereit/eingeschränkt/nicht einsatzbereit
je Objekt, siehe app/services/dashboard.py:einsatzbereitschaft für die
Kriterien und die Mindermenge-Sonderrolle (U2, Karte 08)."""
return EinsatzbereitschaftRead(**await einsatzbereitschaft(db))
@router.get("/dashboard/prueftermine", response_model=list[PrueftermineintragRead])
async def hole_prueftermine(
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
) -> list[PrueftermineintragRead]:
"""Kachel 2 Leitungs-Dashboard: Geräteprüfungen (Karte 14), bewusst getrennt
von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf)."""
return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)]
+19 -6
View File
@@ -10,7 +10,7 @@ from app.db.session import get_db
from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition
from app.models.stammdaten import Material
from app.models.stammdaten import Material, Objekttyp
from app.models.vorlage import Beladungsvorlage
from app.services.label import generiere_code128_label_pdf
from app.schemas.objekt import (
@@ -36,6 +36,22 @@ _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,
@@ -196,8 +212,7 @@ async def erstelle_objekt(
if vorlage is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden")
if payload.fahrzeug_id is not None:
if await db.get(Objekt, payload.fahrzeug_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden")
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()
@@ -231,9 +246,7 @@ async def aendere_objekt(
daten = payload.model_dump(exclude_unset=True)
ziel_id = daten.get("fahrzeug_id")
if ziel_id is not None:
ziel = await db.get(Objekt, ziel_id)
if ziel is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden")
ziel = await _pruefe_zugfahrzeug(db, ziel_id)
if ziel.fahrzeug_id == objekt_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,