Files
MABEA/backend/app/services/kontrolle/erfassung.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

203 lines
8.0 KiB
Python

import uuid
from datetime import date, 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.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
from app.services import historie as historie_service
from app.services.objekte import sollmenge_effektiv
async def erfasse_position(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
ablaufdatum: date | None = None,
chargennummer: str | None = None,
seriennummer: str | None = None,
) -> 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). 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.
Ablaufdatum/Chargennummer/Seriennummer sind KEINE Kernbegriffe-Verletzung:
sie beschreiben das physische Exemplar, nicht die Ist-Menge, und werden
daher hier - auf Wunsch als Teil der normalen Kontroll-Erfassung, nicht nur
als separate Admin-Pflege - direkt mitgepflegt."""
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id
)
)
objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition) if objektposition else Decimal(0)
if objektposition is not None:
if ablaufdatum is not None:
objektposition.ablaufdatum = ablaufdatum
if chargennummer is not None:
objektposition.chargennummer = chargennummer
if seriennummer is not None:
objektposition.seriennummer = seriennummer
abweichung = istmenge != sollmenge
result = await db.execute(
select(Kontrollposition).where(
Kontrollposition.kontrolle_id == kontrolle.id, Kontrollposition.material_id == material_id
)
)
kontrollposition = result.scalar_one_or_none()
if kontrollposition is None:
kontrollposition = Kontrollposition(
kontrolle_id=kontrolle.id,
material_id=material_id,
sollmenge_snapshot=sollmenge,
istmenge_erfasst=istmenge,
abweichung=abweichung,
)
db.add(kontrollposition)
else:
kontrollposition.sollmenge_snapshot = sollmenge
kontrollposition.istmenge_erfasst = istmenge
kontrollposition.abweichung = abweichung
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="istmenge_erfasst",
entitaet_typ="kontrollposition",
entitaet_id=kontrollposition.id,
neuer_wert={"material_id": material_id, "sollmenge": str(sollmenge), "istmenge": str(istmenge)},
)
fehlbestand_id = await _synchronisiere_fehlbestand(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
sollmenge=sollmenge,
istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return kontrollposition, fehlbestand_id
async def _synchronisiere_fehlbestand(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
sollmenge: Decimal,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> uuid.UUID | None:
result = await db.execute(
select(Fehlbestand).where(
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:
# Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4
# ausdrücklich KEINEN Fehlbestand.
if bestehender is None:
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)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="fehlbestand_entstanden",
entitaet_typ="fehlbestand",
entitaet_id=bestehender.id,
neuer_wert={
"objekt_id": objekt.id,
"material_id": material_id,
"sollmenge": str(sollmenge),
"istmenge": str(istmenge),
"fehlmenge": str(unterdeckung),
},
)
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender)
else:
# 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:
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