Sprint 4: Fehlbestand-Statusmaschine komplett, Nachfüllung sofort/später/teilweise
CI / backend-tests (push) Successful in 40s

- 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
This commit is contained in:
2026-09-03 23:46:21 +02:00
co-authored by Claude Sonnet 5
parent 8fd507cc5d
commit 18fba28eae
11 changed files with 456 additions and 22 deletions
+42
View File
@@ -1199,3 +1199,45 @@ Keine Commits in dieser Session.
- backend/tests/test_vorlagen.py | 34 ++++++++++++ - backend/tests/test_vorlagen.py | 34 ++++++++++++
--- ---
## 2026-09-03 23:34 23:40 (6m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 8fd507c Sprint 3: Kontroll-Kern (Statusmaschine, Objekt-Sperre, automatische Fehlbestand-Erzeugung)
### Geänderte Dateien
- DEVLOG.md | 39 +++++++++++++
- backend/app/api/v1/api.py | 3 +-
- backend/app/api/v1/endpoints/kontrollen.py | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/app/models/__init__.py | 9 +++
- backend/app/models/fehlbestand.py | 47 +++++++++++++++
- backend/app/models/historie.py | 27 +++++++++
- backend/app/models/kontrolle.py | 53 +++++++++++++++++
- backend/app/schemas/kontrolle.py | 47 +++++++++++++++
- backend/app/services/kontrolle.py | 240 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/tests/conftest.py | 52 +++++++++++++++++
- backend/tests/test_kontrolle.py | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-03 23:40 23:40 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 39 +++++++++++++
- backend/app/api/v1/api.py | 3 +-
- backend/app/api/v1/endpoints/kontrollen.py | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/app/models/__init__.py | 9 +++
- backend/app/models/fehlbestand.py | 47 +++++++++++++++
- backend/app/models/historie.py | 27 +++++++++
- backend/app/models/kontrolle.py | 53 +++++++++++++++++
- backend/app/schemas/kontrolle.py | 47 +++++++++++++++
- backend/app/services/kontrolle.py | 240 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/tests/conftest.py | 52 +++++++++++++++++
- backend/tests/test_kontrolle.py | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+12 -1
View File
@@ -1,6 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, benutzer, health, kontrollen, objekte, stammdaten, vorlagen, zustaendigkeit from app.api.v1.endpoints import (
auth,
benutzer,
fehlbestaende,
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"])
@@ -11,3 +21,4 @@ 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"]) api_router.include_router(kontrollen.router, tags=["kontrollen"])
api_router.include_router(fehlbestaende.router, tags=["fehlbestaende"])
@@ -0,0 +1,86 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import RolleTyp
from app.models.fehlbestand import Fehlbestand
from app.schemas.fehlbestand import (
FehlbestandRead,
NachfuellungCreate,
NachfuellungRead,
NachfuellungResponse,
)
from app.services.fehlbestand import FehlbestandBereitsErledigtError, nachfuellen
router = APIRouter()
_mitarbeiter_plus = require_roles(
RolleTyp.mitarbeiter,
RolleTyp.materialverantwortlicher,
RolleTyp.leitungsverantwortlicher,
RolleTyp.administration,
)
_verantwortliche = require_roles(
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
)
@router.get("/fehlbestaende", response_model=list[FehlbestandRead])
async def liste_fehlbestaende(
status_filter: str | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(_verantwortliche),
) -> list[Fehlbestand]:
"""Vollständige Filterung nach Zuständigkeit/Standort folgt Sprint 6 (Dashboard)."""
stmt = select(Fehlbestand)
if status_filter is not None:
stmt = stmt.where(Fehlbestand.status == status_filter)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.get("/fehlbestaende/{fehlbestand_id}", response_model=FehlbestandRead)
async def hole_fehlbestand(
fehlbestand_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_mitarbeiter_plus)
) -> Fehlbestand:
fehlbestand = await db.get(Fehlbestand, fehlbestand_id)
if fehlbestand is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fehlbestand nicht gefunden")
return fehlbestand
@router.post(
"/fehlbestaende/{fehlbestand_id}/nachfuellungen",
response_model=NachfuellungResponse,
status_code=status.HTTP_201_CREATED,
)
async def erfasse_nachfuellung(
fehlbestand_id: uuid.UUID,
payload: NachfuellungCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(_mitarbeiter_plus),
) -> NachfuellungResponse:
"""Prompt 02.3/02.5: deckt Sofort-Nachfüllung während der Kontrolle (Karte 07)
und spätere/externe Nachfüllung gleichermaßen ab."""
fehlbestand = await db.get(Fehlbestand, fehlbestand_id)
if fehlbestand is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fehlbestand nicht gefunden")
try:
nachfuellung, ueberbestand = await nachfuellen(
db, fehlbestand=fehlbestand, menge=payload.menge, benutzer_id=current_user.id
)
except FehlbestandBereitsErledigtError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Fehlbestand ist bereits erledigt"
) from exc
return NachfuellungResponse(
nachfuellung=NachfuellungRead.model_validate(nachfuellung),
fehlbestand=FehlbestandRead.model_validate(fehlbestand),
ueberbestand=ueberbestand,
)
+11 -2
View File
@@ -121,13 +121,13 @@ async def erfasse_kontrollposition(
payload: KontrollpositionInput, payload: KontrollpositionInput,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus), current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> Kontrollposition: ) -> KontrollpositionRead:
kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id)
_pruefe_besitz(kontrolle, current_user) _pruefe_besitz(kontrolle, current_user)
objekt = await db.get(Objekt, kontrolle.objekt_id) objekt = await db.get(Objekt, kontrolle.objekt_id)
zustaendiger_server_id = await _hauptserver_id(db) zustaendiger_server_id = await _hauptserver_id(db)
return await erfasse_position( kontrollposition, fehlbestand_id = await erfasse_position(
db, db,
kontrolle=kontrolle, kontrolle=kontrolle,
objekt=objekt, objekt=objekt,
@@ -135,6 +135,15 @@ async def erfasse_kontrollposition(
istmenge=payload.istmenge, istmenge=payload.istmenge,
zustaendiger_server_id=zustaendiger_server_id, 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) @router.post("/kontrollen/{kontrolle_id}/abschliessen", response_model=KontrolleRead)
+2
View File
@@ -2,6 +2,7 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, Kno
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.historie import Historie from app.models.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.nachfuellung import Nachfuellung
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
@@ -20,6 +21,7 @@ __all__ = [
"Kontrolle", "Kontrolle",
"KontrollStatus", "KontrollStatus",
"Kontrollposition", "Kontrollposition",
"Nachfuellung",
"Objekt", "Objekt",
"ObjektStatus", "ObjektStatus",
"Objektposition", "Objektposition",
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Nachfuellung(Base):
"""Vier-Kernbegriffe (Prompt 02.9): einzige Aktion, die Objektposition.istmenge
tatsächlich ändert. fehlbestand_id nullable: Schema erlaubt Nachfüllung ohne
Fehlbestand-Bezug, V1 nutzt sie aber ausschließlich fehlbestand-gebunden
(Prompt 21 Abschnitt 7)."""
__tablename__ = "nachfuellung"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
fehlbestand_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("fehlbestand.id"))
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
zeitpunkt: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
+46
View File
@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from app.models.fehlbestand import FehlbestandStatus
class FehlbestandRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
objekt_id: int
material_id: int
standort_id: int
sollmenge: Decimal
istmenge: Decimal
fehlmenge: Decimal
entstanden_am: datetime
festgestellt_von: int
kontrolle_id: uuid.UUID | None
ursache: str | None
verantwortlicher_id: int | None
status: FehlbestandStatus
erledigt_am: datetime | None
class NachfuellungCreate(BaseModel):
menge: Decimal
class NachfuellungRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
fehlbestand_id: uuid.UUID | None
objekt_id: int
material_id: int
menge: Decimal
benutzer_id: int
zeitpunkt: datetime
class NachfuellungResponse(BaseModel):
nachfuellung: NachfuellungRead
fehlbestand: FehlbestandRead
ueberbestand: Decimal
+1
View File
@@ -34,6 +34,7 @@ class KontrollpositionRead(BaseModel):
sollmenge_snapshot: Decimal sollmenge_snapshot: Decimal
istmenge_erfasst: Decimal istmenge_erfasst: Decimal
abweichung: bool abweichung: bool
fehlbestand_id: uuid.UUID | None = None
class KontrolleAbbrechenRequest(BaseModel): class KontrolleAbbrechenRequest(BaseModel):
+69
View File
@@ -0,0 +1,69 @@
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.nachfuellung import Nachfuellung
from app.models.objektposition import Objektposition
class FehlbestandBereitsErledigtError(Exception):
"""Ein bereits erledigter Fehlbestand kann nicht erneut nachgefüllt werden."""
async def nachfuellen(
db: AsyncSession, *, fehlbestand: Fehlbestand, menge: Decimal, benutzer_id: int
) -> tuple[Nachfuellung, Decimal]:
"""Vier-Kernbegriffe (Prompt 02.9): Nachfüllung ist die einzige Aktion, die
Objektposition.istmenge tatsächlich ändert. Deckt sowohl Sofort-Nachfüllung
während der Kontrolle (Karte 07) als auch spätere/externe Nachfüllung
(Prompt 02.5) ab - technisch derselbe Vorgang, nur anderer Zeitpunkt.
Überbestand durch Nachfüllung ist erlaubt (Prompt 21.9/E4): kein Fehler, nur
als Info im Rückgabewert `ueberbestand` gekennzeichnet.
Wichtig: `fehlbestand.istmenge` ist die zuletzt GEZÄHLTE Wahrheit (aus der
Kontrolle, die den Fehlbestand ausgelöst hat) - Objektposition.istmenge wird
hier auf den neuen Wert GESETZT, nicht unabhängig davon inkrementiert. Sonst
bliebe Objektposition.istmenge bei ihrem alten (ggf. 0/"nicht kontrolliert")
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
"Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen.
"""
if fehlbestand.status == FehlbestandStatus.erledigt:
raise FehlbestandBereitsErledigtError()
neue_istmenge = fehlbestand.istmenge + menge
ueberbestand = max(Decimal(0), neue_istmenge - fehlbestand.sollmenge)
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == fehlbestand.objekt_id,
Objektposition.material_id == fehlbestand.material_id,
)
)
objektposition = result.scalar_one_or_none()
if objektposition is not None:
objektposition.istmenge = neue_istmenge
fehlbestand.istmenge = neue_istmenge
fehlbestand.fehlmenge = neue_fehlmenge
if neue_fehlmenge <= 0:
fehlbestand.status = FehlbestandStatus.erledigt
fehlbestand.erledigt_am = datetime.now(timezone.utc)
else:
fehlbestand.status = FehlbestandStatus.nachgefuellt_teilweise
nachfuellung = Nachfuellung(
fehlbestand_id=fehlbestand.id,
objekt_id=fehlbestand.objekt_id,
material_id=fehlbestand.material_id,
menge=menge,
benutzer_id=benutzer_id,
zeitpunkt=datetime.now(timezone.utc),
)
db.add(nachfuellung)
await db.flush()
return nachfuellung, ueberbestand
+23 -19
View File
@@ -1,3 +1,4 @@
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
@@ -102,10 +103,12 @@ async def erfasse_position(
material_id: int, material_id: int,
istmenge: Decimal, istmenge: Decimal,
zustaendiger_server_id: int, zustaendiger_server_id: int,
) -> Kontrollposition: ) -> tuple[Kontrollposition, uuid.UUID | None]:
"""Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die """Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die
persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein
Fehlbestand (nie bei Überbestand, Prompt 02.4).""" Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die
Fehlbestand-ID zurück (falls einer aktiv ist), damit die UI direkt eine
Sofort-Nachfüllung (Karte 07) anbieten kann, ohne separat nachzufragen."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id) vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute( result = await db.execute(
select(Objektposition).where( select(Objektposition).where(
@@ -138,7 +141,7 @@ async def erfasse_position(
kontrollposition.abweichung = abweichung kontrollposition.abweichung = abweichung
await db.flush() await db.flush()
await _synchronisiere_fehlbestand( fehlbestand_id = await _synchronisiere_fehlbestand(
db, db,
kontrolle=kontrolle, kontrolle=kontrolle,
objekt=objekt, objekt=objekt,
@@ -147,7 +150,7 @@ async def erfasse_position(
istmenge=istmenge, istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id, zustaendiger_server_id=zustaendiger_server_id,
) )
return kontrollposition return kontrollposition, fehlbestand_id
async def _synchronisiere_fehlbestand( async def _synchronisiere_fehlbestand(
@@ -159,7 +162,7 @@ async def _synchronisiere_fehlbestand(
sollmenge: Decimal, sollmenge: Decimal,
istmenge: Decimal, istmenge: Decimal,
zustaendiger_server_id: int, zustaendiger_server_id: int,
) -> None: ) -> uuid.UUID | None:
result = await db.execute( result = await db.execute(
select(Fehlbestand).where( select(Fehlbestand).where(
Fehlbestand.kontrolle_id == kontrolle.id, Fehlbestand.kontrolle_id == kontrolle.id,
@@ -174,21 +177,20 @@ async def _synchronisiere_fehlbestand(
# Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4 # Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4
# ausdrücklich KEINEN Fehlbestand. # ausdrücklich KEINEN Fehlbestand.
if bestehender is None: if bestehender is None:
db.add( bestehender = Fehlbestand(
Fehlbestand( erzeugt_von_server_id=zustaendiger_server_id,
erzeugt_von_server_id=zustaendiger_server_id, objekt_id=objekt.id,
objekt_id=objekt.id, material_id=material_id,
material_id=material_id, standort_id=objekt.standort_id,
standort_id=objekt.standort_id, sollmenge=sollmenge,
sollmenge=sollmenge, istmenge=istmenge,
istmenge=istmenge, fehlmenge=unterdeckung,
fehlmenge=unterdeckung, entstanden_am=datetime.now(timezone.utc),
entstanden_am=datetime.now(timezone.utc), festgestellt_von=kontrolle.benutzer_id,
festgestellt_von=kontrolle.benutzer_id, kontrolle_id=kontrolle.id,
kontrolle_id=kontrolle.id, status=FehlbestandStatus.offen,
status=FehlbestandStatus.offen,
)
) )
db.add(bestehender)
else: else:
# Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die # Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die
# Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht # Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht
@@ -200,7 +202,9 @@ async def _synchronisiere_fehlbestand(
# Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle # Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle
# erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde. # erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde.
await db.delete(bestehender) await db.delete(bestehender)
bestehender = None
await db.flush() await db.flush()
return bestehender.id if bestehender is not None else None
async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]: async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]:
+138
View File
@@ -0,0 +1,138 @@
import pytest
from sqlalchemy import select
from app.models.objektposition import Objektposition
from tests.conftest import auth_header, login
async def _fehlbestand_erzeugen(client, token, objekt, material, istmenge: str):
start = await client.post(
f"/api/v1/objekte/{objekt.id}/kontrollen", json={"uebernehmen": False}, headers=auth_header(token)
)
kontrolle_id = start.json()["id"]
put_response = await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": istmenge},
headers=auth_header(token),
)
return put_response.json()["fehlbestand_id"]
@pytest.mark.asyncio
async def test_sofort_nachfuellung_erledigt_ohne_zwischenstatus(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""U4: Sofort-Nachfüllung während der Kontrolle - Fehlbestand entsteht und
wird im selben Moment erledigt, Ist-Menge wird auf Soll korrigiert."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
fehlbestand_id = await _fehlbestand_erzeugen(client, token, objekt, material, "7")
response = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "3"},
headers=auth_header(token),
)
assert response.status_code == 201
body = response.json()
assert body["fehlbestand"]["status"] == "erledigt"
assert float(body["fehlbestand"]["fehlmenge"]) == 0
assert float(body["ueberbestand"]) == 0
objektposition = (
await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id
)
)
).scalar_one()
assert float(objektposition.istmenge) == 10
@pytest.mark.asyncio
async def test_teilnachfuellung_bleibt_offen_mit_reduzierter_fehlmenge(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""U5: Teilnachfüllung reduziert Fehlmenge, Status bleibt (fachlich weiterhin) offen."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
fehlbestand_id = await _fehlbestand_erzeugen(client, token, objekt, material, "6")
response = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "2"},
headers=auth_header(token),
)
assert response.status_code == 201
body = response.json()["fehlbestand"]
assert body["status"] == "nachgefuellt_teilweise"
assert float(body["fehlmenge"]) == 2
assert float(body["istmenge"]) == 8
@pytest.mark.asyncio
async def test_erledigt_ausschliesslich_bei_ist_gleich_soll(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""U3: Fehlbestand wird ausschließlich automatisch bei Ist=Soll erledigt."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
fehlbestand_id = await _fehlbestand_erzeugen(client, token, objekt, material, "4")
teil = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "3"},
headers=auth_header(token),
)
assert teil.json()["fehlbestand"]["status"] == "nachgefuellt_teilweise"
rest = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "3"},
headers=auth_header(token),
)
assert rest.json()["fehlbestand"]["status"] == "erledigt"
assert float(rest.json()["fehlbestand"]["fehlmenge"]) == 0
@pytest.mark.asyncio
async def test_ueberbestand_durch_nachfuellung_kein_fehler(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""E4/Prompt 21.9: Nachfüllung über Soll hinaus erlaubt, als Überbestand markiert."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
fehlbestand_id = await _fehlbestand_erzeugen(client, token, objekt, material, "7")
response = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "5"},
headers=auth_header(token),
)
assert response.status_code == 201
body = response.json()
assert body["fehlbestand"]["status"] == "erledigt"
assert float(body["ueberbestand"]) == 2
@pytest.mark.asyncio
async def test_nachfuellung_auf_erledigten_fehlbestand_ist_409(
client, objekt_mit_position, mitarbeiter_user
):
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
fehlbestand_id = await _fehlbestand_erzeugen(client, token, objekt, material, "7")
erste = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "3"},
headers=auth_header(token),
)
assert erste.json()["fehlbestand"]["status"] == "erledigt"
zweite = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "1"},
headers=auth_header(token),
)
assert zweite.status_code == 409