Files
MABEA/backend/app/services/kontrolle.py
T
patrickandClaude Sonnet 5 18fba28eae
CI / backend-tests (push) Successful in 40s
Sprint 4: Fehlbestand-Statusmaschine komplett, Nachfüllung sofort/später/teilweise
- Nachfuellung-Modell: einzige Aktion, die Objektposition.istmenge tatsächlich
  ändert (Vier-Kernbegriffe). Deckt Sofort-Nachfüllung während Kontrolle (Karte 07)
  und spätere/externe Nachfüllung (Prompt 02.5) mit derselben Logik ab
- Wichtiger Fix vor dem ersten Testlauf: Objektposition.istmenge wird bei Nachfüllung
  auf den neuen Wert GESETZT (fehlbestand.istmenge + menge), nicht unabhängig
  inkrementiert - sonst bliebe sie beim initialen "nicht kontrolliert"-Stand (0)
  stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt
- Statusübergänge: offen -> nachgefuellt_teilweise (Fehlmenge>0) -> erledigt
  (Fehlmenge=0), ausschließlich automatisch bei Ist=Soll (U3)
- Überbestand durch Nachfüllung erlaubt, als Info gekennzeichnet, kein Fehler (E4)
- KontrollpositionRead liefert jetzt fehlbestand_id mit, damit die UI direkt eine
  Sofort-Nachfüllung anbieten kann, ohne separat nachzufragen
- Tests: U3, U4 (Sofort-Nachfüllung ohne Zwischenstatus), U5 (Teilnachfüllung),
  Überbestand-Fall, Nachfüllung auf bereits erledigten Fehlbestand -> 409

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
2026-09-03 23:46:21 +02:00

245 lines
9.1 KiB
Python

import uuid
from datetime import 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.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.services.objekte import sollmenge_effektiv, sollmenge_map
class ObjektGesperrtError(Exception):
"""Prompt 02.8: Objekt wird bereits von einer anderen, laufenden Kontrolle belegt."""
def __init__(self, aktive_kontrolle: Kontrolle):
self.aktive_kontrolle = aktive_kontrolle
super().__init__("Objekt wird bereits kontrolliert")
class UnvollstaendigError(Exception):
"""Prompt 16.1: Abschluss mit unbestätigten Positionen."""
def __init__(self, fehlende_material_ids: list[int]):
self.fehlende_material_ids = fehlende_material_ids
super().__init__("Nicht alle Positionen wurden erfasst")
async def aktive_kontrolle(db: AsyncSession, objekt_id: int) -> Kontrolle | None:
result = await db.execute(
select(Kontrolle).where(
Kontrolle.objekt_id == objekt_id, Kontrolle.status == KontrollStatus.in_bearbeitung
)
)
return result.scalar_one_or_none()
async def starte_kontrolle(
db: AsyncSession,
*,
objekt_id: int,
benutzer_id: int,
uebernehmen: bool,
zustaendiger_server_id: int,
) -> Kontrolle:
laufende = await aktive_kontrolle(db, objekt_id)
if laufende is not None:
if not uebernehmen:
raise ObjektGesperrtError(laufende)
# Übernahme (Prompt 02.8): alte Kontrolle wird abgebrochen, nicht gelöscht -
# bleibt als Datensatz nachvollziehbar (Prompt 16.6-Analogie).
laufende.status = KontrollStatus.abgebrochen
laufende.abbruch_grund = f"Übernommen durch Benutzer {benutzer_id}"
laufende.beendet_am = datetime.now(timezone.utc)
# Vom vorherigen Inhaber während der abgebrochenen Kontrolle erzeugte
# Fehlbestände dürfen nicht stehen bleiben (Prompt 16.6: kein Fehlbestand
# durch eine abgebrochene Kontrolle).
await _verwirf_kontrolldaten(db, laufende.id)
db.add(
Historie(
erzeugt_von_server_id=zustaendiger_server_id,
zeitpunkt=datetime.now(timezone.utc),
benutzer_id=benutzer_id,
ereignistyp="kontrolle_uebernommen",
entitaet_typ="kontrolle",
entitaet_id=str(laufende.id),
alter_wert={"benutzer_id": laufende.benutzer_id},
neuer_wert={"benutzer_id": benutzer_id},
)
)
await db.flush()
neue_kontrolle = Kontrolle(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt_id,
benutzer_id=benutzer_id,
status=KontrollStatus.in_bearbeitung,
gestartet_am=datetime.now(timezone.utc),
)
db.add(neue_kontrolle)
await db.flush()
return neue_kontrolle
async def _verwirf_kontrolldaten(db: AsyncSession, kontrolle_id) -> None:
fehlbestaende = await db.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id))
for fehlbestand in fehlbestaende.scalars().all():
await db.delete(fehlbestand)
positionen = await db.execute(select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id))
for position in positionen.scalars().all():
await db.delete(position)
await db.flush()
async def erfasse_position(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> 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."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
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, vorlagen_sollmengen) if objektposition else Decimal(0)
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()
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.kontrolle_id == kontrolle.id,
Fehlbestand.material_id == material_id,
Fehlbestand.status == FehlbestandStatus.offen,
)
)
bestehender = result.scalar_one_or_none()
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)
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.
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
await db.flush()
return bestehender.id if bestehender is not None else None
async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]:
result = await db.execute(
select(Objektposition.material_id).where(
Objektposition.objekt_id == kontrolle.objekt_id,
Objektposition.ist_status == ObjektpositionStatus.aktiv,
)
)
erwartete = {m for (m,) in result.all()}
result = await db.execute(
select(Kontrollposition.material_id).where(Kontrollposition.kontrolle_id == kontrolle.id)
)
erfasste = {m for (m,) in result.all()}
return sorted(erwartete - erfasste)
async def abschliesse_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle) -> None:
fehlende = await fehlende_material_ids(db, kontrolle)
if fehlende:
raise UnvollstaendigError(fehlende)
kontrolle.status = KontrollStatus.abgeschlossen
kontrolle.beendet_am = datetime.now(timezone.utc)
await db.flush()
async def abbreche_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle, grund: str | None) -> None:
"""Prompt 16.6: keine Ist-Übernahme (Kontrolle rührt Objektposition ohnehin nie
an), kein Fehlbestand aus dieser Kontrolle bleibt bestehen. Kontrolle selbst
bleibt als Datensatz erhalten (Status "abgebrochen"), nicht gelöscht."""
await _verwirf_kontrolldaten(db, kontrolle.id)
kontrolle.status = KontrollStatus.abgebrochen
kontrolle.abbruch_grund = grund
kontrolle.beendet_am = datetime.now(timezone.utc)
await db.flush()