import logging from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.email import send_email from app.models.auth import Benutzer, BenutzerRolle, RolleTyp from app.models.eskalation import EskalationKonfiguration from app.models.fehlbestand import Fehlbestand, FehlbestandStatus from app.models.stammdaten import Material logger = logging.getLogger(__name__) async def hole_konfiguration(db: AsyncSession) -> EskalationKonfiguration: """Singleton-Zeile (id=1), von Migration 0006 geseedet.""" konfiguration = await db.get(EskalationKonfiguration, 1) if konfiguration is None: konfiguration = EskalationKonfiguration(id=1) db.add(konfiguration) await db.flush() return konfiguration async def _empfaenger(db: AsyncSession, rollen: list[RolleTyp]) -> list[str]: result = await db.execute( select(Benutzer.email) .join(BenutzerRolle, BenutzerRolle.benutzer_id == Benutzer.id) .where(BenutzerRolle.rolle.in_(rollen), Benutzer.aktiv.is_(True), Benutzer.email.is_not(None)) ) return [email for (email,) in result.all() if email] async def _sende(db: AsyncSession, *, fehlbestand: Fehlbestand, rollen: list[RolleTyp], betreff_praefix: str) -> None: empfaenger = await _empfaenger(db, rollen) if not empfaenger: return material = await db.get(Material, fehlbestand.material_id) material_name = material.name if material else str(fehlbestand.material_id) alter_tage = (datetime.now(timezone.utc) - fehlbestand.entstanden_am).days subject = f"{betreff_praefix}: {material_name} seit {alter_tage} Tagen offen" body = ( f"Material: {material_name}\n" f"Objekt-ID: {fehlbestand.objekt_id}\n" f"Fehlmenge: {fehlbestand.fehlmenge}\n" f"Entstanden am: {fehlbestand.entstanden_am.isoformat()}\n" f"Offen seit: {alter_tage} Tagen\n" ) try: await send_email(to=empfaenger, subject=subject, body=body) except Exception: # noqa: BLE001 - Versandfehler dürfen den Prüflauf nie abbrechen logger.exception("Eskalations-E-Mail für Fehlbestand %s fehlgeschlagen", fehlbestand.id) async def pruefe_offene_fehlbestaende(db: AsyncSession) -> dict[str, int]: """Karte 12: zwei Stufen, Zeitschwellen aus eskalation_konfiguration (DB, zur Laufzeit änderbar, nicht fix im Code). Jede Stufe wird pro Fehlbestand nur einmal ausgelöst (Tracking-Zeitstempel), unabhängig davon, wie oft dieser Prüflauf angestoßen wird - Aufrufer (Cron/manueller Trigger) ist bewusst nicht Teil dieser Funktion, Scheduling ist Betriebsaufgabe (Prompt 19 Deployment-Regel).""" konfiguration = await hole_konfiguration(db) jetzt = datetime.now(timezone.utc) erinnerung_schwelle = jetzt - timedelta(days=konfiguration.erinnerung_tage) eskalation_schwelle = jetzt - timedelta(days=konfiguration.leitung_tage) result = await db.execute( select(Fehlbestand).where(Fehlbestand.status != FehlbestandStatus.erledigt) ) offene = result.scalars().all() erinnerungen = 0 eskalationen = 0 for fehlbestand in offene: if fehlbestand.erinnerung_gesendet_am is None and fehlbestand.entstanden_am <= erinnerung_schwelle: await _sende( db, fehlbestand=fehlbestand, rollen=[RolleTyp.materialverantwortlicher], betreff_praefix="Erinnerung: Fehlbestand offen", ) fehlbestand.erinnerung_gesendet_am = jetzt erinnerungen += 1 if fehlbestand.eskalation_gesendet_am is None and fehlbestand.entstanden_am <= eskalation_schwelle: await _sende( db, fehlbestand=fehlbestand, rollen=[RolleTyp.leitungsverantwortlicher, RolleTyp.administration], betreff_praefix="Eskalation: Fehlbestand lange offen", ) fehlbestand.eskalation_gesendet_am = jetzt eskalationen += 1 await db.flush() return {"erinnerungen": erinnerungen, "eskalationen": eskalationen}