_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
376 lines
14 KiB
Python
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
|