feat(fahrzeug): Fahrzeug-Spezialfelder + 5-stufiger Status (Roadmap Phase 3)
CI / backend-tests (push) Failing after 1m40s
CI / frontend-build (push) Successful in 17s

Neue Tabelle fahrzeugdetails (1:1 zu Objekt, additiv/optional): Kennzeichen,
Funkrufname, Baujahr, Kilometerstand, Betriebsstunden, nächste HU/UVV.
objekt_status um 'in_wartung' erweitert (Nutzer-Vorgabe: einsatzbereit/
eingeschränkt/nicht einsatzbereit/in Wartung/außer Dienst - die ersten drei
bleiben weiterhin rein rechnerisch über dashboard.einsatzbereitschaft(),
in_wartung/ausser_dienst sind die beiden manuell gesetzten Zustände).

dashboard.einsatzbereitschaft(): ausser_dienst-Objekte zählen nicht mehr mit,
in_wartung und überfällige HU/UVV sind neue Gründe für "nicht einsatzbereit".

dashboard.bevorstehende_prueftermine() (Kachel 2) zeigt jetzt Geräte- UND
Fahrzeugprüfungen (HU/UVV) in einer Liste (Nutzer-Vorgabe: ein Konzept für
"welche Prüfungen bald fällig sind") - Schema dafür verallgemeinert
(typ/bezeichnung/faelligkeitsdatum statt geräte-spezifischer Felder).

Frontend: Status-Dropdown + Fahrzeugdetails-Formular in ObjektSection
(nur für Objekttypen mit ist_zugfahrzeug=True), Dashboard-Kachel 2 zeigt
gemischte Liste.

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 13:36:01 +02:00
co-authored by Claude Sonnet 5
parent eb20affd5d
commit 15fb8dbb16
12 changed files with 545 additions and 31 deletions
+40
View File
@@ -8,12 +8,15 @@ 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.label import generiere_code128_label_pdf
from app.schemas.objekt import (
FahrzeugdetailsRead,
FahrzeugdetailsUpsert,
ObjektCreate,
ObjektDuplizierenRequest,
ObjektpositionCreate,
@@ -391,3 +394,40 @@ async def fuege_zusatzposition_hinzu(
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
+24
View File
@@ -0,0 +1,24 @@
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Fahrzeugdetails(Base):
"""Roadmap Phase 3 (Modul "Fahrzeuge"): optionale 1:1-Erweiterung zu Objekt
für Fahrzeug-Spezialfelder. Objekt bleibt generisch (Karte 11) - nicht jedes
Objekt braucht das, daher eigene Tabelle statt Spalten direkt an Objekt."""
__tablename__ = "fahrzeugdetails"
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), primary_key=True)
kennzeichen: Mapped[str | None] = mapped_column(String)
funkrufname: Mapped[str | None] = mapped_column(String)
baujahr: Mapped[int | None] = mapped_column(Integer)
kilometerstand: Mapped[int | None] = mapped_column(Integer)
betriebsstunden: Mapped[Decimal | None] = mapped_column(Numeric)
naechste_hu: Mapped[date | None] = mapped_column(Date)
naechste_uvv: Mapped[date | None] = mapped_column(Date)
+1
View File
@@ -10,6 +10,7 @@ from app.db.base import Base
class ObjektStatus(str, enum.Enum):
aktiv = "aktiv"
ausser_dienst = "ausser_dienst"
in_wartung = "in_wartung"
objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False)
+12 -9
View File
@@ -3,8 +3,6 @@ from datetime import date
from pydantic import BaseModel
from app.models.geraet_instanz import GeraetStatus
class KennzahlenRead(BaseModel):
anzahl_offener_fehlbestaende: int
@@ -39,13 +37,18 @@ class EinsatzbereitschaftRead(BaseModel):
class PrueftermineintragRead(BaseModel):
geraet_instanz_id: uuid.UUID
"""typ='geraet' (Karte 14) oder 'fahrzeug_hu'/'fahrzeug_uvv' (Roadmap Phase
3, Modul Fahrzeuge) - eine Kachel für beide (Nutzer-Vorgabe: "welche
Prüfungen bald fällig sind" ist ein Konzept). Die geraet-spezifischen
Felder sind bei Fahrzeug-Einträgen None."""
typ: str
objekt_id: int
objektposition_id: uuid.UUID
material_id: int
material_name: str
seriennummer: str
geraet_status: GeraetStatus
naechste_pruefung: date
bezeichnung: str
faelligkeitsdatum: date
verbleibende_tage: int
status: str
geraet_instanz_id: uuid.UUID | None = None
objektposition_id: uuid.UUID | None = None
material_id: int | None = None
seriennummer: str | None = None
+23
View File
@@ -32,6 +32,29 @@ class ObjektRead(BaseModel):
class ObjektUpdate(BaseModel):
fahrzeug_id: int | None = None
status: ObjektStatus | None = None
class FahrzeugdetailsUpsert(BaseModel):
kennzeichen: str | None = None
funkrufname: str | None = None
baujahr: int | None = None
kilometerstand: int | None = None
betriebsstunden: Decimal | None = None
naechste_hu: date | None = None
naechste_uvv: date | None = None
class FahrzeugdetailsRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
objekt_id: int
kennzeichen: str | None
funkrufname: str | None
baujahr: int | None
kilometerstand: int | None
betriebsstunden: Decimal | None
naechste_hu: date | None
naechste_uvv: date | None
class ObjektDuplizierenRequest(BaseModel):
+76 -10
View File
@@ -3,11 +3,12 @@ from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fahrzeugdetails import Fahrzeugdetails
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.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition
from app.models.stammdaten import Material
from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel
@@ -104,7 +105,12 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
(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()
# ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3,
# Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als
# aktiver Grund weiter unten erscheint statt das Objekt komplett auszublenden.
objekte = (
(await db.execute(select(Objekt).where(Objekt.status != ObjektStatus.ausser_dienst))).scalars().all()
)
offene_fehlbestaende = (
await db.execute(
@@ -163,6 +169,25 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
objekt_ids_kritischer_mangel = await objekt_ids_mit_kritischem_offenem_mangel(db)
objekt_ids_hu_uvv_ueberfaellig = set(
(
await db.execute(
select(func.distinct(Fahrzeugdetails.objekt_id)).where(
(
(Fahrzeugdetails.naechste_hu.is_not(None))
& (Fahrzeugdetails.naechste_hu < heute)
)
| (
(Fahrzeugdetails.naechste_uvv.is_not(None))
& (Fahrzeugdetails.naechste_uvv < heute)
)
)
)
)
.scalars()
.all()
)
bereit = eingeschraenkt = 0
details: list[dict] = []
for objekt in objekte:
@@ -179,6 +204,10 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
gruende.append("kontrolle_laeuft")
if objekt.id in objekt_ids_kritischer_mangel:
gruende.append("mangel_kritisch_offen")
if objekt.status == ObjektStatus.in_wartung:
gruende.append("in_wartung")
if objekt.id in objekt_ids_hu_uvv_ueberfaellig:
gruende.append("hu_uvv_ueberfaellig")
if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt:
eingeschraenkt += 1
@@ -205,30 +234,67 @@ async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int =
Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist
Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung)."""
heute = datetime.now(timezone.utc).date()
eintraege = []
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,
"typ": "geraet",
"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,
"bezeichnung": f"{material.name} (SN {geraet.seriennummer})",
"faelligkeitsdatum": geraet.naechste_pruefung,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
"geraet_instanz_id": geraet.id,
"objektposition_id": objektposition.id,
"material_id": material.id,
"seriennummer": geraet.seriennummer,
}
)
# Fahrzeug-HU/UVV (Roadmap Phase 3, Modul Fahrzeuge) - gleiche Kachel wie
# Geräteprüfungen (Nutzer-Vorgabe: "welche Prüfungen bald fällig sind" ist
# EIN Konzept, egal ob Gerät oder Fahrzeug).
result = await db.execute(
select(Fahrzeugdetails, Objekt)
.join(Objekt, Fahrzeugdetails.objekt_id == Objekt.id)
.where(
(Fahrzeugdetails.naechste_hu.is_not(None)) | (Fahrzeugdetails.naechste_uvv.is_not(None))
)
)
for details, objekt in result.all():
for feld, typ, label in (
(details.naechste_hu, "fahrzeug_hu", "HU"),
(details.naechste_uvv, "fahrzeug_uvv", "UVV"),
):
if feld is None:
continue
status_wert, verbleibende_tage = _ablauf_status(feld, warnzeitraum_tage, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"typ": typ,
"objekt_id": objekt.id,
"bezeichnung": f"{label} {objekt.name} ({details.kennzeichen or objekt.code})",
"faelligkeitsdatum": feld,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
"geraet_instanz_id": None,
"objektposition_id": None,
"material_id": None,
"seriennummer": None,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege