diff --git a/DEVLOG.md b/DEVLOG.md index 51634c1..21037fa 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -3675,3 +3675,24 @@ Keine Commits in dieser Session. - frontend/src/pages/admin/ObjektSection.tsx | 29 ++++++++++++++++++++++++----- --- +## 2026-09-05 00:09 – 01:13 (1h 03m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +- 38b8ce1 fix(objekt): Fahrzeug-Zuordnung als 1:n statt n:m (fachliche Korrektur) +- ebd6a95 fix(vorlagen): DELETE-Reihenfolge - flush zwischen Positionen und Vorlage +- 8666503 DELETE /vorlagen/{id}: echtes Löschen nur wenn unbenutzt +- 13ff283 Materialstamm: Blutdruckmanschette ALT geklärt + Zuständigkeitsliste mit Suche + +### Geänderte Dateien +- backend/alembic/versions/0010_objekt_kopplung.py | 36 +++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/objekte.py | 24 +++++++++--------- +- backend/app/models/objekt.py | 9 ++++--- +- backend/app/schemas/objekt.py | 6 ++--- +- backend/app/services/objekte.py | 7 +++--- +- backend/tests/test_objekte.py | 75 ++++++++++++++++++++++++++------------------------------ +- frontend/src/api/types.ts | 2 +- +- frontend/src/pages/admin/ObjektSection.tsx | 94 ++++++++++++++++++++++++++++++++++++---------------------------------- + +--- diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md index 1df655e..8653330 100644 --- a/backend/DEVLOG.md +++ b/backend/DEVLOG.md @@ -242,3 +242,21 @@ Keine Commits in dieser Session. - backend/tests/test_objekte.py | 131 +++++++++++++++++++++++++++ --- +## 2026-09-05 09:55 – 10:31 (36m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/alembic/versions/0010_objekt_kopplung.py | 36 +++++++++++++++ +- backend/app/api/v1/endpoints/objekte.py | 24 +++++----- +- backend/app/models/objekt.py | 9 ++-- +- backend/app/schemas/objekt.py | 6 +-- +- backend/app/services/objekte.py | 7 ++- +- backend/tests/test_objekte.py | 75 ++++++++++++++---------------- +- frontend/src/api/types.ts | 2 +- +- frontend/src/pages/admin/ObjektSection.tsx | 94 ++++++++++++++++++++------------------ + +--- diff --git a/backend/alembic/versions/0011_objekttyp_zugfahrzeug.py b/backend/alembic/versions/0011_objekttyp_zugfahrzeug.py new file mode 100644 index 0000000..536ce33 --- /dev/null +++ b/backend/alembic/versions/0011_objekttyp_zugfahrzeug.py @@ -0,0 +1,31 @@ +"""objekttyp.ist_zugfahrzeug: markiert Objekttypen, die als Fahrzeug einem +anderen Objekt zugeordnet werden dürfen (Karte 11 Nachtrag). + +Revision ID: 0011_objekttyp_zugfahrzeug +Revises: 0010_objekt_kopplung +Create Date: 2026-09-05 + +Fachlicher Hintergrund: Objekt bleibt generisch (kein eigener Fahrzeug- +Objekttyp), aber die Fahrzeug-Dropdowns im Frontend boten bisher alle Objekte +an (auch Rucksäcke). Flag am Objekttyp grenzt das serverseitig ein. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0011_objekttyp_zugfahrzeug" +down_revision: Union[str, None] = "0010_objekt_kopplung" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "objekttyp", + sa.Column("ist_zugfahrzeug", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("objekttyp", "ist_zugfahrzeug") diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 9862764..bb4e19f 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -4,8 +4,18 @@ 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.dashboard import AblaufdatumRead, KennzahlenRead -from app.services.dashboard import bevorstehende_ablaufdaten, kennzahlen +from app.schemas.dashboard import ( + AblaufdatumRead, + EinsatzbereitschaftRead, + KennzahlenRead, + PrueftermineintragRead, +) +from app.services.dashboard import ( + bevorstehende_ablaufdaten, + bevorstehende_prueftermine, + einsatzbereitschaft, + kennzahlen, +) router = APIRouter() @@ -25,3 +35,22 @@ async def hole_kennzahlen(db: AsyncSession = Depends(get_db), _=Depends(_verantw @router.get("/dashboard/ablaufdaten", response_model=list[AblaufdatumRead]) async def hole_ablaufdaten(db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)) -> list[AblaufdatumRead]: return [AblaufdatumRead(**eintrag) for eintrag in await bevorstehende_ablaufdaten(db)] + + +@router.get("/dashboard/einsatzbereitschaft", response_model=EinsatzbereitschaftRead) +async def hole_einsatzbereitschaft( + db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> EinsatzbereitschaftRead: + """Kachel 1 Leitungs-Dashboard: einsatzbereit/eingeschränkt/nicht einsatzbereit + je Objekt, siehe app/services/dashboard.py:einsatzbereitschaft für die + Kriterien und die Mindermenge-Sonderrolle (U2, Karte 08).""" + return EinsatzbereitschaftRead(**await einsatzbereitschaft(db)) + + +@router.get("/dashboard/prueftermine", response_model=list[PrueftermineintragRead]) +async def hole_prueftermine( + db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> list[PrueftermineintragRead]: + """Kachel 2 Leitungs-Dashboard: Geräteprüfungen (Karte 14), bewusst getrennt + von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf).""" + return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)] diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 2398507..6d19bad 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -10,7 +10,7 @@ from app.db.session import get_db from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.objekt import Objekt from app.models.objektposition import Objektposition -from app.models.stammdaten import Material +from app.models.stammdaten import Material, Objekttyp from app.models.vorlage import Beladungsvorlage from app.services.label import generiere_code128_label_pdf from app.schemas.objekt import ( @@ -36,6 +36,22 @@ _admin_only = require_roles(RolleTyp.administration) _materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher) +async def _pruefe_zugfahrzeug(db: AsyncSession, fahrzeug_id: int) -> Objekt: + """Karte 11 Nachtrag (Option A+C): Fahrzeug-Zuordnung nur auf Objekte, deren + Objekttyp ist_zugfahrzeug=True ist - grenzt Rucksäcke/Geräte serverseitig aus, + unabhängig vom (evtl. umgangenen) Frontend-Dropdown.""" + ziel = await db.get(Objekt, fahrzeug_id) + if ziel is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden") + objekttyp = await db.get(Objekttyp, ziel.objekttyp_id) + if objekttyp is None or not objekttyp.ist_zugfahrzeug: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Zielobjekt ist kein Zugfahrzeug (Objekttyp.ist_zugfahrzeug=False)", + ) + return ziel + + @router.get("/objekte", response_model=list[ObjektRead]) async def liste_objekte( standort_id: int | None = None, @@ -196,8 +212,7 @@ async def erstelle_objekt( if vorlage is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden") if payload.fahrzeug_id is not None: - if await db.get(Objekt, payload.fahrzeug_id) is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden") + await _pruefe_zugfahrzeug(db, payload.fahrzeug_id) hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) zustaendiger_server_id = hauptserver.scalar_one() @@ -231,9 +246,7 @@ async def aendere_objekt( daten = payload.model_dump(exclude_unset=True) ziel_id = daten.get("fahrzeug_id") if ziel_id is not None: - ziel = await db.get(Objekt, ziel_id) - if ziel is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden") + ziel = await _pruefe_zugfahrzeug(db, ziel_id) if ziel.fahrzeug_id == objekt_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, diff --git a/backend/app/models/stammdaten.py b/backend/app/models/stammdaten.py index 415c1d1..d162d2a 100644 --- a/backend/app/models/stammdaten.py +++ b/backend/app/models/stammdaten.py @@ -48,6 +48,10 @@ class Objekttyp(Base): bereich_id: Mapped[int] = mapped_column(ForeignKey("bereich.id"), nullable=False) kategorie_id: Mapped[int | None] = mapped_column(ForeignKey("kategorie.id")) name: Mapped[str] = mapped_column(String, nullable=False) + # Karte 11 Nachtrag: nur Objekte dieses Typs dürfen als Fahrzeug (Objekt. + # fahrzeug_id) zugeordnet werden - grenzt die Fahrzeug-Dropdowns serverseitig + # ein, ohne einen eigenen Fahrzeug-Objekttyp fachlich zu erzwingen. + ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class Fach(Base): diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 1fa5cf4..07b3fba 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -1,7 +1,10 @@ +import uuid from datetime import date from pydantic import BaseModel +from app.models.geraet_instanz import GeraetStatus + class KennzahlenRead(BaseModel): anzahl_offener_fehlbestaende: int @@ -18,3 +21,31 @@ class AblaufdatumRead(BaseModel): ablaufdatum: date verbleibende_tage: int status: str + + +class ObjektMangelRead(BaseModel): + objekt_id: int + code: str + name: str + gruende: list[str] + + +class EinsatzbereitschaftRead(BaseModel): + einsatzbereit: int + eingeschraenkt_einsatzbereit: int + nicht_einsatzbereit: int + gesamt: int + details: list[ObjektMangelRead] + + +class PrueftermineintragRead(BaseModel): + geraet_instanz_id: uuid.UUID + objekt_id: int + objektposition_id: uuid.UUID + material_id: int + material_name: str + seriennummer: str + geraet_status: GeraetStatus + naechste_pruefung: date + verbleibende_tage: int + status: str diff --git a/backend/app/schemas/stammdaten.py b/backend/app/schemas/stammdaten.py index 8a12762..2d2d51b 100644 --- a/backend/app/schemas/stammdaten.py +++ b/backend/app/schemas/stammdaten.py @@ -54,6 +54,7 @@ class ObjekttypCreate(BaseModel): bereich_id: int kategorie_id: int | None = None name: str + ist_zugfahrzeug: bool = False class ObjekttypRead(ObjekttypCreate): @@ -65,6 +66,7 @@ class ObjekttypUpdate(BaseModel): bereich_id: int | None = None kategorie_id: int | None = None name: str | None = None + ist_zugfahrzeug: bool | None = None class FachCreate(BaseModel): diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 783f406..ec994c4 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -4,6 +4,8 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.fehlbestand import Fehlbestand, FehlbestandStatus +from app.models.geraet_instanz import GeraetInstanz +from app.models.kontrolle import Kontrolle, KontrollStatus from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung from app.models.objekt import Objekt from app.models.objektposition import Objektposition @@ -89,3 +91,139 @@ async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]: ) eintraege.sort(key=lambda e: e["verbleibende_tage"]) 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".""" + heute = datetime.now(timezone.utc).date() + objekte = (await db.execute(select(Objekt))).scalars().all() + + offene_fehlbestaende = ( + await db.execute( + select(Fehlbestand.objekt_id, Fehlbestand.id).where(Fehlbestand.status.in_(OFFEN_STATI)) + ) + ).all() + objekt_ids_mit_fehlbestand: dict[int, set] = {} + for objekt_id, fehlbestand_id in offene_fehlbestaende: + objekt_ids_mit_fehlbestand.setdefault(objekt_id, set()).add(fehlbestand_id) + + fehlbestand_ids_genehmigt = set( + ( + await db.execute( + select(func.distinct(MindermengenGenehmigung.fehlbestand_id)).where( + MindermengenGenehmigung.status == MindermengeStatus.aktiv + ) + ) + ) + .scalars() + .all() + ) + + objekt_ids_abgelaufen = set( + ( + await db.execute( + select(func.distinct(Objektposition.objekt_id)).where( + Objektposition.ablaufdatum.is_not(None), Objektposition.ablaufdatum < heute + ) + ) + ) + .scalars() + .all() + ) + + objekt_ids_pruefung_ueberfaellig = set( + ( + await db.execute( + select(func.distinct(Objektposition.objekt_id)) + .join(GeraetInstanz, GeraetInstanz.objektposition_id == Objektposition.id) + .where(GeraetInstanz.naechste_pruefung.is_not(None), GeraetInstanz.naechste_pruefung < heute) + ) + ) + .scalars() + .all() + ) + + objekt_ids_gesperrt = set( + ( + await db.execute( + select(func.distinct(Kontrolle.objekt_id)).where(Kontrolle.status == KontrollStatus.in_bearbeitung) + ) + ) + .scalars() + .all() + ) + + bereit = eingeschraenkt = 0 + details: list[dict] = [] + for objekt in objekte: + gruende: list[str] = [] + fehlbestand_ids = objekt_ids_mit_fehlbestand.get(objekt.id, set()) + ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt + if ungenehmigt: + gruende.append("fehlbestand_offen") + if objekt.id in objekt_ids_abgelaufen: + gruende.append("ablaufdatum_abgelaufen") + if objekt.id in objekt_ids_pruefung_ueberfaellig: + gruende.append("pruefung_ueberfaellig") + if objekt.id in objekt_ids_gesperrt: + gruende.append("kontrolle_laeuft") + + if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt: + eingeschraenkt += 1 + details.append( + {"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["mindermenge_genehmigt"]} + ) + elif not gruende: + bereit += 1 + else: + details.append({"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": gruende}) + + return { + "einsatzbereit": bereit, + "eingeschraenkt_einsatzbereit": eingeschraenkt, + "nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt, + "gesamt": len(objekte), + "details": details, + } + + +async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = 30) -> list[dict]: + """Karte-Nachtrag (Leitungs-Dashboard Kachel 2): Geräteprüfungen (Karte 14), + nicht Ablaufdaten von Verbrauchsmaterial (bleibt eigene, bestehende Kachel/ + Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist + Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung).""" + heute = datetime.now(timezone.utc).date() + result = await db.execute( + select(GeraetInstanz, Objektposition, Material) + .join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id) + .join(Material, Objektposition.material_id == Material.id) + .where(GeraetInstanz.naechste_pruefung.is_not(None)) + ) + eintraege = [] + for geraet, objektposition, material in result.all(): + status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute) + if status_wert == "gueltig": + continue + eintraege.append( + { + "geraet_instanz_id": geraet.id, + "objekt_id": objektposition.objekt_id, + "objektposition_id": objektposition.id, + "material_id": material.id, + "material_name": material.name, + "seriennummer": geraet.seriennummer, + "geraet_status": geraet.status, + "naechste_pruefung": geraet.naechste_pruefung, + "verbleibende_tage": verbleibende_tage, + "status": status_wert, + } + ) + eintraege.sort(key=lambda e: e["verbleibende_tage"]) + return eintraege diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e055a9f..6655c4a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -200,11 +200,11 @@ async def material_factory(db_session): @pytest_asyncio.fixture async def objekttyp_factory(db_session): - async def _make(name: str) -> Objekttyp: + async def _make(name: str, ist_zugfahrzeug: bool = False) -> Objekttyp: bereich = Bereich(name=f"Bereich-{name}") db_session.add(bereich) await db_session.flush() - objekttyp = Objekttyp(bereich_id=bereich.id, name=name) + objekttyp = Objekttyp(bereich_id=bereich.id, name=name, ist_zugfahrzeug=ist_zugfahrzeug) db_session.add(objekttyp) await db_session.flush() return objekttyp diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 63539c4..9b05a15 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -1,5 +1,10 @@ -import pytest +from datetime import date, timedelta +import pytest +from sqlalchemy import select + +from app.models.geraet_instanz import GeraetInstanz +from app.models.objektposition import Objektposition from tests.conftest import auth_header, login @@ -145,3 +150,128 @@ async def test_abgelaufenes_material_wird_als_abgelaufen_markiert( assert len(body) == 1 assert body[0]["status"] == "abgelaufen" assert body[0]["verbleibende_tage"] == -3 + + +@pytest.mark.asyncio +async def test_objekt_ohne_maengel_ist_einsatzbereit( + client, objekt_mit_position, materialverantwortlicher_user +): + objekt, _material = objekt_mit_position + token = await login(client, "materialverantwortlicher1") + response = await client.get("/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(token)) + body = response.json() + assert body["einsatzbereit"] == 1 + assert body["eingeschraenkt_einsatzbereit"] == 0 + assert body["nicht_einsatzbereit"] == 0 + assert body["details"] == [] + + +@pytest.mark.asyncio +async def test_offener_fehlbestand_macht_objekt_nicht_einsatzbereit( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + await _fehlbestand(client, mitarbeiter_token, objekt, material, "6") + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + response = await client.get( + "/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token) + ) + body = response.json() + assert body["einsatzbereit"] == 0 + assert body["nicht_einsatzbereit"] == 1 + assert body["details"][0]["gruende"] == ["fehlbestand_offen"] + + +@pytest.mark.asyncio +async def test_genehmigte_mindermenge_macht_objekt_eingeschraenkt_einsatzbereit( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + """U2/Karte 08: Genehmigung erledigt den Fehlbestand fachlich nicht - + eingeschränkt einsatzbereit, nicht (voll) einsatzbereit.""" + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + fehlbestand_id = await _fehlbestand(client, mitarbeiter_token, objekt, material, "6") + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Lieferengpass"}, + headers=auth_header(verantwortlicher_token), + ) + + response = await client.get( + "/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token) + ) + body = response.json() + assert body["einsatzbereit"] == 0 + assert body["eingeschraenkt_einsatzbereit"] == 1 + assert body["nicht_einsatzbereit"] == 0 + assert body["details"][0]["gruende"] == ["mindermenge_genehmigt"] + + +@pytest.mark.asyncio +async def test_ueberfaellige_pruefung_macht_objekt_nicht_einsatzbereit( + client, db_session, objekt_mit_position, materialverantwortlicher_user +): + objekt, material = objekt_mit_position + result = await db_session.execute( + select(Objektposition).where( + Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id + ) + ) + position = result.scalar_one() + db_session.add( + GeraetInstanz( + objektposition_id=position.id, + seriennummer="SN-DASH-1", + naechste_pruefung=date.today() - timedelta(days=1), + ) + ) + await db_session.flush() + + token = await login(client, "materialverantwortlicher1") + response = await client.get("/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(token)) + body = response.json() + assert body["nicht_einsatzbereit"] == 1 + assert body["details"][0]["gruende"] == ["pruefung_ueberfaellig"] + + prueftermine = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(token)) + prueftermine_body = prueftermine.json() + assert len(prueftermine_body) == 1 + assert prueftermine_body[0]["status"] == "abgelaufen" + assert prueftermine_body[0]["seriennummer"] == "SN-DASH-1" + + +@pytest.mark.asyncio +async def test_gueltige_pruefung_taucht_nicht_in_prueftermine_auf( + client, db_session, objekt_mit_position, materialverantwortlicher_user +): + objekt, material = objekt_mit_position + result = await db_session.execute( + select(Objektposition).where( + Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id + ) + ) + position = result.scalar_one() + db_session.add( + GeraetInstanz( + objektposition_id=position.id, + seriennummer="SN-DASH-2", + naechste_pruefung=date.today() + timedelta(days=365), + ) + ) + await db_session.flush() + + token = await login(client, "materialverantwortlicher1") + response = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(token)) + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_mitarbeiter_darf_neue_dashboard_endpunkte_nicht_sehen(client, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + for pfad in ("/api/v1/dashboard/einsatzbereitschaft", "/api/v1/dashboard/prueftermine"): + response = await client.get(pfad, headers=auth_header(token)) + assert response.status_code == 403 diff --git a/backend/tests/test_objekte.py b/backend/tests/test_objekte.py index c1fb47f..a79ec0f 100644 --- a/backend/tests/test_objekte.py +++ b/backend/tests/test_objekte.py @@ -308,7 +308,7 @@ async def test_fahrzeug_zuordnung_bei_anlage_und_nachtraeglich( """Nutzer-Vorgabe: jedes Objekt (Rucksack/Gerät/Anhänger) gehört zu jedem Zeitpunkt höchstens einem Fahrzeug (1:n) - Fahrzeug selbst ist einfach ein Objekt ohne eigene fahrzeug_id.""" - objekttyp = await objekttyp_factory("Fahrzeug/Zuordnung") + objekttyp = await objekttyp_factory("Fahrzeug/Zuordnung", ist_zugfahrzeug=True) standort = await standort_factory("Wache-Zuordnung") vorlage = await erstelle_vorlage( db_session, objekttyp_id=objekttyp.id, name="Zuordnung Standard", positionen=[] @@ -375,7 +375,7 @@ async def test_fahrzeug_zuordnung_bei_anlage_und_nachtraeglich( async def test_fahrzeug_zuordnung_lehnt_selbstbezug_und_zyklus_ab( client, db_session, objekttyp_factory, standort_factory, admin_user ): - objekttyp = await objekttyp_factory("Fahrzeug/Zuordnung Zyklus") + objekttyp = await objekttyp_factory("Fahrzeug/Zuordnung Zyklus", ist_zugfahrzeug=True) standort = await standort_factory("Wache-Zyklus-Zuordnung") vorlage = await erstelle_vorlage( db_session, objekttyp_id=objekttyp.id, name="Zyklus-Zuordnung Standard", positionen=[] @@ -424,3 +424,65 @@ async def test_fahrzeug_zuordnung_lehnt_selbstbezug_und_zyklus_ab( f"/api/v1/objekte/{a_id}", json={"fahrzeug_id": b_id}, headers=auth_header(token) ) assert zyklus.status_code == 409 + + +@pytest.mark.asyncio +async def test_fahrzeug_zuordnung_lehnt_nicht_zugfahrzeug_typ_ab( + client, db_session, objekttyp_factory, standort_factory, admin_user +): + """Karte 11 Nachtrag (Option A+C): nur Objekte, deren Objekttyp + ist_zugfahrzeug=True ist, dürfen als Fahrzeug zugeordnet werden - grenzt + z.B. Rucksäcke serverseitig aus der Fahrzeug-Auswahl aus.""" + rucksack_typ = await objekttyp_factory("Rucksack-NichtZugfahrzeug", ist_zugfahrzeug=False) + standort = await standort_factory("Wache-Zugfahrzeug-Ablehnung") + vorlage = await erstelle_vorlage( + db_session, objekttyp_id=rucksack_typ.id, name="Rucksack Standard", positionen=[] + ) + token = await login(client, "admin1") + + kein_fahrzeug = await client.post( + "/api/v1/objekte", + json={ + "code": "NZF-01", + "name": "Rucksack", + "objekttyp_id": rucksack_typ.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + rucksack_id = kein_fahrzeug.json()["id"] + + abgelehnt = await client.post( + "/api/v1/objekte", + json={ + "code": "NZF-02", + "name": "Anderer Rucksack", + "objekttyp_id": rucksack_typ.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + "fahrzeug_id": rucksack_id, + }, + headers=auth_header(token), + ) + assert abgelehnt.status_code == 422 + + zweiter_rucksack = await client.post( + "/api/v1/objekte", + json={ + "code": "NZF-03", + "name": "Dritter Rucksack", + "objekttyp_id": rucksack_typ.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + zweiter_rucksack_id = zweiter_rucksack.json()["id"] + + abgelehnt_patch = await client.patch( + f"/api/v1/objekte/{zweiter_rucksack_id}", + json={"fahrzeug_id": rucksack_id}, + headers=auth_header(token), + ) + assert abgelehnt_patch.status_code == 422 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd5345f..ca118c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,8 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider, useAuth } from "./auth/AuthContext"; import { AppShell } from "./components/AppShell"; import { AdminPage } from "./pages/AdminPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { FehlbestandListePage } from "./pages/FehlbestandListePage"; import { KontrollPage } from "./pages/KontrollPage"; import { LoginPage } from "./pages/LoginPage"; import { ObjektListPage } from "./pages/ObjektListPage"; @@ -59,6 +61,22 @@ export default function App() { } /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c114119..45567be 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -44,6 +44,23 @@ export interface Kontrolle { abbruch_grund: string | null; } +export interface Fehlbestand { + id: string; + objekt_id: number; + material_id: number; + standort_id: number; + sollmenge: string; + istmenge: string; + fehlmenge: string; + entstanden_am: string; + festgestellt_von: number; + kontrolle_id: string | null; + ursache: string | null; + verantwortlicher_id: number | null; + status: "offen" | "in_bearbeitung" | "nachgefuellt_teilweise" | "erledigt"; + erledigt_am: string | null; +} + export interface Kontrollposition { id: string; kontrolle_id: string; @@ -109,6 +126,7 @@ export interface Objekttyp { bereich_id: number; kategorie_id: number | null; name: string; + ist_zugfahrzeug: boolean; } export interface Fach { diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 5f9a6b9..7c7eeb1 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -23,6 +23,16 @@ export function AppShell({ children }: { children: ReactNode }) { (isActive ? "active" : "")}> Objekte + {istVerantwortlich && ( + (isActive ? "active" : "")}> + Dashboard + + )} + {istVerantwortlich && ( + (isActive ? "active" : "")}> + Fehlbestände + + )} {istVerantwortlich && ( (isActive ? "active" : "")}> {istAdmin ? "Administration" : "Verwaltung"} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 5936cef..61ea5b8 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -143,6 +143,7 @@ export function AdminPage() { {aktiverTab === "objekte" && ( v.status === "aktiv")} standorte={standorte} materialien={materialien} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..5c7aef5 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { apiRequest } from "../api/client"; + +interface ObjektMangel { + objekt_id: number; + code: string; + name: string; + gruende: string[]; +} + +interface Einsatzbereitschaft { + einsatzbereit: number; + eingeschraenkt_einsatzbereit: number; + nicht_einsatzbereit: number; + gesamt: number; + details: ObjektMangel[]; +} + +interface Prueftermineintrag { + geraet_instanz_id: string; + objekt_id: number; + material_name: string; + seriennummer: string; + naechste_pruefung: string; + verbleibende_tage: number; + status: string; +} + +interface Ablaufdatumeintrag { + objekt_id: number; + material_id: number; + ablaufdatum: string; + verbleibende_tage: number; + status: string; +} + +const GRUND_TEXT: Record = { + fehlbestand_offen: "Offener Fehlbestand", + ablaufdatum_abgelaufen: "Ablaufdatum überschritten", + pruefung_ueberfaellig: "Geräteprüfung überfällig", + kontrolle_laeuft: "Kontrolle läuft (gesperrt)", + mindermenge_genehmigt: "Mindermenge genehmigt", +}; + +// Leitungs-Dashboard (Karte-Nachtrag): zwei neue Kacheln (Einsatzbereitschaft, +// Prüftermine) plus die bestehende Ablaufdaten-Kachel, alle drei eigenständig - +// widersprechen sich nicht, da überfällige Prüfung/Ablauf beide zusätzlich als +// Grund in die Einsatzbereitschaft einfließen. +export function DashboardPage() { + const [bereitschaft, setBereitschaft] = useState(null); + const [prueftermine, setPrueftermine] = useState([]); + const [ablaufdaten, setAblaufdaten] = useState([]); + const [laedt, setLaedt] = useState(true); + const [fehler, setFehler] = useState(null); + const [detailsOffen, setDetailsOffen] = useState(false); + + useEffect(() => { + Promise.all([ + apiRequest("/dashboard/einsatzbereitschaft"), + apiRequest("/dashboard/prueftermine"), + apiRequest("/dashboard/ablaufdaten"), + ]) + .then(([b, p, a]) => { + setBereitschaft(b); + setPrueftermine(p); + setAblaufdaten(a); + }) + .catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden.")) + .finally(() => setLaedt(false)); + }, []); + + return ( +
+

Dashboard

+ {fehler &&
{fehler}
} + {laedt &&

Lade…

} + {!laedt && bereitschaft && ( +
+
+

Einsatzbereites Material

+

+ {bereitschaft.einsatzbereit} von {bereitschaft.gesamt} Objekten einsatzbereit +

+ {bereitschaft.eingeschraenkt_einsatzbereit > 0 && ( +

+ {bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt einsatzbereit (aktive + Mindermengen-Genehmigung) +

+ )} + {bereitschaft.nicht_einsatzbereit > 0 && ( +

+ {bereitschaft.nicht_einsatzbereit} nicht einsatzbereit +

+ )} + {bereitschaft.details.length > 0 && ( + <> + + {detailsOffen && ( +
    + {bereitschaft.details.map((d) => ( +
  • + + {d.name} ({d.code}) + + + {d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")} + +
  • + ))} +
+ )} + + )} +
+ +
+

Nächste Prüftermine

+ {prueftermine.length === 0 ? ( +

Keine anstehenden oder überfälligen Geräteprüfungen.

+ ) : ( +
    + {prueftermine.map((p) => ( +
  • + + {p.material_name} (SN {p.seriennummer}) + + + {p.naechste_pruefung} ({p.verbleibende_tage} Tage) + +
  • + ))} +
+ )} +
+ +
+

Bevorstehende Ablaufdaten

+ {ablaufdaten.length === 0 ? ( +

Keine bald ablaufenden Chargen.

+ ) : ( +
    + {ablaufdaten.map((a) => ( +
  • + Objekt #{a.objekt_id} – Material #{a.material_id} + + {a.ablaufdatum} ({a.verbleibende_tage} Tage) + +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/FehlbestandListePage.tsx b/frontend/src/pages/FehlbestandListePage.tsx new file mode 100644 index 0000000..56ad487 --- /dev/null +++ b/frontend/src/pages/FehlbestandListePage.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import type { Fehlbestand, Material, Objekt } from "../api/types"; + +// Eigenständige Abarbeitung offener Fehlbestände (Nutzer-Vorgabe): Nachfüllen/ +// Mindermenge-Genehmigung sollen ohne komplette Kontrolle möglich sein - Backend +// (GET /fehlbestaende, POST .../nachfuellungen, POST .../mindermenge) existierte +// bereits, hier nur das fehlende Frontend dafür. +export function FehlbestandListePage() { + const { istVerantwortlich } = useAuth(); + const [fehlbestaende, setFehlbestaende] = useState([]); + const [objekte, setObjekte] = useState([]); + const [materialien, setMaterialien] = useState([]); + const [laedt, setLaedt] = useState(true); + const [fehler, setFehler] = useState(null); + const [nachfuellMengen, setNachfuellMengen] = useState>({}); + const [begruendungen, setBegruendungen] = useState>({}); + const [nurOffene, setNurOffene] = useState(true); + + async function laden() { + setLaedt(true); + try { + const [fb, obj, mat] = await Promise.all([ + apiRequest("/fehlbestaende"), + apiRequest("/objekte"), + apiRequest("/materialien"), + ]); + setFehlbestaende(fb); + setObjekte(obj); + setMaterialien(mat); + } catch { + setFehler("Fehlbestände konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + }, []); + + function objektName(id: number): string { + const o = objekte.find((x) => x.id === id); + return o ? `${o.name} (${o.code})` : `#${id}`; + } + + function materialName(id: number): string { + const m = materialien.find((x) => x.id === id); + return m ? m.name : `#${id}`; + } + + async function nachfuellen(fb: Fehlbestand) { + const menge = nachfuellMengen[fb.id]; + if (!menge) { + setFehler("Menge eingeben."); + return; + } + try { + await apiRequest(`/fehlbestaende/${fb.id}/nachfuellungen`, { + method: "POST", + body: { menge }, + }); + setNachfuellMengen((m) => ({ ...m, [fb.id]: "" })); + await laden(); + } catch { + setFehler("Nachfüllung konnte nicht erfasst werden."); + } + } + + async function mindermengeGenehmigen(fb: Fehlbestand) { + const begruendung = begruendungen[fb.id]; + if (!begruendung) { + setFehler("Begründung eingeben."); + return; + } + try { + await apiRequest(`/fehlbestaende/${fb.id}/mindermenge`, { + method: "POST", + body: { begruendung }, + }); + setBegruendungen((b) => ({ ...b, [fb.id]: "" })); + await laden(); + } catch { + setFehler("Mindermenge konnte nicht genehmigt werden (evtl. bereits genehmigt)."); + } + } + + const angezeigt = nurOffene + ? fehlbestaende.filter((f) => f.status !== "erledigt") + : fehlbestaende; + + return ( +
+

Offene Fehlbestände

+

+ Nachfüllen oder Mindermenge genehmigen, ohne eine komplette Kontrolle des + Objekts zu starten. +

+ + {fehler &&
{fehler}
} + {laedt &&

Lade…

} + {!laedt && angezeigt.length === 0 &&

Keine Fehlbestände.

} +
    + {angezeigt.map((fb) => ( +
  • +
    + + {objektName(fb.objekt_id)} – {materialName(fb.material_id)} + + {fb.status} +
    +
    + Soll {fb.sollmenge} / Ist {fb.istmenge} – Fehlmenge {fb.fehlmenge} +
    + {fb.status !== "erledigt" && ( +
    + setNachfuellMengen((m) => ({ ...m, [fb.id]: e.target.value }))} + /> + + {istVerantwortlich && ( + <> + setBegruendungen((b) => ({ ...b, [fb.id]: e.target.value }))} + /> + + + )} +
    + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx index 9dbd183..aeaae81 100644 --- a/frontend/src/pages/admin/ObjektSection.tsx +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { apiRequest } from "../../api/client"; -import type { Beladungsvorlage, Material, Objekt, Standort } from "../../api/types"; +import type { Beladungsvorlage, Material, Objekt, Objekttyp, Standort } from "../../api/types"; import { ObjektPositionenPanel } from "./ObjektPositionenPanel"; interface Props { objekte: Objekt[]; + objekttypen: Objekttyp[]; vorlagen: Beladungsvorlage[]; standorte: Standort[]; materialien: Material[]; @@ -21,6 +22,7 @@ interface Props { export function ObjektSection({ objekte, + objekttypen, vorlagen, standorte, materialien, @@ -45,6 +47,14 @@ export function ObjektSection({ const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState(""); const [suche, setSuche] = useState(""); + // Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp- + // Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per + // 422 erzwungen, das ist hier nur die Vorauswahl fürs Formular. + const zugfahrzeuge = objekte.filter((x) => { + const typ = objekttypen.find((t) => t.id === x.objekttyp_id); + return typ?.ist_zugfahrzeug && x.fahrzeug_id === null; + }); + function objektName(id: number | null): string { if (id === null) return ""; const gefunden = objekte.find((x) => x.id === id); @@ -233,7 +243,7 @@ export function ObjektSection({ onChange={(e) => setFahrzeugId(Number(e.target.value) || "")} > - {objekte.map((x) => ( + {zugfahrzeuge.map((x) => ( @@ -295,7 +305,7 @@ export function ObjektSection({ onChange={(e) => setFahrzeugAuswahl(Number(e.target.value) || "")} > - {objekte + {zugfahrzeuge .filter((x) => x.id !== o.id) .map((x) => (