Files
MABEA/backend/app/services/dashboard.py
T
patrickandClaude Sonnet 5 eb20affd5d
CI / backend-tests (push) Failing after 1m37s
CI / frontend-build (push) Successful in 18s
feat(mangel): Mangel-Ticket-System (Roadmap Phase 2)
Eigener Workflow für Schäden/Defekte, getrennt von Fehlbestand (Mengen-
Abweichung Soll/Ist). Status: neu → in_bearbeitung → ersatzteil_bestellt/
reparatur_geplant → erledigt. entitaet_typ/entitaet_id optional (Objektposition
oder Geräteinstanz), analog zum bestehenden historie.entitaet_typ-Muster,
objekt_id immer gesetzt für Dashboard-Abfragen.

Ein offener Mangel mit prioritaet=hoch macht das Objekt "nicht einsatzbereit"
(neuer Grund mangel_kritisch_offen in dashboard.einsatzbereitschaft) -
niedrige/normale Mängel blockieren nicht.

Melden/Lesen: alle Mitarbeiter+. Status ändern: Materialverantwortliche+.
Neue Seite /maengel für alle eingeloggten Nutzer (Nav-Link "Mängel").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
2026-09-05 13:23:38 +02:00

235 lines
9.5 KiB
Python

from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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.objekt import Objekt
from app.models.objektposition import Objektposition
from app.models.stammdaten import Material
from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel
# 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()
objekte = (await db.execute(select(Objekt))).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)
bereit = eingeschraenkt = 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 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:
bereit += 1
else:
details.append({"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": gruende})
return {
"einsatzbereit": bereit,
"eingeschraenkt_einsatzbereit": eingeschraenkt,
"nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt,
"gesamt": len(objekte),
"details": details,
}
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()
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))
)
eintraege = []
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(
{
"geraet_instanz_id": geraet.id,
"objekt_id": objektposition.objekt_id,
"objektposition_id": objektposition.id,
"material_id": material.id,
"material_name": material.name,
"seriennummer": geraet.seriennummer,
"geraet_status": geraet.status,
"naechste_pruefung": geraet.naechste_pruefung,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege