Zwei Stufen (Erinnerung an Materialverantwortliche, Eskalation an Leitungsverantwortliche/Administration) mit Tracking-Zeitstempel je Stufe gegen Mehrfachversand. Zeitschwellen in eskalation_konfiguration (DB, Singleton), admin-editierbar über GET/PUT /eskalation/konfiguration - bewusst nicht fix im Code. Prüflauf per POST /eskalation/pruefen angestoßen, Scheduling (Cron/systemd-Timer) bleibt Betriebsaufgabe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
99 lines
4.1 KiB
Python
99 lines
4.1 KiB
Python
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}
|