feat(warehouse): WH-005 Umlagerung zwischen Lagerplätzen
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:
@@ -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.
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user