Sprint 4: Fehlbestand-Statusmaschine komplett, Nachfüllung sofort/später/teilweise
CI / backend-tests (push) Successful in 40s

- 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
This commit is contained in:
2026-09-03 23:46:21 +02:00
co-authored by Claude Sonnet 5
parent 8fd507cc5d
commit 18fba28eae
11 changed files with 456 additions and 22 deletions
+69
View File
@@ -0,0 +1,69 @@
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.nachfuellung import Nachfuellung
from app.models.objektposition import Objektposition
class FehlbestandBereitsErledigtError(Exception):
"""Ein bereits erledigter Fehlbestand kann nicht erneut nachgefüllt werden."""
async def nachfuellen(
db: AsyncSession, *, fehlbestand: Fehlbestand, menge: Decimal, benutzer_id: int
) -> tuple[Nachfuellung, Decimal]:
"""Vier-Kernbegriffe (Prompt 02.9): Nachfüllung ist die einzige Aktion, die
Objektposition.istmenge tatsächlich ändert. Deckt sowohl Sofort-Nachfüllung
während der Kontrolle (Karte 07) als auch spätere/externe Nachfüllung
(Prompt 02.5) ab - technisch derselbe Vorgang, nur anderer Zeitpunkt.
Überbestand durch Nachfüllung ist erlaubt (Prompt 21.9/E4): kein Fehler, nur
als Info im Rückgabewert `ueberbestand` gekennzeichnet.
Wichtig: `fehlbestand.istmenge` ist die zuletzt GEZÄHLTE Wahrheit (aus der
Kontrolle, die den Fehlbestand ausgelöst hat) - Objektposition.istmenge wird
hier auf den neuen Wert GESETZT, nicht unabhängig davon inkrementiert. Sonst
bliebe Objektposition.istmenge bei ihrem alten (ggf. 0/"nicht kontrolliert")
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
"Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen.
"""
if fehlbestand.status == FehlbestandStatus.erledigt:
raise FehlbestandBereitsErledigtError()
neue_istmenge = fehlbestand.istmenge + menge
ueberbestand = max(Decimal(0), neue_istmenge - fehlbestand.sollmenge)
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == fehlbestand.objekt_id,
Objektposition.material_id == fehlbestand.material_id,
)
)
objektposition = result.scalar_one_or_none()
if objektposition is not None:
objektposition.istmenge = neue_istmenge
fehlbestand.istmenge = neue_istmenge
fehlbestand.fehlmenge = neue_fehlmenge
if neue_fehlmenge <= 0:
fehlbestand.status = FehlbestandStatus.erledigt
fehlbestand.erledigt_am = datetime.now(timezone.utc)
else:
fehlbestand.status = FehlbestandStatus.nachgefuellt_teilweise
nachfuellung = Nachfuellung(
fehlbestand_id=fehlbestand.id,
objekt_id=fehlbestand.objekt_id,
material_id=fehlbestand.material_id,
menge=menge,
benutzer_id=benutzer_id,
zeitpunkt=datetime.now(timezone.utc),
)
db.add(nachfuellung)
await db.flush()
return nachfuellung, ueberbestand
+23 -19
View File
@@ -1,3 +1,4 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
@@ -102,10 +103,12 @@ async def erfasse_position(
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> Kontrollposition:
) -> 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)."""
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(
@@ -138,7 +141,7 @@ async def erfasse_position(
kontrollposition.abweichung = abweichung
await db.flush()
await _synchronisiere_fehlbestand(
fehlbestand_id = await _synchronisiere_fehlbestand(
db,
kontrolle=kontrolle,
objekt=objekt,
@@ -147,7 +150,7 @@ async def erfasse_position(
istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return kontrollposition
return kontrollposition, fehlbestand_id
async def _synchronisiere_fehlbestand(
@@ -159,7 +162,7 @@ async def _synchronisiere_fehlbestand(
sollmenge: Decimal,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> None:
) -> uuid.UUID | None:
result = await db.execute(
select(Fehlbestand).where(
Fehlbestand.kontrolle_id == kontrolle.id,
@@ -174,21 +177,20 @@ async def _synchronisiere_fehlbestand(
# 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,
)
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
@@ -200,7 +202,9 @@ async def _synchronisiere_fehlbestand(
# 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]: