diff --git a/arbeitskacheln/07_warehouse.md b/arbeitskacheln/07_warehouse.md index 1b1e96d..a28038e 100644 --- a/arbeitskacheln/07_warehouse.md +++ b/arbeitskacheln/07_warehouse.md @@ -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 -umgesetzt und live deployed — `lagerort` (Hierarchie), `lagerplatz`, `bestand` je -Lagerplatz+Material, Ein-/Auslagerung mit lückenlosem `materialbewegung`-Protokoll. -Bewusst zurückgestellt (Nutzer-Entscheidung, Kern zuerst): **WH-005 (Umlagerung)** — aktuell -nur Aus- dann Einlagern als zwei Einzelschritte, keine atomare Kombi-Aktion. **WH-006 -(Inventur)** fehlt weiterhin komplett — MABEA hat kein Inventurlauf-Konzept mit -Korrekturbuchung, nur objektbezogene Kontrolle. `Lagerbewegung` (Objekt-Ebene) bleibt -unverändert parallel bestehen, keine Vermischung mit dem neuen Material-/Lagerplatz- -Bewegungsprotokoll. +**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-06):** WH-001..005+007 sind umgesetzt +— `lagerort` (Hierarchie), `lagerplatz`, `bestand` je Lagerplatz+Material, Ein-/ +Auslagerung sowie atomare Umlagerung (`POST /lagerplaetze/{id}/umlagern`) mit +lückenlosem `materialbewegung`-Protokoll (eine Zeile mit Von+Nach statt zwei +Einzelbuchungen). **WH-006 (Inventur)** fehlt weiterhin komplett — MABEA hat kein +Inventurlauf-Konzept mit Korrekturbuchung, nur objektbezogene Kontrolle. +`Lagerbewegung` (Objekt-Ebene) bleibt unverändert parallel bestehen, keine Vermischung +mit dem Material-/Lagerplatz-Bewegungsprotokoll. diff --git a/arbeitskacheln/18_notifications.md b/arbeitskacheln/18_notifications.md index bf36a83..9754f78 100644 --- a/arbeitskacheln/18_notifications.md +++ b/arbeitskacheln/18_notifications.md @@ -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 und erscheint im Dashboard. - **Tests:** Trigger-Test (Fehlbestand-Entstehung → E-Mail-Mock aufgerufen). -- **DoD:** **Lücke in MABEA** — Dashboard-Anzeige existiert, aber kein E-Mail-Versand bei - Fehlbestand-Entstehung. Nur die Eskalationsstufen (NOTIF-002) versenden aktuell E-Mails. +- **DoD:** deckt sich 1:1 mit MABEA — bereits vollständig umgesetzt und produktiv + (`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 @@ -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 -produktiv. **NOTIF-001 hat eine echte Lücke:** E-Mail bei Erst-Entstehung eines -Fehlbestands fehlt — aktuell wird nur bei Eskalationsstufen gemailt, nicht sofort beim -ersten Auftreten. Diese Epic-Datei existierte bislang nicht — reine Nachdokumentation -plus eine offene Lücke, die im ursprünglichen 16-Epic-Backlog übersehen wurde. +**MABEA-Ist-Stand-Abgleich (korrigiert 2026-09-06):** NOTIF-001 und NOTIF-002 sind +BEIDE vollständig umgesetzt und produktiv. Die zuvor hier dokumentierte Lücke bei +NOTIF-001 war veraltet — E-Mail-Versand bei Fehlbestand-Entstehung existiert bereits +seit Sprint 6 (`app/services/benachrichtigung.py`). Diese Epic-Datei existierte bislang +nicht — reine Nachdokumentation, keine offene Lücke mehr. ## Referenzen Alte Arbeitskarten (übernommen, Karten-Dateien gelöscht): Karte 05 Benachrichtigungen, diff --git a/backend/app/api/v1/endpoints/lager.py b/backend/app/api/v1/endpoints/lager.py index d4bb276..2d37b8a 100644 --- a/backend/app/api/v1/endpoints/lager.py +++ b/backend/app/api/v1/endpoints/lager.py @@ -17,8 +17,15 @@ from app.schemas.lager import ( LagerplatzCreate, LagerplatzRead, MaterialbewegungRead, + UmlagernRequest, +) +from app.services.lager import ( + GleicherLagerplatzError, + NichtGenugBestandError, + auslagern, + einlagern, + umlagern, ) -from app.services.lager import NichtGenugBestandError, auslagern, einlagern 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 +@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) -------------------------------------------------- @router.get("/materialbewegungen", response_model=list[MaterialbewegungRead]) diff --git a/backend/app/schemas/lager.py b/backend/app/schemas/lager.py index 22957dd..983d58f 100644 --- a/backend/app/schemas/lager.py +++ b/backend/app/schemas/lager.py @@ -55,6 +55,13 @@ class EinAuslagernRequest(BaseModel): grund: str | None = None +class UmlagernRequest(BaseModel): + nach_lagerplatz_id: int + material_id: int + menge: Decimal + grund: str | None = None + + class MaterialbewegungRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID diff --git a/backend/app/services/lager.py b/backend/app/services/lager.py index a8fd177..847fc5a 100644 --- a/backend/app/services/lager.py +++ b/backend/app/services/lager.py @@ -42,6 +42,51 @@ async def einlagern( 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( db: AsyncSession, *, lagerplatz_id: int, material_id: int, menge: Decimal, benutzer_id: int, grund: str | None ) -> Materialbewegung: diff --git a/backend/tests/test_lager.py b/backend/tests/test_lager.py index 3b62734..7cfd332 100644 --- a/backend/tests/test_lager.py +++ b/backend/tests/test_lager.py @@ -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) ) 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