Files
MABEA/backend/app/services/dashboard.py
T
patrickandClaude Sonnet 5 209b541220
CI / backend-tests (push) Successful in 2m2s
CI / frontend-build (push) Successful in 19s
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
2026-09-06 21:35:52 +02:00

465 lines
19 KiB
Python

from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fahrzeugdetails import Fahrzeugdetails
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz
from app.models.kontrolle import Kontrolle, KontrollStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.auth import Benutzer
from app.models.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition
from app.models.personal import BenutzerQualifikation, Qualifikationstyp
from app.models.stammdaten import Material
from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel
from app.services.objekte import letzte_kontrollen_map
# Prompt 12.2: offen/in_bearbeitung/nachgefuellt_teilweise zählen gemeinsam als "offen"
# (technischer Substatus ist keine Erledigung, Prompt 03/20).
OFFEN_STATI = (FehlbestandStatus.offen, FehlbestandStatus.in_bearbeitung, FehlbestandStatus.nachgefuellt_teilweise)
STANDARD_WARNZEITRAUM_TAGE = 30
async def kennzahlen(db: AsyncSession) -> dict:
jetzt = datetime.now(timezone.utc)
result = await db.execute(select(func.count()).select_from(Fehlbestand).where(Fehlbestand.status.in_(OFFEN_STATI)))
anzahl_offen = result.scalar_one()
result = await db.execute(
select(func.count(func.distinct(MindermengenGenehmigung.fehlbestand_id))).where(
MindermengenGenehmigung.status == MindermengeStatus.aktiv
)
)
anzahl_genehmigt = result.scalar_one()
result = await db.execute(
select(func.count())
.select_from(Fehlbestand)
.where(Fehlbestand.status == FehlbestandStatus.erledigt, Fehlbestand.erledigt_am >= jetzt - timedelta(days=7))
)
anzahl_kuerzlich_erledigt = result.scalar_one()
result = await db.execute(select(func.count(func.distinct(Fehlbestand.objekt_id))).where(Fehlbestand.status.in_(OFFEN_STATI)))
anzahl_problematische_objekte = result.scalar_one()
result = await db.execute(select(func.count()).select_from(Objekt))
anzahl_objekte_gesamt = result.scalar_one()
ablaufdaten_liste = await bevorstehende_ablaufdaten(db)
return {
"anzahl_offener_fehlbestaende": anzahl_offen,
"anzahl_genehmigter_mindermengen": anzahl_genehmigt,
"anzahl_kuerzlich_erledigter_fehlbestaende": anzahl_kuerzlich_erledigt,
"anzahl_bevorstehender_ablaufdaten": len(ablaufdaten_liste),
"anzahl_problematischer_objekte": anzahl_problematische_objekte,
"anzahl_objekte_gesamt": anzahl_objekte_gesamt,
}
def _ablauf_status(ablaufdatum: date, warnzeitraum_tage: int, heute: date) -> tuple[str, int]:
verbleibende_tage = (ablaufdatum - heute).days
if verbleibende_tage < 0:
return "abgelaufen", verbleibende_tage
if verbleibende_tage <= warnzeitraum_tage:
return "bald_ablaufend", verbleibende_tage
return "gueltig", verbleibende_tage
async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]:
"""Prompt 14.5: nur bald_ablaufend/abgelaufen, sortiert nach verbleibenden Tagen
aufsteigend (dringendste zuerst). "gueltig" gehört nicht in diese Liste."""
heute = datetime.now(timezone.utc).date()
result = await db.execute(
select(Objektposition, Material)
.join(Material, Objektposition.material_id == Material.id)
.where(Objektposition.ablaufdatum.is_not(None))
)
eintraege = []
for objektposition, material in result.all():
warnzeitraum = material.warnzeitraum_tage or STANDARD_WARNZEITRAUM_TAGE
status_wert, verbleibende_tage = _ablauf_status(objektposition.ablaufdatum, warnzeitraum, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"objekt_id": objektposition.objekt_id,
"material_id": objektposition.material_id,
"ablaufdatum": objektposition.ablaufdatum,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege
async def einsatzbereitschaft(db: AsyncSession) -> dict:
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit,
wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da
Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt,
siehe app/services/geraet_instanz.py), kein abgelaufenes ablaufdatum, keine
überfällige Geräteprüfung und keine laufende Kontrolle (Sperre) vorliegt.
Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT
(Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt
einsatzbereit", nicht "einsatzbereit"."""
heute = datetime.now(timezone.utc).date()
# ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3,
# Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als
# aktiver Grund weiter unten erscheint statt das Objekt komplett auszublenden.
objekte = (
(await db.execute(select(Objekt).where(Objekt.status != ObjektStatus.ausser_dienst))).scalars().all()
)
offene_fehlbestaende = (
await db.execute(
select(Fehlbestand.objekt_id, Fehlbestand.id).where(Fehlbestand.status.in_(OFFEN_STATI))
)
).all()
objekt_ids_mit_fehlbestand: dict[int, set] = {}
for objekt_id, fehlbestand_id in offene_fehlbestaende:
objekt_ids_mit_fehlbestand.setdefault(objekt_id, set()).add(fehlbestand_id)
fehlbestand_ids_genehmigt = set(
(
await db.execute(
select(func.distinct(MindermengenGenehmigung.fehlbestand_id)).where(
MindermengenGenehmigung.status == MindermengeStatus.aktiv
)
)
)
.scalars()
.all()
)
objekt_ids_abgelaufen = set(
(
await db.execute(
select(func.distinct(Objektposition.objekt_id)).where(
Objektposition.ablaufdatum.is_not(None), Objektposition.ablaufdatum < heute
)
)
)
.scalars()
.all()
)
objekt_ids_pruefung_ueberfaellig = set(
(
await db.execute(
select(func.distinct(Objektposition.objekt_id))
.join(GeraetInstanz, GeraetInstanz.objektposition_id == Objektposition.id)
.where(GeraetInstanz.naechste_pruefung.is_not(None), GeraetInstanz.naechste_pruefung < heute)
)
)
.scalars()
.all()
)
objekt_ids_gesperrt = set(
(
await db.execute(
select(func.distinct(Kontrolle.objekt_id)).where(Kontrolle.status == KontrollStatus.in_bearbeitung)
)
)
.scalars()
.all()
)
objekt_ids_kritischer_mangel = await objekt_ids_mit_kritischem_offenem_mangel(db)
# Nutzer-Fund (Readiness-Review): ein nie kontrolliertes Objekt hatte bisher als
# "einsatzbereit" gegolten, weil keine Kriterien verletzt waren - fachlich falsch,
# "nie geprüft" ist nicht dasselbe wie "geprüft und in Ordnung".
letzte_kontrolle_map = await letzte_kontrollen_map(db, [o.id for o in objekte])
objekt_ids_hu_uvv_ueberfaellig = set(
(
await db.execute(
select(func.distinct(Fahrzeugdetails.objekt_id)).where(
(
(Fahrzeugdetails.naechste_hu.is_not(None))
& (Fahrzeugdetails.naechste_hu < heute)
)
| (
(Fahrzeugdetails.naechste_uvv.is_not(None))
& (Fahrzeugdetails.naechste_uvv < heute)
)
)
)
)
.scalars()
.all()
)
bereit = eingeschraenkt = unbekannt = 0
details: list[dict] = []
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] = []
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/
Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist
Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung)."""
heute = datetime.now(timezone.utc).date()
eintraege = []
result = await db.execute(
select(GeraetInstanz, Objektposition, Material)
.join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id)
.join(Material, Objektposition.material_id == Material.id)
.where(GeraetInstanz.naechste_pruefung.is_not(None))
)
for geraet, objektposition, material in result.all():
status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"typ": "geraet",
"objekt_id": objektposition.objekt_id,
"bezeichnung": f"{material.name} (SN {geraet.seriennummer})",
"faelligkeitsdatum": geraet.naechste_pruefung,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
"geraet_instanz_id": geraet.id,
"objektposition_id": objektposition.id,
"material_id": material.id,
"seriennummer": geraet.seriennummer,
}
)
# Fahrzeug-HU/UVV (Roadmap Phase 3, Modul Fahrzeuge) - gleiche Kachel wie
# Geräteprüfungen (Nutzer-Vorgabe: "welche Prüfungen bald fällig sind" ist
# EIN Konzept, egal ob Gerät oder Fahrzeug).
result = await db.execute(
select(Fahrzeugdetails, Objekt)
.join(Objekt, Fahrzeugdetails.objekt_id == Objekt.id)
.where(
(Fahrzeugdetails.naechste_hu.is_not(None)) | (Fahrzeugdetails.naechste_uvv.is_not(None))
)
)
for details, objekt in result.all():
for feld, typ, label in (
(details.naechste_hu, "fahrzeug_hu", "HU"),
(details.naechste_uvv, "fahrzeug_uvv", "UVV"),
):
if feld is None:
continue
status_wert, verbleibende_tage = _ablauf_status(feld, warnzeitraum_tage, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"typ": typ,
"objekt_id": objekt.id,
"bezeichnung": f"{label} {objekt.name} ({details.kennzeichen or objekt.code})",
"faelligkeitsdatum": feld,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
"geraet_instanz_id": None,
"objektposition_id": None,
"material_id": None,
"seriennummer": None,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege
async def bevorstehende_qualifikationsablaeufe(db: AsyncSession, warnzeitraum_tage: int = 60) -> list[dict]:
"""Personal-Modul-Kachel (Nutzer-Vorgabe: "wer verliert bald Führerschein/
Lehrgang-Gültigkeit"). Analog bevorstehende_prueftermine, aber 60 statt 30
Tage Standard-Vorlauf (Nachweise wie Führerscheine brauchen typischerweise
mehr Vorlaufzeit zur Erneuerung als eine Geräteprüfung)."""
heute = datetime.now(timezone.utc).date()
result = await db.execute(
select(BenutzerQualifikation, Benutzer, Qualifikationstyp)
.join(Benutzer, BenutzerQualifikation.benutzer_id == Benutzer.id)
.join(Qualifikationstyp, BenutzerQualifikation.qualifikationstyp_id == Qualifikationstyp.id)
.where(BenutzerQualifikation.gueltig_bis.is_not(None))
)
eintraege = []
for qualifikation, benutzer, qualifikationstyp in result.all():
status_wert, verbleibende_tage = _ablauf_status(qualifikation.gueltig_bis, warnzeitraum_tage, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"benutzer_qualifikation_id": qualifikation.id,
"benutzer_id": benutzer.id,
"benutzer_name": benutzer.name,
"qualifikationstyp_id": qualifikationstyp.id,
"qualifikationstyp_name": qualifikationstyp.name,
"gueltig_bis": qualifikation.gueltig_bis,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege