feat(objekt): Zugfahrzeug-Flag am Objekttyp + Leitungs-Dashboard-Kacheln
CI / backend-tests (push) Failing after 1m26s
CI / frontend-build (push) Successful in 24s

Objekttyp.ist_zugfahrzeug (Migration 0011) grenzt die Fahrzeug-Zuordnung
serverseitig auf tatsächliche Zugfahrzeuge ein (422 bei Verstoß), Frontend
filtert die Fahrzeug-Dropdowns entsprechend.

Neue Dashboard-Endpunkte /dashboard/einsatzbereitschaft und
/dashboard/prueftermine plus DashboardPage mit drei Kacheln
(Einsatzbereitschaft, Prüftermine, Ablaufdaten). Aktive
Mindermengen-Genehmigung zählt als "eingeschränkt einsatzbereit", nicht als
voll einsatzbereit (U2/Karte 08).

Neue FehlbestandListePage: Fehlbestände eigenständig nachfüllen/Mindermenge
genehmigen, ohne komplette Kontrolle des Objekts zu starten (Backend
existierte bereits, nur das Frontend fehlte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 10:33:00 +02:00
co-authored by Claude Sonnet 5
parent 38b8ce1795
commit 54fc29690a
20 changed files with 894 additions and 19 deletions
+21
View File
@@ -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 ++++++++++++++++++++++++++++++++++++----------------------------------
---
+18
View File
@@ -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 ++++++++++++++++++++------------------
---
@@ -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")
+31 -2
View File
@@ -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)]
+19 -6
View File
@@ -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,
+4
View File
@@ -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):
+31
View File
@@ -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
+2
View File
@@ -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):
+138
View File
@@ -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
+2 -2
View File
@@ -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
+131 -1
View File
@@ -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
+64 -2
View File
@@ -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
+18
View File
@@ -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() {
</AdminRoute>
}
/>
<Route
path="/fehlbestaende"
element={
<AdminRoute>
<FehlbestandListePage />
</AdminRoute>
}
/>
<Route
path="/dashboard"
element={
<AdminRoute>
<DashboardPage />
</AdminRoute>
}
/>
<Route path="*" element={<Navigate to="/objekte" replace />} />
</Routes>
</AuthProvider>
+18
View File
@@ -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 {
+10
View File
@@ -23,6 +23,16 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavLink to="/objekte" className={({ isActive }) => (isActive ? "active" : "")}>
Objekte
</NavLink>
{istVerantwortlich && (
<NavLink to="/dashboard" className={({ isActive }) => (isActive ? "active" : "")}>
Dashboard
</NavLink>
)}
{istVerantwortlich && (
<NavLink to="/fehlbestaende" className={({ isActive }) => (isActive ? "active" : "")}>
Fehlbestände
</NavLink>
)}
{istVerantwortlich && (
<NavLink to="/admin" className={({ isActive }) => (isActive ? "active" : "")}>
{istAdmin ? "Administration" : "Verwaltung"}
+1
View File
@@ -143,6 +143,7 @@ export function AdminPage() {
{aktiverTab === "objekte" && (
<ObjektSection
objekte={objekte}
objekttypen={objekttypen}
vorlagen={vorlagen.filter((v) => v.status === "aktiv")}
standorte={standorte}
materialien={materialien}
+161
View File
@@ -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<string, string> = {
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<Einsatzbereitschaft | null>(null);
const [prueftermine, setPrueftermine] = useState<Prueftermineintrag[]>([]);
const [ablaufdaten, setAblaufdaten] = useState<Ablaufdatumeintrag[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [detailsOffen, setDetailsOffen] = useState(false);
useEffect(() => {
Promise.all([
apiRequest<Einsatzbereitschaft>("/dashboard/einsatzbereitschaft"),
apiRequest<Prueftermineintrag[]>("/dashboard/prueftermine"),
apiRequest<Ablaufdatumeintrag[]>("/dashboard/ablaufdaten"),
])
.then(([b, p, a]) => {
setBereitschaft(b);
setPrueftermine(p);
setAblaufdaten(a);
})
.catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden."))
.finally(() => setLaedt(false));
}, []);
return (
<main className="page">
<h1>Dashboard</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{!laedt && bereitschaft && (
<div style={{ display: "flex", gap: "1.25rem", flexWrap: "wrap" }}>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Einsatzbereites Material</h3>
<p style={{ fontSize: "1.5rem", margin: "0.25rem 0" }}>
{bereitschaft.einsatzbereit} von {bereitschaft.gesamt} Objekten einsatzbereit
</p>
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
<p className="text-muted" style={{ color: "#b8860b" }}>
{bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt einsatzbereit (aktive
Mindermengen-Genehmigung)
</p>
)}
{bereitschaft.nicht_einsatzbereit > 0 && (
<p className="text-muted" style={{ color: "#b00020" }}>
{bereitschaft.nicht_einsatzbereit} nicht einsatzbereit
</p>
)}
{bereitschaft.details.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
{detailsOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{detailsOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{bereitschaft.details.map((d) => (
<li key={d.objekt_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
{d.name} ({d.code})
</Link>
<span className="text-muted">
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
</span>
</li>
))}
</ul>
)}
</>
)}
</div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Nächste Prüftermine</h3>
{prueftermine.length === 0 ? (
<p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>
) : (
<ul className="card-list">
{prueftermine.map((p) => (
<li key={p.geraet_instanz_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{p.material_name} <span className="text-muted">(SN {p.seriennummer})</span>
</span>
<span className={p.status === "abgelaufen" ? "text-muted" : "text-muted"} style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
{p.naechste_pruefung} ({p.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
)}
</div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Bevorstehende Ablaufdaten</h3>
{ablaufdaten.length === 0 ? (
<p className="text-muted">Keine bald ablaufenden Chargen.</p>
) : (
<ul className="card-list">
{ablaufdaten.map((a) => (
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>Objekt #{a.objekt_id} Material #{a.material_id}</span>
<span className="text-muted" style={{ color: a.status === "abgelaufen" ? "#b00020" : undefined }}>
{a.ablaufdatum} ({a.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</main>
);
}
+152
View File
@@ -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<Fehlbestand[]>([]);
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [materialien, setMaterialien] = useState<Material[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [nachfuellMengen, setNachfuellMengen] = useState<Record<string, string>>({});
const [begruendungen, setBegruendungen] = useState<Record<string, string>>({});
const [nurOffene, setNurOffene] = useState(true);
async function laden() {
setLaedt(true);
try {
const [fb, obj, mat] = await Promise.all([
apiRequest<Fehlbestand[]>("/fehlbestaende"),
apiRequest<Objekt[]>("/objekte"),
apiRequest<Material[]>("/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 (
<main className="page">
<h1>Offene Fehlbestände</h1>
<p className="text-muted">
Nachfüllen oder Mindermenge genehmigen, ohne eine komplette Kontrolle des
Objekts zu starten.
</p>
<label className="row" style={{ alignItems: "center", gap: "0.4rem", marginBottom: "1rem" }}>
<input type="checkbox" checked={nurOffene} onChange={(e) => setNurOffene(e.target.checked)} />
Nur offene/in Bearbeitung anzeigen
</label>
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{!laedt && angezeigt.length === 0 && <p className="text-muted">Keine Fehlbestände.</p>}
<ul className="card-list">
{angezeigt.map((fb) => (
<li key={fb.id} className="card" style={{ marginBottom: "0.75rem" }}>
<div className="row-between">
<span>
<strong>{objektName(fb.objekt_id)}</strong> {materialName(fb.material_id)}
</span>
<span className="badge badge-neutral">{fb.status}</span>
</div>
<div className="text-muted" style={{ margin: "0.4rem 0" }}>
Soll {fb.sollmenge} / Ist {fb.istmenge} Fehlmenge {fb.fehlmenge}
</div>
{fb.status !== "erledigt" && (
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
<input
className="input"
style={{ maxWidth: "8rem" }}
placeholder="Menge"
value={nachfuellMengen[fb.id] ?? ""}
onChange={(e) => setNachfuellMengen((m) => ({ ...m, [fb.id]: e.target.value }))}
/>
<button className="btn btn-primary" onClick={() => nachfuellen(fb)}>
Nachfüllen
</button>
{istVerantwortlich && (
<>
<input
className="input"
placeholder="Begründung Mindermenge"
value={begruendungen[fb.id] ?? ""}
onChange={(e) => setBegruendungen((b) => ({ ...b, [fb.id]: e.target.value }))}
/>
<button className="btn btn-secondary" onClick={() => mindermengeGenehmigen(fb)}>
Mindermenge genehmigen
</button>
</>
)}
</div>
)}
</li>
))}
</ul>
</main>
);
}
+13 -3
View File
@@ -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<number | "">("");
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) || "")}
>
<option value="">Kein Fahrzeug</option>
{objekte.map((x) => (
{zugfahrzeuge.map((x) => (
<option key={x.id} value={x.id}>
{x.name} ({x.code})
</option>
@@ -295,7 +305,7 @@ export function ObjektSection({
onChange={(e) => setFahrzeugAuswahl(Number(e.target.value) || "")}
>
<option value="">Kein Fahrzeug</option>
{objekte
{zugfahrzeuge
.filter((x) => x.id !== o.id)
.map((x) => (
<option key={x.id} value={x.id}>
+29 -3
View File
@@ -18,15 +18,21 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
function bearbeiten(o: Objekttyp) {
setBearbeiteteId(o.id);
setBearbeitungsName(o.name);
setBearbeitungsZugfahrzeug(o.ist_zugfahrzeug);
}
async function speichern(id: number) {
try {
await apiRequest(`/objekttypen/${id}`, { method: "PATCH", body: { name: bearbeitungsName } });
await apiRequest(`/objekttypen/${id}`, {
method: "PATCH",
body: { name: bearbeitungsName, ist_zugfahrzeug: bearbeitungsZugfahrzeug },
});
setBearbeiteteId(null);
await onGeaendert();
} catch {
@@ -43,9 +49,10 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
try {
await apiRequest("/objekttypen", {
method: "POST",
body: { name, bereich_id: bereichId, kategorie_id: kategorieId || null },
body: { name, bereich_id: bereichId, kategorie_id: kategorieId || null, ist_zugfahrzeug: istZugfahrzeug },
});
setName("");
setIstZugfahrzeug(false);
await onGeaendert();
} catch {
onFehler("Objekttyp konnte nicht angelegt werden.");
@@ -82,6 +89,14 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
</div>
<div className="row">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
<input
type="checkbox"
checked={istZugfahrzeug}
onChange={(e) => setIstZugfahrzeug(e.target.checked)}
/>
Zugfahrzeug (koppelbar)
</label>
<button className="btn btn-primary" onClick={anlegen} disabled={!name || wirdAngelegt}>
Anlegen
</button>
@@ -99,6 +114,14 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
/>
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
<input
type="checkbox"
checked={bearbeitungsZugfahrzeug}
onChange={(e) => setBearbeitungsZugfahrzeug(e.target.checked)}
/>
Zugfahrzeug
</label>
<button className="btn btn-primary" onClick={() => speichern(o.id)}>
Speichern
</button>
@@ -108,7 +131,10 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
</div>
) : (
<>
<span>{o.name}</span>
<span>
{o.name}
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
Bearbeiten
</button>