feat(notif): NOTIF-003 proaktive Wartungsfälligkeits-Benachrichtigung
CI / backend-tests (push) Successful in 2m46s
CI / frontend-build (push) Successful in 17s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 12:17:57 +02:00
1 parent d095db4172
commit 91cfb1087f
14 files changed
+386 -2

No files matched your search

+19 -1
View File
@@ -80,7 +80,25 @@ ursprünglichen 16-Epic-Backlog, obwohl Karte 12 bereits vollständig implementi
- **Akzeptanzkriterien:** fällige Wartung löst einmalig E-Mail+Dashboard-Eintrag aus,
kein Mehrfachversand für dieselbe Fälligkeit.
- **Tests:** Trigger-Test analog NOTIF-001, Mehrfachversand-Schutz-Test analog NOTIF-002.
- **DoD:** offen. **Referenz (Open-Source-Vergleich 2026-09-09):** Shelf.nu nennt dies
- **Granularität — entschieden 2026-09-10: pro Wartungsplan-Position, nicht
pro Objekt.** Zwei unterschiedliche Wartungsarten am selben Objekt, die
unabhängig voneinander fällig werden, sind zwei getrennte, eigenständig
relevante Meldungen — eine Objekt-Ebene-Tracking-Zeile hätte die zweite
Fälligkeit verschluckt, solange die erste schon gemeldet war.
- **DoD:** ✅ umgesetzt (2026-09-10). `wartung_benachrichtigung`
(Migration 0041, UNIQUE je objekt_id+wartungsplan_position_id) als
Tracking-Tabelle, gelöscht beim Erledigen des Wartungsauftrags
(`erledige_wartungsauftrag`) — nächste Fälligkeit derselben Position kann
dann wieder benachrichtigen. `app/services/wartung.py:alle_faelligen_wartungen`
als neue globale Aggregation (bisher nur objektbezogen über MAINT-002
verfügbar), `app/services/wartung_benachrichtigung.py:pruefe_faellige_wartungen`
als Prüflauf (gleiches E-Mail-Empfänger-Muster wie NOTIF-001),
`scripts/wartung_benachrichtigung_pruefen.py` für einen systemd-Timer
(`mabea-wartung-benachrichtigung.timer`, analog Eskalation/Readiness-Snapshot
serverseitig einzurichten). `GET /dashboard/faellige-wartungen` +
Dashboard-Kachel „Fällige Wartungen". `backend/tests/test_wartung_benachrichtigung.py`
(Einmalversand + Mehrfachversand-Schutz-Test). **Referenz
(Open-Source-Vergleich 2026-09-09):** Shelf.nu nennt dies
„Schedule alerts for maintenance, calibration, warranty expiry" — bestätigt den
Bedarf als verbreitetes Muster, MABEA-Umsetzung folgt dem eigenen NOTIF-001/002-Stil.
@@ -0,0 +1,33 @@
"""NOTIF-003: Tracking-Tabelle für Wartungsfälligkeits-Benachrichtigung.
Revision ID: 0041_wartung_benachrichtigung
Revises: 0040_custody
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0041_wartung_benachrichtigung"
down_revision: Union[str, None] = "0040_custody"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE wartung_benachrichtigung (
id UUID PRIMARY KEY,
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
wartungsplan_position_id INTEGER NOT NULL REFERENCES wartungsplan_position(id),
benachrichtigt_am TIMESTAMPTZ NOT NULL,
UNIQUE (objekt_id, wartungsplan_position_id)
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE wartung_benachrichtigung")
+12
View File
@@ -7,6 +7,7 @@ from app.models.auth import RolleTyp
from app.schemas.dashboard import (
AblaufdatumRead,
EinsatzbereitschaftRead,
FaelligeWartungRead,
KennzahlenRead,
PrueftermineintragRead,
QualifikationsablaufRead,
@@ -18,6 +19,7 @@ from app.services.dashboard import (
einsatzbereitschaft,
kennzahlen,
)
from app.services.wartung import alle_faelligen_wartungen
router = APIRouter()
@@ -58,6 +60,16 @@ async def hole_prueftermine(
return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)]
@router.get("/dashboard/faellige-wartungen", response_model=list[FaelligeWartungRead])
async def hole_faellige_wartungen_global(
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
) -> list[FaelligeWartungRead]:
"""NOTIF-003: globale Dashboard-Kachel "Fällige Wartungen" - ergänzt die
bereits bestehende Objekt-bezogene Sicht (MAINT-002,
/objekte/{id}/faellige-wartungen) um eine Übersicht über alle Objekte."""
return [FaelligeWartungRead(**eintrag) for eintrag in await alle_faelligen_wartungen(db)]
@router.get("/dashboard/qualifikationsablaeufe", response_model=list[QualifikationsablaufRead])
async def hole_qualifikationsablaeufe(
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
+2
View File
@@ -28,6 +28,7 @@ from app.models.personal import (
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
from app.models.tankbuch import TankbuchEintrag
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
from app.models.wartung import (
IntervallTyp,
Wartungsauftrag,
@@ -106,4 +107,5 @@ __all__ = [
"Wartungsintervall",
"Wartungsplan",
"WartungsplanPosition",
"WartungBenachrichtigung",
]
@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class WartungBenachrichtigung(Base):
"""NOTIF-003: Tracking-Zeile pro (objekt_id, wartungsplan_position_id) -
Granularität bewusst je Position, nicht je Objekt (Entscheidung 2026-09-10):
zwei unterschiedliche Wartungsarten am selben Objekt, die unabhängig
voneinander fällig werden, sind zwei getrennte, eigenständig relevante
Meldungen - eine einzige Objekt-Zeile würde die zweite Fälligkeit
verschlucken, solange die erste schon gemeldet wurde. Zeile existiert =
"für die aktuelle Fälligkeits-Episode bereits benachrichtigt", wird beim
Erledigen des Wartungsauftrags gelöscht (siehe
app/services/wartung.py:erledige_wartungsauftrag)."""
__tablename__ = "wartung_benachrichtigung"
__table_args__ = (UniqueConstraint("objekt_id", "wartungsplan_position_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
wartungsplan_position_id: Mapped[int] = mapped_column(
ForeignKey("wartungsplan_position.id"), nullable=False
)
benachrichtigt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
+16
View File
@@ -1,5 +1,6 @@
import uuid
from datetime import date
from decimal import Decimal
from pydantic import BaseModel
@@ -40,6 +41,21 @@ class EinsatzbereitschaftRead(BaseModel):
details: list[ObjektMangelRead]
class FaelligeWartungRead(BaseModel):
"""NOTIF-003: globale Sicht über alle Objekte, gefiltert auf faellig=True -
faellig_am/faellig_bei_km/faellig_bei_betriebsstunden sind je nach
Intervall-Typ der Position gesetzt, die jeweils anderen bleiben None."""
objekt_id: int
objekt_code: str
objekt_name: str
wartungsplan_position_id: int
wartungsart: str
faellig_am: date | None
faellig_bei_km: int | None
faellig_bei_betriebsstunden: Decimal | None
class PrueftermineintragRead(BaseModel):
"""typ='geraet' (Karte 14) oder 'fahrzeug_hu'/'fahrzeug_uvv' (Roadmap Phase
3, Modul Fahrzeuge) - eine Kachel für beide (Nutzer-Vorgabe: "welche
+18
View File
@@ -57,3 +57,21 @@ async def benachrichtige_neuer_fehlbestand(db: AsyncSession, *, fehlbestand: Feh
logger.exception("E-Mail-Versand für Fehlbestand %s fehlgeschlagen", fehlbestand.id)
return asyncio.create_task(_senden())
async def benachrichtige_faellige_wartung(
db: AsyncSession, *, objekt_id: int, objekt_name: str, wartungsart: str
) -> asyncio.Task:
"""NOTIF-003: gleiches Muster wie benachrichtige_neuer_fehlbestand - fire-
and-forget, Versandfehler stören den Prüflauf nicht."""
empfaenger = await _empfaenger_fuer_objekt(db)
subject = f"Wartung fällig: {wartungsart} ({objekt_name})"
body = f"Objekt: {objekt_name}\nWartungsart: {wartungsart}\nObjekt-ID: {objekt_id}\n"
async def _senden() -> None:
try:
await send_email(to=empfaenger, subject=subject, body=body)
except Exception: # noqa: BLE001
logger.exception("E-Mail-Versand für fällige Wartung Objekt %s fehlgeschlagen", objekt_id)
return asyncio.create_task(_senden())
+29
View File
@@ -15,6 +15,7 @@ from app.models.wartung import (
Wartungsplan,
WartungsplanPosition,
)
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
from app.schemas.wartung import WartungsplanPositionCreate
from app.services.datumshilfen import plus_monate
@@ -119,6 +120,20 @@ async def erledige_wartungsauftrag(
auftrag.kilometerstand_bei_erledigung = kilometerstand_bei_erledigung
auftrag.betriebsstunden_bei_erledigung = betriebsstunden_bei_erledigung
# NOTIF-003: die alte Fälligkeit ist erledigt, eine künftige neue
# Fälligkeit derselben Position soll wieder eine Benachrichtigung
# auslösen dürfen - Tracking-Zeile löschen statt sie stehen zu lassen.
bestehende_benachrichtigung = (
await db.execute(
select(WartungBenachrichtigung).where(
WartungBenachrichtigung.objekt_id == auftrag.objekt_id,
WartungBenachrichtigung.wartungsplan_position_id == auftrag.wartungsplan_position_id,
)
)
).scalar_one_or_none()
if bestehende_benachrichtigung is not None:
await db.delete(bestehende_benachrichtigung)
await db.flush()
return auftrag
@@ -236,3 +251,17 @@ async def faellige_wartungen_fuer_objekt(db: AsyncSession, objekt: Objekt) -> li
)
return ergebnisse
async def alle_faelligen_wartungen(db: AsyncSession) -> list[dict]:
"""NOTIF-003: globale Sicht über alle Objekte für Dashboard-Kachel und
Benachrichtigungs-Prüflauf - iteriert `faellige_wartungen_fuer_objekt` je
Objekt (keine bestehende Aggregation vorhanden), filtert auf faellig=True."""
objekte = (await db.execute(select(Objekt))).scalars().all()
ergebnisse: list[dict] = []
for objekt in objekte:
for eintrag in await faellige_wartungen_fuer_objekt(db, objekt):
if eintrag["faellig"]:
ergebnisse.append({**eintrag, "objekt_code": objekt.code, "objekt_name": objekt.name})
return ergebnisse
@@ -0,0 +1,43 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
from app.services.benachrichtigung import benachrichtige_faellige_wartung
from app.services.wartung import alle_faelligen_wartungen
async def pruefe_faellige_wartungen(db: AsyncSession) -> int:
"""NOTIF-003: periodischer Prüflauf (systemd-Timer, analog
scripts/eskalation_pruefen.py) - eine Benachrichtigung je
(objekt_id, wartungsplan_position_id), nicht mehrfach für dieselbe
Fälligkeits-Episode (Tracking-Zeile, gelöscht bei Erledigung siehe
app/services/wartung.py:erledige_wartungsauftrag)."""
bereits_benachrichtigt = set(
(b.objekt_id, b.wartungsplan_position_id)
for b in (await db.execute(select(WartungBenachrichtigung))).scalars().all()
)
anzahl_versendet = 0
for eintrag in await alle_faelligen_wartungen(db):
schluessel = (eintrag["objekt_id"], eintrag["wartungsplan_position_id"])
if schluessel in bereits_benachrichtigt:
continue
await benachrichtige_faellige_wartung(
db,
objekt_id=eintrag["objekt_id"],
objekt_name=eintrag["objekt_name"],
wartungsart=eintrag["wartungsart"],
)
db.add(
WartungBenachrichtigung(
objekt_id=eintrag["objekt_id"],
wartungsplan_position_id=eintrag["wartungsplan_position_id"],
benachrichtigt_am=datetime.now(timezone.utc),
)
)
anzahl_versendet += 1
await db.commit()
return anzahl_versendet
@@ -0,0 +1,29 @@
"""NOTIF-003: periodischer Prüflauf für fällige Wartungen, für einen
systemd-Timer gedacht (gleiches Muster wie scripts/eskalation_pruefen.py).
Aufruf (im aktivierten venv auf dem Server):
python -m scripts.wartung_benachrichtigung_pruefen
"""
from __future__ import annotations
import asyncio
import logging
import app.models # noqa: F401 (alle ORM-Modelle registrieren, siehe create_admin.py)
from app.db.session import SessionLocal
from app.services.wartung_benachrichtigung import pruefe_faellige_wartungen
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def main() -> None:
async with SessionLocal() as db:
anzahl = await pruefe_faellige_wartungen(db)
logger.info("Wartungsfälligkeits-Prüflauf: %d neue Benachrichtigung(en) versendet.", anzahl)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,86 @@
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import select
from app.models.objekt import Objekt
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
from app.services.wartung import erstelle_wartungsplan, hole_positionen_mit_intervallen
from app.services.wartung_benachrichtigung import pruefe_faellige_wartungen
from app.schemas.wartung import WartungsplanPositionCreate
async def _objekt_mit_faelliger_wartung(db_session, hauptserver_id, objekttyp_factory, standort_factory, code):
objekttyp = await objekttyp_factory(f"Typ-{code}")
standort = await standort_factory(f"Standort-{code}")
objekt = Objekt(
code=code,
name=f"Objekt {code}",
objekttyp_id=objekttyp.id,
standort_id=standort.id,
zustaendiger_server_id=hauptserver_id,
)
db_session.add(objekt)
await db_session.flush()
plan = await erstelle_wartungsplan(
db_session,
objekttyp_id=objekttyp.id,
name=f"Plan-{code}",
positionen=[WartungsplanPositionCreate(wartungsart="Ölwechsel", intervalle=[])],
)
positionen = await hole_positionen_mit_intervallen(db_session, plan.id)
await db_session.commit()
return objekt, positionen[0].id
@pytest.mark.asyncio
async def test_faellige_wartung_versendet_einmalig(
db_session, admin_user, hauptserver_id, objekttyp_factory, standort_factory
):
objekt, position_id = await _objekt_mit_faelliger_wartung(
db_session, hauptserver_id, objekttyp_factory, standort_factory, "NOTIF-W1"
)
with patch("app.services.benachrichtigung.send_email", new=AsyncMock()) as mock_send:
anzahl = await pruefe_faellige_wartungen(db_session)
for _ in range(20):
await asyncio.sleep(0.01)
if mock_send.await_count:
break
assert anzahl == 1
mock_send.assert_awaited_once()
assert "Ölwechsel" in mock_send.await_args.kwargs["subject"]
getrackt = (
await db_session.execute(
select(WartungBenachrichtigung).where(
WartungBenachrichtigung.objekt_id == objekt.id,
WartungBenachrichtigung.wartungsplan_position_id == position_id,
)
)
).scalar_one_or_none()
assert getrackt is not None
@pytest.mark.asyncio
async def test_kein_zweiter_versand_fuer_dieselbe_faelligkeit(
db_session, admin_user, hauptserver_id, objekttyp_factory, standort_factory
):
await _objekt_mit_faelliger_wartung(
db_session, hauptserver_id, objekttyp_factory, standort_factory, "NOTIF-W2"
)
with patch("app.services.benachrichtigung.send_email", new=AsyncMock()):
erste_runde = await pruefe_faellige_wartungen(db_session)
await asyncio.sleep(0.05)
with patch("app.services.benachrichtigung.send_email", new=AsyncMock()) as mock_send_zweite:
zweite_runde = await pruefe_faellige_wartungen(db_session)
await asyncio.sleep(0.05)
assert erste_runde == 1
assert zweite_runde == 0
mock_send_zweite.assert_not_awaited()
@@ -19,6 +19,7 @@ import { KennzahlenTile } from "./tiles/KennzahlenTile";
import { MaengelTile } from "./tiles/MaengelTile";
import { PrueftermineTile } from "./tiles/PrueftermineTile";
import { QualifikationTile } from "./tiles/QualifikationTile";
import { FaelligeWartungenTile } from "./tiles/FaelligeWartungenTile";
import { ReadinessVerlaufTile } from "./tiles/ReadinessVerlaufTile";
import type { Ablaufdatumeintrag, Einsatzbereitschaft, Kennzahlen, Prueftermineintrag, Qualifikationsablauf } from "./types";
@@ -34,6 +35,7 @@ const TITEL: Record<DashboardTileId, string> = {
qualifikation: "Qualifikationsablauf",
aktivitaet: "Letzte Aktivität",
readinessVerlauf: "Bereitschaft im Zeitverlauf",
faelligeWartungen: "Fällige Wartungen",
};
export function DashboardPage() {
@@ -201,6 +203,8 @@ export function DashboardPage() {
return <AktivitaetTile aktivitaet={aktivitaet} />;
case "readinessVerlauf":
return <ReadinessVerlaufTile />;
case "faelligeWartungen":
return <FaelligeWartungenTile />;
default:
return null;
}
+4 -1
View File
@@ -7,7 +7,8 @@ export type DashboardTileId =
| "maengel"
| "qualifikation"
| "aktivitaet"
| "readinessVerlauf";
| "readinessVerlauf"
| "faelligeWartungen";
export const ALLE_TILE_IDS: DashboardTileId[] = [
"aufgaben",
@@ -19,6 +20,7 @@ export const ALLE_TILE_IDS: DashboardTileId[] = [
"qualifikation",
"aktivitaet",
"readinessVerlauf",
"faelligeWartungen",
];
// 24 statt 12 Spalten - feineres Raster, mehr Kontrolle beim Breiter/Schmaler
@@ -47,6 +49,7 @@ export const STANDARD_RASTER: Record<DashboardTileId, RasterPosition> = {
qualifikation: { x: 16, y: 8, w: 8, h: 4 },
aktivitaet: { x: 0, y: 13, w: 16, h: 5 },
readinessVerlauf: { x: 16, y: 13, w: 8, h: 5 },
faelligeWartungen: { x: 0, y: 18, w: 12, h: 5 },
};
export interface DashboardLayout {
@@ -0,0 +1,61 @@
import { memo, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiRequest } from "../../../api/client";
import { EmptyState } from "../EmptyState";
interface FaelligeWartung {
objekt_id: number;
objekt_code: string;
objekt_name: string;
wartungsplan_position_id: number;
wartungsart: string;
faellig_am: string | null;
faellig_bei_km: number | null;
faellig_bei_betriebsstunden: string | null;
}
// NOTIF-003: eigenständiger Fetch (anderer Endpunkt/Daten-Shape als der Rest
// des Dashboards, gleiche self-fetching-Bauweise wie ReadinessVerlaufTile).
function FaelligeWartungenTileKomponente() {
const [eintraege, setEintraege] = useState<FaelligeWartung[] | null>(null);
const [fehler, setFehler] = useState(false);
useEffect(() => {
apiRequest<FaelligeWartung[]>("/dashboard/faellige-wartungen")
.then(setEintraege)
.catch(() => setFehler(true));
}, []);
if (fehler) return <p className="text-muted">Konnte nicht geladen werden.</p>;
if (eintraege === null) return <p className="text-muted">Lade…</p>;
if (eintraege.length === 0) return <EmptyState text="Keine fälligen Wartungen." />;
return (
<ul>
{eintraege.map((e) => (
<li
key={`${e.objekt_id}-${e.wartungsplan_position_id}`}
className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] py-2 last:border-0"
>
<div className="min-w-0 flex-1">
<Link to={`/akte/objekt/${e.objekt_id}`} className="block truncate font-medium">
{e.wartungsart} ({e.objekt_name})
</Link>
<span className="text-xs text-[var(--color-text-muted)]">
{e.faellig_am
? `fällig seit/am ${e.faellig_am}`
: e.faellig_bei_km !== null
? `fällig bei ${e.faellig_bei_km} km`
: e.faellig_bei_betriebsstunden !== null
? `fällig bei ${e.faellig_bei_betriebsstunden} Bh`
: "noch nie durchgeführt"}
</span>
</div>
</li>
))}
</ul>
);
}
export const FaelligeWartungenTile = memo(FaelligeWartungenTileKomponente);