feat(ui): UI-005 begründete Einsatzbereitschaft + Dokumente in der Akte
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:
@@ -24,6 +24,8 @@ class AkteRead(BaseModel):
|
||||
03_digital_file.md)."""
|
||||
|
||||
objekt: ObjektRead
|
||||
einsatzbereitschaft_status: str
|
||||
einsatzbereitschaft_gruende: list[str]
|
||||
objekttyp_name: str
|
||||
standort_name: str
|
||||
fahrzeugdetails: FahrzeugdetailsRead | None
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.schemas.historie import HistorieRead
|
||||
from app.schemas.mangel import MangelRead
|
||||
from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead
|
||||
from app.schemas.zustaendigkeit import KontrollverantwortungRead
|
||||
from app.services.dashboard import objekt_readiness
|
||||
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))
|
||||
|
||||
readiness = await objekt_readiness(db, objekt_id)
|
||||
|
||||
objekt_read = ObjektRead.model_validate(objekt).model_copy(
|
||||
update={"letzte_kontrolle_am": letzte_map.get(objekt_id)}
|
||||
)
|
||||
|
||||
return AkteRead(
|
||||
objekt=objekt_read,
|
||||
einsatzbereitschaft_status=readiness["status"],
|
||||
einsatzbereitschaft_gruende=readiness["gruende"],
|
||||
objekttyp_name=objekttyp.name if objekttyp else "",
|
||||
standort_name=standort.name if standort else "",
|
||||
fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None,
|
||||
|
||||
@@ -199,37 +199,24 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
bereit = eingeschraenkt = unbekannt = 0
|
||||
details: list[dict] = []
|
||||
for objekt in objekte:
|
||||
gruende: list[str] = []
|
||||
fehlbestand_ids = objekt_ids_mit_fehlbestand.get(objekt.id, set())
|
||||
ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt
|
||||
if ungenehmigt:
|
||||
gruende.append("fehlbestand_offen")
|
||||
if objekt.id in objekt_ids_abgelaufen:
|
||||
gruende.append("ablaufdatum_abgelaufen")
|
||||
if objekt.id in objekt_ids_pruefung_ueberfaellig:
|
||||
gruende.append("pruefung_ueberfaellig")
|
||||
if objekt.id in objekt_ids_gesperrt:
|
||||
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
|
||||
details.append(
|
||||
{"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["mindermenge_genehmigt"]}
|
||||
)
|
||||
elif not gruende and letzte_kontrolle_map.get(objekt.id) is None:
|
||||
unbekannt += 1
|
||||
details.append(
|
||||
{"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["noch_nie_kontrolliert"]}
|
||||
)
|
||||
elif not gruende:
|
||||
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 {
|
||||
@@ -242,6 +229,136 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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] = []
|
||||
ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt
|
||||
if ungenehmigt:
|
||||
gruende.append("fehlbestand_offen")
|
||||
if objekt.id in objekt_ids_abgelaufen:
|
||||
gruende.append("ablaufdatum_abgelaufen")
|
||||
if objekt.id in objekt_ids_pruefung_ueberfaellig:
|
||||
gruende.append("pruefung_ueberfaellig")
|
||||
if objekt.id in objekt_ids_gesperrt:
|
||||
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:
|
||||
return "limited", ["mindermenge_genehmigt"]
|
||||
if not gruende and letzte_kontrolle is None:
|
||||
return "unknown", ["noch_nie_kontrolliert"]
|
||||
if not gruende:
|
||||
return "ready", []
|
||||
return "not-ready", gruende
|
||||
|
||||
|
||||
async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict:
|
||||
"""UI-005 (Epic 20): begründete Einsatzbereitschaft für EIN Objekt (Objektakte),
|
||||
dieselbe Fachlogik wie `einsatzbereitschaft()` (Dashboard-Aggregat), nur für
|
||||
einen einzelnen Datensatz statt aller Objekte. `ausser_dienst` zählt hier
|
||||
bewusst nicht als eigener Fall - Objektakte zeigt das Objekt so oder so an,
|
||||
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]:
|
||||
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 2): Geräteprüfungen (Karte 14),
|
||||
nicht Ablaufdaten von Verbrauchsmaterial (bleibt eigene, bestehende Kachel/
|
||||
|
||||
@@ -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 len(body["historie"]) == 1
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user