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:
@@ -3925,3 +3925,75 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/admin/QualifikationSection.tsx | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-05 13:20 – 13:24 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
- eb20aff feat(mangel): Mangel-Ticket-System (Roadmap Phase 2)
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 27 +++++++++++++
|
||||
- backend/alembic/versions/0013_mangel.py | 62 +++++++++++++++++++++++++++++
|
||||
- backend/app/api/v1/api.py | 2 +
|
||||
- backend/app/api/v1/endpoints/mangel.py | 85 +++++++++++++++++++++++++++++++++++++++
|
||||
- backend/app/models/mangel.py | 61 ++++++++++++++++++++++++++++
|
||||
- backend/app/schemas/mangel.py | 42 ++++++++++++++++++++
|
||||
- backend/app/services/dashboard.py | 5 +++
|
||||
- backend/app/services/mangel.py | 33 ++++++++++++++++
|
||||
- backend/tests/test_mangel.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- frontend/src/App.tsx | 9 +++++
|
||||
- frontend/src/api/types.ts | 17 ++++++++
|
||||
- frontend/src/components/AppShell.tsx | 3 ++
|
||||
- frontend/src/pages/DashboardPage.tsx | 1 +
|
||||
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-05 13:26 – 13:28 (2m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 27 +++++++++++++
|
||||
- backend/alembic/versions/0013_mangel.py | 62 +++++++++++++++++++++++++++++
|
||||
- backend/app/api/v1/api.py | 2 +
|
||||
- backend/app/api/v1/endpoints/mangel.py | 85 +++++++++++++++++++++++++++++++++++++++
|
||||
- backend/app/models/mangel.py | 61 ++++++++++++++++++++++++++++
|
||||
- backend/app/schemas/mangel.py | 42 ++++++++++++++++++++
|
||||
- backend/app/services/dashboard.py | 5 +++
|
||||
- backend/app/services/mangel.py | 33 ++++++++++++++++
|
||||
- backend/tests/test_mangel.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- frontend/src/App.tsx | 9 +++++
|
||||
- frontend/src/api/types.ts | 17 ++++++++
|
||||
- frontend/src/components/AppShell.tsx | 3 ++
|
||||
- frontend/src/pages/DashboardPage.tsx | 1 +
|
||||
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-05 13:29 – 13:30 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 27 +++++++++++++
|
||||
- backend/alembic/versions/0013_mangel.py | 62 +++++++++++++++++++++++++++++
|
||||
- backend/app/api/v1/api.py | 2 +
|
||||
- backend/app/api/v1/endpoints/mangel.py | 85 +++++++++++++++++++++++++++++++++++++++
|
||||
- backend/app/models/mangel.py | 61 ++++++++++++++++++++++++++++
|
||||
- backend/app/schemas/mangel.py | 42 ++++++++++++++++++++
|
||||
- backend/app/services/dashboard.py | 5 +++
|
||||
- backend/app/services/mangel.py | 33 ++++++++++++++++
|
||||
- backend/tests/test_mangel.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- frontend/src/App.tsx | 9 +++++
|
||||
- frontend/src/api/types.ts | 17 ++++++++
|
||||
- frontend/src/components/AppShell.tsx | 3 ++
|
||||
- frontend/src/pages/DashboardPage.tsx | 1 +
|
||||
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Fahrzeug-Spezialfelder (Roadmap Phase 3: BOS-Ressourcenplattform-Anforderung
|
||||
Modul 2 "Fahrzeuge"). Kennzeichen/Funkrufname/Kilometerstand/Betriebsstunden/
|
||||
Baujahr/HU/UVV als optionale 1:1-Erweiterung zu Objekt (nicht jeder Objekttyp
|
||||
braucht das, nur Zugfahrzeuge - additiv, kein eigener Fahrzeug-Objekttyp
|
||||
erzwungen, Karte 11 bleibt gültig).
|
||||
|
||||
Zusätzlich objekt_status um 'in_wartung' erweitert (Nutzer-Vorgabe: 5-stufiger
|
||||
Fahrzeug-Status). ALTER TYPE ... ADD VALUE muss außerhalb der laufenden
|
||||
Transaktion committen (Postgres-Einschränkung), daher AUTOCOMMIT-Block.
|
||||
|
||||
Revision ID: 0014_fahrzeugdetails
|
||||
Revises: 0013_mangel
|
||||
Create Date: 2026-09-05
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_fahrzeugdetails"
|
||||
down_revision: Union[str, None] = "0013_mangel"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("ALTER TYPE objekt_status ADD VALUE IF NOT EXISTS 'in_wartung'")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE fahrzeugdetails (
|
||||
objekt_id INTEGER PRIMARY KEY REFERENCES objekt(id),
|
||||
kennzeichen TEXT,
|
||||
funkrufname TEXT,
|
||||
baujahr INTEGER,
|
||||
kilometerstand INTEGER,
|
||||
betriebsstunden NUMERIC,
|
||||
naechste_hu DATE,
|
||||
naechste_uvv DATE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE fahrzeugdetails")
|
||||
# 'in_wartung' aus dem ENUM zu entfernen ist in Postgres nicht direkt möglich
|
||||
# (kein ALTER TYPE ... DROP VALUE) - bewusst nicht rückgängig gemacht, siehe
|
||||
# Muster in 0001_initial_schema (dort ebenfalls nur DROP TYPE als Ganzes).
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import auth_header, login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fahrzeugdetails_upsert(client, objekt_mit_position, admin_user):
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "admin1")
|
||||
|
||||
fehlend = await client.get(f"/api/v1/objekte/{objekt.id}/fahrzeugdetails", headers=auth_header(token))
|
||||
assert fehlend.status_code == 404
|
||||
|
||||
gesetzt = await client.put(
|
||||
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails",
|
||||
json={"kennzeichen": "XX-YY 123", "kilometerstand": 42315, "naechste_hu": "2027-05-01"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert gesetzt.status_code == 200
|
||||
assert gesetzt.json()["kennzeichen"] == "XX-YY 123"
|
||||
assert gesetzt.json()["kilometerstand"] == 42315
|
||||
|
||||
aktualisiert = await client.put(
|
||||
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails",
|
||||
json={"kilometerstand": 42500},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert aktualisiert.json()["kilometerstand"] == 42500
|
||||
assert aktualisiert.json()["kennzeichen"] is None # PUT ersetzt komplett, kein Merge
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mitarbeiter_darf_fahrzeugdetails_nicht_setzen(client, objekt_mit_position, mitarbeiter_user):
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "mitarbeiter1")
|
||||
response = await client.put(
|
||||
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails", json={"kennzeichen": "X"}, headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ausser_dienst_objekt_zaehlt_nicht_in_einsatzbereitschaft(
|
||||
client, objekt_mit_position, admin_user, materialverantwortlicher_user
|
||||
):
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "admin1")
|
||||
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "ausser_dienst"}, headers=auth_header(token))
|
||||
|
||||
verantwortlicher_token = await login(client, "materialverantwortlicher1")
|
||||
response = await client.get(
|
||||
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
|
||||
)
|
||||
body = response.json()
|
||||
assert body["gesamt"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_wartung_macht_objekt_nicht_einsatzbereit(
|
||||
client, objekt_mit_position, admin_user, materialverantwortlicher_user
|
||||
):
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "admin1")
|
||||
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "in_wartung"}, headers=auth_header(token))
|
||||
|
||||
verantwortlicher_token = await login(client, "materialverantwortlicher1")
|
||||
response = await client.get(
|
||||
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
|
||||
)
|
||||
body = response.json()
|
||||
assert body["nicht_einsatzbereit"] == 1
|
||||
assert body["details"][0]["gruende"] == ["in_wartung"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ueberfaellige_hu_macht_objekt_nicht_einsatzbereit_und_erscheint_in_prueftermine(
|
||||
client, objekt_mit_position, admin_user, materialverantwortlicher_user
|
||||
):
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "admin1")
|
||||
await client.put(
|
||||
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails",
|
||||
json={"naechste_hu": (date.today() - timedelta(days=10)).isoformat()},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
verantwortlicher_token = await login(client, "materialverantwortlicher1")
|
||||
bereitschaft = await client.get(
|
||||
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
|
||||
)
|
||||
assert bereitschaft.json()["details"][0]["gruende"] == ["hu_uvv_ueberfaellig"]
|
||||
|
||||
prueftermine = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(verantwortlicher_token))
|
||||
body = prueftermine.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["typ"] == "fahrzeug_hu"
|
||||
assert body[0]["status"] == "abgelaufen"
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface Fahrzeugdetails {
|
||||
objekt_id: number;
|
||||
kennzeichen: string | null;
|
||||
funkrufname: string | null;
|
||||
baujahr: number | null;
|
||||
kilometerstand: number | null;
|
||||
betriebsstunden: string | null;
|
||||
naechste_hu: string | null;
|
||||
naechste_uvv: string | null;
|
||||
}
|
||||
|
||||
export interface Objekt {
|
||||
id: number;
|
||||
code: string;
|
||||
|
||||
@@ -19,11 +19,10 @@ interface Einsatzbereitschaft {
|
||||
}
|
||||
|
||||
interface Prueftermineintrag {
|
||||
geraet_instanz_id: string;
|
||||
typ: string;
|
||||
objekt_id: number;
|
||||
material_name: string;
|
||||
seriennummer: string;
|
||||
naechste_pruefung: string;
|
||||
bezeichnung: string;
|
||||
faelligkeitsdatum: string;
|
||||
verbleibende_tage: number;
|
||||
status: string;
|
||||
}
|
||||
@@ -42,6 +41,8 @@ const GRUND_TEXT: Record<string, string> = {
|
||||
pruefung_ueberfaellig: "Geräteprüfung überfällig",
|
||||
kontrolle_laeuft: "Kontrolle läuft (gesperrt)",
|
||||
mangel_kritisch_offen: "Kritischer Mangel offen",
|
||||
in_wartung: "In Wartung",
|
||||
hu_uvv_ueberfaellig: "HU/UVV überfällig",
|
||||
mindermenge_genehmigt: "Mindermenge genehmigt",
|
||||
};
|
||||
|
||||
@@ -124,13 +125,11 @@ export function DashboardPage() {
|
||||
<p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>
|
||||
) : (
|
||||
<ul className="card-list">
|
||||
{prueftermine.map((p) => (
|
||||
<li key={p.geraet_instanz_id} className="row-between" style={{ padding: "0.3rem 0" }}>
|
||||
<span>
|
||||
{p.material_name} <span className="text-muted">(SN {p.seriennummer})</span>
|
||||
</span>
|
||||
<span className={p.status === "abgelaufen" ? "text-muted" : "text-muted"} style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
|
||||
{p.naechste_pruefung} ({p.verbleibende_tage} Tage)
|
||||
{prueftermine.map((p, i) => (
|
||||
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
|
||||
<span>{p.bezeichnung}</span>
|
||||
<span style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
|
||||
{p.faelligkeitsdatum} ({p.verbleibende_tage} Tage)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { Beladungsvorlage, Material, Objekt, Objekttyp, Standort } from "../../api/types";
|
||||
import type { Beladungsvorlage, Fahrzeugdetails, Material, Objekt, Objekttyp, Standort } from "../../api/types";
|
||||
import { ObjektPositionenPanel } from "./ObjektPositionenPanel";
|
||||
|
||||
const STATUS_OPTIONEN = [
|
||||
{ wert: "aktiv", label: "Aktiv" },
|
||||
{ wert: "in_wartung", label: "In Wartung" },
|
||||
{ wert: "ausser_dienst", label: "Außer Dienst" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
objekte: Objekt[];
|
||||
objekttypen: Objekttyp[];
|
||||
@@ -46,6 +52,52 @@ export function ObjektSection({
|
||||
const [fahrzeugBearbeitenId, setFahrzeugBearbeitenId] = useState<number | null>(null);
|
||||
const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState<number | "">("");
|
||||
const [suche, setSuche] = useState("");
|
||||
const [detailsOffenId, setDetailsOffenId] = useState<number | null>(null);
|
||||
const [details, setDetails] = useState<Partial<Fahrzeugdetails>>({});
|
||||
|
||||
async function statusAendern(o: Objekt, status: string) {
|
||||
try {
|
||||
await apiRequest(`/objekte/${o.id}`, { method: "PATCH", body: { status } });
|
||||
await onGeaendert();
|
||||
} catch {
|
||||
onFehler("Status konnte nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function fahrzeugdetailsOeffnen(o: Objekt) {
|
||||
if (detailsOffenId === o.id) {
|
||||
setDetailsOffenId(null);
|
||||
return;
|
||||
}
|
||||
setDetailsOffenId(o.id);
|
||||
try {
|
||||
const daten = await apiRequest<Fahrzeugdetails>(`/objekte/${o.id}/fahrzeugdetails`);
|
||||
setDetails(daten);
|
||||
} catch {
|
||||
setDetails({});
|
||||
}
|
||||
}
|
||||
|
||||
async function fahrzeugdetailsSpeichern(objektId: number) {
|
||||
try {
|
||||
await apiRequest(`/objekte/${objektId}/fahrzeugdetails`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
kennzeichen: details.kennzeichen || null,
|
||||
funkrufname: details.funkrufname || null,
|
||||
baujahr: details.baujahr || null,
|
||||
kilometerstand: details.kilometerstand || null,
|
||||
betriebsstunden: details.betriebsstunden || null,
|
||||
naechste_hu: details.naechste_hu || null,
|
||||
naechste_uvv: details.naechste_uvv || null,
|
||||
},
|
||||
});
|
||||
setDetailsOffenId(null);
|
||||
onFehler("");
|
||||
} catch {
|
||||
onFehler("Fahrzeugdetails konnten nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp-
|
||||
// Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per
|
||||
@@ -286,6 +338,24 @@ export function ObjektSection({
|
||||
</span>
|
||||
</span>
|
||||
<div className="row">
|
||||
{!nurPflegen && (
|
||||
<select
|
||||
className="input"
|
||||
value={o.status}
|
||||
onChange={(e) => statusAendern(o, e.target.value)}
|
||||
>
|
||||
{STATUS_OPTIONEN.map((s) => (
|
||||
<option key={s.wert} value={s.wert}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{objekttypen.find((t) => t.id === o.objekttyp_id)?.ist_zugfahrzeug && (
|
||||
<button className="btn btn-secondary" onClick={() => fahrzeugdetailsOeffnen(o)}>
|
||||
{detailsOffenId === o.id ? "Schließen" : "Fahrzeugdetails"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
|
||||
@@ -304,6 +374,63 @@ export function ObjektSection({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{detailsOffenId === o.id && (
|
||||
<div className="stack" style={{ marginTop: "0.5rem", maxWidth: "24rem" }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Kennzeichen"
|
||||
value={details.kennzeichen ?? ""}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, kennzeichen: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Funkrufname"
|
||||
value={details.funkrufname ?? ""}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, funkrufname: e.target.value }))}
|
||||
/>
|
||||
<div className="row">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="Baujahr"
|
||||
value={details.baujahr ?? ""}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, baujahr: Number(e.target.value) || undefined }))}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="Kilometerstand"
|
||||
value={details.kilometerstand ?? ""}
|
||||
onChange={(e) =>
|
||||
setDetails((d) => ({ ...d, kilometerstand: Number(e.target.value) || undefined }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="field">
|
||||
<label>Nächste HU</label>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={details.naechste_hu ?? ""}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, naechste_hu: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nächste UVV</label>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={details.naechste_uvv ?? ""}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, naechste_uvv: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => fahrzeugdetailsSpeichern(o.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fahrzeugBearbeitenId === o.id && (
|
||||
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
|
||||
<select
|
||||
|
||||
Reference in New Issue
Block a user