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()