From 0aa5d6d249ea119fc7c381fc26d5d3174feebc6f Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 10 Sep 2026 15:11:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(fleet):=20FLEET-006=20Fahrzeug-Einsatzhist?= =?UTF-8?q?orie=20=C3=BCber=20OPS-002?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/05_fleet.md | 8 +++- backend/app/api/v1/endpoints/einsatz.py | 15 +++++++ backend/app/services/einsatz.py | 13 ++++++ backend/tests/test_einsatz.py | 43 +++++++++++++++++++ .../src/components/EinsatzHistoriePanel.tsx | 35 +++++++++++++++ frontend/src/pages/AktePage.tsx | 8 ++++ frontend/src/styles/global/akte-grid.css | 1 + frontend/src/styles/global/responsive.css | 1 + 8 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/EinsatzHistoriePanel.tsx diff --git a/arbeitskacheln/05_fleet.md b/arbeitskacheln/05_fleet.md index 8d8ecdf..d7cd0ce 100644 --- a/arbeitskacheln/05_fleet.md +++ b/arbeitskacheln/05_fleet.md @@ -128,8 +128,12 @@ Details zu FLEET-001 … FLEET-006 (vollständig). TBD — nicht sinnvoll spezifizierbar vor OPS-001/002. - **Akzeptanzkriterien:** TBD. - **Tests:** TBD. -- **DoD:** **DECISION REQUIRED:** diese Kachel bleibt bewusst Platzhalter, erst bei - tatsächlichem Angehen des Operations-Epics neu aufsetzen statt jetzt blind zu spezifizieren. +- **DoD:** ✅ umgesetzt (2026-09-10) — Operations-Epic hat seit 2026-09-10 einen + echten Bedarf (Epic 16, OPS-001/002), Platzhalter-Status damit aufgelöst. + `app/services/einsatz.py:einsaetze_fuer_objekt()` (Join über + `einsatz_ressource`), `GET /objekte/{id}/einsaetze` (mitarbeiter+), + `EinsatzHistoriePanel.tsx` in der Fahrzeug-Akte. AVL/GPS-Tracking (siehe + Referenz unten) weiterhin kein Bedarf, nicht umgesetzt. - **Referenz (Open-Source-Vergleich 2026-09-09):** Resgrid (`Resgrid/Core`, .NET/Angular, Apache-2.0) nutzt für sein Unit/Personnel-Modell zusätzlich AVL (Automatic Vehicle Location, GPS-Tracking) und einen feingranularen Verfügbarkeits-Status. Kein aktueller diff --git a/backend/app/api/v1/endpoints/einsatz.py b/backend/app/api/v1/endpoints/einsatz.py index 5c2bc0b..2cccc92 100644 --- a/backend/app/api/v1/endpoints/einsatz.py +++ b/backend/app/api/v1/endpoints/einsatz.py @@ -11,6 +11,7 @@ from app.models.objekt import Objekt from app.schemas.einsatz import EinsatzCreate, EinsatzRead, EinsatzUpdate from app.services.einsatz import ( aendere_einsatz, + einsaetze_fuer_objekt, entferne_ressource, erstelle_einsatz, liste_einsaetze, @@ -21,6 +22,12 @@ from app.services.einsatz import ( router = APIRouter() _leitung_plus = require_roles(RolleTyp.administration, RolleTyp.leitungsverantwortlicher) +_lesend = require_roles( + RolleTyp.mitarbeiter, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + RolleTyp.administration, +) async def _einsatz_read(db: AsyncSession, einsatz: Einsatz) -> EinsatzRead: @@ -85,3 +92,11 @@ async def einsatz_ressource_entfernen( einsatz_id: uuid.UUID, objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_leitung_plus) ) -> None: await entferne_ressource(db, einsatz_id, objekt_id) + + +@router.get("/objekte/{objekt_id}/einsaetze", response_model=list[EinsatzRead]) +async def hole_einsaetze_fuer_objekt( + objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_lesend) +) -> list[EinsatzRead]: + """FLEET-006: Einsatzhistorie eines Objekts (i.d.R. Fahrzeug), für die Akte.""" + return [await _einsatz_read(db, e) for e in await einsaetze_fuer_objekt(db, objekt_id)] diff --git a/backend/app/services/einsatz.py b/backend/app/services/einsatz.py index 9baa333..f4a6e3d 100644 --- a/backend/app/services/einsatz.py +++ b/backend/app/services/einsatz.py @@ -56,3 +56,16 @@ async def entferne_ressource(db: AsyncSession, einsatz_id: uuid.UUID, objekt_id: if bestehend is not None: await db.delete(bestehend) await db.commit() + + +async def einsaetze_fuer_objekt(db: AsyncSession, objekt_id: int) -> list[Einsatz]: + """FLEET-006: welche Einsätze ein Fahrzeug/Objekt gefahren ist - nutzt + OPS-002 (`einsatz_ressource`), jetzt möglich seit Epic 16 einen echten + Bedarf bekommen hat (vorher Platzhalter ohne Operations-Datenmodell).""" + result = await db.execute( + select(Einsatz) + .join(EinsatzRessource, EinsatzRessource.einsatz_id == Einsatz.id) + .where(EinsatzRessource.objekt_id == objekt_id) + .order_by(Einsatz.datum.desc()) + ) + return list(result.scalars().all()) diff --git a/backend/tests/test_einsatz.py b/backend/tests/test_einsatz.py index 5e11aa7..0618c55 100644 --- a/backend/tests/test_einsatz.py +++ b/backend/tests/test_einsatz.py @@ -52,3 +52,46 @@ async def test_einsatz_nur_leitung_plus(client, mitarbeiter_user): token = await login(client, "mitarbeiter1") response = await client.get("/api/v1/einsaetze", headers=auth_header(token)) assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_fleet006_einsatzhistorie_je_objekt( + client, db_session, admin_user, mitarbeiter_user, standort_factory, objekt_factory +): + standort = await standort_factory("Wache-Historie") + fahrzeug_a = await objekt_factory(name="RTW A", code="RTW-HIST-A", standort=standort) + fahrzeug_b = await objekt_factory(name="RTW B", code="RTW-HIST-B", standort=standort) + admin_token = await login(client, "admin1") + + einsatz1 = ( + await client.post( + "/api/v1/einsaetze", + json={"bezeichnung": "Einsatz 1", "datum": "2026-09-01"}, + headers=auth_header(admin_token), + ) + ).json() + einsatz2 = ( + await client.post( + "/api/v1/einsaetze", + json={"bezeichnung": "Einsatz 2", "datum": "2026-09-05"}, + headers=auth_header(admin_token), + ) + ).json() + + await client.put(f"/api/v1/einsaetze/{einsatz1['id']}/ressourcen/{fahrzeug_a.id}", headers=auth_header(admin_token)) + await client.put(f"/api/v1/einsaetze/{einsatz2['id']}/ressourcen/{fahrzeug_a.id}", headers=auth_header(admin_token)) + await client.put(f"/api/v1/einsaetze/{einsatz1['id']}/ressourcen/{fahrzeug_b.id}", headers=auth_header(admin_token)) + + mitarbeiter_token = await login(client, "mitarbeiter1") + historie_a = await client.get( + f"/api/v1/objekte/{fahrzeug_a.id}/einsaetze", headers=auth_header(mitarbeiter_token) + ) + assert historie_a.status_code == 200 + bezeichnungen = [e["bezeichnung"] for e in historie_a.json()] + assert bezeichnungen == ["Einsatz 2", "Einsatz 1"] # neueste zuerst + + historie_b = await client.get( + f"/api/v1/objekte/{fahrzeug_b.id}/einsaetze", headers=auth_header(mitarbeiter_token) + ) + assert len(historie_b.json()) == 1 + assert historie_b.json()[0]["bezeichnung"] == "Einsatz 1" diff --git a/frontend/src/components/EinsatzHistoriePanel.tsx b/frontend/src/components/EinsatzHistoriePanel.tsx new file mode 100644 index 0000000..1b0386c --- /dev/null +++ b/frontend/src/components/EinsatzHistoriePanel.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../api/client"; +import type { Einsatz } from "../api/types"; + +interface Props { + objektId: number; + onFehler: (text: string) => void; +} + +// FLEET-006: welche Einsätze dieses Fahrzeug gefahren ist - nutzt OPS-002 +// (einsatz_ressource), self-fetching wie TankbuchPanel/ReadinessVerlaufPanel. +export function EinsatzHistoriePanel({ objektId, onFehler }: Props) { + const [einsaetze, setEinsaetze] = useState(null); + + useEffect(() => { + apiRequest(`/objekte/${objektId}/einsaetze`) + .then(setEinsaetze) + .catch(() => onFehler("Einsatzhistorie konnte nicht geladen werden.")); + }, [objektId, onFehler]); + + if (einsaetze === null) return

Lade…

; + if (einsaetze.length === 0) return

Keine Einsätze erfasst.

; + + return ( +
    + {einsaetze.map((e) => ( +
  • + {new Date(e.datum).toLocaleDateString("de-DE")} — {e.bezeichnung} + {e.ort && ({e.ort})} +
  • + ))} +
+ ); +} diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index 26af776..756f4a8 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -5,6 +5,7 @@ import { apiRequest, ladeObjektEtikett } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { Akte } from "../api/types"; import { DokumentePanel } from "../components/DokumentePanel"; +import { EinsatzHistoriePanel } from "../components/EinsatzHistoriePanel"; import { HistorieIntegritaetBadge } from "../components/HistorieIntegritaetBadge"; import { NotizenPanel } from "../components/NotizenPanel"; import { ReadinessVerlaufPanel } from "../components/ReadinessVerlaufPanel"; @@ -300,6 +301,13 @@ export function AktePage() { )} + {istFahrzeug && ( +
+

Einsatzhistorie

+ +
+ )} +

Bereitschaftshistorie

diff --git a/frontend/src/styles/global/akte-grid.css b/frontend/src/styles/global/akte-grid.css index e26841a..7c4bb8f 100644 --- a/frontend/src/styles/global/akte-grid.css +++ b/frontend/src/styles/global/akte-grid.css @@ -28,5 +28,6 @@ .akte-dokumente { grid-area: dokumente; } .akte-notizen { grid-area: notizen; } .akte-tankbuch { grid-area: tankbuch; } +.akte-einsatzhistorie { grid-area: einsatzhistorie; } .akte-readiness-verlauf { grid-area: readiness; } .akte-historie { grid-area: historie; } diff --git a/frontend/src/styles/global/responsive.css b/frontend/src/styles/global/responsive.css index b30483d..85966f4 100644 --- a/frontend/src/styles/global/responsive.css +++ b/frontend/src/styles/global/responsive.css @@ -21,6 +21,7 @@ "dokumente dokumente" "notizen notizen" "tankbuch tankbuch" + "einsatzhistorie einsatzhistorie" "readiness readiness" "historie historie"; }