Files
MABEA/backend/app/api/v1/endpoints/kontrollen.py
T
patrickandClaude Sonnet 5 1e9012cba3
CI / backend-tests (push) Successful in 47s
Sprint 5: Mindermengen-Genehmigung + automatischer Ablauf, Historie für Kernkette
- MindermengenGenehmigung-Modell/Service (Prompt 04): reine fachliche Bewertung,
  ändert NIEMALS Ist-Menge/Fehlmenge/Fehlbestand-Status (Leitplanke, U2)
- Automatischer Ablauf bei Start der nächsten Kontrolle desselben Objekts (Karte 08,
  U6) - kein Zeit-Trigger, der Kontrollstart selbst ist der Ablauf-Zeitpunkt
- Genehmigung endet gemeinsam mit dem Fehlbestand, wenn dieser durch Nachfüllung
  erledigt wird (U7), unabhängig davon ob vorher eine neue Kontrolle stattfand
- Historie-Service (app/services/historie.py) + Verdrahtung in die komplette
  Kernkette: kontrolle_gestartet/-abgeschlossen/-abgebrochen/-uebernommen,
  istmenge_erfasst, fehlbestand_entstanden/-erledigt, nachfuellung_erfasst,
  mindermenge_genehmigt/-abgelaufen/-beendet_durch_erledigung (U14)
  Hinweis: Stammdaten-/Vorlagen-/Benutzerverwaltung noch nicht retrofittet -
  Sprint 5 deckt bewusst die im Testkonzept referenzierte Kernkette ab, keine
  flächendeckende Audit-Abdeckung aller CRUD-Endpunkte.
- Tests: U2, U6, U7, vollständige Historie-Kette nach Prompt-13.3-Beispiel (U14)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
2026-09-03 23:53:14 +02:00

185 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, KnotenTyp, RolleTyp, Systemknoten
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.objekt import Objekt
from app.schemas.kontrolle import (
KontrolleAbbrechenRequest,
KontrolleCreate,
KontrolleFortschritt,
KontrolleRead,
KontrollpositionInput,
KontrollpositionRead,
)
from app.services.kontrolle import (
ObjektGesperrtError,
UnvollstaendigError,
abbreche_kontrolle,
abschliesse_kontrolle,
erfasse_position,
fehlende_material_ids,
starte_kontrolle,
)
router = APIRouter()
_mitarbeiter_plus = require_roles(
RolleTyp.mitarbeiter,
RolleTyp.materialverantwortlicher,
RolleTyp.leitungsverantwortlicher,
RolleTyp.administration,
)
async def _hauptserver_id(db: AsyncSession) -> int:
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
return result.scalar_one()
async def _hole_kontrolle_oder_404(db: AsyncSession, kontrolle_id: uuid.UUID) -> Kontrolle:
kontrolle = await db.get(Kontrolle, kontrolle_id)
if kontrolle is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kontrolle nicht gefunden")
return kontrolle
def _pruefe_besitz(kontrolle: Kontrolle, current_user: Benutzer) -> None:
"""Objekt-Sperre (Prompt 02.8) wirkt auch hier: nur wer die Kontrolle gestartet
(oder übernommen) hat, darf sie fortführen/abschließen/abbrechen."""
if kontrolle.benutzer_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Diese Kontrolle gehört einem anderen Benutzer ggf. Übernahme nötig",
)
if kontrolle.status != KontrollStatus.in_bearbeitung:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Kontrolle ist nicht mehr aktiv")
@router.post(
"/objekte/{objekt_id}/kontrollen", response_model=KontrolleRead, status_code=status.HTTP_201_CREATED
)
async def starte_neue_kontrolle(
objekt_id: int,
payload: KontrolleCreate,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> Kontrolle:
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
zustaendiger_server_id = await _hauptserver_id(db)
try:
return await starte_kontrolle(
db,
objekt_id=objekt_id,
benutzer_id=current_user.id,
uebernehmen=payload.uebernehmen,
zustaendiger_server_id=zustaendiger_server_id,
)
except ObjektGesperrtError as exc:
aktive = exc.aktive_kontrolle
besitzer = await db.get(Benutzer, aktive.benutzer_id)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"nachricht": "wird gerade kontrolliert",
"benutzer": besitzer.name if besitzer else None,
"seit": aktive.gestartet_am.isoformat(),
},
) from exc
@router.get("/kontrollen/{kontrolle_id}", response_model=KontrolleFortschritt)
async def hole_kontroll_fortschritt(
kontrolle_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> KontrolleFortschritt:
kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id)
result = await db.execute(
select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id)
)
positionen = list(result.scalars().all())
fehlende = await fehlende_material_ids(db, kontrolle)
return KontrolleFortschritt(
kontrolle=KontrolleRead.model_validate(kontrolle),
positionen=[KontrollpositionRead.model_validate(p) for p in positionen],
anzahl_positionen_gesamt=len(positionen) + len(fehlende),
anzahl_positionen_erfasst=len(positionen),
)
@router.put("/kontrollen/{kontrolle_id}/positionen/{material_id}", response_model=KontrollpositionRead)
async def erfasse_kontrollposition(
kontrolle_id: uuid.UUID,
material_id: int,
payload: KontrollpositionInput,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> KontrollpositionRead:
kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id)
_pruefe_besitz(kontrolle, current_user)
objekt = await db.get(Objekt, kontrolle.objekt_id)
zustaendiger_server_id = await _hauptserver_id(db)
kontrollposition, fehlbestand_id = await erfasse_position(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
istmenge=payload.istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return KontrollpositionRead(
id=kontrollposition.id,
kontrolle_id=kontrollposition.kontrolle_id,
material_id=kontrollposition.material_id,
sollmenge_snapshot=kontrollposition.sollmenge_snapshot,
istmenge_erfasst=kontrollposition.istmenge_erfasst,
abweichung=kontrollposition.abweichung,
fehlbestand_id=fehlbestand_id,
)
@router.post("/kontrollen/{kontrolle_id}/abschliessen", response_model=KontrolleRead)
async def abschliesse(
kontrolle_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> Kontrolle:
kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id)
_pruefe_besitz(kontrolle, current_user)
zustaendiger_server_id = await _hauptserver_id(db)
try:
await abschliesse_kontrolle(db, kontrolle=kontrolle, zustaendiger_server_id=zustaendiger_server_id)
except UnvollstaendigError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"nachricht": "nicht alle Positionen erfasst",
"fehlende_material_ids": exc.fehlende_material_ids,
},
) from exc
return kontrolle
@router.post("/kontrollen/{kontrolle_id}/abbrechen", response_model=KontrolleRead)
async def abbreche(
kontrolle_id: uuid.UUID,
payload: KontrolleAbbrechenRequest,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> Kontrolle:
kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id)
_pruefe_besitz(kontrolle, current_user)
zustaendiger_server_id = await _hauptserver_id(db)
await abbreche_kontrolle(
db, kontrolle=kontrolle, grund=payload.grund, zustaendiger_server_id=zustaendiger_server_id
)
return kontrolle