Files
MABEA/backend/tests/test_kontrolle.py
T
patrickandClaude Sonnet 5 9721873088
CI / backend-tests (push) Failing after 2m4s
CI / frontend-build (push) Successful in 20s
fix(kontrolle): doppelten Fehlbestand bei Folge-Kontrolle vermieden
_synchronisiere_fehlbestand suchte nur nach Fehlbeständen der eigenen
Kontroll-Sitzung. Ein bereits offener Fehlbestand aus einer früheren
Kontrolle für dasselbe Objekt+Material wurde nicht wiedergefunden -
es entstand ein zweiter, paralleler Fehlbestand mit eigener (neuerer)
Sollmenge, der alte blieb mit veralteter Sollmenge offen liegen.
Lookup jetzt über objekt_id+material_id+status=offen statt
kontrolle_id; kontrolle_id am gefundenen Datensatz bleibt unverändert,
damit ein Abbruch der aktuellen Kontrolle ihn nicht fälschlich
mitlöscht. Löst sich die Abweichung über eine andere Kontrolle als
die erzeugende auf, wird der Fehlbestand jetzt sauber als erledigt
markiert (inkl. Mindermenge-Abschluss) statt stillschweigend gelöscht.

Nebenbei: Kommentare mit Kachel-/Datums-Referenzen ("Bug-Fund
2026-09-06", "UI-00X (Epic 20)") aus dem Code dieser Session entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-06 22:26:57 +02:00

376 lines
14 KiB
Python

import pytest
from sqlalchemy import select
from app.models.fehlbestand import Fehlbestand
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.objektposition import Objektposition
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_ablauf_charge_werden_bei_erfassung_mitgepflegt(client, db_session, objekt_mit_position, mitarbeiter_user):
"""Nutzer-Vorgabe: Ablauf/Charge/SN werden während der Kontroll-Erfassung
mitgepflegt, wie die Menge - nicht nur separat über die Admin-Pflege."""
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": "10", "ablaufdatum": "2027-06-01", "chargennummer": "LOT-1"},
headers=auth_header(token),
)
assert put_response.status_code == 200
result = await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id
)
)
position = result.scalar_one()
assert str(position.ablaufdatum) == "2027-06-01"
assert position.chargennummer == "LOT-1"
# Vier-Kernbegriffe bleiben unberührt: istmenge auf Objektposition wird von
# der Kontrolle weiterhin NICHT gesetzt (nur von Nachfüllung).
assert position.istmenge == 0
@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_abbruch_nach_sofort_nachfuellung_loescht_fehlbestand_nicht(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""Karte 07: wird während der laufenden Kontrolle sofort nachgefüllt und die
Kontrolle danach abgebrochen (z. B. Einsatzalarmierung), darf der bereits
nachgefüllte Fehlbestand NICHT gelöscht werden - er ist eine echte
dokumentierte Bestandsbewegung, kein reiner Kontroll-Zwischenstand."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
start = await _starte(client, token, objekt.id)
kontrolle_id = start.json()["id"]
erfassung = await client.put(
f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}",
json={"istmenge": "8"},
headers=auth_header(token),
)
fehlbestand_id = erfassung.json()["fehlbestand_id"]
assert fehlbestand_id is not None
nf = await client.post(
f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen",
json={"menge": "2"},
headers=auth_header(token),
)
assert nf.status_code == 201
response = await client.post(
f"/api/v1/kontrollen/{kontrolle_id}/abbrechen",
json={"grund": "Einsatzalarmierung"},
headers=auth_header(token),
)
assert response.status_code == 200
fehlbestand = await db_session.get(Fehlbestand, fehlbestand_id)
assert fehlbestand is not None
assert fehlbestand.status.value == "erledigt"
@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
@pytest.mark.asyncio
async def test_zweite_kontrolle_aktualisiert_offenen_fehlbestand_statt_duplikat(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""Ein offener Fehlbestand aus einer früheren Kontrolle wird aktualisiert
statt einen zweiten parallelen zu erzeugen."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
erste_kontrolle = (await _starte(client, token, objekt.id)).json()["id"]
await client.put(
f"/api/v1/kontrollen/{erste_kontrolle}/positionen/{material.id}",
json={"istmenge": "8"},
headers=auth_header(token),
)
await client.post(f"/api/v1/kontrollen/{erste_kontrolle}/abschliessen", headers=auth_header(token))
zweite_kontrolle = (await _starte(client, token, objekt.id)).json()["id"]
await client.put(
f"/api/v1/kontrollen/{zweite_kontrolle}/positionen/{material.id}",
json={"istmenge": "7"},
headers=auth_header(token),
)
result = await db_session.execute(
select(Fehlbestand).where(Fehlbestand.objekt_id == objekt.id, Fehlbestand.material_id == material.id)
)
fehlbestaende = result.scalars().all()
assert len(fehlbestaende) == 1
assert float(fehlbestaende[0].istmenge) == 7
assert float(fehlbestaende[0].fehlmenge) == 3
assert fehlbestaende[0].kontrolle_id == erste_kontrolle
@pytest.mark.asyncio
async def test_zweite_kontrolle_erledigt_offenen_fehlbestand_bei_korrektur(
client, db_session, objekt_mit_position, mitarbeiter_user
):
"""Ergänzend: löst die Folge-Kontrolle die Abweichung auf, wird der
Fehlbestand aus der früheren Kontrolle als erledigt markiert (nicht
gelöscht - er war bereits real, anders als der Same-Session-Fall)."""
objekt, material = objekt_mit_position
token = await login(client, "mitarbeiter1")
erste_kontrolle = (await _starte(client, token, objekt.id)).json()["id"]
await client.put(
f"/api/v1/kontrollen/{erste_kontrolle}/positionen/{material.id}",
json={"istmenge": "8"},
headers=auth_header(token),
)
await client.post(f"/api/v1/kontrollen/{erste_kontrolle}/abschliessen", headers=auth_header(token))
zweite_kontrolle = (await _starte(client, token, objekt.id)).json()["id"]
await client.put(
f"/api/v1/kontrollen/{zweite_kontrolle}/positionen/{material.id}",
json={"istmenge": "10"},
headers=auth_header(token),
)
result = await db_session.execute(
select(Fehlbestand).where(Fehlbestand.objekt_id == objekt.id, Fehlbestand.material_id == material.id)
)
fehlbestand = result.scalar_one()
assert fehlbestand.status.value == "erledigt"
assert fehlbestand.fehlmenge == 0