feat(warehouse): WH-005 Umlagerung zwischen Lagerplätzen
CI / backend-tests (push) Successful in 1m58s
CI / frontend-build (push) Successful in 18s

Atomare Aus+Ein-Buchung in einer Materialbewegung-Zeile statt zwei
Einzelschritten. NOTIF-001 war bereits produktiv umgesetzt, Backlog
war nur veraltet - korrigiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 09:36:19 +02:00
co-authored by Claude Sonnet 5
parent 0c8f375a51
commit b33d1c83a9
6 changed files with 163 additions and 17 deletions
+8 -9
View File
@@ -161,12 +161,11 @@ Details zu WH-001 … WH-007 (vollständig).
--- ---
**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-05):** WH-001..004+007 (Kern) sind **MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-06):** WH-001..005+007 sind umgesetzt
umgesetzt und live deployed `lagerort` (Hierarchie), `lagerplatz`, `bestand` je `lagerort` (Hierarchie), `lagerplatz`, `bestand` je Lagerplatz+Material, Ein-/
Lagerplatz+Material, Ein-/Auslagerung mit lückenlosem `materialbewegung`-Protokoll. Auslagerung sowie atomare Umlagerung (`POST /lagerplaetze/{id}/umlagern`) mit
Bewusst zurückgestellt (Nutzer-Entscheidung, Kern zuerst): **WH-005 (Umlagerung)** — aktuell lückenlosem `materialbewegung`-Protokoll (eine Zeile mit Von+Nach statt zwei
nur Aus- dann Einlagern als zwei Einzelschritte, keine atomare Kombi-Aktion. **WH-006 Einzelbuchungen). **WH-006 (Inventur)** fehlt weiterhin komplett — MABEA hat kein
(Inventur)** fehlt weiterhin komplett — MABEA hat kein Inventurlauf-Konzept mit Inventurlauf-Konzept mit Korrekturbuchung, nur objektbezogene Kontrolle.
Korrekturbuchung, nur objektbezogene Kontrolle. `Lagerbewegung` (Objekt-Ebene) bleibt `Lagerbewegung` (Objekt-Ebene) bleibt unverändert parallel bestehen, keine Vermischung
unverändert parallel bestehen, keine Vermischung mit dem neuen Material-/Lagerplatz- mit dem Material-/Lagerplatz-Bewegungsprotokoll.
Bewegungsprotokoll.
+8 -7
View File
@@ -25,8 +25,9 @@ ursprünglichen 16-Epic-Backlog, obwohl Karte 12 bereits vollständig implementi
- **Akzeptanzkriterien:** neuer Fehlbestand löst E-Mail an zuständigen Verantwortlichen aus - **Akzeptanzkriterien:** neuer Fehlbestand löst E-Mail an zuständigen Verantwortlichen aus
und erscheint im Dashboard. und erscheint im Dashboard.
- **Tests:** Trigger-Test (Fehlbestand-Entstehung → E-Mail-Mock aufgerufen). - **Tests:** Trigger-Test (Fehlbestand-Entstehung → E-Mail-Mock aufgerufen).
- **DoD:** **Lücke in MABEA** — Dashboard-Anzeige existiert, aber kein E-Mail-Versand bei - **DoD:** deckt sich 1:1 mit MABEA — bereits vollständig umgesetzt und produktiv
Fehlbestand-Entstehung. Nur die Eskalationsstufen (NOTIF-002) versenden aktuell E-Mails. (`app/services/benachrichtigung.py`, ausgelöst in `kontrolle/erfassung.py` und
`geraet_instanz.py` bei jeder Fehlbestand-Entstehung, Test in `test_email.py`).
## NOTIF-002 — Eskalation lange offener Fehlbestände ## NOTIF-002 — Eskalation lange offener Fehlbestände
@@ -57,11 +58,11 @@ ursprünglichen 16-Epic-Backlog, obwohl Karte 12 bereits vollständig implementi
--- ---
**MABEA-Ist-Stand-Abgleich:** NOTIF-002 (Eskalation) ist vollständig umgesetzt und **MABEA-Ist-Stand-Abgleich (korrigiert 2026-09-06):** NOTIF-001 und NOTIF-002 sind
produktiv. **NOTIF-001 hat eine echte Lücke:** E-Mail bei Erst-Entstehung eines BEIDE vollständig umgesetzt und produktiv. Die zuvor hier dokumentierte Lücke bei
Fehlbestands fehlt — aktuell wird nur bei Eskalationsstufen gemailt, nicht sofort beim NOTIF-001 war veraltet — E-Mail-Versand bei Fehlbestand-Entstehung existiert bereits
ersten Auftreten. Diese Epic-Datei existierte bislang nicht — reine Nachdokumentation seit Sprint 6 (`app/services/benachrichtigung.py`). Diese Epic-Datei existierte bislang
plus eine offene Lücke, die im ursprünglichen 16-Epic-Backlog übersehen wurde. nicht — reine Nachdokumentation, keine offene Lücke mehr.
## Referenzen ## Referenzen
Alte Arbeitskarten (übernommen, Karten-Dateien gelöscht): Karte 05 Benachrichtigungen, Alte Arbeitskarten (übernommen, Karten-Dateien gelöscht): Karte 05 Benachrichtigungen,
+40 -1
View File
@@ -17,8 +17,15 @@ from app.schemas.lager import (
LagerplatzCreate, LagerplatzCreate,
LagerplatzRead, LagerplatzRead,
MaterialbewegungRead, MaterialbewegungRead,
UmlagernRequest,
)
from app.services.lager import (
GleicherLagerplatzError,
NichtGenugBestandError,
auslagern,
einlagern,
umlagern,
) )
from app.services.lager import NichtGenugBestandError, auslagern, einlagern
router = APIRouter() router = APIRouter()
@@ -154,6 +161,38 @@ async def route_auslagern(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genug Bestand am Lagerplatz") from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genug Bestand am Lagerplatz") from exc
@router.post(
"/lagerplaetze/{lagerplatz_id}/umlagern",
response_model=MaterialbewegungRead,
status_code=status.HTTP_201_CREATED,
)
async def route_umlagern(
lagerplatz_id: int,
payload: UmlagernRequest,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
) -> Materialbewegung:
"""WH-005: Verschieben in einem Schritt statt Aus+Ein manuell."""
await _get_or_404(db, Lagerplatz, lagerplatz_id, "Quell-Lagerplatz nicht gefunden")
await _get_or_404(db, Lagerplatz, payload.nach_lagerplatz_id, "Ziel-Lagerplatz nicht gefunden")
try:
return await umlagern(
db,
von_lagerplatz_id=lagerplatz_id,
nach_lagerplatz_id=payload.nach_lagerplatz_id,
material_id=payload.material_id,
menge=payload.menge,
benutzer_id=current_user.id,
grund=payload.grund,
)
except GleicherLagerplatzError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Quell- und Ziel-Lagerplatz sind identisch"
) from exc
except NichtGenugBestandError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genug Bestand am Lagerplatz") from exc
# -- Materialbewegung (WH-007) -------------------------------------------------- # -- Materialbewegung (WH-007) --------------------------------------------------
@router.get("/materialbewegungen", response_model=list[MaterialbewegungRead]) @router.get("/materialbewegungen", response_model=list[MaterialbewegungRead])
+7
View File
@@ -55,6 +55,13 @@ class EinAuslagernRequest(BaseModel):
grund: str | None = None grund: str | None = None
class UmlagernRequest(BaseModel):
nach_lagerplatz_id: int
material_id: int
menge: Decimal
grund: str | None = None
class MaterialbewegungRead(BaseModel): class MaterialbewegungRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
+45
View File
@@ -42,6 +42,51 @@ async def einlagern(
return bewegung return bewegung
class GleicherLagerplatzError(Exception):
pass
async def umlagern(
db: AsyncSession,
*,
von_lagerplatz_id: int,
nach_lagerplatz_id: int,
material_id: int,
menge: Decimal,
benutzer_id: int,
grund: str | None,
) -> Materialbewegung:
"""WH-005: Aus- und Einlagerung atomar in einer Transaktion (get_db committet
erst nach Request-Ende), eine einzige Materialbewegung-Zeile mit Von+Nach
statt zwei Einzelbuchungen wie bei WH-004."""
if von_lagerplatz_id == nach_lagerplatz_id:
raise GleicherLagerplatzError
quelle = await _bestand_zeile(db, von_lagerplatz_id, material_id)
if quelle is None or quelle.menge < menge:
raise NichtGenugBestandError
quelle.menge -= menge
ziel = await _bestand_zeile(db, nach_lagerplatz_id, material_id)
if ziel is None:
ziel = Bestand(lagerplatz_id=nach_lagerplatz_id, material_id=material_id, menge=Decimal(0))
db.add(ziel)
ziel.menge += menge
bewegung = Materialbewegung(
material_id=material_id,
menge=menge,
von_lagerplatz_id=von_lagerplatz_id,
nach_lagerplatz_id=nach_lagerplatz_id,
benutzer_id=benutzer_id,
zeitpunkt=datetime.now(timezone.utc),
grund=grund,
)
db.add(bewegung)
await db.flush()
return bewegung
async def auslagern( async def auslagern(
db: AsyncSession, *, lagerplatz_id: int, material_id: int, menge: Decimal, benutzer_id: int, grund: str | None db: AsyncSession, *, lagerplatz_id: int, material_id: int, menge: Decimal, benutzer_id: int, grund: str | None
) -> Materialbewegung: ) -> Materialbewegung:
+55
View File
@@ -82,3 +82,58 @@ async def test_ein_und_auslagerung_veraendert_bestand_korrekt(client, admin_user
f"/api/v1/materialbewegungen?lagerplatz_id={platz_id}", headers=auth_header(token) f"/api/v1/materialbewegungen?lagerplatz_id={platz_id}", headers=auth_header(token)
) )
assert len(bewegungen.json()) == 2 assert len(bewegungen.json()) == 2
@pytest.mark.asyncio
async def test_umlagerung_verschiebt_bestand_atomar(client, admin_user, material_factory):
token = await login(client, "admin1")
material = await material_factory("Beatmungsbeutel")
lagerort = await client.post(
"/api/v1/lagerorte", json={"name": "Lager Y", "typ": "lager"}, headers=auth_header(token)
)
quelle = await client.post(
"/api/v1/lagerplaetze",
json={"lagerort_id": lagerort.json()["id"], "code": "QUELLE-1"},
headers=auth_header(token),
)
ziel = await client.post(
"/api/v1/lagerplaetze",
json={"lagerort_id": lagerort.json()["id"], "code": "ZIEL-1"},
headers=auth_header(token),
)
quelle_id, ziel_id = quelle.json()["id"], ziel.json()["id"]
await client.post(
f"/api/v1/lagerplaetze/{quelle_id}/einlagern",
json={"material_id": material.id, "menge": "10"},
headers=auth_header(token),
)
um = await client.post(
f"/api/v1/lagerplaetze/{quelle_id}/umlagern",
json={"nach_lagerplatz_id": ziel_id, "material_id": material.id, "menge": "4"},
headers=auth_header(token),
)
assert um.status_code == 201
assert um.json()["von_lagerplatz_id"] == quelle_id
assert um.json()["nach_lagerplatz_id"] == ziel_id
bestand_quelle = await client.get(f"/api/v1/lagerplaetze/{quelle_id}/bestand", headers=auth_header(token))
assert bestand_quelle.json()[0]["menge"] == "6"
bestand_ziel = await client.get(f"/api/v1/lagerplaetze/{ziel_id}/bestand", headers=auth_header(token))
assert bestand_ziel.json()[0]["menge"] == "4"
gleich = await client.post(
f"/api/v1/lagerplaetze/{quelle_id}/umlagern",
json={"nach_lagerplatz_id": quelle_id, "material_id": material.id, "menge": "1"},
headers=auth_header(token),
)
assert gleich.status_code == 409
zu_viel = await client.post(
f"/api/v1/lagerplaetze/{quelle_id}/umlagern",
json={"nach_lagerplatz_id": ziel_id, "material_id": material.id, "menge": "999"},
headers=auth_header(token),
)
assert zu_viel.status_code == 409