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.objekt import Objekt, ObjektStatus 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() # 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) 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 = 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: 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() 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