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
+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