feat(readiness): READY-006 täglicher Bereitschafts-Snapshot mit Trend-Dashboard und Akte-Historie
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
bb0c601b2f
commit
a1ad2d6cef
18 files changed
+550
-29
No files matched your search
@@ -155,11 +155,24 @@ Details zu READY-001 … READY-006. READY-001-005 vollständig, READY-006 ergän
|
||||
- **Akzeptanzkriterien:** mind. eine Kennzahl (z.B. Bereitschaftsquote) über
|
||||
mehrere Zeitpunkte hinweg korrekt dargestellt.
|
||||
- **Tests:** Aggregations-Test mit synthetischen Daten über mehrere Zeitpunkte.
|
||||
- **DoD:** offen — Design vollständig geklärt (2026-09-10), bereit für
|
||||
Implementierung. **Referenz (HiOrg-Server-Vergleich 2026-09-09):** HiOrg bietet
|
||||
dedizierte Statistiken über Dienste/Kurse — MABEA hat vergleichbare Rohdaten
|
||||
(Einsatzbereitschaft, Mängel, Wartung), aber keine Verlaufs-/Trend-Ansicht,
|
||||
nur den aktuellen Zustand (READY-004).
|
||||
- **DoD:** ✅ umgesetzt (2026-09-10). **Datenmodell-Korrektur beim Bauen:** ein
|
||||
Snapshot pro STANDORT+ZUSTAND (wie ursprünglich geplant) hätte keine
|
||||
objektbezogene Akte-Historie zugelassen (kein `objekt_id`-Feld) — auf einen
|
||||
Snapshot pro OBJEKT pro Tag umgestellt (`readiness_snapshot`: zeitpunkt,
|
||||
objekt_id, zustand), Standort-/globale Aggregation läuft per `GROUP BY`/Join
|
||||
zur Abfragezeit, dieselbe Tabelle bedient jetzt tatsächlich beide
|
||||
Sichten aus einem Datenspeicher, wie ursprünglich beabsichtigt. `Objekt_kategorien()`
|
||||
aus `dashboard.einsatzbereitschaft()` extrahiert (kein Big-Bang, nur
|
||||
Wiederverwendung derselben Fachlogik), Snapshot-Skript
|
||||
`scripts/readiness_snapshot_erstellen.py` (systemd-Timer
|
||||
`mabea-readiness-snapshot.timer` serverseitig einzurichten, analog
|
||||
Eskalation), `GET /readiness/verlauf` + `GET /objekte/{id}/readiness-verlauf`
|
||||
(Administration/Leitungsverantwortliche), Dashboard-Kachel
|
||||
„Bereitschaft im Zeitverlauf" + Akte-Abschnitt „Bereitschaftshistorie".
|
||||
`backend/tests/test_readiness_snapshot.py`. **Referenz
|
||||
(HiOrg-Server-Vergleich 2026-09-09):** HiOrg bietet dedizierte Statistiken
|
||||
über Dienste/Kurse — MABEA hat jetzt eine vergleichbare Trend-Ansicht statt
|
||||
nur des aktuellen Zustands (READY-004).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""READY-006: täglicher Bereitschafts-Snapshot je Objekt.
|
||||
|
||||
Revision ID: 0037_readiness_snapshot
|
||||
Revises: 0036_tankbuch
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0037_readiness_snapshot"
|
||||
down_revision: Union[str, None] = "0036_tankbuch"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE TYPE readiness_zustand AS ENUM "
|
||||
"('einsatzbereit', 'eingeschraenkt_einsatzbereit', 'unbekannt', 'nicht_einsatzbereit')"
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE readiness_snapshot (
|
||||
id UUID PRIMARY KEY,
|
||||
zeitpunkt DATE NOT NULL,
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
zustand readiness_zustand NOT NULL,
|
||||
UNIQUE (zeitpunkt, objekt_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_readiness_snapshot_objekt ON readiness_snapshot (objekt_id, zeitpunkt)")
|
||||
op.execute("CREATE INDEX ix_readiness_snapshot_zeitpunkt ON readiness_snapshot (zeitpunkt)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE readiness_snapshot")
|
||||
op.execute("DROP TYPE IF EXISTS readiness_zustand")
|
||||
@@ -21,6 +21,7 @@ from app.api.v1.endpoints import (
|
||||
objekte,
|
||||
permission,
|
||||
personal,
|
||||
readiness_snapshot,
|
||||
stammdaten,
|
||||
suche,
|
||||
tankbuch,
|
||||
@@ -62,3 +63,4 @@ api_router.include_router(ausgabe.router, tags=["ausgabe"])
|
||||
api_router.include_router(wartung.router, tags=["wartung"])
|
||||
api_router.include_router(suche.router, tags=["suche"])
|
||||
api_router.include_router(tankbuch.router, tags=["tankbuch"])
|
||||
api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"])
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models.auth import RolleTyp
|
||||
from app.schemas.readiness_snapshot import ReadinessVerlaufObjektPunkt, ReadinessVerlaufPunkt
|
||||
from app.services.readiness_snapshot import verlauf_fuer_objekt, verlauf_global
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_leitung_plus = require_roles(RolleTyp.administration, RolleTyp.leitungsverantwortlicher)
|
||||
|
||||
|
||||
@router.get("/readiness/verlauf", response_model=list[ReadinessVerlaufPunkt])
|
||||
async def readiness_verlauf(
|
||||
von: date | None = None,
|
||||
bis: date | None = None,
|
||||
standort_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_leitung_plus),
|
||||
) -> list[ReadinessVerlaufPunkt]:
|
||||
bis = bis or date.today()
|
||||
von = von or bis - timedelta(days=90)
|
||||
daten = await verlauf_global(db, von=von, bis=bis, standort_id=standort_id)
|
||||
return [ReadinessVerlaufPunkt(**d) for d in daten]
|
||||
|
||||
|
||||
@router.get("/objekte/{objekt_id}/readiness-verlauf", response_model=list[ReadinessVerlaufObjektPunkt])
|
||||
async def objekt_readiness_verlauf(
|
||||
objekt_id: int,
|
||||
tage: int = 180,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_leitung_plus),
|
||||
) -> list[ReadinessVerlaufObjektPunkt]:
|
||||
daten = await verlauf_fuer_objekt(db, objekt_id, tage=tage)
|
||||
return [ReadinessVerlaufObjektPunkt(**d) for d in daten]
|
||||
@@ -16,6 +16,7 @@ from app.models.notiz import AkteNotiz
|
||||
from app.models.objekt import Objekt, ObjektStatus
|
||||
from app.models.objektposition import Objektposition, ObjektpositionStatus
|
||||
from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung
|
||||
from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand
|
||||
from app.models.personal import (
|
||||
BenutzerQualifikation,
|
||||
Einheit,
|
||||
@@ -76,6 +77,8 @@ __all__ = [
|
||||
"BenutzerRolleZuordnung",
|
||||
"Rolle",
|
||||
"RolleBerechtigung",
|
||||
"ReadinessSnapshot",
|
||||
"ReadinessZustand",
|
||||
"BenutzerQualifikation",
|
||||
"Einheit",
|
||||
"ObjekttypQualifikationsanforderung",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ReadinessZustand(str, enum.Enum):
|
||||
einsatzbereit = "einsatzbereit"
|
||||
eingeschraenkt_einsatzbereit = "eingeschraenkt_einsatzbereit"
|
||||
unbekannt = "unbekannt"
|
||||
nicht_einsatzbereit = "nicht_einsatzbereit"
|
||||
|
||||
|
||||
readiness_zustand_pg = PgEnum(ReadinessZustand, name="readiness_zustand", create_type=False)
|
||||
|
||||
|
||||
class ReadinessSnapshot(Base):
|
||||
"""READY-006: EIN Snapshot-Eintrag pro Objekt pro Tag (nicht pro Standort
|
||||
aggregiert) - erlaubt sowohl das globale Trend-Dashboard (GROUP BY
|
||||
zeitpunkt/zustand, optional über objekt.standort_id) als auch die
|
||||
Bereitschaftshistorie EINES Objekts in der Akte aus derselben Tabelle,
|
||||
ohne zweiten Datenspeicher (Kachel-Vorgabe). Erzeugt durch den täglichen
|
||||
systemd-Timer (scripts/readiness_snapshot_erstellen.py), nicht live
|
||||
berechnet."""
|
||||
|
||||
__tablename__ = "readiness_snapshot"
|
||||
__table_args__ = (UniqueConstraint("zeitpunkt", "objekt_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
zeitpunkt: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
|
||||
zustand: Mapped[ReadinessZustand] = mapped_column(readiness_zustand_pg, nullable=False)
|
||||
@@ -0,0 +1,14 @@
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReadinessVerlaufPunkt(BaseModel):
|
||||
zeitpunkt: date
|
||||
zustand: str
|
||||
anzahl: int
|
||||
|
||||
|
||||
class ReadinessVerlaufObjektPunkt(BaseModel):
|
||||
zeitpunkt: date
|
||||
zustand: str
|
||||
@@ -101,15 +101,11 @@ async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]:
|
||||
return eintraege
|
||||
|
||||
|
||||
async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit,
|
||||
wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da
|
||||
Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt,
|
||||
siehe app/services/geraet_instanz.py), kein abgelaufenes ablaufdatum, keine
|
||||
überfällige Geräteprüfung und keine laufende Kontrolle (Sperre) vorliegt.
|
||||
Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT
|
||||
(Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt
|
||||
einsatzbereit", nicht "einsatzbereit"."""
|
||||
async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[str]]]:
|
||||
"""Aus `einsatzbereitschaft()` extrahiert (READY-006), damit der
|
||||
Snapshot-Job (app/services/readiness_snapshot.py) dieselbe Fachlogik je
|
||||
Objekt nutzt statt sie zu duplizieren - liefert Kategorie+Gründe JE Objekt,
|
||||
`einsatzbereitschaft()` aggregiert das nur noch zu Summen."""
|
||||
heute = datetime.now(timezone.utc).date()
|
||||
# ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3,
|
||||
# Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als
|
||||
@@ -199,21 +195,40 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
(
|
||||
objekt,
|
||||
*_kategorie_und_gruende(
|
||||
objekt,
|
||||
letzte_kontrolle_map.get(objekt.id),
|
||||
objekt_ids_mit_fehlbestand.get(objekt.id, set()),
|
||||
fehlbestand_ids_genehmigt,
|
||||
objekt_ids_abgelaufen,
|
||||
objekt_ids_pruefung_ueberfaellig,
|
||||
objekt_ids_gesperrt,
|
||||
objekt_ids_kritischer_mangel,
|
||||
objekt_ids_hu_uvv_ueberfaellig,
|
||||
),
|
||||
)
|
||||
for objekt in objekte
|
||||
]
|
||||
|
||||
|
||||
async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit,
|
||||
wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da
|
||||
Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt,
|
||||
siehe app/services/geraet_instanz.py), kein abgelaufenes ablaufdatum, keine
|
||||
überfällige Geräteprüfung und keine laufende Kontrolle (Sperre) vorliegt.
|
||||
Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT
|
||||
(Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt
|
||||
einsatzbereit", nicht "einsatzbereit"."""
|
||||
kategorien = await objekt_kategorien(db)
|
||||
|
||||
bereit = eingeschraenkt = unbekannt = 0
|
||||
nie_kontrolliert_gesamt = 0
|
||||
details: list[dict] = []
|
||||
for objekt in objekte:
|
||||
kategorie, gruende = _kategorie_und_gruende(
|
||||
objekt,
|
||||
letzte_kontrolle_map.get(objekt.id),
|
||||
objekt_ids_mit_fehlbestand.get(objekt.id, set()),
|
||||
fehlbestand_ids_genehmigt,
|
||||
objekt_ids_abgelaufen,
|
||||
objekt_ids_pruefung_ueberfaellig,
|
||||
objekt_ids_gesperrt,
|
||||
objekt_ids_kritischer_mangel,
|
||||
objekt_ids_hu_uvv_ueberfaellig,
|
||||
)
|
||||
for objekt, kategorie, gruende in kategorien:
|
||||
if "noch_nie_kontrolliert" in gruende:
|
||||
nie_kontrolliert_gesamt += 1
|
||||
if kategorie == "ready":
|
||||
@@ -229,8 +244,8 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
"einsatzbereit": bereit,
|
||||
"eingeschraenkt_einsatzbereit": eingeschraenkt,
|
||||
"unbekannt": unbekannt,
|
||||
"nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt - unbekannt,
|
||||
"gesamt": len(objekte),
|
||||
"nicht_einsatzbereit": len(kategorien) - bereit - eingeschraenkt - unbekannt,
|
||||
"gesamt": len(kategorien),
|
||||
# "unbekannt" zählt nur Objekte ohne jeden anderen Grund - diese Zahl
|
||||
# zählt "nie kontrolliert" unabhängig von sonstigen Problemen mit.
|
||||
"nie_kontrolliert_gesamt": nie_kontrolliert_gesamt,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand
|
||||
from app.services.dashboard import objekt_kategorien
|
||||
|
||||
_KATEGORIE_ZU_ZUSTAND = {
|
||||
"ready": ReadinessZustand.einsatzbereit,
|
||||
"limited": ReadinessZustand.eingeschraenkt_einsatzbereit,
|
||||
"unknown": ReadinessZustand.unbekannt,
|
||||
"not-ready": ReadinessZustand.nicht_einsatzbereit,
|
||||
}
|
||||
|
||||
|
||||
async def erstelle_snapshot(db: AsyncSession, *, zeitpunkt: date | None = None) -> int:
|
||||
"""READY-006: EIN Snapshot-Lauf pro Tag - ruft dieselbe Fachlogik wie das
|
||||
Live-Dashboard auf (`objekt_kategorien`), schreibt aber je Objekt eine Zeile
|
||||
statt nur die Summe. Erneuter Lauf am selben Tag überschreibt (UPSERT über
|
||||
UNIQUE(zeitpunkt, objekt_id)) statt zu duplizieren - macht den Job
|
||||
idempotent bei einem Timer-Nachlauf/manuellen Wiederholung."""
|
||||
zeitpunkt = zeitpunkt or date.today()
|
||||
kategorien = await objekt_kategorien(db)
|
||||
|
||||
bestehende = (
|
||||
await db.execute(select(ReadinessSnapshot).where(ReadinessSnapshot.zeitpunkt == zeitpunkt))
|
||||
).scalars().all()
|
||||
bestehend_je_objekt = {s.objekt_id: s for s in bestehende}
|
||||
|
||||
anzahl = 0
|
||||
for objekt, kategorie, _gruende in kategorien:
|
||||
zustand = _KATEGORIE_ZU_ZUSTAND[kategorie]
|
||||
eintrag = bestehend_je_objekt.get(objekt.id)
|
||||
if eintrag is not None:
|
||||
eintrag.zustand = zustand
|
||||
else:
|
||||
db.add(ReadinessSnapshot(zeitpunkt=zeitpunkt, objekt_id=objekt.id, zustand=zustand))
|
||||
anzahl += 1
|
||||
|
||||
await db.commit()
|
||||
return anzahl
|
||||
|
||||
|
||||
async def verlauf_global(db: AsyncSession, *, von: date, bis: date, standort_id: int | None = None) -> list[dict]:
|
||||
"""Globales Dashboard: Anzahl je Zeitpunkt/Zustand, optional auf einen
|
||||
Standort gefiltert (Join über objekt.standort_id - kein eigenes
|
||||
Standort-Feld im Snapshot nötig, siehe Modell-Docstring)."""
|
||||
query = (
|
||||
select(ReadinessSnapshot.zeitpunkt, ReadinessSnapshot.zustand, func.count())
|
||||
.join(Objekt, ReadinessSnapshot.objekt_id == Objekt.id)
|
||||
.where(ReadinessSnapshot.zeitpunkt >= von, ReadinessSnapshot.zeitpunkt <= bis)
|
||||
.group_by(ReadinessSnapshot.zeitpunkt, ReadinessSnapshot.zustand)
|
||||
.order_by(ReadinessSnapshot.zeitpunkt)
|
||||
)
|
||||
if standort_id is not None:
|
||||
query = query.where(Objekt.standort_id == standort_id)
|
||||
result = await db.execute(query)
|
||||
return [
|
||||
{"zeitpunkt": zeitpunkt, "zustand": zustand.value, "anzahl": anzahl}
|
||||
for zeitpunkt, zustand, anzahl in result.all()
|
||||
]
|
||||
|
||||
|
||||
async def verlauf_fuer_objekt(db: AsyncSession, objekt_id: int, *, tage: int = 180) -> list[dict]:
|
||||
"""Akte-Ergänzung (2026-09-10): Bereitschaftshistorie EINES Objekts, letzte
|
||||
`tage` Tage - dieselbe Tabelle wie `verlauf_global`, nur gefiltert statt
|
||||
aggregiert."""
|
||||
seit = date.today() - timedelta(days=tage)
|
||||
result = await db.execute(
|
||||
select(ReadinessSnapshot)
|
||||
.where(ReadinessSnapshot.objekt_id == objekt_id, ReadinessSnapshot.zeitpunkt >= seit)
|
||||
.order_by(ReadinessSnapshot.zeitpunkt)
|
||||
)
|
||||
return [{"zeitpunkt": s.zeitpunkt, "zustand": s.zustand.value} for s in result.scalars().all()]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""READY-006: täglicher Bereitschafts-Snapshot, für einen systemd-Timer gedacht
|
||||
(gleiches Muster wie scripts/eskalation_pruefen.py) - Zeitplan über
|
||||
`mabea-readiness-snapshot.timer` auf dem Server (OnCalendar), nicht hier
|
||||
konfiguriert.
|
||||
|
||||
Aufruf (im aktivierten venv auf dem Server):
|
||||
|
||||
python -m scripts.readiness_snapshot_erstellen
|
||||
"""
|
||||
|
||||
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.readiness_snapshot import erstelle_snapshot
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with SessionLocal() as db:
|
||||
anzahl = await erstelle_snapshot(db)
|
||||
logger.info("Readiness-Snapshot: %d Objekt(e) erfasst.", anzahl)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand
|
||||
from app.services.readiness_snapshot import erstelle_snapshot, verlauf_fuer_objekt, verlauf_global
|
||||
from tests.conftest import login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_erstellt_zeile_je_objekt(db_session, standort_factory, objekt_factory):
|
||||
standort = await standort_factory("Wache-Ready")
|
||||
objekt = await objekt_factory(name="Rucksack Ready", code="RS-READY-1", standort=standort)
|
||||
|
||||
anzahl = await erstelle_snapshot(db_session)
|
||||
assert anzahl >= 1
|
||||
|
||||
treffer = (
|
||||
await db_session.execute(select(ReadinessSnapshot).where(ReadinessSnapshot.objekt_id == objekt.id))
|
||||
).scalar_one()
|
||||
# noch nie kontrolliert -> unbekannt (READY-002)
|
||||
assert treffer.zustand == ReadinessZustand.unbekannt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_erneuter_lauf_am_selben_tag_ueberschreibt(db_session, standort_factory, objekt_factory):
|
||||
standort = await standort_factory("Wache-Ready2")
|
||||
await objekt_factory(name="Rucksack Ready2", code="RS-READY-2", standort=standort)
|
||||
|
||||
erste = await erstelle_snapshot(db_session)
|
||||
zweite = await erstelle_snapshot(db_session)
|
||||
assert erste == zweite
|
||||
|
||||
anzahl_zeilen = (
|
||||
await db_session.execute(select(func.count()).select_from(ReadinessSnapshot))
|
||||
).scalar_one()
|
||||
assert anzahl_zeilen == erste
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verlauf_global_und_fuer_objekt(db_session, standort_factory, objekt_factory):
|
||||
standort = await standort_factory("Wache-Ready3")
|
||||
objekt = await objekt_factory(name="Rucksack Ready3", code="RS-READY-3", standort=standort)
|
||||
|
||||
gestern = date.today() - timedelta(days=1)
|
||||
await erstelle_snapshot(db_session, zeitpunkt=gestern)
|
||||
await erstelle_snapshot(db_session)
|
||||
|
||||
global_verlauf = await verlauf_global(db_session, von=gestern, bis=date.today())
|
||||
zeitpunkte = {v["zeitpunkt"] for v in global_verlauf}
|
||||
assert gestern in zeitpunkte and date.today() in zeitpunkte
|
||||
|
||||
objekt_verlauf = await verlauf_fuer_objekt(db_session, objekt.id)
|
||||
assert len(objekt_verlauf) == 2
|
||||
assert all(p["zustand"] == "unbekannt" for p in objekt_verlauf)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_verlauf_endpunkt_erfordert_leitung(client, admin_user, mitarbeiter_user):
|
||||
token_mitarbeiter = await login(client, "mitarbeiter1")
|
||||
verboten = await client.get(
|
||||
"/api/v1/readiness/verlauf", headers={"Authorization": f"Bearer {token_mitarbeiter}"}
|
||||
)
|
||||
assert verboten.status_code == 403
|
||||
|
||||
token_admin = await login(client, "admin1")
|
||||
erlaubt = await client.get(
|
||||
"/api/v1/readiness/verlauf", headers={"Authorization": f"Bearer {token_admin}"}
|
||||
)
|
||||
assert erlaubt.status_code == 200
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
interface Punkt {
|
||||
zeitpunkt: string;
|
||||
zustand: string;
|
||||
}
|
||||
|
||||
const ZUSTAND_LABEL: Record<string, string> = {
|
||||
einsatzbereit: "Einsatzbereit",
|
||||
eingeschraenkt_einsatzbereit: "Eingeschränkt einsatzbereit",
|
||||
unbekannt: "Unbekannt",
|
||||
nicht_einsatzbereit: "Nicht einsatzbereit",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
objektId: number;
|
||||
}
|
||||
|
||||
// READY-006 (Akte-Ergänzung 2026-09-10): "in den letzten X Tagen Y Tage nicht
|
||||
// einsatzbereit" aus derselben Snapshot-Tabelle wie das globale
|
||||
// Dashboard-Trend-Tile, nur auf ein Objekt gefiltert.
|
||||
export function ReadinessVerlaufPanel({ objektId }: Props) {
|
||||
const { istAdmin, istLeitungsverantwortlich } = useAuth();
|
||||
const [punkte, setPunkte] = useState<Punkt[] | null>(null);
|
||||
const berechtigt = istAdmin || istLeitungsverantwortlich;
|
||||
|
||||
useEffect(() => {
|
||||
if (!berechtigt) return;
|
||||
apiRequest<Punkt[]>(`/objekte/${objektId}/readiness-verlauf`)
|
||||
.then(setPunkte)
|
||||
.catch(() => setPunkte([]));
|
||||
}, [objektId, berechtigt]);
|
||||
|
||||
if (!berechtigt) return <p className="text-muted">Nur für Administration/Leitungsverantwortliche.</p>;
|
||||
if (punkte === null) return <p className="text-muted">Lade…</p>;
|
||||
if (punkte.length === 0) return <p className="text-muted">Noch keine Bereitschaftshistorie vorhanden.</p>;
|
||||
|
||||
const tageGesamt = punkte.length;
|
||||
const tageProblematisch = punkte.filter(
|
||||
(p) => p.zustand === "nicht_einsatzbereit" || p.zustand === "eingeschraenkt_einsatzbereit"
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
In den letzten {tageGesamt} erfassten Tagen: <strong>{tageProblematisch}</strong> Tag(e) nicht/eingeschränkt
|
||||
einsatzbereit.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "1px", flexWrap: "wrap" }}>
|
||||
{punkte.map((p) => (
|
||||
<span
|
||||
key={p.zeitpunkt}
|
||||
title={`${new Date(p.zeitpunkt).toLocaleDateString("de-DE")}: ${ZUSTAND_LABEL[p.zustand] ?? p.zustand}`}
|
||||
style={{
|
||||
width: "0.6rem",
|
||||
height: "0.6rem",
|
||||
display: "inline-block",
|
||||
background:
|
||||
p.zustand === "einsatzbereit"
|
||||
? "var(--color-success)"
|
||||
: p.zustand === "nicht_einsatzbereit"
|
||||
? "var(--color-danger)"
|
||||
: p.zustand === "eingeschraenkt_einsatzbereit"
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import type { Akte } from "../api/types";
|
||||
import { DokumentePanel } from "../components/DokumentePanel";
|
||||
import { NotizenPanel } from "../components/NotizenPanel";
|
||||
import { ReadinessVerlaufPanel } from "../components/ReadinessVerlaufPanel";
|
||||
import { TankbuchPanel } from "../components/TankbuchPanel";
|
||||
import { WartungSection } from "../components/WartungSection";
|
||||
import { ReadinessBadge } from "../components/status/ReadinessBadge";
|
||||
@@ -297,6 +298,11 @@ export function AktePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="akte-readiness-verlauf card">
|
||||
<h3 style={{ marginTop: 0 }}>Bereitschaftshistorie</h3>
|
||||
<ReadinessVerlaufPanel objektId={objekt.id} />
|
||||
</div>
|
||||
|
||||
<div className="akte-historie card">
|
||||
<h3 style={{ marginTop: 0 }}>Historie</h3>
|
||||
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
|
||||
|
||||
@@ -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 { ReadinessVerlaufTile } from "./tiles/ReadinessVerlaufTile";
|
||||
import type { Ablaufdatumeintrag, Einsatzbereitschaft, Kennzahlen, Prueftermineintrag, Qualifikationsablauf } from "./types";
|
||||
|
||||
const AKTUALISIERUNGS_INTERVALL_MS = 60_000;
|
||||
@@ -32,6 +33,7 @@ const TITEL: Record<DashboardTileId, string> = {
|
||||
maengel: "Mängel",
|
||||
qualifikation: "Qualifikationsablauf",
|
||||
aktivitaet: "Letzte Aktivität",
|
||||
readinessVerlauf: "Bereitschaft im Zeitverlauf",
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -197,6 +199,8 @@ export function DashboardPage() {
|
||||
return <QualifikationTile qualifikationsablaeufe={qualifikationsablaeufe} />;
|
||||
case "aktivitaet":
|
||||
return <AktivitaetTile aktivitaet={aktivitaet} />;
|
||||
case "readinessVerlauf":
|
||||
return <ReadinessVerlaufTile />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export type DashboardTileId =
|
||||
| "kennzahlen"
|
||||
| "maengel"
|
||||
| "qualifikation"
|
||||
| "aktivitaet";
|
||||
| "aktivitaet"
|
||||
| "readinessVerlauf";
|
||||
|
||||
export const ALLE_TILE_IDS: DashboardTileId[] = [
|
||||
"aufgaben",
|
||||
@@ -17,6 +18,7 @@ export const ALLE_TILE_IDS: DashboardTileId[] = [
|
||||
"maengel",
|
||||
"qualifikation",
|
||||
"aktivitaet",
|
||||
"readinessVerlauf",
|
||||
];
|
||||
|
||||
// 24 statt 12 Spalten - feineres Raster, mehr Kontrolle beim Breiter/Schmaler
|
||||
@@ -44,6 +46,7 @@ export const STANDARD_RASTER: Record<DashboardTileId, RasterPosition> = {
|
||||
maengel: { x: 8, y: 8, w: 8, h: 5 },
|
||||
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 },
|
||||
};
|
||||
|
||||
export interface DashboardLayout {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../../api/client";
|
||||
import { useAuth } from "../../../auth/AuthContext";
|
||||
|
||||
interface Punkt {
|
||||
zeitpunkt: string;
|
||||
zustand: string;
|
||||
anzahl: number;
|
||||
}
|
||||
|
||||
const ZUSTAND_LABEL: Record<string, string> = {
|
||||
einsatzbereit: "Einsatzbereit",
|
||||
eingeschraenkt_einsatzbereit: "Eingeschränkt",
|
||||
unbekannt: "Unbekannt",
|
||||
nicht_einsatzbereit: "Nicht einsatzbereit",
|
||||
};
|
||||
|
||||
const ZUSTAND_FARBE: Record<string, string> = {
|
||||
einsatzbereit: "var(--color-success)",
|
||||
eingeschraenkt_einsatzbereit: "var(--color-warning)",
|
||||
unbekannt: "var(--color-text-muted)",
|
||||
nicht_einsatzbereit: "var(--color-danger)",
|
||||
};
|
||||
|
||||
// READY-006: Trend statt Momentaufnahme - täglicher Snapshot je Objekt
|
||||
// (backend/app/services/readiness_snapshot.py), hier auf Zeitpunkt/Zustand
|
||||
// aggregiert. Eigenständiger Fetch statt Anbindung an DashboardPage.ladeDaten,
|
||||
// da diese Kachel einen anderen Endpunkt/Datenzeitraum als der Rest nutzt.
|
||||
function ReadinessVerlaufTileKomponente() {
|
||||
const { istAdmin, istLeitungsverantwortlich } = useAuth();
|
||||
const [punkte, setPunkte] = useState<Punkt[] | null>(null);
|
||||
const [fehler, setFehler] = useState(false);
|
||||
const berechtigt = istAdmin || istLeitungsverantwortlich;
|
||||
|
||||
useEffect(() => {
|
||||
if (!berechtigt) return;
|
||||
apiRequest<Punkt[]>("/readiness/verlauf")
|
||||
.then(setPunkte)
|
||||
.catch(() => setFehler(true));
|
||||
}, [berechtigt]);
|
||||
|
||||
// Backend gibt 403 für alle außer Administration/Leitungsverantwortliche
|
||||
// (READY-006: Trend-Reporting ist Leitungsthema, kein Helfer-Feature).
|
||||
if (!berechtigt) return <p className="text-muted">Nur für Administration/Leitungsverantwortliche.</p>;
|
||||
if (fehler) return <p className="text-muted">Verlauf konnte nicht geladen werden.</p>;
|
||||
if (punkte === null) return <p className="text-muted">Lade…</p>;
|
||||
if (punkte.length === 0) return <p className="text-muted">Noch keine Snapshots vorhanden.</p>;
|
||||
|
||||
const zeitpunkte = [...new Set(punkte.map((p) => p.zeitpunkt))].sort();
|
||||
const letzterZeitpunkt = zeitpunkte[zeitpunkte.length - 1];
|
||||
const letzteVerteilung = punkte.filter((p) => p.zeitpunkt === letzterZeitpunkt);
|
||||
const gesamt = letzteVerteilung.reduce((s, p) => s + p.anzahl, 0) || 1;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<p className="text-xs text-muted">
|
||||
Letzter Snapshot: {new Date(letzterZeitpunkt).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<div style={{ display: "flex", height: "1.25rem", borderRadius: "0.25rem", overflow: "hidden" }}>
|
||||
{Object.keys(ZUSTAND_LABEL).map((zustand) => {
|
||||
const anzahl = letzteVerteilung.find((p) => p.zustand === zustand)?.anzahl ?? 0;
|
||||
if (anzahl === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={zustand}
|
||||
title={`${ZUSTAND_LABEL[zustand]}: ${anzahl}`}
|
||||
style={{ width: `${(anzahl / gesamt) * 100}%`, background: ZUSTAND_FARBE[zustand] }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ul>
|
||||
{Object.keys(ZUSTAND_LABEL).map((zustand) => {
|
||||
const anzahl = letzteVerteilung.find((p) => p.zustand === zustand)?.anzahl ?? 0;
|
||||
return (
|
||||
<li key={zustand} className="row-between" style={{ padding: "0.15rem 0" }}>
|
||||
<span style={{ color: ZUSTAND_FARBE[zustand] }}>{ZUSTAND_LABEL[zustand]}</span>
|
||||
<span className="tabular-nums">{anzahl}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-xs text-muted">{zeitpunkte.length} Tag(e) Verlauf verfügbar.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ReadinessVerlaufTile = memo(ReadinessVerlaufTileKomponente);
|
||||
@@ -13,6 +13,7 @@
|
||||
"maengel"
|
||||
"dokumente"
|
||||
"notizen"
|
||||
"readiness"
|
||||
"historie";
|
||||
}
|
||||
|
||||
@@ -27,4 +28,5 @@
|
||||
.akte-dokumente { grid-area: dokumente; }
|
||||
.akte-notizen { grid-area: notizen; }
|
||||
.akte-tankbuch { grid-area: tankbuch; }
|
||||
.akte-readiness-verlauf { grid-area: readiness; }
|
||||
.akte-historie { grid-area: historie; }
|
||||
@@ -21,6 +21,7 @@
|
||||
"dokumente dokumente"
|
||||
"notizen notizen"
|
||||
"tankbuch tankbuch"
|
||||
"readiness readiness"
|
||||
"historie historie";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user