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:
@@ -1160,3 +1160,42 @@ Keine Commits in dieser Session.
|
|||||||
- backend/tests/test_zustaendigkeit.py | 80 +++++++++++++++++++++++++++++++++++
|
- backend/tests/test_zustaendigkeit.py | 80 +++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-09-03 23:13 – 23:31 (18m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- fdcac2f Sprint 2: Beladungsvorlagen (Versionierung), Objekte anlegen/duplizieren, Override-Logik
|
||||||
|
- 67cebbb Test-Engine pro Test statt Modul-Global erzeugen (echter Fix für Event-Loop-Konflikt)
|
||||||
|
- 8f9bd57 CI-Bugfixes aus erstem echten Testlauf: bcrypt-Pin, pytest-asyncio Loop-Scope
|
||||||
|
- d7d61f2 CI umgebaut: Host-Runner ohne Docker statt Service-Container
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/api/v1/api.py | 4 +-
|
||||||
|
- backend/app/api/v1/endpoints/objekte.py | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/vorlagen.py | 78 +++++++++++++++++++++++++++
|
||||||
|
- backend/app/main.py | 3 ++
|
||||||
|
- backend/app/models/__init__.py | 7 +++
|
||||||
|
- backend/tests/conftest.py | 31 ++++++++++-
|
||||||
|
- backend/tests/test_objekte.py | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_vorlagen.py | 34 ++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-09-03 23:31 – 23:31 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- backend/app/api/v1/api.py | 4 +-
|
||||||
|
- backend/app/api/v1/endpoints/objekte.py | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/vorlagen.py | 78 +++++++++++++++++++++++++++
|
||||||
|
- backend/app/main.py | 3 ++
|
||||||
|
- backend/app/models/__init__.py | 7 +++
|
||||||
|
- backend/tests/conftest.py | 31 ++++++++++-
|
||||||
|
- backend/tests/test_objekte.py | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/tests/test_vorlagen.py | 34 ++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
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(zustaendigkeit.router, tags=["zustaendigkeit"])
|
||||||
api_router.include_router(vorlagen.router, tags=["vorlagen"])
|
api_router.include_router(vorlagen.router, tags=["vorlagen"])
|
||||||
api_router.include_router(objekte.router, tags=["objekte"])
|
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
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
|
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.objekt import Objekt, ObjektStatus
|
||||||
from app.models.objektposition import Objektposition, ObjektpositionStatus
|
from app.models.objektposition import Objektposition, ObjektpositionStatus
|
||||||
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
|
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
|
||||||
@@ -11,6 +14,12 @@ __all__ = [
|
|||||||
"RolleTyp",
|
"RolleTyp",
|
||||||
"Systemknoten",
|
"Systemknoten",
|
||||||
"KnotenTyp",
|
"KnotenTyp",
|
||||||
|
"Fehlbestand",
|
||||||
|
"FehlbestandStatus",
|
||||||
|
"Historie",
|
||||||
|
"Kontrolle",
|
||||||
|
"KontrollStatus",
|
||||||
|
"Kontrollposition",
|
||||||
"Objekt",
|
"Objekt",
|
||||||
"ObjektStatus",
|
"ObjektStatus",
|
||||||
"Objektposition",
|
"Objektposition",
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -76,6 +76,21 @@ async def mitarbeiter_user(db_session):
|
|||||||
return benutzer
|
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
|
@pytest_asyncio.fixture
|
||||||
async def admin_user(db_session):
|
async def admin_user(db_session):
|
||||||
benutzer = Benutzer(
|
benutzer = Benutzer(
|
||||||
@@ -173,3 +188,40 @@ async def objekttyp_factory(db_session):
|
|||||||
return objekttyp
|
return objekttyp
|
||||||
|
|
||||||
return _make
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user