feat(fleet): FLEET-006 Fahrzeug-Einsatzhistorie über OPS-002
CI / backend-tests (push) Successful in 2m51s
CI / frontend-build (push) Successful in 18s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 15:11:17 +02:00
1 parent 7219762a4c
commit 0aa5d6d249
8 files changed
+122 -2

No files matched your search

+6 -2
View File
@@ -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
+15
View File
@@ -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)]
+13
View File
@@ -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())
+43
View File
@@ -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"
@@ -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<Einsatz[] | null>(null);
useEffect(() => {
apiRequest<Einsatz[]>(`/objekte/${objektId}/einsaetze`)
.then(setEinsaetze)
.catch(() => onFehler("Einsatzhistorie konnte nicht geladen werden."));
}, [objektId, onFehler]);
if (einsaetze === null) return <p className="text-muted">Lade…</p>;
if (einsaetze.length === 0) return <p className="text-muted">Keine Einsätze erfasst.</p>;
return (
<ul className="card-list">
{einsaetze.map((e) => (
<li key={e.id} style={{ padding: "0.3rem 0" }}>
{new Date(e.datum).toLocaleDateString("de-DE")} — {e.bezeichnung}
{e.ort && <span className="text-muted"> ({e.ort})</span>}
</li>
))}
</ul>
);
}
+8
View File
@@ -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() {
</div>
)}
{istFahrzeug && (
<div className="akte-einsatzhistorie card">
<h3 style={{ marginTop: 0 }}>Einsatzhistorie</h3>
<EinsatzHistoriePanel objektId={objekt.id} onFehler={setFehler} />
</div>
)}
<div className="akte-readiness-verlauf card">
<h3 style={{ marginTop: 0 }}>Bereitschaftshistorie</h3>
<ReadinessVerlaufPanel objektId={objekt.id} />
+1
View File
@@ -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; }
@@ -21,6 +21,7 @@
"dokumente dokumente"
"notizen notizen"
"tankbuch tankbuch"
"einsatzhistorie einsatzhistorie"
"readiness readiness"
"historie historie";
}