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
465 lines
19 KiB
Python
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
|