feat(zustaendigkeit): ZUST-004 Runde 5 - Lagerort mit echtem Standort verknüpft
Lagerort.standort_id (Migration 0060, nullable, nur an typ=standort-Knoten fachlich sinnvoll) löst die bisherige Blockade auf: standort_fuer_lagerort()/ standort_fuer_lagerplatz() laufen die parent_id-Kette hoch bis zum nächsten Standort-Treffer. Damit jetzt eingeschränkt: GET /lagerplaetze(+bestand), POST einlagern/auslagern/umlagern (Umlagern prüft Quelle UND Ziel). Admin-UI zeigt Standort-Auswahl nur bei typ=standort. Lagerbewegung (Objekt-Ebene, WH-007) bleibt unberührt - anderes Modell, war nie Teil der Blockade. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
1 parent
6f116edd51
commit
9084e6c9d7
11 files changed
+306
-20
No files matched your search
@@ -15991,3 +15991,90 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/admin/ObjekttypSection.tsx | 163 ++++++++++++++++++++++++++++++++++++++++--------------------------------
|
||||
|
||||
---
|
||||
## 2026-09-14 23:04 – 23:07 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
- 6c05d45 feat(zustaendigkeit): ZUST-004 Runde 4 - Historie und Dashboard-Aggregate
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 24 +++++++++++++++++++-----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 21 +++++++++++++++------
|
||||
- backend/app/api/v1/endpoints/historie.py | 11 +++++++++--
|
||||
- backend/app/services/dashboard.py | 43 +++++++++++++++++++++++++------------------
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-14 23:11 – 23:11 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 24 +++++++++++++++++++-----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 21 +++++++++++++++------
|
||||
- backend/app/api/v1/endpoints/historie.py | 11 +++++++++--
|
||||
- backend/app/services/dashboard.py | 43 +++++++++++++++++++++++++------------------
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-14 23:14 – 23:15 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
- 6f116ed fix(tests): test_historie_standort_eingeschraenkt falsche Rolle
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 11 ++++++++---
|
||||
|
||||
---
|
||||
## 2026-09-14 23:20 – 23:20 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 11 ++++++++---
|
||||
|
||||
---
|
||||
## 2026-09-14 23:29 – 23:29 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 11 ++++++++---
|
||||
|
||||
---
|
||||
## 2026-09-14 23:30 – 23:30 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 11 ++++++++---
|
||||
|
||||
---
|
||||
## 2026-09-14 23:30 – 23:31 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 11 ++++++++---
|
||||
|
||||
---
|
||||
@@ -191,13 +191,9 @@ war die Vorgängerlücke, diese hier ist die zweite gefundene Lücke).
|
||||
Umgesetzt (Runde 3): `GET /wartungsauftraege`, `GET /verleihe`,
|
||||
`GET /verleihe/{id}/uebergabeliste`, `PATCH /objekte/{id}/zaehlerstand`,
|
||||
`GET /objekte/{id}/uebergabeliste` (Aussonderung).
|
||||
**Blockiert, braucht erst eine eigene Entscheidung:** Lagerbewegungen/
|
||||
Lagerplätze - `Lagerort` hat KEINE FK auf die echte `Standort`-Tabelle
|
||||
(eigene, unabhängige Hierarchie mit `standort` nur als Typ-Label, siehe
|
||||
Docstring `app/models/lager.py:Lagerort` - "Objekt/Zustaendigkeit bleiben
|
||||
unverändert an `standort` hängen, Lagerort ist ein zusätzliches, feineres
|
||||
Konzept"). ZUST-004 lässt sich hier nicht 1:1 anwenden, ohne vorher zu
|
||||
klären, wie ein `Lagerort`-Knoten einem echten `Standort` zugeordnet wird.
|
||||
**War blockiert bis Runde 5 (siehe unten):** Lagerbewegungen/Lagerplätze -
|
||||
`Lagerort` hatte KEINE FK auf die echte `Standort`-Tabelle (eigene,
|
||||
unabhängige Hierarchie mit `standort` nur als Typ-Label).
|
||||
Umgesetzt (Runde 4, 2026-09-14): `GET /historie` (nur wenn
|
||||
`entitaet_typ`+`entitaet_id` gesetzt sind - ohne diese Filter ist es eine
|
||||
globale Liste ohne Einzelobjekt-Bezug, bleibt unrestricted). **Nutzer-
|
||||
@@ -212,15 +208,33 @@ war die Vorgängerlücke, diese hier ist die zweite gefundene Lücke).
|
||||
`bevorstehende_ablaufdaten()`, `alle_faelligen_wartungen()` (dashboard-
|
||||
weit), `bevorstehende_qualifikationsablaeufe()` (Personal-bezogen, kein
|
||||
Objekt-/Standort-Konzept) - eigene Follow-up-Runde bei Bedarf, gleiche
|
||||
"kein Big-Bang"-Linie. Lagerbewegungen/Lagerplätze weiterhin blockiert
|
||||
(siehe oben). Tests:
|
||||
"kein Big-Bang"-Linie.
|
||||
**Runde 5 (2026-09-14): Lagerbewegungen/Lagerplätze-Blockade aufgehoben.**
|
||||
`Lagerort` bekommt ein neues, nullable `standort_id`-Feld (Migration
|
||||
`0060_lagerort_standort_id.py`), das NUR fachlich sinnvoll an einem
|
||||
Knoten mit `typ=standort` gesetzt wird (Anwendungslogik, kein DB-
|
||||
Constraint). `app/services/lager.py:standort_fuer_lagerort()`/
|
||||
`standort_fuer_lagerplatz()` lösen für einen beliebigen Lagerort/
|
||||
Lagerplatz den zuständigen Standort über die `parent_id`-Kette auf (erster
|
||||
Vorfahre mit gesetzter `standort_id`, `None` = weiterhin unrestricted -
|
||||
bestehende Lagerorte ohne Zuordnung ändern ihr Verhalten nicht). Umgesetzt:
|
||||
`GET /lagerplaetze` (Filterung je Eintrag statt SQL-Join, da Kettenlänge
|
||||
variabel), `GET /lagerplaetze/{id}/bestand`, `POST .../einlagern`,
|
||||
`POST .../auslagern`, `POST .../umlagern` (BEIDE Lagerplätze geprüft -
|
||||
Standort wechseln braucht Sichtbarkeit an Quelle UND Ziel). Admin-UI:
|
||||
`LagerSection.tsx` zeigt eine Standort-Auswahl nur, wenn beim Anlegen
|
||||
`typ=standort` gewählt ist. `Lagerbewegung` (Objekt-Ebene, WH-007) bleibt
|
||||
unberührt - das ist ein anderes Modell als `Materialbewegung`
|
||||
(Lagerplatz-Ebene), war nie Teil dieser Blockade.
|
||||
Tests:
|
||||
`test_mangel_liste_standort_eingeschraenkt`,
|
||||
`test_kontroll_fortschritt_standort_eingeschraenkt`,
|
||||
`test_wartungsauftraege_liste_standort_eingeschraenkt`,
|
||||
`test_verleih_liste_und_uebergabeliste_standort_eingeschraenkt`,
|
||||
`test_objekt_zaehlerstand_und_aussonderung_uebergabeliste_standort_eingeschraenkt`,
|
||||
`test_historie_standort_eingeschraenkt`,
|
||||
`test_dashboard_einsatzbereitschaft_und_prueftermine_standort_eingeschraenkt`
|
||||
`test_dashboard_einsatzbereitschaft_und_prueftermine_standort_eingeschraenkt`,
|
||||
`test_lagerplaetze_und_bestand_standort_eingeschraenkt`
|
||||
in `backend/tests/test_zust004_standort_sichtbarkeit.py`.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""ZUST-004-Nachtrag: Lagerort mit echtem Standort verknüpfen. Nullable FK
|
||||
nur fachlich sinnvoll an typ=standort-Knoten, Anwendungslogik erzwingt das
|
||||
(kein DB-CHECK, da SQLAlchemy-Enum-Vergleich in einem CHECK umständlich wäre
|
||||
und der Wert ohnehin nur über den Service-Layer gesetzt wird).
|
||||
|
||||
Revision ID: 0060_lagerort_standort_id
|
||||
Revises: 0059_objekttyp_wartung_aktiv
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0060_lagerort_standort_id"
|
||||
down_revision: Union[str, None] = "0059_objekttyp_wartung_aktiv"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE lagerort ADD COLUMN standort_id INTEGER REFERENCES standort(id) ON DELETE SET NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE lagerort DROP COLUMN standort_id")
|
||||
@@ -24,8 +24,10 @@ from app.services.lager import (
|
||||
NichtGenugBestandError,
|
||||
auslagern,
|
||||
einlagern,
|
||||
standort_fuer_lagerplatz,
|
||||
umlagern,
|
||||
)
|
||||
from app.services.standort_sichtbarkeit import pruefe_standort_sichtbar, sichtbare_standort_ids
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -80,13 +82,26 @@ async def aendere_lagerort(
|
||||
|
||||
@router.get("/lagerplaetze", response_model=list[LagerplatzRead])
|
||||
async def liste_lagerplaetze(
|
||||
lagerort_id: int | None = None, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
lagerort_id: int | None = None, db: AsyncSession = Depends(get_db), current_user=Depends(get_current_user)
|
||||
) -> list[Lagerplatz]:
|
||||
"""ZUST-004-Erweiterung: Standort-Auflösung je Lagerplatz (Eltern-Kette,
|
||||
kein direktes Feld) - Filterung nach Laden statt per SQL-Join, da die
|
||||
Kettenlänge variabel ist und Lagerplatz-Listen typischerweise klein sind."""
|
||||
stmt = select(Lagerplatz)
|
||||
if lagerort_id is not None:
|
||||
stmt = stmt.where(Lagerplatz.lagerort_id == lagerort_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
lagerplaetze = list(result.scalars().all())
|
||||
|
||||
sichtbare = await sichtbare_standort_ids(db, current_user.id)
|
||||
if sichtbare is None:
|
||||
return lagerplaetze
|
||||
gefiltert = []
|
||||
for lagerplatz in lagerplaetze:
|
||||
standort_id = await standort_fuer_lagerplatz(db, lagerplatz.id)
|
||||
if standort_id is None or standort_id in sichtbare:
|
||||
gefiltert.append(lagerplatz)
|
||||
return gefiltert
|
||||
|
||||
|
||||
@router.post("/lagerplaetze", response_model=LagerplatzRead, status_code=status.HTTP_201_CREATED)
|
||||
@@ -107,9 +122,11 @@ async def erstelle_lagerplatz(
|
||||
|
||||
@router.get("/lagerplaetze/{lagerplatz_id}/bestand", response_model=list[BestandRead])
|
||||
async def hole_bestand(
|
||||
lagerplatz_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
lagerplatz_id: int, db: AsyncSession = Depends(get_db), current_user=Depends(get_current_user)
|
||||
) -> list[Bestand]:
|
||||
await _get_or_404(db, Lagerplatz, lagerplatz_id, "Lagerplatz nicht gefunden")
|
||||
standort_id = await standort_fuer_lagerplatz(db, lagerplatz_id)
|
||||
await pruefe_standort_sichtbar(db, benutzer_id=current_user.id, standort_id=standort_id)
|
||||
result = await db.execute(select(Bestand).where(Bestand.lagerplatz_id == lagerplatz_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -126,6 +143,8 @@ async def route_einlagern(
|
||||
current_user=Depends(get_current_user),
|
||||
) -> Materialbewegung:
|
||||
await _get_or_404(db, Lagerplatz, lagerplatz_id, "Lagerplatz nicht gefunden")
|
||||
standort_id = await standort_fuer_lagerplatz(db, lagerplatz_id)
|
||||
await pruefe_standort_sichtbar(db, benutzer_id=current_user.id, standort_id=standort_id)
|
||||
return await einlagern(
|
||||
db,
|
||||
lagerplatz_id=lagerplatz_id,
|
||||
@@ -148,6 +167,8 @@ async def route_auslagern(
|
||||
current_user=Depends(get_current_user),
|
||||
) -> Materialbewegung:
|
||||
await _get_or_404(db, Lagerplatz, lagerplatz_id, "Lagerplatz nicht gefunden")
|
||||
standort_id = await standort_fuer_lagerplatz(db, lagerplatz_id)
|
||||
await pruefe_standort_sichtbar(db, benutzer_id=current_user.id, standort_id=standort_id)
|
||||
try:
|
||||
return await auslagern(
|
||||
db,
|
||||
@@ -175,6 +196,10 @@ async def route_umlagern(
|
||||
"""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")
|
||||
von_standort_id = await standort_fuer_lagerplatz(db, lagerplatz_id)
|
||||
await pruefe_standort_sichtbar(db, benutzer_id=current_user.id, standort_id=von_standort_id)
|
||||
nach_standort_id = await standort_fuer_lagerplatz(db, payload.nach_lagerplatz_id)
|
||||
await pruefe_standort_sichtbar(db, benutzer_id=current_user.id, standort_id=nach_standort_id)
|
||||
try:
|
||||
return await umlagern(
|
||||
db,
|
||||
|
||||
@@ -24,7 +24,15 @@ lagerort_typ_pg = PgEnum(LagerortTyp, name="lagerort_typ", create_type=False)
|
||||
class Lagerort(Base):
|
||||
"""WH-001: mehrstufige Hierarchie statt flachem `standort`-Feld (Objekt/
|
||||
Zustaendigkeit bleiben unverändert an `standort` hängen - Lagerort ist ein
|
||||
zusätzliches, feineres Konzept für das Warehouse-Epic, kein Ersatz)."""
|
||||
zusätzliches, feineres Konzept für das Warehouse-Epic, kein Ersatz).
|
||||
|
||||
ZUST-004-Nachtrag (2026-09-14): `standort_id` verknüpft NUR Knoten mit
|
||||
`typ=standort` mit der echten `Standort`-Tabelle (fachlich sinnlos an
|
||||
einem `raum`/`lager`-Knoten - dessen Standort ergibt sich aus dem
|
||||
nächsten `standort`-Vorfahren in der Kette, siehe
|
||||
app/services/lager.py:standort_fuer_lagerort()). Nullable, weil ein
|
||||
`standort`-Knoten ohne Zuordnung weiterhin funktionieren muss
|
||||
(Rückwärtskompatibilität - bestehende Lagerorte haben keine Zuordnung)."""
|
||||
|
||||
__tablename__ = "lagerort"
|
||||
|
||||
@@ -32,6 +40,7 @@ class Lagerort(Base):
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(ForeignKey("lagerort.id", ondelete="SET NULL"))
|
||||
typ: Mapped[LagerortTyp] = mapped_column(lagerort_typ_pg, nullable=False)
|
||||
standort_id: Mapped[int | None] = mapped_column(ForeignKey("standort.id", ondelete="SET NULL"))
|
||||
|
||||
|
||||
class Lagerplatz(Base):
|
||||
|
||||
@@ -11,12 +11,14 @@ class LagerortCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: int | None = None
|
||||
typ: LagerortTyp
|
||||
standort_id: int | None = None
|
||||
|
||||
|
||||
class LagerortUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
parent_id: int | None = None
|
||||
typ: LagerortTyp | None = None
|
||||
standort_id: int | None = None
|
||||
|
||||
|
||||
class LagerortRead(BaseModel):
|
||||
@@ -25,6 +27,7 @@ class LagerortRead(BaseModel):
|
||||
name: str
|
||||
parent_id: int | None
|
||||
typ: LagerortTyp
|
||||
standort_id: int | None
|
||||
|
||||
|
||||
class LagerplatzCreate(BaseModel):
|
||||
|
||||
@@ -4,13 +4,40 @@ from decimal import Decimal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.lager import Bestand, Materialbewegung
|
||||
from app.models.lager import Bestand, Lagerort, Lagerplatz, Materialbewegung
|
||||
|
||||
|
||||
class NichtGenugBestandError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def standort_fuer_lagerort(db: AsyncSession, lagerort_id: int | None) -> int | None:
|
||||
"""ZUST-004-Nachtrag: läuft die `parent_id`-Kette nach oben bis zum
|
||||
nächsten Knoten mit gesetztem `standort_id` (nicht zwingend `typ=
|
||||
standort` - ein Admin könnte die Zuordnung theoretisch an jeder Ebene
|
||||
setzen, das schadet nicht, es zählt einfach der nächste Treffer).
|
||||
`None`, wenn kein Vorfahre eine Zuordnung hat (unrestricted, wie bisher).
|
||||
Feste Obergrenze gegen einen versehentlichen Parent-Zyklus."""
|
||||
aktuelle_id = lagerort_id
|
||||
for _ in range(50):
|
||||
if aktuelle_id is None:
|
||||
return None
|
||||
lagerort = await db.get(Lagerort, aktuelle_id)
|
||||
if lagerort is None:
|
||||
return None
|
||||
if lagerort.standort_id is not None:
|
||||
return lagerort.standort_id
|
||||
aktuelle_id = lagerort.parent_id
|
||||
return None
|
||||
|
||||
|
||||
async def standort_fuer_lagerplatz(db: AsyncSession, lagerplatz_id: int) -> int | None:
|
||||
lagerplatz = await db.get(Lagerplatz, lagerplatz_id)
|
||||
if lagerplatz is None:
|
||||
return None
|
||||
return await standort_fuer_lagerort(db, lagerplatz.lagerort_id)
|
||||
|
||||
|
||||
async def _bestand_zeile(db: AsyncSession, lagerplatz_id: int, material_id: int) -> Bestand | None:
|
||||
result = await db.execute(
|
||||
select(Bestand).where(Bestand.lagerplatz_id == lagerplatz_id, Bestand.material_id == material_id)
|
||||
|
||||
@@ -351,6 +351,63 @@ async def test_dashboard_einsatzbereitschaft_und_prueftermine_standort_eingeschr
|
||||
assert prueftermine.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lagerplaetze_und_bestand_standort_eingeschraenkt(
|
||||
client, admin_user, materialverantwortlicher_user, standort_factory, material_factory
|
||||
):
|
||||
"""ZUST-004 Runde 5: Lagerort mit echtem Standort verknüpft (neues Feld
|
||||
Lagerort.standort_id), Sichtbarkeit löst über die parent_id-Kette auf."""
|
||||
standort_a = await standort_factory("Standort A12")
|
||||
standort_b = await standort_factory("Standort B12")
|
||||
material = await material_factory("Verbandpäckchen ZUST4")
|
||||
admin_token = await login(client, "admin1")
|
||||
|
||||
ort_a = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Wache A12", "typ": "standort", "standort_id": standort_a.id},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
ort_b = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Wache B12", "typ": "standort", "standort_id": standort_b.id},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
# Unterknoten OHNE eigene standort_id - muss die Kette hoch zum Standort-
|
||||
# Knoten auflösen.
|
||||
unterordner_b = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Regal B12", "typ": "lager", "parent_id": ort_b.json()["id"]},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
platz_b = await client.post(
|
||||
"/api/v1/lagerplaetze",
|
||||
json={"lagerort_id": unterordner_b.json()["id"], "code": "ZUST4-PLATZ-B12"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
platz_b_id = platz_b.json()["id"]
|
||||
|
||||
await client.put(
|
||||
f"/api/v1/benutzer/{materialverantwortlicher_user.id}/standorte",
|
||||
json={"standort_ids": [standort_a.id]},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
liste = await client.get("/api/v1/lagerplaetze", headers=auth_header(token))
|
||||
ids = {p["id"] for p in liste.json()}
|
||||
assert platz_b_id not in ids
|
||||
|
||||
bestand_verboten = await client.get(f"/api/v1/lagerplaetze/{platz_b_id}/bestand", headers=auth_header(token))
|
||||
assert bestand_verboten.status_code == 403
|
||||
|
||||
einlagern_verboten = await client.post(
|
||||
f"/api/v1/lagerplaetze/{platz_b_id}/einlagern",
|
||||
json={"material_id": material.id, "menge": "5"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert einlagern_verboten.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standort_zugriff_setzen_nur_admin(client, mitarbeiter_user, mitarbeiter2_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
|
||||
@@ -255,6 +255,7 @@ export interface Lagerort {
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
typ: LagerortTyp;
|
||||
standort_id: number | null;
|
||||
}
|
||||
|
||||
export interface Lagerplatz {
|
||||
|
||||
@@ -349,7 +349,9 @@ export function AdminPage() {
|
||||
{aktiverTab === "lagerbewegungen" && (
|
||||
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
|
||||
)}
|
||||
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
|
||||
{aktiverTab === "lager" && (
|
||||
<LagerSection materialien={materialien} standorte={standorte} onFehler={setFehler} />
|
||||
)}
|
||||
{aktiverTab === "ausgabe" && (
|
||||
<AusgabeSection materialien={materialien} benutzer={benutzer} onFehler={setFehler} />
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest, ApiError } from "../../api/client";
|
||||
import type { Bestand, Lagerort, LagerortTyp, Lagerplatz, Material, Materialbewegung } from "../../api/types";
|
||||
import type { Bestand, Lagerort, LagerortTyp, Lagerplatz, Material, Materialbewegung, Standort } from "../../api/types";
|
||||
import * as lagerbewegungQueue from "../../offline/lagerbewegungQueue";
|
||||
|
||||
interface Props {
|
||||
materialien: Material[];
|
||||
standorte: Standort[];
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
@@ -19,13 +20,19 @@ const TYP_LABEL: Record<LagerortTyp, string> = {
|
||||
|
||||
// WH-001..005+007: Lagerort-Hierarchie, Lagerplätze, Bestand je Lagerplatz,
|
||||
// Ein-/Auslagerung, Umlagerung. Inventur (WH-006) noch offen (arbeitskacheln/07_warehouse.md).
|
||||
export function LagerSection({ materialien, onFehler }: Props) {
|
||||
export function LagerSection({ materialien, standorte, onFehler }: Props) {
|
||||
const [lagerorte, setLagerorte] = useState<Lagerort[]>([]);
|
||||
const [lagerplaetze, setLagerplaetze] = useState<Lagerplatz[]>([]);
|
||||
|
||||
const [neuerOrtName, setNeuerOrtName] = useState("");
|
||||
const [neuerOrtTyp, setNeuerOrtTyp] = useState<LagerortTyp>("lager");
|
||||
const [neuerOrtParent, setNeuerOrtParent] = useState<number | "">("");
|
||||
const [neuerOrtStandortId, setNeuerOrtStandortId] = useState<number | "">("");
|
||||
|
||||
function standortName(id: number | null): string {
|
||||
if (id === null) return "–";
|
||||
return standorte.find((s) => s.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
const [neuerPlatzOrt, setNeuerPlatzOrt] = useState<number | "">("");
|
||||
const [neuerPlatzCode, setNeuerPlatzCode] = useState("");
|
||||
@@ -99,10 +106,16 @@ export function LagerSection({ materialien, onFehler }: Props) {
|
||||
try {
|
||||
await apiRequest("/lagerorte", {
|
||||
method: "POST",
|
||||
body: { name: neuerOrtName, typ: neuerOrtTyp, parent_id: neuerOrtParent === "" ? null : neuerOrtParent },
|
||||
body: {
|
||||
name: neuerOrtName,
|
||||
typ: neuerOrtTyp,
|
||||
parent_id: neuerOrtParent === "" ? null : neuerOrtParent,
|
||||
standort_id: neuerOrtTyp === "standort" && neuerOrtStandortId !== "" ? neuerOrtStandortId : null,
|
||||
},
|
||||
});
|
||||
setNeuerOrtName("");
|
||||
setNeuerOrtParent("");
|
||||
setNeuerOrtStandortId("");
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Lagerort konnte nicht angelegt werden.");
|
||||
@@ -223,6 +236,21 @@ export function LagerSection({ materialien, onFehler }: Props) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{neuerOrtTyp === "standort" && (
|
||||
<select
|
||||
className="input"
|
||||
value={neuerOrtStandortId}
|
||||
onChange={(e) => setNeuerOrtStandortId(Number(e.target.value) || "")}
|
||||
title="Verknüpfung mit echtem Standort (steuert Sichtbarkeit, ZUST-004)"
|
||||
>
|
||||
<option value="">Kein Standort verknüpft</option>
|
||||
{standorte.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
className="input"
|
||||
value={neuerOrtParent}
|
||||
@@ -242,7 +270,12 @@ export function LagerSection({ materialien, onFehler }: Props) {
|
||||
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
|
||||
{lagerorte.map((o) => (
|
||||
<li key={o.id}>
|
||||
{o.name} <span className="text-muted">({TYP_LABEL[o.typ]}{o.parent_id !== null ? `, unter ${ortName(o.parent_id)}` : ""})</span>
|
||||
{o.name}{" "}
|
||||
<span className="text-muted">
|
||||
({TYP_LABEL[o.typ]}
|
||||
{o.parent_id !== null ? `, unter ${ortName(o.parent_id)}` : ""}
|
||||
{o.standort_id !== null ? `, Standort: ${standortName(o.standort_id)}` : ""})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in new issue
Block a user