_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
203 lines
8.0 KiB
Python
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
|