feat(fahrzeug): Fahrzeug-Spezialfelder + 5-stufiger Status (Roadmap Phase 3)
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user