Sprint 6: Dashboard-Kennzahlen, Ablaufdaten-Warnungen, E-Mail-Benachrichtigung
CI / backend-tests (push) Successful in 54s

- benutzer.email ergänzt (additive Migration 0003) - Prompt 20 hatte kein E-Mail-
  Feld vorgesehen, aber Karte 05 braucht es für Benachrichtigungen
- Dashboard-Kennzahlen (Prompt 12.2): offen/in_bearbeitung/nachgefuellt_teilweise
  zählen gemeinsam als "offen", genehmigte Mindermengen separat, kürzlich erledigt
  (7 Tage), problematische Objekte
- Ablaufdaten-Warnungen (Prompt 14): serverseitige Statusberechnung
  gueltig/bald_ablaufend/abgelaufen je Objektposition, Standard-Warnzeitraum 30
  Tage falls am Material nicht gesetzt, sortiert nach verbleibenden Tagen (E1:
  einzige Zeit-/Fälligkeits-Sicht in V1, keine Kontrollintervall-Logik)
- GET /fehlbestaende um Filter (Standort/Objekt/Material/genehmigt) und
  Alter-Sortierung erweitert (Prompt 12.3)
- Asynchrone E-Mail-Benachrichtigung bei neuem Fehlbestand (Karte 05): fire-and-
  forget an aktive Materialverantwortliche/Leitungsverantwortliche mit hinterlegter
  E-Mail; kein SMTP konfiguriert -> wird nur geloggt, kein harter Fehler
- Tests: Aggregationsregel, Ablaufdaten-Filterung/Sortierung, Rollenrechte,
  E-Mail-Versand (SMTP gemockt, kein Docker/Test-Mailserver im Host-Runner
  verfügbar - Aufruf mit korrekten Empfängern/Betreff wird geprüft)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-03 23:59:40 +02:00
co-authored by Claude Sonnet 5
parent 1e9012cba3
commit aba991bbe2
18 changed files with 562 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
import asyncio
import logging
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.fehlbestand import Fehlbestand
from app.models.stammdaten import Material
logger = logging.getLogger(__name__)
async def _empfaenger_fuer_objekt(db: AsyncSession) -> list[str]:
"""V1-Vereinfachung: alle aktiven Materialverantwortlichen/Leitungsverantwortlichen
(Karte 05 spezifiziert keine feinere, zuständigkeitsbasierte Adressierung).
Feinere Filterung nach Zuständigkeit (Karte 04) wäre eine spätere Ausbaustufe."""
result = await db.execute(
select(Benutzer.email)
.join(BenutzerRolle, BenutzerRolle.benutzer_id == Benutzer.id)
.where(
BenutzerRolle.rolle.in_(
[RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher]
),
Benutzer.aktiv.is_(True),
Benutzer.email.is_not(None),
)
)
return [email for (email,) in result.all() if email]
async def benachrichtige_neuer_fehlbestand(db: AsyncSession, *, fehlbestand: Fehlbestand) -> asyncio.Task:
"""Karte 05: asynchroner E-Mail-Versand bei neuem Fehlbestand, blockiert den
Kontroll-Workflow nicht (fire-and-forget, Fehler beim Versand werden geloggt,
nicht an den aufrufenden Request weitergereicht). Gibt den Task zurück, damit
Tests den Versand deterministisch abwarten können - im normalen Betrieb wird
der Rückgabewert bewusst nicht awaited."""
empfaenger = await _empfaenger_fuer_objekt(db)
material = await db.get(Material, fehlbestand.material_id)
material_name = material.name if material else str(fehlbestand.material_id)
subject = f"Neuer Fehlbestand: {material_name}"
body = (
f"Material: {material_name}\n"
f"Objekt-ID: {fehlbestand.objekt_id}\n"
f"Sollmenge: {fehlbestand.sollmenge}\n"
f"Istmenge: {fehlbestand.istmenge}\n"
f"Fehlmenge: {fehlbestand.fehlmenge}\n"
f"Entstanden am: {fehlbestand.entstanden_am.isoformat()}\n"
)
async def _senden() -> None:
try:
await send_email(to=empfaenger, subject=subject, body=body)
except Exception: # noqa: BLE001 - Versandfehler dürfen den Workflow nie stören
logger.exception("E-Mail-Versand für Fehlbestand %s fehlgeschlagen", fehlbestand.id)
return asyncio.create_task(_senden())
+91
View File
@@ -0,0 +1,91 @@
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.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition
from app.models.stammdaten import Material
# 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
+2
View File
@@ -10,6 +10,7 @@ from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.services import benachrichtigung as benachrichtigung_service
from app.services import historie as historie_service
from app.services.objekte import sollmenge_effektiv, sollmenge_map
@@ -257,6 +258,7 @@ async def _synchronisiere_fehlbestand(
"fehlmenge": str(unterdeckung),
},
)
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender)
else:
# Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die
# Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht