feat(fleet): FLEET-006 Fahrzeug-Einsatzhistorie über OPS-002
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
7219762a4c
commit
0aa5d6d249
8 files changed
+122
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user