diff --git a/arbeitskacheln/10_maintenance.md b/arbeitskacheln/10_maintenance.md index cec5ea5..a5bd040 100644 --- a/arbeitskacheln/10_maintenance.md +++ b/arbeitskacheln/10_maintenance.md @@ -23,8 +23,8 @@ Details zu MAINT-001 … MAINT-006 (vollständig). - **Audit:** Änderung geloggt - **Akzeptanzkriterien:** Wartungsplan mit mehreren Positionen anlegen. - **Tests:** CRUD-Test. -- **DoD:** mind. ein Plan für einen Fahrzeugtyp angelegt. **Lücke:** MABEA hat KEIN - Wartungs-Modul, nur Prüfung (Inspections-Epic) — dieses ganze Epic ist neu. +- **DoD:** umgesetzt (2026-09-06). `wartungsplan`/`wartungsplan_position` je + Objekttyp, `POST/GET /wartungsplaene`, Admin-Tab "Wartungspläne". ## MAINT-002 — Wartungsintervalle @@ -49,7 +49,14 @@ Details zu MAINT-001 … MAINT-006 (vollständig). berechnet. - **Tests:** Berechnungstest je Typ + Kombinationstest („je nachdem was zuerst eintritt"). -- **DoD:** funktioniert mit ASSET-009-Zählerstand verzahnt. Komplett neu in MABEA. +- **DoD:** umgesetzt (2026-09-06), abgewandelt: `wartungsintervall` (zeit/km/ + betriebsstunden je Position, mehrere kombinierbar). Statt eines eigenen + Zähler-Abgleich-Konzepts (ASSET-009 existiert in MABEA nicht) werden die + bereits vorhandenen `Fahrzeugdetails.kilometerstand`/`betriebsstunden` + genutzt — für Geräte ohne Fahrzeugdetails wirkt nur das Zeit-Intervall. + `GET /objekte/{id}/faellige-wartungen` berechnet je Position, welches + Kriterium zuerst eintritt; nie durchgeführt gilt immer als fällig. Getestet: + alle drei Typen einzeln + Kombination ("km vor Zeit fällig"). ## MAINT-003 — Wartungsauftrag @@ -70,7 +77,12 @@ Details zu MAINT-001 … MAINT-006 (vollständig). - **Audit:** Statuswechsel geloggt - **Akzeptanzkriterien:** Auftrag anlegen, Status durchlaufen bis erledigt. - **Tests:** Statuswechsel-Test. -- **DoD:** kompletter Auftragszyklus getestet. Komplett neu in MABEA. +- **DoD:** umgesetzt (2026-09-06). `wartungsauftrag` (Objekt + optional + Geräte-Instanz + optional Wartungsplan-Position — auch ad-hoc ohne Plan, + z.B. "kleinere Reparatur"), Status offen/in_arbeit/erledigt, **werkstatt_intern + + werkstatt_name** (beantwortet die Nutzerfrage nach externer Werkstatt), + Kosten optional. Zyklus offen→in_arbeit→erledigt getestet, inkl. ad-hoc- + Auftrag ohne Plan. ## MAINT-004 — Wartungshistorie @@ -88,7 +100,9 @@ Details zu MAINT-001 … MAINT-006 (vollständig). - **Audit:** zeigt Audit-relevante Daten - **Akzeptanzkriterien:** chronologische Liste korrekt. - **Tests:** Anzeigetest. -- **DoD:** funktioniert mit mind. 2 erledigten Aufträgen. Komplett neu in MABEA. +- **DoD:** umgesetzt (2026-09-06). Kein neuer Endpunkt nötig - + `GET /wartungsauftraege?objekt_id=` liefert offene+erledigte, Akte zeigt + beide getrennt in der neuen "Wartung"-Sektion. ## MAINT-005 — Ersatzteile/Kosten (P3) @@ -124,15 +138,13 @@ Details zu MAINT-001 … MAINT-006 (vollständig). - **Audit:** wie DOC-001 - **Akzeptanzkriterien:** Dokument anhängen. - **Tests:** Integrationstest. -- **DoD:** analog INSP-005 — kleine Whitelist-Erweiterung im Dokumente-Modul - (`entitaet_typ=wartungsauftrag` ergänzen), sonst nichts Neues. +- **DoD:** umgesetzt (2026-09-06). `entitaet_typ=wartungsauftrag` zur Whitelist + ergänzt, `DokumentePanel` in der Wartung-Sektion der Akte je Auftrag. --- -**MABEA-Ist-Stand-Abgleich:** Das komplette Maintenance-Epic **fehlt in MABEA** — es gibt -keinerlei Wartungskonzept, nur Prüfung (Inspections). Anders als bei den meisten bisherigen -Epics ist hier praktisch nichts wiederverwendbar außer der generellen Berechnungsmechanik -aus Inspections (Intervall→Fälligkeit) und dem Dokumente-Modul (DOC-001, per -Whitelist-Erweiterung). Zusammen mit Warehouse und Digital File eines der drei Epics mit -dem größten Umsetzungsaufwand gegenüber dem MABEA-Ist-Stand — bewusst als P2 eingestuft -(nicht MVP-kritisch). +**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-06):** MAINT-001..004+006 +vollständig umgesetzt — echtes Wartungskonzept existiert jetzt (Wartungspläne, +Intervalle, Werkstattaufträge intern/extern, Historie, Dokument-Anhang). +MAINT-005 (Ersatzteile/Kosten-Detailliste) bleibt bewusst zurückgestellt (P3) — +Kosten als einzelnes Feld am Auftrag reicht für den MVP. diff --git a/arbeitskacheln/21_akte_redesign.md b/arbeitskacheln/21_akte_redesign.md new file mode 100644 index 0000000..53cfbe6 --- /dev/null +++ b/arbeitskacheln/21_akte_redesign.md @@ -0,0 +1,39 @@ +# Epic 21 — Objektakte-Redesign (Fahrzeug/Gerät getrennt, Mobile/Desktop getrennt) + +Nutzer-Feedback 2026-09-06: Die Akte wirkt wie ein gestapelter Formular-Stoß, nicht wie +eine moderne Verwaltungsansicht. Drei konkrete Punkte: + +1. Kompakte Mobile/Tablet-Kontroll-Ansicht vs. reichhaltige Desktop-Verwaltungsansicht + fehlten bisher als Unterscheidung — eine Ansicht für beides. +2. Fahrzeug-Akte und Geräte/Rucksack-Akte brauchen unterschiedliche Schwerpunkte + (Fahrzeug: Kennzeichen/HU/UVV/Wartung vorn; Gerät/Rucksack: Beladung/Material vorn). +3. Echte Lücke dabei entdeckt: die Beladung (Material-Ist/Soll je Position) fehlte in + der Akte komplett — nur über eine laufende Kontrolle sichtbar, nicht als reine + Verwaltungsübersicht. + +## AKTE-001 — Beladung/Material in der Akte sichtbar + +- **Ziel:** Aktuelle Soll-/Ist-Menge je Position ohne Kontrolle zu starten einsehbar. +- **Backend:** `AkteRead.beladung` (Liste: material_name, fach, sollmenge_effektiv, + istmenge, einheit) aus bereits vorhandenen Objektposition/Material-Daten. +- **Frontend:** neue Karte "Beladung" in der Akte. +- **DoD:** umgesetzt. + +## AKTE-002 — Responsive Grid statt Karten-Stapel + +- **Ziel:** Desktop nutzt die Breite (mehrspaltig), Mobile bleibt eine Spalte - ohne + zwei getrennte Komponenten/Datenladungen pflegen zu müssen. +- **Beschreibung:** Ein CSS-Grid mit `grid-template-areas`, Mobile-first (eine Spalte + in natürlicher DOM-Reihenfolge), ab ~900px zweispaltig. Kein JS-Breakpoint-Hook nötig. +- **DoD:** umgesetzt. + +## AKTE-003 — Fahrzeug- vs. Geräte-Schwerpunkt + +- **Ziel:** Fahrzeug-Objekte zeigen Wartung/Fahrzeugdaten prominent, Geräte/Rucksäcke + die Beladung. +- **Beschreibung:** Reihenfolge/Grid-Zuordnung hängt an `akte.fahrzeugdetails !== null` + (bereits vorhandene Unterscheidung, keine neue Datenmodellierung nötig). +- **DoD:** umgesetzt. + +**Referenz:** baut auf Epic 20 (UI-Redesign, insb. UI-005) auf, ersetzt dessen +Akte-Layout nicht, sondern erweitert es strukturell. diff --git a/backend/alembic/versions/0022_wartung.py b/backend/alembic/versions/0022_wartung.py new file mode 100644 index 0000000..6cdf527 --- /dev/null +++ b/backend/alembic/versions/0022_wartung.py @@ -0,0 +1,82 @@ +"""MAINT-001..006: Wartungsplaene, Intervalle, Wartungsauftraege. + +Revision ID: 0022_wartung +Revises: 0021_ausgabe +Create Date: 2026-09-06 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0022_wartung" +down_revision: Union[str, None] = "0021_ausgabe" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE TYPE intervall_typ AS ENUM ('zeit', 'km', 'betriebsstunden')") + op.execute("CREATE TYPE wartungsauftrag_status AS ENUM ('offen', 'in_arbeit', 'erledigt')") + + op.execute( + """ + CREATE TABLE wartungsplan ( + id SERIAL PRIMARY KEY, + objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id), + name TEXT NOT NULL + ) + """ + ) + op.execute( + """ + CREATE TABLE wartungsplan_position ( + id SERIAL PRIMARY KEY, + wartungsplan_id INTEGER NOT NULL REFERENCES wartungsplan(id), + wartungsart TEXT NOT NULL, + beschreibung TEXT + ) + """ + ) + op.execute( + """ + CREATE TABLE wartungsintervall ( + id SERIAL PRIMARY KEY, + wartungsplan_position_id INTEGER NOT NULL REFERENCES wartungsplan_position(id), + typ intervall_typ NOT NULL, + wert INTEGER NOT NULL + ) + """ + ) + op.execute( + """ + CREATE TABLE wartungsauftrag ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + objekt_id INTEGER NOT NULL REFERENCES objekt(id), + geraet_instanz_id UUID REFERENCES geraet_instanz(id), + wartungsplan_position_id INTEGER REFERENCES wartungsplan_position(id), + wartungsart TEXT NOT NULL, + beschreibung TEXT, + status wartungsauftrag_status NOT NULL DEFAULT 'offen', + werkstatt_intern BOOLEAN NOT NULL DEFAULT TRUE, + werkstatt_name TEXT, + faelligkeit_am DATE, + erstellt_am TIMESTAMPTZ NOT NULL, + erledigt_am TIMESTAMPTZ, + durchgefuehrt_von INTEGER REFERENCES benutzer(id), + kosten NUMERIC, + kilometerstand_bei_erledigung INTEGER, + betriebsstunden_bei_erledigung NUMERIC + ) + """ + ) + op.execute("CREATE INDEX idx_wartungsauftrag_objekt ON wartungsauftrag (objekt_id)") + op.execute("CREATE INDEX idx_wartungsauftrag_status ON wartungsauftrag (status)") + + +def downgrade() -> None: + op.execute("DROP TABLE wartungsauftrag") + op.execute("DROP TABLE wartungsintervall") + op.execute("DROP TABLE wartungsplan_position") + op.execute("DROP TABLE wartungsplan") + op.execute("DROP TYPE wartungsauftrag_status") + op.execute("DROP TYPE intervall_typ") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 8e4ad65..f14cd76 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -21,6 +21,7 @@ from app.api.v1.endpoints import ( personal, stammdaten, vorlagen, + wartung, zustaendigkeit, ) @@ -46,3 +47,4 @@ api_router.include_router(permission.router, tags=["permission"]) api_router.include_router(akte.router, tags=["akte"]) api_router.include_router(lager.router, tags=["lager"]) api_router.include_router(ausgabe.router, tags=["ausgabe"]) +api_router.include_router(wartung.router, tags=["wartung"]) diff --git a/backend/app/api/v1/endpoints/wartung.py b/backend/app/api/v1/endpoints/wartung.py new file mode 100644 index 0000000..e9f76c3 --- /dev/null +++ b/backend/app/api/v1/endpoints/wartung.py @@ -0,0 +1,153 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_roles +from app.db.session import get_db +from app.models.auth import Benutzer, RolleTyp +from app.models.objekt import Objekt +from app.models.wartung import Wartungsauftrag, Wartungsplan, WartungsauftragStatus +from app.schemas.wartung import ( + FaelligeWartungRead, + WartungsauftragCreate, + WartungsauftragErledigen, + WartungsauftragRead, + WartungsauftragStatusUpdate, + WartungsplanCreate, + WartungsplanPositionRead, + WartungsplanRead, +) +from app.services.wartung import ( + aendere_status, + erledige_wartungsauftrag, + erstelle_wartungsauftrag, + erstelle_wartungsplan, + faellige_wartungen_fuer_objekt, + hole_intervalle, + hole_positionen_mit_intervallen, + liste_wartungsauftraege, +) + +router = APIRouter() + +_verantwortliche = require_roles( + RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher +) + + +async def _wartungsplan_read(db: AsyncSession, plan: Wartungsplan) -> WartungsplanRead: + positionen = await hole_positionen_mit_intervallen(db, plan.id) + positionen_read = [] + for position in positionen: + intervalle = await hole_intervalle(db, position.id) + positionen_read.append( + WartungsplanPositionRead( + id=position.id, + wartungsplan_id=position.wartungsplan_id, + wartungsart=position.wartungsart, + beschreibung=position.beschreibung, + intervalle=list(intervalle), + ) + ) + return WartungsplanRead( + id=plan.id, objekttyp_id=plan.objekttyp_id, name=plan.name, positionen=positionen_read + ) + + +@router.get("/wartungsplaene", response_model=list[WartungsplanRead]) +async def liste_wartungsplaene( + objekttyp_id: int | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> list[WartungsplanRead]: + stmt = select(Wartungsplan) + if objekttyp_id is not None: + stmt = stmt.where(Wartungsplan.objekttyp_id == objekttyp_id) + result = await db.execute(stmt) + return [await _wartungsplan_read(db, p) for p in result.scalars().all()] + + +@router.post("/wartungsplaene", response_model=WartungsplanRead, status_code=status.HTTP_201_CREATED) +async def erstelle_neuer_wartungsplan( + payload: WartungsplanCreate, db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> WartungsplanRead: + plan = await erstelle_wartungsplan( + db, objekttyp_id=payload.objekttyp_id, name=payload.name, positionen=payload.positionen + ) + return await _wartungsplan_read(db, plan) + + +@router.get("/objekte/{objekt_id}/faellige-wartungen", response_model=list[FaelligeWartungRead]) +async def hole_faellige_wartungen( + objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[dict]: + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + return await faellige_wartungen_fuer_objekt(db, objekt) + + +@router.get("/wartungsauftraege", response_model=list[WartungsauftragRead]) +async def liste_auftraege( + objekt_id: int | None = None, + status_filter: WartungsauftragStatus | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> list[Wartungsauftrag]: + return await liste_wartungsauftraege(db, objekt_id=objekt_id, status=status_filter) + + +@router.post("/wartungsauftraege", response_model=WartungsauftragRead, status_code=status.HTTP_201_CREATED) +async def erstelle_auftrag( + payload: WartungsauftragCreate, db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> Wartungsauftrag: + objekt = await db.get(Objekt, payload.objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + return await erstelle_wartungsauftrag( + db, + objekt_id=payload.objekt_id, + geraet_instanz_id=payload.geraet_instanz_id, + wartungsplan_position_id=payload.wartungsplan_position_id, + wartungsart=payload.wartungsart, + beschreibung=payload.beschreibung, + werkstatt_intern=payload.werkstatt_intern, + werkstatt_name=payload.werkstatt_name, + faelligkeit_am=payload.faelligkeit_am, + ) + + +@router.patch("/wartungsauftraege/{auftrag_id}/status", response_model=WartungsauftragRead) +async def route_status_aendern( + auftrag_id: uuid.UUID, + payload: WartungsauftragStatusUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_verantwortliche), +) -> Wartungsauftrag: + auftrag = await db.get(Wartungsauftrag, auftrag_id) + if auftrag is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wartungsauftrag nicht gefunden") + return await aendere_status(db, auftrag=auftrag, status=payload.status) + + +@router.post("/wartungsauftraege/{auftrag_id}/erledigen", response_model=WartungsauftragRead) +async def route_erledigen( + auftrag_id: uuid.UUID, + payload: WartungsauftragErledigen, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_verantwortliche), +) -> Wartungsauftrag: + auftrag = await db.get(Wartungsauftrag, auftrag_id) + if auftrag is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wartungsauftrag nicht gefunden") + return await erledige_wartungsauftrag( + db, + auftrag=auftrag, + benutzer_id=current_user.id, + erledigt_am=payload.erledigt_am, + kosten=payload.kosten, + kilometerstand_bei_erledigung=payload.kilometerstand_bei_erledigung, + betriebsstunden_bei_erledigung=payload.betriebsstunden_bei_erledigung, + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6557b0e..6e71eee 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -24,6 +24,14 @@ from app.models.personal import ( ) from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition +from app.models.wartung import ( + IntervallTyp, + Wartungsauftrag, + WartungsauftragStatus, + Wartungsintervall, + Wartungsplan, + WartungsplanPosition, +) from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit __all__ = [ @@ -81,4 +89,10 @@ __all__ = [ "Vorlagenposition", "Kontrollverantwortung", "Zustaendigkeit", + "IntervallTyp", + "Wartungsauftrag", + "WartungsauftragStatus", + "Wartungsintervall", + "Wartungsplan", + "WartungsplanPosition", ] diff --git a/backend/app/models/wartung.py b/backend/app/models/wartung.py new file mode 100644 index 0000000..b8612b1 --- /dev/null +++ b/backend/app/models/wartung.py @@ -0,0 +1,80 @@ +import enum +import uuid +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import ForeignKey, Integer, Numeric, String, Text +from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class IntervallTyp(str, enum.Enum): + zeit = "zeit" + km = "km" + betriebsstunden = "betriebsstunden" + + +class WartungsauftragStatus(str, enum.Enum): + offen = "offen" + in_arbeit = "in_arbeit" + erledigt = "erledigt" + + +intervall_typ_pg = PgEnum(IntervallTyp, name="intervall_typ", create_type=False) +wartungsauftrag_status_pg = PgEnum(WartungsauftragStatus, name="wartungsauftrag_status", create_type=False) + + +class Wartungsplan(Base): + __tablename__ = "wartungsplan" + + id: Mapped[int] = mapped_column(primary_key=True) + objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + + +class WartungsplanPosition(Base): + __tablename__ = "wartungsplan_position" + + id: Mapped[int] = mapped_column(primary_key=True) + wartungsplan_id: Mapped[int] = mapped_column(ForeignKey("wartungsplan.id"), nullable=False) + wartungsart: Mapped[str] = mapped_column(String, nullable=False) + beschreibung: Mapped[str | None] = mapped_column(Text) + + +class Wartungsintervall(Base): + __tablename__ = "wartungsintervall" + + id: Mapped[int] = mapped_column(primary_key=True) + wartungsplan_position_id: Mapped[int] = mapped_column( + ForeignKey("wartungsplan_position.id"), nullable=False + ) + typ: Mapped[IntervallTyp] = mapped_column(intervall_typ_pg, nullable=False) + # zeit: Monate. km/betriebsstunden: Einheit des jeweiligen Fahrzeugdetails-Feldes. + wert: Mapped[int] = mapped_column(Integer, nullable=False) + + +class Wartungsauftrag(Base): + __tablename__ = "wartungsauftrag" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + geraet_instanz_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("geraet_instanz.id") + ) + wartungsplan_position_id: Mapped[int | None] = mapped_column(ForeignKey("wartungsplan_position.id")) + wartungsart: Mapped[str] = mapped_column(String, nullable=False) + beschreibung: Mapped[str | None] = mapped_column(Text) + status: Mapped[WartungsauftragStatus] = mapped_column( + wartungsauftrag_status_pg, nullable=False, default=WartungsauftragStatus.offen + ) + werkstatt_intern: Mapped[bool] = mapped_column(nullable=False, default=True) + werkstatt_name: Mapped[str | None] = mapped_column(String) + faelligkeit_am: Mapped[date | None] = mapped_column() + erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + erledigt_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + durchgefuehrt_von: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id")) + kosten: Mapped[Decimal | None] = mapped_column(Numeric) + kilometerstand_bei_erledigung: Mapped[int | None] = mapped_column(Integer) + betriebsstunden_bei_erledigung: Mapped[Decimal | None] = mapped_column(Numeric) diff --git a/backend/app/schemas/akte.py b/backend/app/schemas/akte.py index 069c7ac..ad0fd82 100644 --- a/backend/app/schemas/akte.py +++ b/backend/app/schemas/akte.py @@ -1,4 +1,6 @@ +import uuid from datetime import datetime +from decimal import Decimal from pydantic import BaseModel, ConfigDict @@ -17,6 +19,16 @@ class ZustaendigerBenutzer(BaseModel): quelle: str # "standort" oder "objekt" (Vereinigungslogik Karte 04) +class BeladungsPosition(BaseModel): + objektposition_id: uuid.UUID + material_id: int + material_name: str + einheit: str + fach: str | None + sollmenge: Decimal + istmenge: Decimal + + class AkteRead(BaseModel): """FILE-001..006: gebündelte Aggregations-Sicht über alle bereits existierenden Teilbereiche einer Ressource (aktuell nur Objekttyp "objekt" - Prompt 24/Backlog- @@ -36,3 +48,4 @@ class AkteRead(BaseModel): dokumente: list[DokumentRead] geraete: list[GeraetInstanzRead] maengel: list[MangelRead] + beladung: list[BeladungsPosition] diff --git a/backend/app/schemas/dokument.py b/backend/app/schemas/dokument.py index cb215ae..0109991 100644 --- a/backend/app/schemas/dokument.py +++ b/backend/app/schemas/dokument.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict # Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen, # muss hier explizit freigeschaltet werden. EntitaetTyp = Literal[ - "objekt", "objektposition", "geraet_instanz", "mangel", "fahrzeugdetails", "benutzer" + "objekt", "objektposition", "geraet_instanz", "mangel", "fahrzeugdetails", "benutzer", "wartungsauftrag" ] diff --git a/backend/app/schemas/wartung.py b/backend/app/schemas/wartung.py new file mode 100644 index 0000000..c4904ec --- /dev/null +++ b/backend/app/schemas/wartung.py @@ -0,0 +1,101 @@ +import uuid +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from app.models.wartung import IntervallTyp, WartungsauftragStatus + + +class WartungsintervallCreate(BaseModel): + typ: IntervallTyp + wert: int + + +class WartungsintervallRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + wartungsplan_position_id: int + typ: IntervallTyp + wert: int + + +class WartungsplanPositionCreate(BaseModel): + wartungsart: str + beschreibung: str | None = None + intervalle: list[WartungsintervallCreate] = [] + + +class WartungsplanPositionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + wartungsplan_id: int + wartungsart: str + beschreibung: str | None + intervalle: list[WartungsintervallRead] = [] + + +class WartungsplanCreate(BaseModel): + objekttyp_id: int + name: str + positionen: list[WartungsplanPositionCreate] = [] + + +class WartungsplanRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + objekttyp_id: int + name: str + positionen: list[WartungsplanPositionRead] = [] + + +class WartungsauftragCreate(BaseModel): + objekt_id: int + geraet_instanz_id: uuid.UUID | None = None + wartungsplan_position_id: int | None = None + wartungsart: str + beschreibung: str | None = None + werkstatt_intern: bool = True + werkstatt_name: str | None = None + faelligkeit_am: date | None = None + + +class WartungsauftragErledigen(BaseModel): + erledigt_am: date | None = None + kosten: Decimal | None = None + kilometerstand_bei_erledigung: int | None = None + betriebsstunden_bei_erledigung: Decimal | None = None + + +class WartungsauftragStatusUpdate(BaseModel): + status: WartungsauftragStatus + + +class WartungsauftragRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + objekt_id: int + geraet_instanz_id: uuid.UUID | None + wartungsplan_position_id: int | None + wartungsart: str + beschreibung: str | None + status: WartungsauftragStatus + werkstatt_intern: bool + werkstatt_name: str | None + faelligkeit_am: date | None + erstellt_am: datetime + erledigt_am: datetime | None + durchgefuehrt_von: int | None + kosten: Decimal | None + kilometerstand_bei_erledigung: int | None + betriebsstunden_bei_erledigung: Decimal | None + + +class FaelligeWartungRead(BaseModel): + wartungsplan_position_id: int + wartungsart: str + objekt_id: int + faellig: bool + faellig_am: date | None + faellig_bei_km: int | None + faellig_bei_betriebsstunden: Decimal | None diff --git a/backend/app/services/akte.py b/backend/app/services/akte.py index 854001e..e146bde 100644 --- a/backend/app/services/akte.py +++ b/backend/app/services/akte.py @@ -9,9 +9,10 @@ from app.models.historie import Historie from app.models.mangel import Mangel from app.models.objekt import Objekt from app.models.objektposition import Objektposition -from app.models.stammdaten import Objekttyp, Standort +from app.models.stammdaten import Material, Objekttyp, Standort +from app.models.vorlage import Vorlagenposition from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit -from app.schemas.akte import AkteRead, ZustaendigerBenutzer +from app.schemas.akte import AkteRead, BeladungsPosition, ZustaendigerBenutzer from app.schemas.dokument import DokumentRead from app.schemas.geraet_instanz import GeraetInstanzRead from app.schemas.historie import HistorieRead @@ -19,7 +20,7 @@ from app.schemas.mangel import MangelRead from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead from app.schemas.zustaendigkeit import KontrollverantwortungRead from app.services.dashboard import objekt_readiness -from app.services.objekte import letzte_kontrollen_map +from app.services.objekte import letzte_kontrollen_map, sollmenge_effektiv async def _zustaendige_benutzer(db: AsyncSession, objekt: Objekt) -> list[ZustaendigerBenutzer]: @@ -90,6 +91,31 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None: maengel_result = await db.execute(select(Mangel).where(Mangel.objekt_id == objekt_id)) + fach_by_material_id: dict[int, str | None] = {} + if objekt.vorlage_id is not None: + vorlagenpositionen_result = await db.execute( + select(Vorlagenposition).where(Vorlagenposition.vorlage_id == objekt.vorlage_id) + ) + fach_by_material_id = {vp.material_id: vp.fach for vp in vorlagenpositionen_result.scalars().all()} + + beladung_result = await db.execute( + select(Objektposition, Material) + .join(Material, Objektposition.material_id == Material.id) + .where(Objektposition.objekt_id == objekt_id) + ) + beladung = [ + BeladungsPosition( + objektposition_id=position.id, + material_id=material.id, + material_name=material.name, + einheit=material.einheit, + fach=fach_by_material_id.get(material.id), + sollmenge=sollmenge_effektiv(position), + istmenge=position.istmenge, + ) + for position, material in beladung_result.all() + ] + readiness = await objekt_readiness(db, objekt_id) objekt_read = ObjektRead.model_validate(objekt).model_copy( @@ -115,4 +141,5 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None: dokumente=[DokumentRead.model_validate(d) for d in dokumente_result.scalars().all()], geraete=[GeraetInstanzRead.model_validate(g) for g in geraete], maengel=[MangelRead.model_validate(m) for m in maengel_result.scalars().all()], + beladung=beladung, ) diff --git a/backend/app/services/datumshilfen.py b/backend/app/services/datumshilfen.py new file mode 100644 index 0000000..cb977b6 --- /dev/null +++ b/backend/app/services/datumshilfen.py @@ -0,0 +1,10 @@ +import calendar +from datetime import date + + +def plus_monate(stichtag: date, monate: int) -> date: + monats_index = stichtag.month - 1 + monate + jahr = stichtag.year + monats_index // 12 + monat = monats_index % 12 + 1 + letzter_tag = calendar.monthrange(jahr, monat)[1] + return date(jahr, monat, min(stichtag.day, letzter_tag)) diff --git a/backend/app/services/geraet_instanz.py b/backend/app/services/geraet_instanz.py index 4f1b52b..2845b38 100644 --- a/backend/app/services/geraet_instanz.py +++ b/backend/app/services/geraet_instanz.py @@ -1,4 +1,3 @@ -import calendar import uuid from datetime import date, datetime, timezone from decimal import Decimal @@ -12,17 +11,7 @@ from app.models.objekt import Objekt from app.models.objektposition import Objektposition from app.services import benachrichtigung as benachrichtigung_service from app.services import historie as historie_service - - -def _plus_monate(stichtag: date, monate: int) -> date: - """Kein python-dateutil als Dependency für eine einzelne Monatsaddition - - Tag wird auf das Monatsende gekappt, falls der Zielmonat kürzer ist - (z.B. 31. Januar + 1 Monat -> 28./29. Februar).""" - monats_index = stichtag.month - 1 + monate - jahr = stichtag.year + monats_index // 12 - monat = monats_index % 12 + 1 - letzter_tag = calendar.monthrange(jahr, monat)[1] - return date(jahr, monat, min(stichtag.day, letzter_tag)) +from app.services.datumshilfen import plus_monate as _plus_monate async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[GeraetInstanz]: diff --git a/backend/app/services/wartung.py b/backend/app/services/wartung.py new file mode 100644 index 0000000..31b7bc9 --- /dev/null +++ b/backend/app/services/wartung.py @@ -0,0 +1,216 @@ +from datetime import date, datetime, timezone +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.fahrzeugdetails import Fahrzeugdetails +from app.models.objekt import Objekt +from app.models.wartung import ( + IntervallTyp, + Wartungsauftrag, + WartungsauftragStatus, + Wartungsintervall, + Wartungsplan, + WartungsplanPosition, +) +from app.schemas.wartung import WartungsplanPositionCreate +from app.services.datumshilfen import plus_monate + + +async def erstelle_wartungsplan( + db: AsyncSession, *, objekttyp_id: int, name: str, positionen: list[WartungsplanPositionCreate] +) -> Wartungsplan: + plan = Wartungsplan(objekttyp_id=objekttyp_id, name=name) + db.add(plan) + await db.flush() + for pos in positionen: + position = WartungsplanPosition( + wartungsplan_id=plan.id, wartungsart=pos.wartungsart, beschreibung=pos.beschreibung + ) + db.add(position) + await db.flush() + for intervall in pos.intervalle: + db.add( + Wartungsintervall( + wartungsplan_position_id=position.id, typ=intervall.typ, wert=intervall.wert + ) + ) + await db.flush() + return plan + + +async def hole_positionen_mit_intervallen(db: AsyncSession, wartungsplan_id: int) -> list[WartungsplanPosition]: + result = await db.execute( + select(WartungsplanPosition).where(WartungsplanPosition.wartungsplan_id == wartungsplan_id) + ) + return list(result.scalars().all()) + + +async def hole_intervalle(db: AsyncSession, wartungsplan_position_id: int) -> list[Wartungsintervall]: + result = await db.execute( + select(Wartungsintervall).where(Wartungsintervall.wartungsplan_position_id == wartungsplan_position_id) + ) + return list(result.scalars().all()) + + +async def erstelle_wartungsauftrag( + db: AsyncSession, + *, + objekt_id: int, + geraet_instanz_id, + wartungsplan_position_id: int | None, + wartungsart: str, + beschreibung: str | None, + werkstatt_intern: bool, + werkstatt_name: str | None, + faelligkeit_am: date | None, +) -> Wartungsauftrag: + auftrag = Wartungsauftrag( + objekt_id=objekt_id, + geraet_instanz_id=geraet_instanz_id, + wartungsplan_position_id=wartungsplan_position_id, + wartungsart=wartungsart, + beschreibung=beschreibung, + status=WartungsauftragStatus.offen, + werkstatt_intern=werkstatt_intern, + werkstatt_name=werkstatt_name, + faelligkeit_am=faelligkeit_am, + erstellt_am=datetime.now(timezone.utc), + ) + db.add(auftrag) + await db.flush() + return auftrag + + +async def aendere_status(db: AsyncSession, *, auftrag: Wartungsauftrag, status: WartungsauftragStatus) -> Wartungsauftrag: + auftrag.status = status + await db.flush() + return auftrag + + +async def erledige_wartungsauftrag( + db: AsyncSession, + *, + auftrag: Wartungsauftrag, + benutzer_id: int, + erledigt_am: date | None, + kosten: Decimal | None, + kilometerstand_bei_erledigung: int | None, + betriebsstunden_bei_erledigung: Decimal | None, +) -> Wartungsauftrag: + auftrag.status = WartungsauftragStatus.erledigt + auftrag.erledigt_am = ( + datetime.combine(erledigt_am, datetime.min.time(), tzinfo=timezone.utc) + if erledigt_am + else datetime.now(timezone.utc) + ) + auftrag.durchgefuehrt_von = benutzer_id + auftrag.kosten = kosten + + if kilometerstand_bei_erledigung is None or betriebsstunden_bei_erledigung is None: + fahrzeugdetails = await db.get(Fahrzeugdetails, auftrag.objekt_id) + if fahrzeugdetails is not None: + if kilometerstand_bei_erledigung is None: + kilometerstand_bei_erledigung = fahrzeugdetails.kilometerstand + if betriebsstunden_bei_erledigung is None: + betriebsstunden_bei_erledigung = fahrzeugdetails.betriebsstunden + auftrag.kilometerstand_bei_erledigung = kilometerstand_bei_erledigung + auftrag.betriebsstunden_bei_erledigung = betriebsstunden_bei_erledigung + + await db.flush() + return auftrag + + +async def liste_wartungsauftraege( + db: AsyncSession, *, objekt_id: int | None = None, status: WartungsauftragStatus | None = None +) -> list[Wartungsauftrag]: + stmt = select(Wartungsauftrag) + if objekt_id is not None: + stmt = stmt.where(Wartungsauftrag.objekt_id == objekt_id) + if status is not None: + stmt = stmt.where(Wartungsauftrag.status == status) + stmt = stmt.order_by(Wartungsauftrag.erstellt_am.desc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def faellige_wartungen_fuer_objekt(db: AsyncSession, objekt: Objekt) -> list[dict]: + """MAINT-002: kombiniert Zeit-/km-/Betriebsstunden-Intervalle je Position - + fällig, sobald das erste Kriterium erreicht ist. Nie durchgeführt gilt + immer als fällig (wie "nie kontrolliert" bei der Einsatzbereitschaft).""" + plaene = ( + await db.execute(select(Wartungsplan).where(Wartungsplan.objekttyp_id == objekt.objekttyp_id)) + ).scalars().all() + if not plaene: + return [] + + fahrzeugdetails = await db.get(Fahrzeugdetails, objekt.id) + heute = datetime.now(timezone.utc).date() + ergebnisse: list[dict] = [] + + for plan in plaene: + positionen = await hole_positionen_mit_intervallen(db, plan.id) + for position in positionen: + intervalle = await hole_intervalle(db, position.id) + letzter = ( + await db.execute( + select(Wartungsauftrag) + .where( + Wartungsauftrag.objekt_id == objekt.id, + Wartungsauftrag.wartungsplan_position_id == position.id, + Wartungsauftrag.status == WartungsauftragStatus.erledigt, + ) + .order_by(Wartungsauftrag.erledigt_am.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + if letzter is None: + ergebnisse.append( + { + "wartungsplan_position_id": position.id, + "wartungsart": position.wartungsart, + "objekt_id": objekt.id, + "faellig": True, + "faellig_am": None, + "faellig_bei_km": None, + "faellig_bei_betriebsstunden": None, + } + ) + continue + + faellig = False + faellig_am = None + faellig_bei_km = None + faellig_bei_bs = None + + for intervall in intervalle: + if intervall.typ == IntervallTyp.zeit: + faellig_am = plus_monate(letzter.erledigt_am.date(), intervall.wert) + if heute >= faellig_am: + faellig = True + elif intervall.typ == IntervallTyp.km and fahrzeugdetails is not None: + basis_km = letzter.kilometerstand_bei_erledigung or 0 + faellig_bei_km = basis_km + intervall.wert + if (fahrzeugdetails.kilometerstand or 0) >= faellig_bei_km: + faellig = True + elif intervall.typ == IntervallTyp.betriebsstunden and fahrzeugdetails is not None: + basis_bs = letzter.betriebsstunden_bei_erledigung or Decimal(0) + faellig_bei_bs = basis_bs + intervall.wert + if (fahrzeugdetails.betriebsstunden or Decimal(0)) >= faellig_bei_bs: + faellig = True + + ergebnisse.append( + { + "wartungsplan_position_id": position.id, + "wartungsart": position.wartungsart, + "objekt_id": objekt.id, + "faellig": faellig, + "faellig_am": faellig_am, + "faellig_bei_km": faellig_bei_km, + "faellig_bei_betriebsstunden": faellig_bei_bs, + } + ) + + return ergebnisse diff --git a/backend/tests/test_akte.py b/backend/tests/test_akte.py index 32509b1..6d0444a 100644 --- a/backend/tests/test_akte.py +++ b/backend/tests/test_akte.py @@ -50,6 +50,10 @@ async def test_akte_buendelt_teilbereiche(client, db_session, objekt_mit_positio assert body["historie"][0]["entitaet_typ"] == "objekt" assert body["einsatzbereitschaft_status"] == "not-ready" assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"] + assert len(body["beladung"]) == 1 + assert body["beladung"][0]["material_id"] == _material.id + assert body["beladung"][0]["sollmenge"] == "10" + assert body["beladung"][0]["istmenge"] == "0" @pytest.mark.asyncio diff --git a/backend/tests/test_wartung.py b/backend/tests/test_wartung.py new file mode 100644 index 0000000..950606b --- /dev/null +++ b/backend/tests/test_wartung.py @@ -0,0 +1,214 @@ +from datetime import date + +import pytest + +from app.models.objekt import Objekt +from app.services.datumshilfen import plus_monate +from tests.conftest import auth_header, login + + +async def _objekt_mit_typ(db_session, hauptserver_id, *, code, name, objekttyp, standort): + objekt = Objekt( + code=code, + name=name, + objekttyp_id=objekttyp.id, + standort_id=standort.id, + zustaendiger_server_id=hauptserver_id, + ) + db_session.add(objekt) + await db_session.flush() + return objekt + + +@pytest.mark.asyncio +async def test_wartungsplan_mit_intervall_anlegen(client, admin_user, objekttyp_factory): + token = await login(client, "admin1") + objekttyp = await objekttyp_factory("RTW-Test") + + response = await client.post( + "/api/v1/wartungsplaene", + json={ + "objekttyp_id": objekttyp.id, + "name": "RTW Standard", + "positionen": [ + { + "wartungsart": "Ölwechsel", + "intervalle": [{"typ": "zeit", "wert": 12}, {"typ": "km", "wert": 15000}], + } + ], + }, + headers=auth_header(token), + ) + assert response.status_code == 201 + body = response.json() + assert len(body["positionen"]) == 1 + assert len(body["positionen"][0]["intervalle"]) == 2 + + +@pytest.mark.asyncio +async def test_nie_durchgefuehrte_wartung_ist_faellig( + client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory +): + token = await login(client, "admin1") + objekttyp = await objekttyp_factory("RTW-Test2") + standort = await standort_factory("Standort-W2") + objekt = await _objekt_mit_typ( + db_session, hauptserver_id, code="RTW-W2", name="RTW W2", objekttyp=objekttyp, standort=standort + ) + objekt_id = objekt.id + + await client.post( + "/api/v1/wartungsplaene", + json={"objekttyp_id": objekttyp.id, "name": "Plan", "positionen": [{"wartungsart": "Ölwechsel"}]}, + headers=auth_header(token), + ) + + response = await client.get(f"/api/v1/objekte/{objekt_id}/faellige-wartungen", headers=auth_header(token)) + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["faellig"] is True + assert body[0]["wartungsart"] == "Ölwechsel" + + +@pytest.mark.asyncio +async def test_wartungsauftrag_erledigen_macht_zeit_intervall_nicht_faellig( + client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory +): + token = await login(client, "admin1") + objekttyp = await objekttyp_factory("RTW-Test3") + standort = await standort_factory("Standort-W3") + objekt = await _objekt_mit_typ( + db_session, hauptserver_id, code="RTW-W3", name="RTW W3", objekttyp=objekttyp, standort=standort + ) + objekt_id = objekt.id + + plan = await client.post( + "/api/v1/wartungsplaene", + json={ + "objekttyp_id": objekttyp.id, + "name": "Plan", + "positionen": [{"wartungsart": "Ölwechsel", "intervalle": [{"typ": "zeit", "wert": 12}]}], + }, + headers=auth_header(token), + ) + position_id = plan.json()["positionen"][0]["id"] + + auftrag = await client.post( + "/api/v1/wartungsauftraege", + json={"objekt_id": objekt_id, "wartungsplan_position_id": position_id, "wartungsart": "Ölwechsel"}, + headers=auth_header(token), + ) + assert auftrag.status_code == 201 + auftrag_id = auftrag.json()["id"] + + erledigt = await client.post( + f"/api/v1/wartungsauftraege/{auftrag_id}/erledigen", + json={"erledigt_am": date.today().isoformat()}, + headers=auth_header(token), + ) + assert erledigt.status_code == 200 + assert erledigt.json()["status"] == "erledigt" + + faellig = await client.get(f"/api/v1/objekte/{objekt_id}/faellige-wartungen", headers=auth_header(token)) + assert faellig.json()[0]["faellig"] is False + assert faellig.json()[0]["faellig_am"] == plus_monate(date.today(), 12).isoformat() + + +@pytest.mark.asyncio +async def test_km_intervall_wird_zuerst_faellig_trotz_gueltigem_zeit_intervall( + client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory +): + """"je nachdem was zuerst eintritt" - km-Schwelle erreicht, obwohl das + Zeit-Intervall noch nicht abgelaufen ist.""" + from app.models.fahrzeugdetails import Fahrzeugdetails + + token = await login(client, "admin1") + objekttyp = await objekttyp_factory("RTW-Test4") + standort = await standort_factory("Standort-W4") + objekt = await _objekt_mit_typ( + db_session, hauptserver_id, code="RTW-W4", name="RTW W4", objekttyp=objekttyp, standort=standort + ) + objekt_id = objekt.id + + db_session.add(Fahrzeugdetails(objekt_id=objekt_id, kilometerstand=20000)) + await db_session.flush() + + plan = await client.post( + "/api/v1/wartungsplaene", + json={ + "objekttyp_id": objekttyp.id, + "name": "Plan", + "positionen": [ + { + "wartungsart": "Ölwechsel", + "intervalle": [{"typ": "zeit", "wert": 12}, {"typ": "km", "wert": 15000}], + } + ], + }, + headers=auth_header(token), + ) + position_id = plan.json()["positionen"][0]["id"] + + auftrag = await client.post( + "/api/v1/wartungsauftraege", + json={"objekt_id": objekt_id, "wartungsplan_position_id": position_id, "wartungsart": "Ölwechsel"}, + headers=auth_header(token), + ) + auftrag_id = auftrag.json()["id"] + await client.post( + f"/api/v1/wartungsauftraege/{auftrag_id}/erledigen", + json={"erledigt_am": date.today().isoformat(), "kilometerstand_bei_erledigung": 5000}, + headers=auth_header(token), + ) + + faellig = await client.get(f"/api/v1/objekte/{objekt_id}/faellige-wartungen", headers=auth_header(token)) + body = faellig.json()[0] + assert body["faellig"] is True + assert body["faellig_bei_km"] == 20000 + + +@pytest.mark.asyncio +async def test_ad_hoc_reparatur_ohne_wartungsplan_mit_externer_werkstatt( + client, admin_user, standort_factory, objekt_factory +): + token = await login(client, "admin1") + standort = await standort_factory("Standort-W5") + await objekt_factory(name="RTW W5", code="RTW-W5", standort=standort) + objekte = await client.get("/api/v1/objekte", headers=auth_header(token)) + objekt_id = [o for o in objekte.json() if o["code"] == "RTW-W5"][0]["id"] + + auftrag = await client.post( + "/api/v1/wartungsauftraege", + json={ + "objekt_id": objekt_id, + "wartungsart": "Bremsleuchte defekt", + "werkstatt_intern": False, + "werkstatt_name": "KFZ Meyer GmbH", + }, + headers=auth_header(token), + ) + assert auftrag.status_code == 201 + body = auftrag.json() + assert body["wartungsplan_position_id"] is None + assert body["werkstatt_intern"] is False + assert body["werkstatt_name"] == "KFZ Meyer GmbH" + assert body["status"] == "offen" + + in_arbeit = await client.patch( + f"/api/v1/wartungsauftraege/{body['id']}/status", + json={"status": "in_arbeit"}, + headers=auth_header(token), + ) + assert in_arbeit.json()["status"] == "in_arbeit" + + erledigt = await client.post( + f"/api/v1/wartungsauftraege/{body['id']}/erledigen", + json={"kosten": "89.90"}, + headers=auth_header(token), + ) + assert erledigt.json()["status"] == "erledigt" + assert erledigt.json()["kosten"] == "89.90" + + historie = await client.get(f"/api/v1/wartungsauftraege?objekt_id={objekt_id}", headers=auth_header(token)) + assert len(historie.json()) == 1 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 844371b..1e1848f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -47,6 +47,57 @@ export interface GeraetInstanz { bemerkung: string | null; } +export interface Wartungsintervall { + id: number; + wartungsplan_position_id: number; + typ: "zeit" | "km" | "betriebsstunden"; + wert: number; +} + +export interface WartungsplanPosition { + id: number; + wartungsplan_id: number; + wartungsart: string; + beschreibung: string | null; + intervalle: Wartungsintervall[]; +} + +export interface Wartungsplan { + id: number; + objekttyp_id: number; + name: string; + positionen: WartungsplanPosition[]; +} + +export interface FaelligeWartung { + wartungsplan_position_id: number; + wartungsart: string; + objekt_id: number; + faellig: boolean; + faellig_am: string | null; + faellig_bei_km: number | null; + faellig_bei_betriebsstunden: string | null; +} + +export interface Wartungsauftrag { + id: string; + objekt_id: number; + geraet_instanz_id: string | null; + wartungsplan_position_id: number | null; + wartungsart: string; + beschreibung: string | null; + status: "offen" | "in_arbeit" | "erledigt"; + werkstatt_intern: boolean; + werkstatt_name: string | null; + faelligkeit_am: string | null; + erstellt_am: string; + erledigt_am: string | null; + durchgefuehrt_von: number | null; + kosten: string | null; + kilometerstand_bei_erledigung: number | null; + betriebsstunden_bei_erledigung: string | null; +} + export interface Kontrolle { id: string; objekt_id: number; @@ -260,6 +311,16 @@ export interface ZustaendigerBenutzer { quelle: "standort" | "objekt"; } +export interface BeladungsPosition { + objektposition_id: string; + material_id: number; + material_name: string; + einheit: string; + fach: string | null; + sollmenge: string; + istmenge: string; +} + export interface Akte { objekt: Objekt; einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown"; @@ -274,6 +335,7 @@ export interface Akte { dokumente: Dokument[]; geraete: GeraetInstanz[]; maengel: Mangel[]; + beladung: BeladungsPosition[]; } export interface HistorieEintrag { diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 158d24c..bf6d9fa 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -70,6 +70,7 @@ export function CommandPalette() { }, { id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") }, { id: "nav-ausgabe", label: "Ausgabe/Rückgabe", gehe: () => navigate("/admin?tab=ausgabe") }, + { id: "nav-wartungsplaene", label: "Wartungspläne", gehe: () => navigate("/admin?tab=wartungsplaene") }, { id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") }, { id: "nav-material", diff --git a/frontend/src/components/WartungSection.tsx b/frontend/src/components/WartungSection.tsx new file mode 100644 index 0000000..f2eac35 --- /dev/null +++ b/frontend/src/components/WartungSection.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import type { FaelligeWartung, Wartungsauftrag } from "../api/types"; +import { DokumentePanel } from "./DokumentePanel"; + +interface Props { + objektId: number; + onFehler: (text: string) => void; +} + +const STATUS_LABEL: Record = { + offen: "Offen", + in_arbeit: "In Arbeit", + erledigt: "Erledigt", +}; + +const STATUS_BADGE: Record = { + offen: "badge-warning", + in_arbeit: "badge-warning", + erledigt: "badge-success", +}; + +export function WartungSection({ objektId, onFehler }: Props) { + const { istVerantwortlich } = useAuth(); + const [faellig, setFaellig] = useState([]); + const [auftraege, setAuftraege] = useState([]); + const [dokumenteOffenId, setDokumenteOffenId] = useState(null); + + const [neuArt, setNeuArt] = useState(""); + const [neuExtern, setNeuExtern] = useState(false); + const [neuWerkstattName, setNeuWerkstattName] = useState(""); + + async function laden() { + try { + const [f, a] = await Promise.all([ + apiRequest(`/objekte/${objektId}/faellige-wartungen`), + apiRequest(`/wartungsauftraege?objekt_id=${objektId}`), + ]); + setFaellig(f); + setAuftraege(a); + } catch { + onFehler("Wartungsdaten konnten nicht geladen werden."); + } + } + + useEffect(() => { + laden(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objektId]); + + async function auftragAnlegen() { + if (!neuArt) { + onFehler("Wartungsart angeben."); + return; + } + try { + await apiRequest("/wartungsauftraege", { + method: "POST", + body: { + objekt_id: objektId, + wartungsart: neuArt, + werkstatt_intern: !neuExtern, + werkstatt_name: neuExtern ? neuWerkstattName || null : null, + }, + }); + setNeuArt(""); + setNeuExtern(false); + setNeuWerkstattName(""); + await laden(); + } catch { + onFehler("Wartungsauftrag konnte nicht angelegt werden."); + } + } + + async function statusAendern(auftrag: Wartungsauftrag, status: Wartungsauftrag["status"]) { + try { + await apiRequest(`/wartungsauftraege/${auftrag.id}/status`, { method: "PATCH", body: { status } }); + await laden(); + } catch { + onFehler("Status konnte nicht geändert werden."); + } + } + + async function erledigen(auftrag: Wartungsauftrag) { + const kosten = window.prompt("Kosten (optional, in €):") ?? undefined; + try { + await apiRequest(`/wartungsauftraege/${auftrag.id}/erledigen`, { + method: "POST", + body: { kosten: kosten || null }, + }); + await laden(); + } catch { + onFehler("Wartungsauftrag konnte nicht als erledigt markiert werden."); + } + } + + const offeneAuftraege = auftraege.filter((a) => a.status !== "erledigt"); + const erledigteAuftraege = auftraege.filter((a) => a.status === "erledigt"); + const faelligeOhneOffenenAuftrag = faellig.filter((f) => f.faellig); + + return ( +
+

Wartung

+ + {faelligeOhneOffenenAuftrag.length > 0 && ( +
+ Fällig: {faelligeOhneOffenenAuftrag.map((f) => f.wartungsart).join(", ")} +
+ )} + + {istVerantwortlich && ( +
+ setNeuArt(e.target.value)} + /> + + {neuExtern && ( + setNeuWerkstattName(e.target.value)} + /> + )} + +
+ )} + + {offeneAuftraege.length === 0 && erledigteAuftraege.length === 0 && ( +

Keine Wartungsaufträge.

+ )} + + {[...offeneAuftraege, ...erledigteAuftraege].length > 0 && ( +
    + {[...offeneAuftraege, ...erledigteAuftraege].map((a) => ( +
  • +
    + + {a.wartungsart}{" "} + + ({a.werkstatt_intern ? "intern" : a.werkstatt_name || "extern"}) + + + {STATUS_LABEL[a.status]} +
    + {a.kosten &&
    Kosten: {a.kosten} €
    } + {a.erledigt_am && ( +
    + Erledigt am {new Date(a.erledigt_am).toLocaleDateString("de-DE")} +
    + )} +
    + {istVerantwortlich && a.status === "offen" && ( + + )} + {istVerantwortlich && a.status !== "erledigt" && ( + + )} + +
    + {dokumenteOffenId === a.id && ( +
    + +
    + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 5361ed1..ce943f9 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -22,6 +22,7 @@ import { ObjekttypSection } from "./admin/ObjekttypSection"; import { QualifikationSection } from "./admin/QualifikationSection"; import { StandortSection } from "./admin/StandortSection"; import { VorlageSection } from "./admin/VorlageSection"; +import { WartungsplanSection } from "./admin/WartungsplanSection"; import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection"; type Tab = @@ -39,6 +40,7 @@ type Tab = | "lagerbewegungen" | "lager" | "ausgabe" + | "wartungsplaene" | "rollen"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte @@ -76,6 +78,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera label: "Ausgabe/Rückgabe", rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, + { + key: "wartungsplaene", + label: "Wartungspläne", + rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], + }, { key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] }, ]; @@ -247,6 +254,9 @@ export function AdminPage() { {aktiverTab === "ausgabe" && ( )} + {aktiverTab === "wartungsplaene" && ( + + )} {aktiverTab === "rollen" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index ccc6d52..d3d6427 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { Akte } from "../api/types"; import { DokumentePanel } from "../components/DokumentePanel"; +import { WartungSection } from "../components/WartungSection"; import { ReadinessBadge } from "../components/status/ReadinessBadge"; import { GRUND_TEXT } from "../components/status/gruendeText"; import { EREIGNIS_LABEL } from "../components/status/ereignisText"; @@ -15,8 +16,6 @@ const MANGEL_PRIORITAET_LABEL: Record = { hoch: "Hoch", }; -// FILE-006: EIN Screen für alle Teilbereiche einer Ressource statt mehrerer -// Einzelseiten - konsumiert den FILE-001-Aggregations-Endpunkt. export function AktePage() { const { objektId } = useParams<{ objektId: string }>(); const { istVerantwortlich } = useAuth(); @@ -52,10 +51,11 @@ export function AktePage() { if (!akte) return null; const { objekt } = akte; + const istFahrzeug = akte.fahrzeugdetails !== null; return ( -
-
+
+

{objekt.name}

@@ -65,7 +65,7 @@ export function AktePage() {
{akte.einsatzbereitschaft_gruende.length > 0 && ( -
    +
      {akte.einsatzbereitschaft_gruende.map((g) => (
    • • {GRUND_TEXT[g] ?? g} @@ -74,9 +74,7 @@ export function AktePage() {
    )} - {/* nach dem QR-Scan (MOBILE-002) landet man hier - die - Kernaktionen sollen direkt sichtbar sein, nicht erst am Seitenende. */} -
    +
    Kontrolle starten @@ -85,7 +83,7 @@ export function AktePage() {
    -
    +

    Stammdaten

    Standort: {akte.standort_name}
    @@ -108,7 +106,40 @@ export function AktePage() {
    -
    +
    +

    Beladung

    + {akte.beladung.length === 0 &&

    Keine Positionen zugeordnet.

    } + {akte.beladung.length > 0 && ( +
    + + + + + + + + + + + {akte.beladung.map((b) => ( + + + + + + + ))} + +
    MaterialFachSollIst
    {b.material_name}{b.fach ?? "–"} + {b.sollmenge} {b.einheit} + + {b.istmenge} {b.einheit} +
    +
    + )} +
    + +

    Verantwortlichkeit

    {akte.zustaendige_benutzer.length === 0 && akte.kontrollverantwortung.length === 0 && (

    Keine Zuordnung hinterlegt.

    @@ -124,7 +155,7 @@ export function AktePage() { )}
    -
    +

    Prüfungen (Geräte)

    {akte.geraete.length === 0 &&

    Keine Geräte-Instanzen erfasst.

    } {akte.geraete.length > 0 && ( @@ -149,7 +180,11 @@ export function AktePage() { )}
    -
    +
    + +
    + +

    Mängel

    {akte.maengel.length === 0 &&

    Keine Mängel gemeldet.

    } {akte.maengel.length > 0 && ( @@ -163,12 +198,12 @@ export function AktePage() { )}
    -
    +

    Dokumente

    -
    +

    Historie

    {akte.historie.length === 0 &&

    Noch keine Änderungen protokolliert.

    } {akte.historie.length > 0 && ( diff --git a/frontend/src/pages/admin/WartungsplanSection.tsx b/frontend/src/pages/admin/WartungsplanSection.tsx new file mode 100644 index 0000000..f86c288 --- /dev/null +++ b/frontend/src/pages/admin/WartungsplanSection.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Objekttyp, Wartungsplan } from "../../api/types"; + +interface Props { + objekttypen: Objekttyp[]; + onFehler: (text: string) => void; +} + +interface NeueIntervallZeile { + typ: "zeit" | "km" | "betriebsstunden"; + wert: string; +} + +export function WartungsplanSection({ objekttypen, onFehler }: Props) { + const [plaene, setPlaene] = useState([]); + const [objekttypId, setObjekttypId] = useState(""); + const [name, setName] = useState(""); + const [wartungsart, setWartungsart] = useState(""); + const [intervalle, setIntervalle] = useState([{ typ: "zeit", wert: "" }]); + + async function laden() { + try { + setPlaene(await apiRequest("/wartungsplaene")); + } catch { + onFehler("Wartungspläne konnten nicht geladen werden."); + } + } + + useEffect(() => { + laden(); + }, []); + + function objekttypName(id: number): string { + return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`; + } + + function intervallZeileAendern(index: number, feld: keyof NeueIntervallZeile, wert: string) { + setIntervalle((zeilen) => zeilen.map((z, i) => (i === index ? { ...z, [feld]: wert } : z))); + } + + async function planAnlegen() { + if (objekttypId === "" || !name || !wartungsart) { + onFehler("Objekttyp, Name und mindestens eine Wartungsart angeben."); + return; + } + try { + await apiRequest("/wartungsplaene", { + method: "POST", + body: { + objekttyp_id: objekttypId, + name, + positionen: [ + { + wartungsart, + intervalle: intervalle + .filter((z) => z.wert) + .map((z) => ({ typ: z.typ, wert: Number(z.wert) })), + }, + ], + }, + }); + setName(""); + setWartungsart(""); + setIntervalle([{ typ: "zeit", wert: "" }]); + await laden(); + } catch { + onFehler("Wartungsplan konnte nicht angelegt werden."); + } + } + + return ( +
    +

    Wartungspläne

    + +
    +

    Neuer Plan (eine Wartungsart)

    +
    + + setName(e.target.value)} /> + setWartungsart(e.target.value)} + /> + {intervalle.map((zeile, i) => ( +
    + + intervallZeileAendern(i, "wert", e.target.value)} + /> +
    + ))} + + +
    +
    + +
    +

    Bestehende Pläne

    + {plaene.length === 0 &&

    Keine Wartungspläne angelegt.

    } +
      + {plaene.map((p) => ( +
    • + {p.name} ({objekttypName(p.objekttyp_id)}) +
        + {p.positionen.map((pos) => ( +
      • + {pos.wartungsart} + {pos.intervalle.length > 0 && ( + + {" "} + – {pos.intervalle.map((i) => `${i.wert} ${i.typ}`).join(", ")} + + )} +
      • + ))} +
      +
    • + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index bf6ec5a..bfc4a04 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -677,6 +677,59 @@ select.input:focus { min-width: 0; } +.akte-grid { + display: grid; + gap: 1rem; + grid-template-columns: 1fr; + grid-template-areas: + "kopf" + "aktionen" + "stamm" + "beladung" + "wartung" + "pruefungen" + "verantwortlich" + "maengel" + "dokumente" + "historie"; +} + +.akte-kopf { grid-area: kopf; } +.akte-aktionen { grid-area: aktionen; } +.akte-stamm { grid-area: stamm; } +.akte-beladung { grid-area: beladung; } +.akte-wartung { grid-area: wartung; } +.akte-pruefungen { grid-area: pruefungen; } +.akte-verantwortlich { grid-area: verantwortlich; } +.akte-maengel { grid-area: maengel; } +.akte-dokumente { grid-area: dokumente; } +.akte-historie { grid-area: historie; } + +@media (min-width: 900px) { + .akte-grid { + grid-template-columns: 2fr 1fr; + grid-template-areas: + "kopf kopf" + "aktionen aktionen" + "stamm wartung" + "beladung pruefungen" + "verantwortlich maengel" + "dokumente dokumente" + "historie historie"; + } + + .akte-grid.ist-fahrzeug { + grid-template-areas: + "kopf kopf" + "aktionen aktionen" + "wartung stamm" + "pruefungen beladung" + "maengel verantwortlich" + "dokumente dokumente" + "historie historie"; + } +} + @media (max-width: 768px) { .split-layout { flex-direction: column;