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,
+4
View File
@@ -48,6 +48,10 @@ class Objekttyp(Base):
bereich_id: Mapped[int] = mapped_column(ForeignKey("bereich.id"), nullable=False)
kategorie_id: Mapped[int | None] = mapped_column(ForeignKey("kategorie.id"))
name: Mapped[str] = mapped_column(String, nullable=False)
# Karte 11 Nachtrag: nur Objekte dieses Typs dürfen als Fahrzeug (Objekt.
# fahrzeug_id) zugeordnet werden - grenzt die Fahrzeug-Dropdowns serverseitig
# ein, ohne einen eigenen Fahrzeug-Objekttyp fachlich zu erzwingen.
ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class Fach(Base):
+31
View File
@@ -1,7 +1,10 @@
import uuid
from datetime import date
from pydantic import BaseModel
from app.models.geraet_instanz import GeraetStatus
class KennzahlenRead(BaseModel):
anzahl_offener_fehlbestaende: int
@@ -18,3 +21,31 @@ class AblaufdatumRead(BaseModel):
ablaufdatum: date
verbleibende_tage: int
status: str
class ObjektMangelRead(BaseModel):
objekt_id: int
code: str
name: str
gruende: list[str]
class EinsatzbereitschaftRead(BaseModel):
einsatzbereit: int
eingeschraenkt_einsatzbereit: int
nicht_einsatzbereit: int
gesamt: int
details: list[ObjektMangelRead]
class PrueftermineintragRead(BaseModel):
geraet_instanz_id: uuid.UUID
objekt_id: int
objektposition_id: uuid.UUID
material_id: int
material_name: str
seriennummer: str
geraet_status: GeraetStatus
naechste_pruefung: date
verbleibende_tage: int
status: str
+2
View File
@@ -54,6 +54,7 @@ class ObjekttypCreate(BaseModel):
bereich_id: int
kategorie_id: int | None = None
name: str
ist_zugfahrzeug: bool = False
class ObjekttypRead(ObjekttypCreate):
@@ -65,6 +66,7 @@ class ObjekttypUpdate(BaseModel):
bereich_id: int | None = None
kategorie_id: int | None = None
name: str | None = None
ist_zugfahrzeug: bool | None = None
class FachCreate(BaseModel):
+138
View File
@@ -4,6 +4,8 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz
from app.models.kontrolle import Kontrolle, KontrollStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition
@@ -89,3 +91,139 @@ async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]:
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege
async def einsatzbereitschaft(db: AsyncSession) -> dict:
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit,
wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da
Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt,
siehe app/services/geraet_instanz.py), kein abgelaufenes ablaufdatum, keine
überfällige Geräteprüfung und keine laufende Kontrolle (Sperre) vorliegt.
Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT
(Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt
einsatzbereit", nicht "einsatzbereit"."""
heute = datetime.now(timezone.utc).date()
objekte = (await db.execute(select(Objekt))).scalars().all()
offene_fehlbestaende = (
await db.execute(
select(Fehlbestand.objekt_id, Fehlbestand.id).where(Fehlbestand.status.in_(OFFEN_STATI))
)
).all()
objekt_ids_mit_fehlbestand: dict[int, set] = {}
for objekt_id, fehlbestand_id in offene_fehlbestaende:
objekt_ids_mit_fehlbestand.setdefault(objekt_id, set()).add(fehlbestand_id)
fehlbestand_ids_genehmigt = set(
(
await db.execute(
select(func.distinct(MindermengenGenehmigung.fehlbestand_id)).where(
MindermengenGenehmigung.status == MindermengeStatus.aktiv
)
)
)
.scalars()
.all()
)
objekt_ids_abgelaufen = set(
(
await db.execute(
select(func.distinct(Objektposition.objekt_id)).where(
Objektposition.ablaufdatum.is_not(None), Objektposition.ablaufdatum < heute
)
)
)
.scalars()
.all()
)
objekt_ids_pruefung_ueberfaellig = set(
(
await db.execute(
select(func.distinct(Objektposition.objekt_id))
.join(GeraetInstanz, GeraetInstanz.objektposition_id == Objektposition.id)
.where(GeraetInstanz.naechste_pruefung.is_not(None), GeraetInstanz.naechste_pruefung < heute)
)
)
.scalars()
.all()
)
objekt_ids_gesperrt = set(
(
await db.execute(
select(func.distinct(Kontrolle.objekt_id)).where(Kontrolle.status == KontrollStatus.in_bearbeitung)
)
)
.scalars()
.all()
)
bereit = eingeschraenkt = 0
details: list[dict] = []
for objekt in objekte:
gruende: list[str] = []
fehlbestand_ids = objekt_ids_mit_fehlbestand.get(objekt.id, set())
ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt
if ungenehmigt:
gruende.append("fehlbestand_offen")
if objekt.id in objekt_ids_abgelaufen:
gruende.append("ablaufdatum_abgelaufen")
if objekt.id in objekt_ids_pruefung_ueberfaellig:
gruende.append("pruefung_ueberfaellig")
if objekt.id in objekt_ids_gesperrt:
gruende.append("kontrolle_laeuft")
if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt:
eingeschraenkt += 1
details.append(
{"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["mindermenge_genehmigt"]}
)
elif not gruende:
bereit += 1
else:
details.append({"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": gruende})
return {
"einsatzbereit": bereit,
"eingeschraenkt_einsatzbereit": eingeschraenkt,
"nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt,
"gesamt": len(objekte),
"details": details,
}
async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = 30) -> list[dict]:
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 2): Geräteprüfungen (Karte 14),
nicht Ablaufdaten von Verbrauchsmaterial (bleibt eigene, bestehende Kachel/
Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist
Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung)."""
heute = datetime.now(timezone.utc).date()
result = await db.execute(
select(GeraetInstanz, Objektposition, Material)
.join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id)
.join(Material, Objektposition.material_id == Material.id)
.where(GeraetInstanz.naechste_pruefung.is_not(None))
)
eintraege = []
for geraet, objektposition, material in result.all():
status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"geraet_instanz_id": geraet.id,
"objekt_id": objektposition.objekt_id,
"objektposition_id": objektposition.id,
"material_id": material.id,
"material_name": material.name,
"seriennummer": geraet.seriennummer,
"geraet_status": geraet.status,
"naechste_pruefung": geraet.naechste_pruefung,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege