feat(ui): UI-005 begründete Einsatzbereitschaft + Dokumente in der Akte
CI / backend-tests (push) Successful in 2m2s
CI / frontend-build (push) Successful in 19s

Backend: objekt_readiness() in dashboard.py liefert Status+Gründe für
ein einzelnes Objekt, Fallunterscheidung aus einsatzbereitschaft()
extrahiert (_kategorie_und_gruende, keine Duplizierung). Neue Felder
an /akte/objekt/{id}. Frontend: ReadinessBadge + Gründeliste ersetzt
den rohen objekt.status-Badge (Objekt-Lebenszyklus-Status bleibt
separat sichtbar). Dokumente waren in der Akte bisher nur Text ohne
Interaktion - jetzt DokumentePanel eingebunden plus neuer "Ansehen"-
Klick (PDF/Bild im neuen Tab statt Zwangs-Download).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 21:35:52 +02:00
co-authored by Claude Sonnet 5
parent 0132bb9ec9
commit 209b541220
11 changed files with 251 additions and 60 deletions
+14 -1
View File
@@ -136,7 +136,20 @@ Aktueller Stand vor Redesign: 39 Seiten-/Komponenten-Dateien, ~7.000 Zeilen, ein
- **Akzeptanzkriterien:** „unbekannt/nie kontrolliert" wird nie als einsatzbereit - **Akzeptanzkriterien:** „unbekannt/nie kontrolliert" wird nie als einsatzbereit
dargestellt (harte Regel, Akzeptanzkriterium testbar). dargestellt (harte Regel, Akzeptanzkriterium testbar).
- **Tests:** Begründungslogik testen (welche Bedingungen führen zu welchem Status). - **Tests:** Begründungslogik testen (welche Bedingungen führen zu welchem Status).
- **DoD:** offen. - **DoD:** umgesetzt (2026-09-06). Backend: `objekt_readiness()` in
`app/services/dashboard.py` - Fallunterscheidung aus `einsatzbereitschaft()`
in `_kategorie_und_gruende()` extrahiert, keine Duplizierung, für ein
einzelnes Objekt statt aller. Neue Felder `einsatzbereitschaft_status`/
`einsatzbereitschaft_gruende` an `/akte/objekt/{id}`. Frontend:
`ReadinessBadge` + Gründeliste (gemeinsames `GRUND_TEXT`-Mapping mit
Dashboard) statt des rohen `objekt.status`-Badges - Objekt-Lebenszyklus-
Status (aktiv/ausser_dienst/in_wartung) bleibt separat als Text sichtbar,
keine Vermischung. Getestet: kritischer Mangel -> "not-ready", nie
kontrolliert ohne sonstige Auffälligkeit -> "unknown" (nie "ready").
Nebenbei (Nutzer-Nachfrage): Dokumente in der Akte waren bisher nur Text
ohne jede Interaktion - jetzt `DokumentePanel` eingebunden (Upload/Liste/
Löschen wie in Mängel/Admin), plus neuer "Ansehen"-Klick (`ladeDokumentAnsehen`
öffnet PDF/Bild im neuen Tab) zusätzlich zum bisherigen Zwangs-Download.
## UI-006 — Kontrolle & Beladung modernisiert ## UI-006 — Kontrolle & Beladung modernisiert
+2
View File
@@ -24,6 +24,8 @@ class AkteRead(BaseModel):
03_digital_file.md).""" 03_digital_file.md)."""
objekt: ObjektRead objekt: ObjektRead
einsatzbereitschaft_status: str
einsatzbereitschaft_gruende: list[str]
objekttyp_name: str objekttyp_name: str
standort_name: str standort_name: str
fahrzeugdetails: FahrzeugdetailsRead | None fahrzeugdetails: FahrzeugdetailsRead | None
+5
View File
@@ -18,6 +18,7 @@ from app.schemas.historie import HistorieRead
from app.schemas.mangel import MangelRead from app.schemas.mangel import MangelRead
from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead
from app.schemas.zustaendigkeit import KontrollverantwortungRead from app.schemas.zustaendigkeit import KontrollverantwortungRead
from app.services.dashboard import objekt_readiness
from app.services.objekte import letzte_kontrollen_map from app.services.objekte import letzte_kontrollen_map
@@ -89,12 +90,16 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None:
maengel_result = await db.execute(select(Mangel).where(Mangel.objekt_id == objekt_id)) maengel_result = await db.execute(select(Mangel).where(Mangel.objekt_id == objekt_id))
readiness = await objekt_readiness(db, objekt_id)
objekt_read = ObjektRead.model_validate(objekt).model_copy( objekt_read = ObjektRead.model_validate(objekt).model_copy(
update={"letzte_kontrolle_am": letzte_map.get(objekt_id)} update={"letzte_kontrolle_am": letzte_map.get(objekt_id)}
) )
return AkteRead( return AkteRead(
objekt=objekt_read, objekt=objekt_read,
einsatzbereitschaft_status=readiness["status"],
einsatzbereitschaft_gruende=readiness["gruende"],
objekttyp_name=objekttyp.name if objekttyp else "", objekttyp_name=objekttyp.name if objekttyp else "",
standort_name=standort.name if standort else "", standort_name=standort.name if standort else "",
fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None, fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None,
+139 -22
View File
@@ -199,8 +199,51 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
bereit = eingeschraenkt = unbekannt = 0 bereit = eingeschraenkt = unbekannt = 0
details: list[dict] = [] details: list[dict] = []
for objekt in objekte: for objekt in objekte:
kategorie, gruende = _kategorie_und_gruende(
objekt,
letzte_kontrolle_map.get(objekt.id),
objekt_ids_mit_fehlbestand.get(objekt.id, set()),
fehlbestand_ids_genehmigt,
objekt_ids_abgelaufen,
objekt_ids_pruefung_ueberfaellig,
objekt_ids_gesperrt,
objekt_ids_kritischer_mangel,
objekt_ids_hu_uvv_ueberfaellig,
)
if kategorie == "ready":
bereit += 1
else:
if kategorie == "limited":
eingeschraenkt += 1
elif kategorie == "unknown":
unbekannt += 1
details.append({"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": gruende})
return {
"einsatzbereit": bereit,
"eingeschraenkt_einsatzbereit": eingeschraenkt,
"unbekannt": unbekannt,
"nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt - unbekannt,
"gesamt": len(objekte),
"details": details,
}
def _kategorie_und_gruende(
objekt: Objekt,
letzte_kontrolle,
fehlbestand_ids: set,
fehlbestand_ids_genehmigt: set,
objekt_ids_abgelaufen: set,
objekt_ids_pruefung_ueberfaellig: set,
objekt_ids_gesperrt: set,
objekt_ids_kritischer_mangel: set,
objekt_ids_hu_uvv_ueberfaellig: set,
) -> tuple[str, list[str]]:
"""UI-005 (Epic 20): aus der `einsatzbereitschaft()`-Schleife extrahiert, damit
`objekt_readiness()` (Begründung auf der Objektakte) dieselbe Logik nutzt statt
sie zu duplizieren."""
gruende: list[str] = [] gruende: list[str] = []
fehlbestand_ids = objekt_ids_mit_fehlbestand.get(objekt.id, set())
ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt
if ungenehmigt: if ungenehmigt:
gruende.append("fehlbestand_offen") gruende.append("fehlbestand_offen")
@@ -218,28 +261,102 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
gruende.append("hu_uvv_ueberfaellig") gruende.append("hu_uvv_ueberfaellig")
if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt: if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt:
eingeschraenkt += 1 return "limited", ["mindermenge_genehmigt"]
details.append( if not gruende and letzte_kontrolle is None:
{"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["mindermenge_genehmigt"]} return "unknown", ["noch_nie_kontrolliert"]
) if not gruende:
elif not gruende and letzte_kontrolle_map.get(objekt.id) is None: return "ready", []
unbekannt += 1 return "not-ready", gruende
details.append(
{"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["noch_nie_kontrolliert"]}
)
elif not gruende:
bereit += 1
else:
details.append({"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": gruende})
return {
"einsatzbereit": bereit, async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict:
"eingeschraenkt_einsatzbereit": eingeschraenkt, """UI-005 (Epic 20): begründete Einsatzbereitschaft für EIN Objekt (Objektakte),
"unbekannt": unbekannt, dieselbe Fachlogik wie `einsatzbereitschaft()` (Dashboard-Aggregat), nur für
"nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt - unbekannt, einen einzelnen Datensatz statt aller Objekte. `ausser_dienst` zählt hier
"gesamt": len(objekte), bewusst nicht als eigener Fall - Objektakte zeigt das Objekt so oder so an,
"details": details, der Objekt-Lebenszyklus-Status wird separat dargestellt."""
} heute = datetime.now(timezone.utc).date()
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
return {"status": "unknown", "gruende": []}
fehlbestand_ids = set(
(
await db.execute(
select(Fehlbestand.id).where(
Fehlbestand.objekt_id == objekt_id, Fehlbestand.status.in_(OFFEN_STATI)
)
)
)
.scalars()
.all()
)
fehlbestand_ids_genehmigt = set(
(
await db.execute(
select(func.distinct(MindermengenGenehmigung.fehlbestand_id)).where(
MindermengenGenehmigung.fehlbestand_id.in_(fehlbestand_ids),
MindermengenGenehmigung.status == MindermengeStatus.aktiv,
)
)
)
.scalars()
.all()
)
hat_ablaufdatum = (
await db.execute(
select(Objektposition.id).where(
Objektposition.objekt_id == objekt_id,
Objektposition.ablaufdatum.is_not(None),
Objektposition.ablaufdatum < heute,
).limit(1)
)
).first() is not None
hat_ueberfaellige_pruefung = (
await db.execute(
select(GeraetInstanz.id)
.join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id)
.where(
Objektposition.objekt_id == objekt_id,
GeraetInstanz.naechste_pruefung.is_not(None),
GeraetInstanz.naechste_pruefung < heute,
)
.limit(1)
)
).first() is not None
ist_gesperrt = (
await db.execute(
select(Kontrolle.id)
.where(Kontrolle.objekt_id == objekt_id, Kontrolle.status == KontrollStatus.in_bearbeitung)
.limit(1)
)
).first() is not None
hat_kritischen_mangel = objekt_id in await objekt_ids_mit_kritischem_offenem_mangel(db)
hat_ueberfaellige_hu_uvv = (
await db.execute(
select(Fahrzeugdetails.objekt_id).where(
Fahrzeugdetails.objekt_id == objekt_id,
(
(Fahrzeugdetails.naechste_hu.is_not(None)) & (Fahrzeugdetails.naechste_hu < heute)
)
| ((Fahrzeugdetails.naechste_uvv.is_not(None)) & (Fahrzeugdetails.naechste_uvv < heute)),
)
)
).first() is not None
letzte_kontrolle_map = await letzte_kontrollen_map(db, [objekt_id])
kategorie, gruende = _kategorie_und_gruende(
objekt,
letzte_kontrolle_map.get(objekt_id),
fehlbestand_ids,
fehlbestand_ids_genehmigt,
{objekt_id} if hat_ablaufdatum else set(),
{objekt_id} if hat_ueberfaellige_pruefung else set(),
{objekt_id} if ist_gesperrt else set(),
{objekt_id} if hat_kritischen_mangel else set(),
{objekt_id} if hat_ueberfaellige_hu_uvv else set(),
)
return {"status": kategorie, "gruende": gruende}
async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = 30) -> list[dict]: async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = 30) -> list[dict]:
+15
View File
@@ -48,6 +48,21 @@ async def test_akte_buendelt_teilbereiche(client, db_session, objekt_mit_positio
assert body["maengel"][0]["beschreibung"] == "Riss im Gurt" assert body["maengel"][0]["beschreibung"] == "Riss im Gurt"
assert len(body["historie"]) == 1 assert len(body["historie"]) == 1
assert body["historie"][0]["entitaet_typ"] == "objekt" assert body["historie"][0]["entitaet_typ"] == "objekt"
assert body["einsatzbereitschaft_status"] == "not-ready"
assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"]
@pytest.mark.asyncio
async def test_akte_readiness_unbekannt_bei_nie_kontrolliert(client, objekt_mit_position, admin_user):
"""UI-005: ein Objekt ohne jede Auffälligkeit, aber noch nie kontrolliert,
gilt als "unbekannt" - NIE als "einsatzbereit" (Akzeptanzkriterium)."""
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
antwort = await client.get(f"/api/v1/akte/objekt/{objekt.id}", headers=auth_header(token))
assert antwort.status_code == 200
body = antwort.json()
assert body["einsatzbereitschaft_status"] == "unknown"
assert body["einsatzbereitschaft_status"] != "ready"
@pytest.mark.asyncio @pytest.mark.asyncio
+18
View File
@@ -106,6 +106,24 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// UI-005 (Epic 20): Dokumente waren bisher nur per Zwangs-Download einsehbar,
// nicht direkt im Browser anschaubar (PDF/Bild in neuem Tab statt Datei
// speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem
// <a href> auf den Download-Endpunkt. Object-URL wird bewusst NICHT sofort
// revoked (Tab braucht sie noch); der Browser räumt sie beim Tab-Schließen auf.
export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers });
if (!response.ok) {
throw new ApiError(response.status, null);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener");
}
export async function login(username: string, password: string): Promise<string> { export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password }); const body = new URLSearchParams({ username, password });
const response = await fetch(`${BASE_URL}/auth/login`, { const response = await fetch(`${BASE_URL}/auth/login`, {
+2
View File
@@ -262,6 +262,8 @@ export interface ZustaendigerBenutzer {
export interface Akte { export interface Akte {
objekt: Objekt; objekt: Objekt;
einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown";
einsatzbereitschaft_gruende: string[];
objekttyp_name: string; objekttyp_name: string;
standort_name: string; standort_name: string;
fahrzeugdetails: Fahrzeugdetails | null; fahrzeugdetails: Fahrzeugdetails | null;
+16 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiRequest, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; import { apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import type { Dokument } from "../api/types"; import type { Dokument } from "../api/types";
@@ -63,6 +63,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
} }
} }
async function ansehen(d: Dokument) {
try {
await ladeDokumentAnsehen(d.id);
} catch {
onFehler("Datei konnte nicht geöffnet werden.");
}
}
async function herunterladen(d: Dokument) { async function herunterladen(d: Dokument) {
try { try {
await ladeDokumentHerunter(d.id, d.dateiname); await ladeDokumentHerunter(d.id, d.dateiname);
@@ -100,7 +108,8 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}> <li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span> <span>
<button <button
onClick={() => herunterladen(d)} onClick={() => ansehen(d)}
title="In neuem Tab ansehen"
style={{ style={{
background: "none", background: "none",
border: "none", border: "none",
@@ -115,11 +124,16 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
{d.beschreibung && <span className="text-muted"> {d.beschreibung}</span>} {d.beschreibung && <span className="text-muted"> {d.beschreibung}</span>}
<span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span> <span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span>
</span> </span>
<span className="row" style={{ gap: "0.4rem" }}>
<button className="btn btn-secondary" onClick={() => herunterladen(d)}>
Herunterladen
</button>
{darfLoeschen && ( {darfLoeschen && (
<button className="btn btn-secondary" onClick={() => loeschen(d)}> <button className="btn btn-secondary" onClick={() => loeschen(d)}>
Löschen Löschen
</button> </button>
)} )}
</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -0,0 +1,13 @@
/** Spiegelt die Grund-Codes aus app/services/dashboard.py (einsatzbereitschaft/
* objekt_readiness) - gemeinsam genutzt von DashboardPage und AktePage. */
export const GRUND_TEXT: Record<string, string> = {
fehlbestand_offen: "Offener Fehlbestand",
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
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",
noch_nie_kontrolliert: "Noch nie kontrolliert",
mindermenge_genehmigt: "Mindermenge genehmigt",
};
+15 -12
View File
@@ -3,6 +3,9 @@ import { Link, useParams } from "react-router-dom";
import { apiRequest } from "../api/client"; import { apiRequest } from "../api/client";
import type { Akte } from "../api/types"; import type { Akte } from "../api/types";
import { DokumentePanel } from "../components/DokumentePanel";
import { ReadinessBadge } from "../components/status/ReadinessBadge";
import { GRUND_TEXT } from "../components/status/gruendeText";
const MANGEL_PRIORITAET_LABEL: Record<string, string> = { const MANGEL_PRIORITAET_LABEL: Record<string, string> = {
niedrig: "Niedrig", niedrig: "Niedrig",
@@ -39,11 +42,20 @@ export function AktePage() {
<div> <div>
<h1 style={{ marginBottom: "0.15rem" }}>{objekt.name}</h1> <h1 style={{ marginBottom: "0.15rem" }}>{objekt.name}</h1>
<div className="text-muted"> <div className="text-muted">
{objekt.code} · {akte.objekttyp_name} {objekt.code} · {akte.objekttyp_name} · Objekt-Status: {objekt.status}
</div> </div>
</div> </div>
<span className="badge badge-neutral">{objekt.status}</span> <ReadinessBadge status={akte.einsatzbereitschaft_status} />
</div> </div>
{akte.einsatzbereitschaft_gruende.length > 0 && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{akte.einsatzbereitschaft_gruende.map((g) => (
<li key={g} className="text-muted" style={{ fontSize: "0.9rem" }}>
{GRUND_TEXT[g] ?? g}
</li>
))}
</ul>
)}
<div className="card" style={{ marginTop: "1rem" }}> <div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Stammdaten</h3> <h3 style={{ marginTop: 0 }}>Stammdaten</h3>
@@ -118,16 +130,7 @@ export function AktePage() {
<div className="card" style={{ marginTop: "1rem" }}> <div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Dokumente</h3> <h3 style={{ marginTop: 0 }}>Dokumente</h3>
{akte.dokumente.length === 0 && <p className="text-muted">Keine Dokumente hinterlegt.</p>} <DokumentePanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
{akte.dokumente.length > 0 && (
<ul>
{akte.dokumente.map((d) => (
<li key={d.id}>
{d.dateiname} <span className="text-muted">({Math.round(d.groesse_bytes / 1024)} KB)</span>
</li>
))}
</ul>
)}
</div> </div>
<div className="card" style={{ marginTop: "1rem" }}> <div className="card" style={{ marginTop: "1rem" }}>
+1 -12
View File
@@ -7,6 +7,7 @@ import { InspectionStatus } from "../components/status/InspectionStatus";
import { InventoryStatus } from "../components/status/InventoryStatus"; import { InventoryStatus } from "../components/status/InventoryStatus";
import type { Readiness } from "../components/status/ReadinessBadge"; import type { Readiness } from "../components/status/ReadinessBadge";
import { ReadinessBadge } from "../components/status/ReadinessBadge"; import { ReadinessBadge } from "../components/status/ReadinessBadge";
import { GRUND_TEXT } from "../components/status/gruendeText";
interface Kennzahlen { interface Kennzahlen {
anzahl_offener_fehlbestaende: number; anzahl_offener_fehlbestaende: number;
@@ -69,18 +70,6 @@ function readinessVonGruenden(gruende: string[]): Readiness {
return "not-ready"; return "not-ready";
} }
const GRUND_TEXT: Record<string, string> = {
fehlbestand_offen: "Offener Fehlbestand",
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
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",
noch_nie_kontrolliert: "Noch nie kontrolliert",
mindermenge_genehmigt: "Mindermenge genehmigt",
};
// Leitungs-Dashboard (Karte-Nachtrag): zwei neue Kacheln (Einsatzbereitschaft, // Leitungs-Dashboard (Karte-Nachtrag): zwei neue Kacheln (Einsatzbereitschaft,
// Prüftermine) plus die bestehende Ablaufdaten-Kachel, alle drei eigenständig - // Prüftermine) plus die bestehende Ablaufdaten-Kachel, alle drei eigenständig -
// widersprechen sich nicht, da überfällige Prüfung/Ablauf beide zusätzlich als // widersprechen sich nicht, da überfällige Prüfung/Ablauf beide zusätzlich als