diff --git a/arbeitskacheln/18_notifications.md b/arbeitskacheln/18_notifications.md index 826fd5c..2fd0f49 100644 --- a/arbeitskacheln/18_notifications.md +++ b/arbeitskacheln/18_notifications.md @@ -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. diff --git a/backend/alembic/versions/0041_wartung_benachrichtigung.py b/backend/alembic/versions/0041_wartung_benachrichtigung.py new file mode 100644 index 0000000..ba9a656 --- /dev/null +++ b/backend/alembic/versions/0041_wartung_benachrichtigung.py @@ -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") diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 72579b3..ef7b3d0 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 035ac79..5bebbf2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/wartung_benachrichtigung.py b/backend/app/models/wartung_benachrichtigung.py new file mode 100644 index 0000000..bd9a18d --- /dev/null +++ b/backend/app/models/wartung_benachrichtigung.py @@ -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) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index c5c37ec..39a6529 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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 diff --git a/backend/app/services/benachrichtigung.py b/backend/app/services/benachrichtigung.py index de79029..c655b5f 100644 --- a/backend/app/services/benachrichtigung.py +++ b/backend/app/services/benachrichtigung.py @@ -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()) diff --git a/backend/app/services/wartung.py b/backend/app/services/wartung.py index 1fa57cf..f835a65 100644 --- a/backend/app/services/wartung.py +++ b/backend/app/services/wartung.py @@ -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 + diff --git a/backend/app/services/wartung_benachrichtigung.py b/backend/app/services/wartung_benachrichtigung.py new file mode 100644 index 0000000..75b907a --- /dev/null +++ b/backend/app/services/wartung_benachrichtigung.py @@ -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 diff --git a/backend/scripts/wartung_benachrichtigung_pruefen.py b/backend/scripts/wartung_benachrichtigung_pruefen.py new file mode 100644 index 0000000..7d78849 --- /dev/null +++ b/backend/scripts/wartung_benachrichtigung_pruefen.py @@ -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()) diff --git a/backend/tests/test_wartung_benachrichtigung.py b/backend/tests/test_wartung_benachrichtigung.py new file mode 100644 index 0000000..a3e608a --- /dev/null +++ b/backend/tests/test_wartung_benachrichtigung.py @@ -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() diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 5603c5c..d62b8e4 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -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 = { 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 ; case "readinessVerlauf": return ; + case "faelligeWartungen": + return ; default: return null; } diff --git a/frontend/src/pages/dashboard/layout.ts b/frontend/src/pages/dashboard/layout.ts index 6fd5f63..e053fbc 100644 --- a/frontend/src/pages/dashboard/layout.ts +++ b/frontend/src/pages/dashboard/layout.ts @@ -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 = { 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 { diff --git a/frontend/src/pages/dashboard/tiles/FaelligeWartungenTile.tsx b/frontend/src/pages/dashboard/tiles/FaelligeWartungenTile.tsx new file mode 100644 index 0000000..c07173d --- /dev/null +++ b/frontend/src/pages/dashboard/tiles/FaelligeWartungenTile.tsx @@ -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(null); + const [fehler, setFehler] = useState(false); + + useEffect(() => { + apiRequest("/dashboard/faellige-wartungen") + .then(setEintraege) + .catch(() => setFehler(true)); + }, []); + + if (fehler) return

Konnte nicht geladen werden.

; + if (eintraege === null) return

Lade…

; + if (eintraege.length === 0) return ; + + return ( +
    + {eintraege.map((e) => ( +
  • +
    + + {e.wartungsart} ({e.objekt_name}) + + + {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"} + +
    +
  • + ))} +
+ ); +} + +export const FaelligeWartungenTile = memo(FaelligeWartungenTileKomponente);