Sprint 3: Kontroll-Kern (Statusmaschine, Objekt-Sperre, automatische Fehlbestand-Erzeugung)
CI / backend-tests (push) Successful in 38s
CI / backend-tests (push) Successful in 38s
- Kontrolle/Kontrollposition-Modelle: Kontrolle ändert NIEMALS Objektposition.istmenge (Vier-Kernbegriffe-Tabelle, Prompt 02.9) - reine Erfassung/Snapshot - Objekt-Sperre (Prompt 02.8): Kontrolle.status='in_bearbeitung' + benutzer_id IST die Sperre, kein separates Sperr-Modell. Zweiter Zugriff -> 409 mit wer/seit; Übernahme bricht alte Kontrolle ab (Datensatz bleibt, Prompt 16.6-Analogie) und protokolliert das Ereignis in einer neuen, minimalen Historie-Tabelle (volle Ausbaustufe Sprint 5) - Automatische Fehlbestand-Erzeugung bei Ist < Soll (U1), Überbestand erzeugt ausdrücklich KEINEN Fehlbestand (Prompt 02.4); Korrektur vor Abschluss möglich (von derselben Kontrolle erzeugter Fehlbestand ist bis zum Abschluss nicht final) - Abschluss verweigert bei unbestätigten Positionen, liefert deren IDs (U11) - Abbruch verwirft Kontrollpositionen UND von dieser Kontrolle erzeugte Fehlbestände, Kontrolle selbst bleibt als Datensatz mit Status "abgebrochen" erhalten (U12) - Tests: U1, U11, U12, Sperre/409, Übernahme, Fremdzugriff/403, parallele Objekte Mitarbeiter-UI (PWA-Frontend) ist bewusst noch nicht Teil dieses Commits - eigenes Techstack-Setup, wird als nächster Schritt separat angegangen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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,
|
||||
) -> Kontrollposition:
|
||||
"""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)."""
|
||||
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()
|
||||
|
||||
await _synchronisiere_fehlbestand(
|
||||
db,
|
||||
kontrolle=kontrolle,
|
||||
objekt=objekt,
|
||||
material_id=material_id,
|
||||
sollmenge=sollmenge,
|
||||
istmenge=istmenge,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
return kontrollposition
|
||||
|
||||
|
||||
async def _synchronisiere_fehlbestand(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
kontrolle: Kontrolle,
|
||||
objekt: Objekt,
|
||||
material_id: int,
|
||||
sollmenge: Decimal,
|
||||
istmenge: Decimal,
|
||||
zustaendiger_server_id: int,
|
||||
) -> 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:
|
||||
db.add(
|
||||
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,
|
||||
)
|
||||
)
|
||||
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)
|
||||
await db.flush()
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user