feat(maintenance): MAINT-Epic (Wartungspläne/-aufträge, Werkstatt) + Akte-Redesign
CI / backend-tests (push) Failing after 2m4s
CI / frontend-build (push) Successful in 18s

Neues Wartungskonzept: Wartungsplan+Position+Intervall (zeit/km/
Betriebsstunden, kombinierbar - fällig sobald ein Kriterium erreicht
ist), Wartungsauftrag mit intern/externer Werkstatt, Status offen/
in_arbeit/erledigt, Kosten, Historie, Dokument-Anhang. Ad-hoc-Aufträge
ohne Plan möglich (z.B. kleinere Reparatur). Admin-Tab "Wartungspläne".

Objektakte-Redesign (Epic 21, Nutzer-Feedback "wirkt nicht modern"):
CSS-Grid statt Karten-Stapel, einspaltig mobil, zweispaltig ab 900px;
Fahrzeug-Objekte zeigen Wartung/Fahrzeugdaten prominent, Geräte/
Rucksäcke die Beladung. Dabei gefundene Lücke: Beladung (Soll/Ist je
Position) fehlte in der Akte komplett, nur über eine laufende
Kontrolle sichtbar - jetzt eigene Karte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 23:17:39 +02:00
co-authored by Claude Sonnet 5
parent d607a1101d
commit 2a4d391711
23 changed files with 1505 additions and 44 deletions
+82
View File
@@ -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")
+2
View File
@@ -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"])
+153
View File
@@ -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,
)
+14
View File
@@ -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",
]
+80
View File
@@ -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)
+13
View File
@@ -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]
+1 -1
View File
@@ -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"
]
+101
View File
@@ -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
+30 -3
View File
@@ -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,
)
+10
View File
@@ -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))
+1 -12
View File
@@ -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]:
+216
View File
@@ -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
+4
View File
@@ -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
+214
View File
@@ -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