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
This commit is contained in:
@@ -228,9 +228,8 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
|
||||
"unbekannt": unbekannt,
|
||||
"nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt - unbekannt,
|
||||
"gesamt": len(objekte),
|
||||
# Bug-Fund 2026-09-06: "unbekannt" zählt nur Objekte OHNE jeden anderen
|
||||
# Grund - diese Zahl zählt "nie kontrolliert" unabhängig von sonstigen
|
||||
# Problemen mit, damit sie beim manuellen Nachzählen stimmt.
|
||||
# "unbekannt" zählt nur Objekte ohne jeden anderen Grund - diese Zahl
|
||||
# zählt "nie kontrolliert" unabhängig von sonstigen Problemen mit.
|
||||
"nie_kontrolliert_gesamt": nie_kontrolliert_gesamt,
|
||||
"details": details,
|
||||
}
|
||||
@@ -247,7 +246,7 @@ def _kategorie_und_gruende(
|
||||
objekt_ids_kritischer_mangel: set,
|
||||
objekt_ids_hu_uvv_ueberfaellig: set,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""UI-005 (Epic 20): aus der `einsatzbereitschaft()`-Schleife extrahiert, damit
|
||||
"""aus der `einsatzbereitschaft()`-Schleife extrahiert, damit
|
||||
`objekt_readiness()` (Begründung auf der Objektakte) dieselbe Logik nutzt statt
|
||||
sie zu duplizieren."""
|
||||
gruende: list[str] = []
|
||||
@@ -267,12 +266,8 @@ def _kategorie_und_gruende(
|
||||
if objekt.id in objekt_ids_hu_uvv_ueberfaellig:
|
||||
gruende.append("hu_uvv_ueberfaellig")
|
||||
|
||||
# Bug-Fund 2026-09-06 (Nutzer: "beim genauen Schauen sind es mehr"):
|
||||
# "noch nie kontrolliert" wurde bisher nur gezählt, wenn es der EINZIGE
|
||||
# Grund war - ein Objekt mit z.B. offenem Fehlbestand UND nie kontrolliert
|
||||
# verlor den Hinweis komplett (fiel unter "nicht einsatzbereit" ohne
|
||||
# diesen Grund zu nennen). Jetzt immer als Zusatzgrund angehängt, wenn
|
||||
# zutreffend, unabhängig von anderen Gründen.
|
||||
# "noch nie kontrolliert" gilt zusätzlich zu jedem anderen Grund, nicht nur
|
||||
# wenn es der einzige ist.
|
||||
nie_kontrolliert = letzte_kontrolle is None
|
||||
|
||||
if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt:
|
||||
@@ -288,7 +283,7 @@ def _kategorie_und_gruende(
|
||||
|
||||
|
||||
async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict:
|
||||
"""UI-005 (Epic 20): begründete Einsatzbereitschaft für EIN Objekt (Objektakte),
|
||||
"""begründete Einsatzbereitschaft für EIN Objekt (Objektakte),
|
||||
dieselbe Fachlogik wie `einsatzbereitschaft()` (Dashboard-Aggregat), nur für
|
||||
einen einzelnen Datensatz statt aller Objekte. `ausser_dienst` zählt hier
|
||||
bewusst nicht als eigener Fall - Objektakte zeigt das Objekt so oder so an,
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||
from app.models.kontrolle import Kontrolle, Kontrollposition
|
||||
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.objektposition import Objektposition
|
||||
from app.services import benachrichtigung as benachrichtigung_service
|
||||
@@ -109,12 +110,13 @@ async def _synchronisiere_fehlbestand(
|
||||
) -> uuid.UUID | None:
|
||||
result = await db.execute(
|
||||
select(Fehlbestand).where(
|
||||
Fehlbestand.kontrolle_id == kontrolle.id,
|
||||
Fehlbestand.objekt_id == objekt.id,
|
||||
Fehlbestand.material_id == material_id,
|
||||
Fehlbestand.status == FehlbestandStatus.offen,
|
||||
)
|
||||
)
|
||||
bestehender = result.scalar_one_or_none()
|
||||
von_dieser_kontrolle = bestehender is not None and bestehender.kontrolle_id == kontrolle.id
|
||||
|
||||
unterdeckung = sollmenge - istmenge
|
||||
if unterdeckung > 0:
|
||||
@@ -153,16 +155,48 @@ async def _synchronisiere_fehlbestand(
|
||||
)
|
||||
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender)
|
||||
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.
|
||||
# kontrolle_id bleibt unangetastet, sonst würde ein Abbruch dieser
|
||||
# Kontrolle einen fremden Fehlbestand mitlöschen (_verwirf_kontrolldaten).
|
||||
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)
|
||||
bestehender = None
|
||||
if von_dieser_kontrolle:
|
||||
await db.delete(bestehender)
|
||||
bestehender = None
|
||||
else:
|
||||
bestehender.istmenge = istmenge
|
||||
bestehender.fehlmenge = Decimal(0)
|
||||
bestehender.status = FehlbestandStatus.erledigt
|
||||
bestehender.erledigt_am = datetime.now(timezone.utc)
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=kontrolle.benutzer_id,
|
||||
ereignistyp="fehlbestand_erledigt",
|
||||
entitaet_typ="fehlbestand",
|
||||
entitaet_id=bestehender.id,
|
||||
neuer_wert={"status": "erledigt"},
|
||||
)
|
||||
genehmigung_result = await db.execute(
|
||||
select(MindermengenGenehmigung).where(
|
||||
MindermengenGenehmigung.fehlbestand_id == bestehender.id,
|
||||
MindermengenGenehmigung.status == MindermengeStatus.aktiv,
|
||||
)
|
||||
)
|
||||
genehmigung = genehmigung_result.scalar_one_or_none()
|
||||
if genehmigung is not None:
|
||||
genehmigung.status = MindermengeStatus.beendet_durch_erledigung
|
||||
genehmigung.beendet_am = datetime.now(timezone.utc)
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=kontrolle.benutzer_id,
|
||||
ereignistyp="mindermenge_beendet_durch_erledigung",
|
||||
entitaet_typ="mindermengen_genehmigung",
|
||||
entitaet_id=genehmigung.id,
|
||||
neuer_wert={"status": "beendet_durch_erledigung"},
|
||||
)
|
||||
bestehender = None
|
||||
await db.flush()
|
||||
return bestehender.id if bestehender is not None else None
|
||||
|
||||
@@ -204,9 +204,6 @@ async def test_offener_fehlbestand_macht_objekt_nicht_einsatzbereit(
|
||||
body = response.json()
|
||||
assert body["einsatzbereit"] == 0
|
||||
assert body["nicht_einsatzbereit"] == 1
|
||||
# Bug-Fund 2026-09-06: "nie kontrolliert" darf nicht verloren gehen, nur
|
||||
# weil das Objekt zusätzlich einen anderen Grund hat (objekt_mit_position
|
||||
# hat nie eine abgeschlossene Kontrolle).
|
||||
assert body["details"][0]["gruende"] == ["fehlbestand_offen", "noch_nie_kontrolliert"]
|
||||
assert body["nie_kontrolliert_gesamt"] == 1
|
||||
|
||||
|
||||
@@ -306,3 +306,70 @@ async def test_verschiedene_objekte_parallel_kontrollierbar(
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user