Files
MABEA/backend/app/api/v1/endpoints/kontrollen.py
T
patrickandClaude Sonnet 5 18fba28eae
CI / backend-tests (push) Successful in 40s
Sprint 4: Fehlbestand-Statusmaschine komplett, Nachfüllung sofort/später/teilweise
- Nachfuellung-Modell: einzige Aktion, die Objektposition.istmenge tatsächlich
  ändert (Vier-Kernbegriffe). Deckt Sofort-Nachfüllung während Kontrolle (Karte 07)
  und spätere/externe Nachfüllung (Prompt 02.5) mit derselben Logik ab
- Wichtiger Fix vor dem ersten Testlauf: Objektposition.istmenge wird bei Nachfüllung
  auf den neuen Wert GESETZT (fehlbestand.istmenge + menge), nicht unabhängig
  inkrementiert - sonst bliebe sie beim initialen "nicht kontrolliert"-Stand (0)
  stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt
- Statusübergänge: offen -> nachgefuellt_teilweise (Fehlmenge>0) -> erledigt
  (Fehlmenge=0), ausschließlich automatisch bei Ist=Soll (U3)
- Überbestand durch Nachfüllung erlaubt, als Info gekennzeichnet, kein Fehler (E4)
- KontrollpositionRead liefert jetzt fehlbestand_id mit, damit die UI direkt eine
  Sofort-Nachfüllung anbieten kann, ohne separat nachzufragen
- Tests: U3, U4 (Sofort-Nachfüllung ohne Zwischenstatus), U5 (Teilnachfüllung),
  Überbestand-Fall, Nachfüllung auf bereits erledigten Fehlbestand -> 409

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

181 lines
6.5 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)
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