From 18fba28eae8c2c959b55ccbf1be7724ec715f575 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 3 Sep 2026 23:46:21 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=204:=20Fehlbestand-Statusmaschine=20komp?= =?UTF-8?q?lett,=20Nachf=C3=BCllung=20sofort/sp=C3=A4ter/teilweise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt --- DEVLOG.md | 42 ++++++ backend/app/api/v1/api.py | 13 +- backend/app/api/v1/endpoints/fehlbestaende.py | 86 +++++++++++ backend/app/api/v1/endpoints/kontrollen.py | 13 +- backend/app/models/__init__.py | 2 + backend/app/models/nachfuellung.py | 26 ++++ backend/app/schemas/fehlbestand.py | 46 ++++++ backend/app/schemas/kontrolle.py | 1 + backend/app/services/fehlbestand.py | 69 +++++++++ backend/app/services/kontrolle.py | 42 +++--- backend/tests/test_fehlbestand.py | 138 ++++++++++++++++++ 11 files changed, 456 insertions(+), 22 deletions(-) create mode 100644 backend/app/api/v1/endpoints/fehlbestaende.py create mode 100644 backend/app/models/nachfuellung.py create mode 100644 backend/app/schemas/fehlbestand.py create mode 100644 backend/app/services/fehlbestand.py create mode 100644 backend/tests/test_fehlbestand.py diff --git a/DEVLOG.md b/DEVLOG.md index e471fc5..d906c9b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1199,3 +1199,45 @@ Keine Commits in dieser Session. - 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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 18ec28b..43f15c7 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,6 +1,16 @@ 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.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(objekte.router, tags=["objekte"]) api_router.include_router(kontrollen.router, tags=["kontrollen"]) +api_router.include_router(fehlbestaende.router, tags=["fehlbestaende"]) diff --git a/backend/app/api/v1/endpoints/fehlbestaende.py b/backend/app/api/v1/endpoints/fehlbestaende.py new file mode 100644 index 0000000..cf57665 --- /dev/null +++ b/backend/app/api/v1/endpoints/fehlbestaende.py @@ -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, + ) diff --git a/backend/app/api/v1/endpoints/kontrollen.py b/backend/app/api/v1/endpoints/kontrollen.py index 00d8f20..f0cb93a 100644 --- a/backend/app/api/v1/endpoints/kontrollen.py +++ b/backend/app/api/v1/endpoints/kontrollen.py @@ -121,13 +121,13 @@ async def erfasse_kontrollposition( payload: KontrollpositionInput, db: AsyncSession = Depends(get_db), current_user: Benutzer = Depends(_mitarbeiter_plus), -) -> Kontrollposition: +) -> KontrollpositionRead: kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) _pruefe_besitz(kontrolle, current_user) objekt = await db.get(Objekt, kontrolle.objekt_id) zustaendiger_server_id = await _hauptserver_id(db) - return await erfasse_position( + kontrollposition, fehlbestand_id = await erfasse_position( db, kontrolle=kontrolle, objekt=objekt, @@ -135,6 +135,15 @@ async def erfasse_kontrollposition( istmenge=payload.istmenge, zustaendiger_server_id=zustaendiger_server_id, ) + return KontrollpositionRead( + id=kontrollposition.id, + kontrolle_id=kontrollposition.kontrolle_id, + material_id=kontrollposition.material_id, + sollmenge_snapshot=kontrollposition.sollmenge_snapshot, + istmenge_erfasst=kontrollposition.istmenge_erfasst, + abweichung=kontrollposition.abweichung, + fehlbestand_id=fehlbestand_id, + ) @router.post("/kontrollen/{kontrolle_id}/abschliessen", response_model=KontrolleRead) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index eb5278b..9c5e2bf 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, Kno from app.models.fehlbestand import Fehlbestand, FehlbestandStatus from app.models.historie import Historie from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition +from app.models.nachfuellung import Nachfuellung 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 @@ -20,6 +21,7 @@ __all__ = [ "Kontrolle", "KontrollStatus", "Kontrollposition", + "Nachfuellung", "Objekt", "ObjektStatus", "Objektposition", diff --git a/backend/app/models/nachfuellung.py b/backend/app/models/nachfuellung.py new file mode 100644 index 0000000..70c8809 --- /dev/null +++ b/backend/app/models/nachfuellung.py @@ -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) diff --git a/backend/app/schemas/fehlbestand.py b/backend/app/schemas/fehlbestand.py new file mode 100644 index 0000000..c4f5843 --- /dev/null +++ b/backend/app/schemas/fehlbestand.py @@ -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 diff --git a/backend/app/schemas/kontrolle.py b/backend/app/schemas/kontrolle.py index ee435a6..3374263 100644 --- a/backend/app/schemas/kontrolle.py +++ b/backend/app/schemas/kontrolle.py @@ -34,6 +34,7 @@ class KontrollpositionRead(BaseModel): sollmenge_snapshot: Decimal istmenge_erfasst: Decimal abweichung: bool + fehlbestand_id: uuid.UUID | None = None class KontrolleAbbrechenRequest(BaseModel): diff --git a/backend/app/services/fehlbestand.py b/backend/app/services/fehlbestand.py new file mode 100644 index 0000000..46036fa --- /dev/null +++ b/backend/app/services/fehlbestand.py @@ -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 diff --git a/backend/app/services/kontrolle.py b/backend/app/services/kontrolle.py index 5ad94a0..8510219 100644 --- a/backend/app/services/kontrolle.py +++ b/backend/app/services/kontrolle.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime, timezone from decimal import Decimal @@ -102,10 +103,12 @@ async def erfasse_position( material_id: int, istmenge: Decimal, zustaendiger_server_id: int, -) -> Kontrollposition: +) -> tuple[Kontrollposition, uuid.UUID | None]: """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).""" + 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) result = await db.execute( select(Objektposition).where( @@ -138,7 +141,7 @@ async def erfasse_position( kontrollposition.abweichung = abweichung await db.flush() - await _synchronisiere_fehlbestand( + fehlbestand_id = await _synchronisiere_fehlbestand( db, kontrolle=kontrolle, objekt=objekt, @@ -147,7 +150,7 @@ async def erfasse_position( istmenge=istmenge, zustaendiger_server_id=zustaendiger_server_id, ) - return kontrollposition + return kontrollposition, fehlbestand_id async def _synchronisiere_fehlbestand( @@ -159,7 +162,7 @@ async def _synchronisiere_fehlbestand( sollmenge: Decimal, istmenge: Decimal, zustaendiger_server_id: int, -) -> None: +) -> uuid.UUID | None: result = await db.execute( select(Fehlbestand).where( 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 # 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, - ) + bestehender = 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, ) + db.add(bestehender) else: # Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die # 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 # erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde. await db.delete(bestehender) + bestehender = None await db.flush() + return bestehender.id if bestehender is not None else None async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]: diff --git a/backend/tests/test_fehlbestand.py b/backend/tests/test_fehlbestand.py new file mode 100644 index 0000000..58fa47f --- /dev/null +++ b/backend/tests/test_fehlbestand.py @@ -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