Sprint 3: Kontroll-Kern (Statusmaschine, Objekt-Sperre, automatische Fehlbestand-Erzeugung)
CI / backend-tests (push) Successful in 38s

- Kontrolle/Kontrollposition-Modelle: Kontrolle ändert NIEMALS Objektposition.istmenge
  (Vier-Kernbegriffe-Tabelle, Prompt 02.9) - reine Erfassung/Snapshot
- Objekt-Sperre (Prompt 02.8): Kontrolle.status='in_bearbeitung' + benutzer_id IST die
  Sperre, kein separates Sperr-Modell. Zweiter Zugriff -> 409 mit wer/seit; Übernahme
  bricht alte Kontrolle ab (Datensatz bleibt, Prompt 16.6-Analogie) und protokolliert
  das Ereignis in einer neuen, minimalen Historie-Tabelle (volle Ausbaustufe Sprint 5)
- Automatische Fehlbestand-Erzeugung bei Ist < Soll (U1), Überbestand erzeugt
  ausdrücklich KEINEN Fehlbestand (Prompt 02.4); Korrektur vor Abschluss möglich
  (von derselben Kontrolle erzeugter Fehlbestand ist bis zum Abschluss nicht final)
- Abschluss verweigert bei unbestätigten Positionen, liefert deren IDs (U11)
- Abbruch verwirft Kontrollpositionen UND von dieser Kontrolle erzeugte Fehlbestände,
  Kontrolle selbst bleibt als Datensatz mit Status "abgebrochen" erhalten (U12)
- Tests: U1, U11, U12, Sperre/409, Übernahme, Fremdzugriff/403, parallele Objekte

Mitarbeiter-UI (PWA-Frontend) ist bewusst noch nicht Teil dieses Commits - eigenes
Techstack-Setup, wird als nächster Schritt separat angegangen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-03 23:40:26 +02:00
co-authored by Claude Sonnet 5
parent fdcac2f160
commit 8fd507cc5d
11 changed files with 925 additions and 1 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, benutzer, health, objekte, stammdaten, vorlagen, zustaendigkeit
from app.api.v1.endpoints import auth, benutzer, health, kontrollen, objekte, stammdaten, vorlagen, zustaendigkeit
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
@@ -10,3 +10,4 @@ api_router.include_router(benutzer.router, tags=["benutzer"])
api_router.include_router(zustaendigkeit.router, tags=["zustaendigkeit"])
api_router.include_router(vorlagen.router, tags=["vorlagen"])
api_router.include_router(objekte.router, tags=["objekte"])
api_router.include_router(kontrollen.router, tags=["kontrollen"])
+171
View File
@@ -0,0 +1,171 @@
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),
) -> Kontrollposition:
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)
return await erfasse_position(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
istmenge=payload.istmenge,
zustaendiger_server_id=zustaendiger_server_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)
try:
await abschliesse_kontrolle(db, kontrolle=kontrolle)
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)
await abbreche_kontrolle(db, kontrolle=kontrolle, grund=payload.grund)
return kontrolle