Sprint 3: Kontroll-Kern (Statusmaschine, Objekt-Sperre, automatische Fehlbestand-Erzeugung)
CI / backend-tests (push) Successful in 38s
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:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user