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
+9
View File
@@ -1,4 +1,7 @@
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
@@ -11,6 +14,12 @@ __all__ = [
"RolleTyp",
"Systemknoten",
"KnotenTyp",
"Fehlbestand",
"FehlbestandStatus",
"Historie",
"Kontrolle",
"KontrollStatus",
"Kontrollposition",
"Objekt",
"ObjektStatus",
"Objektposition",
+47
View File
@@ -0,0 +1,47 @@
import enum
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class FehlbestandStatus(str, enum.Enum):
offen = "offen"
in_bearbeitung = "in_bearbeitung"
nachgefuellt_teilweise = "nachgefuellt_teilweise"
erledigt = "erledigt"
fehlbestand_status_pg = PgEnum(FehlbestandStatus, name="fehlbestand_status", create_type=False)
class Fehlbestand(Base):
"""Prompt 03: automatisch entstandener Vorgang bei Ist < Soll, ändert selbst
NIEMALS die Ist-Menge (nur Nachfüllung tut das, Sprint 4). Sollmenge/Istmenge/
Fehlmenge hier sind Snapshot-Werte zum Feststellungszeitpunkt, nicht live
referenziert (Prompt 03 Punkt 2: "Sollmenge zum Zeitpunkt der Feststellung")."""
__tablename__ = "fehlbestand"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
erzeugt_von_server_id: Mapped[int] = mapped_column(ForeignKey("systemknoten.id"), nullable=False)
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
standort_id: Mapped[int] = mapped_column(ForeignKey("standort.id"), nullable=False)
sollmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
istmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
fehlmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
entstanden_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
festgestellt_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
kontrolle_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("kontrolle.id"))
ursache: Mapped[str | None] = mapped_column(String)
verantwortlicher_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
status: Mapped[FehlbestandStatus] = mapped_column(
fehlbestand_status_pg, nullable=False, default=FehlbestandStatus.offen
)
erledigt_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Historie(Base):
"""Prompt 13: vollständige Audit-Protokollierung ist Sprint 5. Hier bereits
angelegt, weil Sprint 3 (Objekt-Übernahme, Prompt 02.8) einen ersten
Ereignistyp braucht append-only, kein Update/Delete durch die Anwendung."""
__tablename__ = "historie"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
erzeugt_von_server_id: Mapped[int] = mapped_column(ForeignKey("systemknoten.id"), nullable=False)
zeitpunkt: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
benutzer_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
ereignistyp: Mapped[str] = mapped_column(String, nullable=False)
entitaet_typ: Mapped[str] = mapped_column(String, nullable=False)
entitaet_id: Mapped[str] = mapped_column(String, nullable=False)
alter_wert: Mapped[dict | None] = mapped_column(JSONB)
neuer_wert: Mapped[dict | None] = mapped_column(JSONB)
begruendung: Mapped[str | None] = mapped_column(String)
+53
View File
@@ -0,0 +1,53 @@
import enum
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class KontrollStatus(str, enum.Enum):
nicht_gestartet = "nicht_gestartet"
in_bearbeitung = "in_bearbeitung"
abgeschlossen = "abgeschlossen"
abgebrochen = "abgebrochen"
kontroll_status_pg = PgEnum(KontrollStatus, name="kontroll_status", create_type=False)
class Kontrolle(Base):
"""Prompt 02: Kontrolle ändert NIEMALS Ist-Menge, ist reine Erfassung (Punkt 9,
Vier-Kernbegriffe-Tabelle). Status='in_bearbeitung' IST die Objekt-Sperre
(Punkt 8) kein separates Sperr-Modell nötig."""
__tablename__ = "kontrolle"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
erzeugt_von_server_id: Mapped[int] = mapped_column(ForeignKey("systemknoten.id"), nullable=False)
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
status: Mapped[KontrollStatus] = mapped_column(
kontroll_status_pg, nullable=False, default=KontrollStatus.in_bearbeitung
)
gestartet_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
beendet_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
abbruch_grund: Mapped[str | None] = mapped_column(String)
signatur: Mapped[bytes | None] = mapped_column()
class Kontrollposition(Base):
__tablename__ = "kontrollposition"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
kontrolle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("kontrolle.id"), nullable=False
)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
sollmenge_snapshot: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
istmenge_erfasst: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
abweichung: Mapped[bool] = mapped_column(Boolean, nullable=False)
+47
View File
@@ -0,0 +1,47 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from app.models.kontrolle import KontrollStatus
class KontrolleCreate(BaseModel):
uebernehmen: bool = False
class KontrolleRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
objekt_id: int
benutzer_id: int
status: KontrollStatus
gestartet_am: datetime
beendet_am: datetime | None
abbruch_grund: str | None
class KontrollpositionInput(BaseModel):
istmenge: Decimal
class KontrollpositionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
kontrolle_id: uuid.UUID
material_id: int
sollmenge_snapshot: Decimal
istmenge_erfasst: Decimal
abweichung: bool
class KontrolleAbbrechenRequest(BaseModel):
grund: str | None = None
class KontrolleFortschritt(BaseModel):
kontrolle: KontrolleRead
positionen: list[KontrollpositionRead]
anzahl_positionen_gesamt: int
anzahl_positionen_erfasst: int
+240
View File
@@ -0,0 +1,240 @@
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.services.objekte import sollmenge_effektiv, sollmenge_map
class ObjektGesperrtError(Exception):
"""Prompt 02.8: Objekt wird bereits von einer anderen, laufenden Kontrolle belegt."""
def __init__(self, aktive_kontrolle: Kontrolle):
self.aktive_kontrolle = aktive_kontrolle
super().__init__("Objekt wird bereits kontrolliert")
class UnvollstaendigError(Exception):
"""Prompt 16.1: Abschluss mit unbestätigten Positionen."""
def __init__(self, fehlende_material_ids: list[int]):
self.fehlende_material_ids = fehlende_material_ids
super().__init__("Nicht alle Positionen wurden erfasst")
async def aktive_kontrolle(db: AsyncSession, objekt_id: int) -> Kontrolle | None:
result = await db.execute(
select(Kontrolle).where(
Kontrolle.objekt_id == objekt_id, Kontrolle.status == KontrollStatus.in_bearbeitung
)
)
return result.scalar_one_or_none()
async def starte_kontrolle(
db: AsyncSession,
*,
objekt_id: int,
benutzer_id: int,
uebernehmen: bool,
zustaendiger_server_id: int,
) -> Kontrolle:
laufende = await aktive_kontrolle(db, objekt_id)
if laufende is not None:
if not uebernehmen:
raise ObjektGesperrtError(laufende)
# Übernahme (Prompt 02.8): alte Kontrolle wird abgebrochen, nicht gelöscht -
# bleibt als Datensatz nachvollziehbar (Prompt 16.6-Analogie).
laufende.status = KontrollStatus.abgebrochen
laufende.abbruch_grund = f"Übernommen durch Benutzer {benutzer_id}"
laufende.beendet_am = datetime.now(timezone.utc)
# Vom vorherigen Inhaber während der abgebrochenen Kontrolle erzeugte
# Fehlbestände dürfen nicht stehen bleiben (Prompt 16.6: kein Fehlbestand
# durch eine abgebrochene Kontrolle).
await _verwirf_kontrolldaten(db, laufende.id)
db.add(
Historie(
erzeugt_von_server_id=zustaendiger_server_id,
zeitpunkt=datetime.now(timezone.utc),
benutzer_id=benutzer_id,
ereignistyp="kontrolle_uebernommen",
entitaet_typ="kontrolle",
entitaet_id=str(laufende.id),
alter_wert={"benutzer_id": laufende.benutzer_id},
neuer_wert={"benutzer_id": benutzer_id},
)
)
await db.flush()
neue_kontrolle = Kontrolle(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt_id,
benutzer_id=benutzer_id,
status=KontrollStatus.in_bearbeitung,
gestartet_am=datetime.now(timezone.utc),
)
db.add(neue_kontrolle)
await db.flush()
return neue_kontrolle
async def _verwirf_kontrolldaten(db: AsyncSession, kontrolle_id) -> None:
fehlbestaende = await db.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id))
for fehlbestand in fehlbestaende.scalars().all():
await db.delete(fehlbestand)
positionen = await db.execute(select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id))
for position in positionen.scalars().all():
await db.delete(position)
await db.flush()
async def erfasse_position(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> Kontrollposition:
"""Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die
persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein
Fehlbestand (nie bei Überbestand, Prompt 02.4)."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id
)
)
objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0)
abweichung = istmenge != sollmenge
result = await db.execute(
select(Kontrollposition).where(
Kontrollposition.kontrolle_id == kontrolle.id, Kontrollposition.material_id == material_id
)
)
kontrollposition = result.scalar_one_or_none()
if kontrollposition is None:
kontrollposition = Kontrollposition(
kontrolle_id=kontrolle.id,
material_id=material_id,
sollmenge_snapshot=sollmenge,
istmenge_erfasst=istmenge,
abweichung=abweichung,
)
db.add(kontrollposition)
else:
kontrollposition.sollmenge_snapshot = sollmenge
kontrollposition.istmenge_erfasst = istmenge
kontrollposition.abweichung = abweichung
await db.flush()
await _synchronisiere_fehlbestand(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
sollmenge=sollmenge,
istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return kontrollposition
async def _synchronisiere_fehlbestand(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
sollmenge: Decimal,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> None:
result = await db.execute(
select(Fehlbestand).where(
Fehlbestand.kontrolle_id == kontrolle.id,
Fehlbestand.material_id == material_id,
Fehlbestand.status == FehlbestandStatus.offen,
)
)
bestehender = result.scalar_one_or_none()
unterdeckung = sollmenge - istmenge
if unterdeckung > 0:
# Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4
# ausdrücklich KEINEN Fehlbestand.
if bestehender is None:
db.add(
Fehlbestand(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt.id,
material_id=material_id,
standort_id=objekt.standort_id,
sollmenge=sollmenge,
istmenge=istmenge,
fehlmenge=unterdeckung,
entstanden_am=datetime.now(timezone.utc),
festgestellt_von=kontrolle.benutzer_id,
kontrolle_id=kontrolle.id,
status=FehlbestandStatus.offen,
)
)
else:
# Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die
# Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht
# final und darf berichtigt werden.
bestehender.sollmenge = sollmenge
bestehender.istmenge = istmenge
bestehender.fehlmenge = unterdeckung
elif bestehender is not None:
# Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle
# erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde.
await db.delete(bestehender)
await db.flush()
async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]:
result = await db.execute(
select(Objektposition.material_id).where(
Objektposition.objekt_id == kontrolle.objekt_id,
Objektposition.ist_status == ObjektpositionStatus.aktiv,
)
)
erwartete = {m for (m,) in result.all()}
result = await db.execute(
select(Kontrollposition.material_id).where(Kontrollposition.kontrolle_id == kontrolle.id)
)
erfasste = {m for (m,) in result.all()}
return sorted(erwartete - erfasste)
async def abschliesse_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle) -> None:
fehlende = await fehlende_material_ids(db, kontrolle)
if fehlende:
raise UnvollstaendigError(fehlende)
kontrolle.status = KontrollStatus.abgeschlossen
kontrolle.beendet_am = datetime.now(timezone.utc)
await db.flush()
async def abbreche_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle, grund: str | None) -> None:
"""Prompt 16.6: keine Ist-Übernahme (Kontrolle rührt Objektposition ohnehin nie
an), kein Fehlbestand aus dieser Kontrolle bleibt bestehen. Kontrolle selbst
bleibt als Datensatz erhalten (Status "abgebrochen"), nicht gelöscht."""
await _verwirf_kontrolldaten(db, kontrolle.id)
kontrolle.status = KontrollStatus.abgebrochen
kontrolle.abbruch_grund = grund
kontrolle.beendet_am = datetime.now(timezone.utc)
await db.flush()
+52
View File
@@ -76,6 +76,21 @@ async def mitarbeiter_user(db_session):
return benutzer
@pytest_asyncio.fixture
async def mitarbeiter2_user(db_session):
benutzer = Benutzer(
name="Test Mitarbeiter 2",
login="mitarbeiter2",
passwort_hash=hash_password("test-passwort-123"),
aktiv=True,
)
db_session.add(benutzer)
await db_session.flush()
db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.mitarbeiter))
await db_session.flush()
return benutzer
@pytest_asyncio.fixture
async def admin_user(db_session):
benutzer = Benutzer(
@@ -173,3 +188,40 @@ async def objekttyp_factory(db_session):
return objekttyp
return _make
@pytest_asyncio.fixture
async def objekt_mit_position(db_session, hauptserver_id):
"""Fertiges Objekt mit einer Vorlagen-Position (Sollmenge 10) für Kontroll-Tests."""
from app.services.objekte import objekt_aus_vorlage
from app.services.vorlagen import erstelle_vorlage
from app.schemas.vorlage import VorlagenpositionCreate
bereich = Bereich(name="Bereich-KT")
db_session.add(bereich)
await db_session.flush()
objekttyp = Objekttyp(bereich_id=bereich.id, name="Rucksack-KT")
db_session.add(objekttyp)
await db_session.flush()
material = Material(name="Kompresse", einheit="Stück", materialtyp=MaterialTyp.standard)
db_session.add(material)
await db_session.flush()
standort = Standort(name="Wache-KT")
db_session.add(standort)
await db_session.flush()
vorlage = await erstelle_vorlage(
db_session,
objekttyp_id=objekttyp.id,
name="Vorlage-KT",
positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=10)],
)
objekt = await objekt_aus_vorlage(
db_session,
code="KT-01",
name="Kontroll-Test-Objekt",
standort_id=standort.id,
vorlage=vorlage,
zustaendiger_server_id=hauptserver_id,
)
return objekt, material
+238
View File
@@ -0,0 +1,238 @@
import pytest
from sqlalchemy import select
from app.models.fehlbestand import Fehlbestand
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from tests.conftest import auth_header, login
async def _starte(client, token, objekt_id, uebernehmen=False):
return await client.post(
f"/api/v1/objekte/{objekt_id}/kontrollen",
json={"uebernehmen": uebernehmen},
headers=auth_header(token),
)
@pytest.mark.asyncio
async def test_fehlbestand_entsteht_bei_abweichung(client, db_session, objekt_mit_position, mitarbeiter_user):
"""U1: Fehlbestand entsteht automatisch bei Ist < Soll."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
start = await _starte(client, token, objekt.id)
assert start.status_code == 201
kontrolle_id = start.json()["id"]
put_response = await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "8"},
headers=auth_header(token),
)
assert put_response.status_code == 200
assert put_response.json()["abweichung"] is True
result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id))
fehlbestand = result.scalar_one()
assert float(fehlbestand.fehlmenge) == 2
assert fehlbestand.status.value == "offen"
@pytest.mark.asyncio
async def test_ueberbestand_erzeugt_keinen_fehlbestand(client, db_session, objekt_mit_position, mitarbeiter_user):
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
start = await _starte(client, token, objekt.id)
kontrolle_id = start.json()["id"]
put_response = await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "12"},
headers=auth_header(token),
)
assert put_response.status_code == 200
assert put_response.json()["abweichung"] is True # protokolliert...
result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id))
assert result.scalar_one_or_none() is None # ...aber KEIN Fehlbestand (Prompt 02.4)
@pytest.mark.asyncio
async def test_abschluss_mit_fehlender_position_ist_409(client, objekt_mit_position, mitarbeiter_user):
"""U11: Abschluss mit unbestätigten Positionen -> Fehler mit Liste."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
start = await _starte(client, token, objekt.id)
kontrolle_id = start.json()["id"]
response = await client.post(
f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(token)
)
assert response.status_code == 409
assert material.id in response.json()["detail"]["fehlende_material_ids"]
@pytest.mark.asyncio
async def test_abschluss_erfolgreich_wenn_vollstaendig(client, objekt_mit_position, mitarbeiter_user):
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
start = await _starte(client, token, objekt.id)
kontrolle_id = start.json()["id"]
await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "10"},
headers=auth_header(token),
)
response = await client.post(
f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(token)
)
assert response.status_code == 200
assert response.json()["status"] == "abgeschlossen"
@pytest.mark.asyncio
async def test_abbruch_verwirft_fehlbestand_und_positionen(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""U12: abgebrochene Kontrolle - keine Ist-Übernahme, kein Fehlbestand, Objekt
bleibt unkontrolliert (Kontrolle-Datensatz selbst bleibt aber erhalten)."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
start = await _starte(client, token, objekt.id)
kontrolle_id = start.json()["id"]
await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "8"},
headers=auth_header(token),
)
response = await client.post(
f"/api/v1/kontrollen/{kontrolle_id}/abbrechen",
json={"grund": "Einsatzalarmierung"},
headers=auth_header(token),
)
assert response.status_code == 200
assert response.json()["status"] == "abgebrochen"
fb_result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id))
assert fb_result.scalar_one_or_none() is None
pos_result = await db_session.execute(
select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id)
)
assert pos_result.scalar_one_or_none() is None
kontrolle = await db_session.get(Kontrolle, kontrolle_id)
assert kontrolle is not None # Datensatz bleibt erhalten (Prompt 16.6)
assert kontrolle.status == KontrollStatus.abgebrochen
@pytest.mark.asyncio
async def test_objekt_sperre_zweiter_zugriff_ist_409(
client, objekt_mit_position, mitarbeiter_user, mitarbeiter2_user
):
objekt, _material = objekt_mit_position
token1 = await login(client, "mitarbeiter1")
token2 = await login(client, "mitarbeiter2")
erster = await _starte(client, token1, objekt.id)
assert erster.status_code == 201
zweiter = await _starte(client, token2, objekt.id)
assert zweiter.status_code == 409
assert zweiter.json()["detail"]["benutzer"] == "Test Mitarbeiter"
@pytest.mark.asyncio
async def test_uebernahme_beendet_alte_kontrolle_und_verwirft_ihren_fehlbestand(
client, db_session, objekt_mit_position, mitarbeiter_user, mitarbeiter2_user
):
objekt, material = objekt_mit_position
token1 = await login(client, "mitarbeiter1")
token2 = await login(client, "mitarbeiter2")
erster = await _starte(client, token1, objekt.id)
alte_kontrolle_id = erster.json()["id"]
await client.put(
f"/api/v1/kontrollen/{alte_kontrolle_id}/positionen/{material.id}",
json={"istmenge": "5"},
headers=auth_header(token1),
)
uebernahme = await _starte(client, token2, objekt.id, uebernehmen=True)
assert uebernahme.status_code == 201
neue_kontrolle_id = uebernahme.json()["id"]
assert neue_kontrolle_id != alte_kontrolle_id
alte_kontrolle = await db_session.get(Kontrolle, alte_kontrolle_id)
assert alte_kontrolle.status == KontrollStatus.abgebrochen
fb_result = await db_session.execute(
select(Fehlbestand).where(Fehlbestand.kontrolle_id == alte_kontrolle_id)
)
assert fb_result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_fremder_benutzer_kann_position_nicht_erfassen(
client, objekt_mit_position, mitarbeiter_user, mitarbeiter2_user
):
objekt, material = objekt_mit_position
token1 = await login(client, "mitarbeiter1")
token2 = await login(client, "mitarbeiter2")
start = await _starte(client, token1, objekt.id)
kontrolle_id = start.json()["id"]
response = await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "5"},
headers=auth_header(token2),
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_verschiedene_objekte_parallel_kontrollierbar(
client,
db_session,
hauptserver_id,
objekt_mit_position,
standort_factory,
objekttyp_factory,
material_factory,
mitarbeiter_user,
mitarbeiter2_user,
):
"""Sperre gilt pro Objekt, nicht global (Prompt 02.8 Klarstellung)."""
from app.schemas.vorlage import VorlagenpositionCreate
from app.services.objekte import objekt_aus_vorlage
from app.services.vorlagen import erstelle_vorlage
objekt_a, _material_a = objekt_mit_position
token1 = await login(client, "mitarbeiter1")
token2 = await login(client, "mitarbeiter2")
objekttyp_b = await objekttyp_factory("Objekttyp-B")
standort_b = await standort_factory("Standort-B")
material_b = await material_factory("Verbandspäckchen")
vorlage_b = await erstelle_vorlage(
db_session,
objekttyp_id=objekttyp_b.id,
name="Vorlage-B",
positionen=[VorlagenpositionCreate(material_id=material_b.id, sollmenge=5)],
)
objekt_b = await objekt_aus_vorlage(
db_session,
code="OB-01",
name="Objekt B",
standort_id=standort_b.id,
vorlage=vorlage_b,
zustaendiger_server_id=hauptserver_id,
)
erster = await _starte(client, token1, objekt_a.id)
assert erster.status_code == 201
zweiter = await _starte(client, token2, objekt_b.id)
assert zweiter.status_code == 201