feat(maintenance): MAINT-Epic (Wartungspläne/-aufträge, Werkstatt) + Akte-Redesign
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,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]:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user