feat(warehouse): WH-006 Inventur & Bestandskorrektur
Inventurlauf je Lagerort (inkl. Unter-Lagerorte), Sollmenge wird beim Start aus Bestand gesnapshotted, Abweichung erzeugt beim Abschluss automatisch Korrekturbuchung + Audit-Log statt stiller Anpassung. Teil-Inventur erlaubt (ungezählte Positionen blockieren nicht). Backend-Grundgerüst von fastapi-expert-Subagent, Review/Frontend/ Tests-Ergänzung/Doku durch Hauptagent. Außerdem: 00_index.md Status-Abgleich (FLEET-006, ASSET-011/012, PERS-008, INSP-001..004 waren bereits umgesetzt, aber im Index noch als offen markiert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
bc1542a5e9
commit
11e6ed4332
13 files changed
+1064
-13
No files matched your search
@@ -165,14 +165,14 @@ vergeben statt geraten.
|
||||
| ASSET-008 | Assets | Consumable | P1 | M | MEDIUM | ASSET-002 | ✅ (`MaterialTyp.standard`/`ablauf_charge` IST das Consumable-Konzept, kein separates Modell nötig) |
|
||||
| ASSET-009 | Assets | Nutzungszähler (Kilometer/Betriebsstunden generisch) | P1 | S | LOW | ASSET-003 | ✅ (`fahrzeugdetails.py` Kilometerstand, MAINT-002 nutzt es) |
|
||||
| ASSET-010 | Assets | Custody/Ausgabe an GeraetInstanz (neu, Referenz: Shelf.nu) | P2 | M | MEDIUM | ASSET-003, PERS-001, DOC-001 | ✅ (`geraet_instanz_custody`+`signier_token`, QR-Signier-Flow, `app/services/custody.py`) |
|
||||
| ASSET-011 | Assets | Externer Verleih mit Rückgabe-Tracking (neu, Referenz: HiOrg-Server) | P2 | M | MEDIUM | ASSET-003, ASSET-010 | ⬜ geplant 2026-09-10 |
|
||||
| ASSET-012 | Assets | Ressourcen-Buchungskalender (neu, Referenz: HiOrg-Server) | P2 | M | MEDIUM | ASSET-003 | ⬜ geplant 2026-09-10 |
|
||||
| ASSET-011 | Assets | Externer Verleih mit Rückgabe-Tracking (neu, Referenz: HiOrg-Server) | P2 | M | MEDIUM | ASSET-003, ASSET-010 | ✅ erledigt 2026-09-10 |
|
||||
| ASSET-012 | Assets | Ressourcen-Buchungskalender (neu, Referenz: HiOrg-Server) | P2 | M | MEDIUM | ASSET-003 | ✅ erledigt 2026-09-10 |
|
||||
| FLEET-001 | Fleet | Fahrzeugtypen | P1 | XS | LOW | ASSET-002 | ✅ (Objekttyp deckt Fahrzeugtypen ab) |
|
||||
| FLEET-002 | Fleet | Fahrzeuge (Kennzeichen/Funkrufname) | P1 | S | LOW | ASSET-003, FLEET-001 | ✅ (`fahrzeugdetails.py`: Kennzeichen, Funkrufname, OPTA/Funkkennung) |
|
||||
| FLEET-003 | Fleet | Kilometerstand (Anwendung von ASSET-009) | P1 | XS | LOW | FLEET-002, ASSET-009 | ✅ (Kilometerstand-Feld + Wartungsintervall-Kopplung) |
|
||||
| FLEET-004 | Fleet | Fahrzeugstatus (5-stufig) | P1 | S | LOW | FLEET-002, READY-002 | ✅ (ObjektStatus 3 manuelle + Readiness-Badge 4 berechnete Zustände, getrennt statt einer Enum — Kriterium "klar getrennt" damit sogar besser erfüllt) |
|
||||
| FLEET-005 | Fleet | Fahrzeug-Dokumente | P2 | XS | LOW | FLEET-002, DOC-001 | ✅ (`Dokument` polymorph, `entitaet_typ` deckt Fahrzeug/Objekt ab) |
|
||||
| FLEET-006 | Fleet | Fahrzeug-Einsatzhistorie (Platzhalter) | P3 | S | MEDIUM | FLEET-002, OPS-002 | ⬜ (kein Einsatz-Modell vorhanden, Operations-Epic ist Platzhalter) |
|
||||
| FLEET-006 | Fleet | Fahrzeug-Einsatzhistorie (Platzhalter) | P3 | S | MEDIUM | FLEET-002, OPS-002 | ✅ erledigt 2026-09-10 (mit Epic 16/Einsatz umgesetzt) |
|
||||
| INV-001 | Inventory | Materialarten/Kategorien | P1 | XS | LOW | ASSET-001 | ✅ (`MaterialTyp`, `Kategorie` in `stammdaten.py`) |
|
||||
| INV-002 | Inventory | Einzelgeräte (SN-pflichtig) | P1 | S | LOW | ASSET-003, IDENT-004 | ✅ (`geraet_instanz.py`) |
|
||||
| INV-003 | Inventory | Verbrauchsmaterial (Mengen) | P1 | S | LOW | ASSET-008 | ✅ (Objektposition Soll-/Istmenge, `Material`) |
|
||||
@@ -185,7 +185,7 @@ vergeben statt geraten.
|
||||
| WH-003 | Warehouse | Bestand je Lagerplatz | P1 | M | MEDIUM | WH-002, INV-003 | ✅ (`Bestand`-Modell referenziert WH-003) |
|
||||
| WH-004 | Warehouse | Einlagerung/Auslagerung | P1 | M | MEDIUM | WH-003 | ✅ (`services/lager.py` Ein-/Auslagerungsfunktionen referenzieren WH-004) |
|
||||
| WH-005 | Warehouse | Umlagerung | P1 | S | LOW | WH-004 | ✅ (`/lagerplaetze/.../umlagerung`-Endpunkt referenziert WH-005) |
|
||||
| WH-006 | Warehouse | Inventur & Bestandskorrektur | P2 | M | MEDIUM | WH-003 | 🔶 (Bestandskorrektur über Ein-/Auslagerung möglich, kein eigener Inventur-Workflow/Zählliste gefunden; Referenz: InvenTree Rolling-Stocktake als optionale Ergänzung) |
|
||||
| WH-006 | Warehouse | Inventur & Bestandskorrektur | P2 | M | MEDIUM | WH-003 | ✅ erledigt 2026-09-10 |
|
||||
| WH-007 | Warehouse | Materialbewegungsprotokoll | P1 | S | LOW | WH-004, FOUND-006 | ✅ (`Materialbewegung`, `lagerbewegung.py`, referenziert WH-007) |
|
||||
| LOAD-001 | Loadout | Soll-Beladung/Beladungsplan | P1 | M | MEDIUM | ASSET-003, INV-002/003 | ✅ (`vorlage.py` Beladungsvorlage, `BeladungsplanerBoard.tsx`) |
|
||||
| LOAD-002 | Loadout | Ist-Beladung | P1 | S | LOW | LOAD-001 | ✅ (Objektposition-Istmenge, `GeraeteInstanzenListe.tsx`) |
|
||||
@@ -194,10 +194,10 @@ vergeben statt geraten.
|
||||
| LOAD-005 | Loadout | Kontrollhistorie | P1 | S | LOW | LOAD-004, FOUND-006 | ✅ (`Kontrolle`-Modell append-only, Historie-Anbindung) |
|
||||
| LOAD-006 | Loadout | Readiness-Kopplung | P1 | S | MEDIUM | LOAD-003, READY-001 | ✅ (Fehlbestand fließt in `einsatzbereitschaft()`-Berechnung ein) |
|
||||
| LOAD-007 | Loadout | Gemeinsame Sollmenge über mehrere Standorte (neu, Referenz: HiOrg-Server) | P3 | M | MEDIUM | LOAD-002, WH-004 | ✅ erledigt 2026-09-10 |
|
||||
| INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | ⬜ (kein eigenständiges Prüfart-Stammdatenmodell gefunden) |
|
||||
| INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 (`pruefintervall_monate` an Objektposition, aber nicht generisch je Prüfart) |
|
||||
| INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | 🔶 (Statuswechsel an Geräteinstanz inkl. `naechste_pruefung`, kein separates Prüfergebnis-Protokoll) |
|
||||
| INSP-004 | Inspections | Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) | P1 | S | LOW | INSP-003 | 🔶 (Prüftermine im Dashboard, aber keine 4-stufige Ampel-Enum gefunden) |
|
||||
| INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`pruefart`-Tabelle, Dual-Write; Phase 2 Lesepfad-Umstellung/Frontend offen) |
|
||||
| INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`objekttyp_pruefart`; Berechnung nutzt weiterhin Phase-1-Altpfad) |
|
||||
| INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`pruefung`-Tabelle mit Ergebnis/Prüfer, Dual-Write) |
|
||||
| INSP-004 | Inspections | Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) | P1 | S | LOW | INSP-003 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (FAILED-Zustand ergänzt in `pruefung.py`; kein Frontend) |
|
||||
| INSP-005 | Inspections | Prüfdokument-Anbindung | P2 | XS | LOW | INSP-003, DOC-001 | ✅ (Dokument polymorph, `entitaet_typ` deckt Geräteinstanz ab) |
|
||||
| INSP-006 | Inspections | Prüftermin-Übersicht | P1 | S | LOW | INSP-004 | ✅ (`PrueftermineTile.tsx`, `/dashboard/prueftermine`) |
|
||||
| MAINT-001 | Maintenance | Wartungspläne | P2 | S | LOW | ASSET-002 | ✅ (`Wartungsplan`, Migration 0022 referenziert MAINT-001) |
|
||||
@@ -220,7 +220,7 @@ vergeben statt geraten.
|
||||
| PERS-005 | Personnel | Qualifikationen/Lehrgänge | P1 | S | LOW | PERS-001 | ✅ (`Qualifikationstyp`, `BenutzerQualifikation`) |
|
||||
| PERS-006 | Personnel | Führerscheine/Berechtigungen mit Gültigkeit | P1 | M | MEDIUM | PERS-005, ASSET-002 | ✅ (`Qualifikationskategorie.fuehrerschein`, `ObjekttypQualifikationsanforderung`) |
|
||||
| PERS-007 | Personnel | Qualifikationsablauf-Warnungen | P1 | S | LOW | PERS-006 | ✅ (`QualifikationTile.tsx`, `/dashboard/qualifikationsablaeufe`) |
|
||||
| PERS-008 | Personnel | Einweisungspflicht vor Geräteausgabe (neu, Referenz: HiOrg-Server) | P2 | S | LOW | PERS-005, PERS-006, ASSET-010 | ⬜ geplant 2026-09-10 |
|
||||
| PERS-008 | Personnel | Einweisungspflicht vor Geräteausgabe (neu, Referenz: HiOrg-Server) | P2 | S | LOW | PERS-005, PERS-006, ASSET-010 | ✅ erledigt 2026-09-10 |
|
||||
| READY-001 | Readiness | Regel-Engine (Grundgerüst) | P1 | M | HIGH | ASSET-004 | ✅ (`readiness_regel`-Tabelle, sieben Kriterien an/abschaltbar, keine volle Gewichtungs-Engine — bewusst reduzierter Scope) |
|
||||
| READY-002 | Readiness | Zustände READY/LIMITED/NOT_READY/UNKNOWN | P1 | S | MEDIUM | READY-001 | ✅ (bereit/eingeschränkt/unbekannt/nicht_einsatzbereit in `services/dashboard.py`, laut DECISION-Abschnitt am 2026-09-05 live gefixt) |
|
||||
| READY-003 | Readiness | Regel-Kopplung (Prüfung/Wartung/Mangel/Beladung) | P1 | L | HIGH | READY-002, INSP-004, DEFECT-005, LOAD-006 | ✅ (`einsatzbereitschaft()` zieht Fehlbestand/Mangel/Gerätestatus zusammen) |
|
||||
|
||||
@@ -133,9 +133,20 @@ Details zu WH-001 … WH-007 (vollständig).
|
||||
- **Akzeptanzkriterien:** Abweichung erzeugt nachvollziehbare Korrekturbuchung, keine
|
||||
stille Anpassung ohne Log.
|
||||
- **Tests:** Inventur-Zyklus-Test mit Abweichung.
|
||||
- **DoD:** ein vollständiger Inventurzyklus durchgeführt. **Lücke:** MABEA hat kein
|
||||
Inventur-Konzept, nur Kontrolle (Soll/Ist je Objekt, nicht als eigener Lagerlauf mit
|
||||
Korrekturbuchung).
|
||||
- **DoD:** ✅ erledigt (2026-09-10). `inventur`/`inventur_position`-Tabellen
|
||||
(Migration `0052_inventur.py`), `soll_menge` wird beim Start einmalig aus
|
||||
`Bestand.menge` gesnapshotted (kein Live-Nachzug). Inventur deckt den
|
||||
Lagerort UND alle darunterliegenden Lagerorte/Lagerplätze ab (Hierarchie-
|
||||
Traversierung über `Lagerort.parent_id`). Abweichung bei Abschluss erzeugt
|
||||
automatisch eine `Materialbewegung` (`grund="inventur_korrektur"`) +
|
||||
Audit-Log-Eintrag — keine stille Anpassung. Ungezählte Positionen blockieren
|
||||
den Abschluss nicht (Teil-Inventur erlaubt). Endpunkte: `POST /inventuren`,
|
||||
`GET /inventuren(/{id})`, `PATCH .../positionen/{id}`,
|
||||
`POST .../abschliessen`, `POST .../abbrechen`. Rechte: Start/Abschluss/
|
||||
Abbruch = Materialverantwortliche+, Erfassung = Mitarbeiter+. Frontend:
|
||||
Admin-Tab „Inventur". Tests: `test_wh006_inventur.py` (Snapshot, Doppel-
|
||||
Start-409, Korrektur, keine Korrektur bei Übereinstimmung, Teil-Inventur,
|
||||
Abbruch ändert nichts, Rollen-Gate).
|
||||
- **Referenz (Open-Source-Vergleich 2026-09-09):** InvenTree (`inventree/InvenTree`) hat
|
||||
als Alternative zur Stichtags-Vollinventur ein „Rolling Stocktake"-Plugin — statt
|
||||
allem auf einmal wird laufend ein rotierender Teilbestand geprüft (Dashboard zeigt
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""WH-006: Inventur & Bestandskorrektur.
|
||||
|
||||
Revision ID: 0052_inventur
|
||||
Revises: 0051_sollmengen_gruppe
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0052_inventur"
|
||||
down_revision: Union[str, None] = "0051_sollmengen_gruppe"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE TYPE inventur_status AS ENUM ('laufend', 'abgeschlossen', 'abgebrochen')")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE inventur (
|
||||
id UUID PRIMARY KEY,
|
||||
lagerort_id INTEGER NOT NULL REFERENCES lagerort(id),
|
||||
gestartet_von INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
gestartet_am TIMESTAMPTZ NOT NULL,
|
||||
status inventur_status NOT NULL DEFAULT 'laufend',
|
||||
abgeschlossen_am TIMESTAMPTZ,
|
||||
abgeschlossen_von INTEGER REFERENCES benutzer(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE inventur_position (
|
||||
id UUID PRIMARY KEY,
|
||||
inventur_id UUID NOT NULL REFERENCES inventur(id),
|
||||
lagerplatz_id INTEGER NOT NULL REFERENCES lagerplatz(id),
|
||||
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||
soll_menge NUMERIC NOT NULL,
|
||||
ist_menge NUMERIC,
|
||||
erfasst_am TIMESTAMPTZ,
|
||||
erfasst_von INTEGER REFERENCES benutzer(id),
|
||||
CONSTRAINT inventur_position_uq UNIQUE (inventur_id, lagerplatz_id, material_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_inventur_lagerort_status ON inventur (lagerort_id, status)")
|
||||
op.execute("CREATE INDEX ix_inventur_position_inventur ON inventur_position (inventur_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX ix_inventur_position_inventur")
|
||||
op.execute("DROP INDEX ix_inventur_lagerort_status")
|
||||
op.execute("DROP TABLE inventur_position")
|
||||
op.execute("DROP TABLE inventur")
|
||||
op.execute("DROP TYPE inventur_status")
|
||||
@@ -15,6 +15,7 @@ from app.api.v1.endpoints import (
|
||||
health,
|
||||
historie,
|
||||
import_export,
|
||||
inventur,
|
||||
kontrollen,
|
||||
lager,
|
||||
lagerbewegung,
|
||||
@@ -80,3 +81,4 @@ api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"]
|
||||
api_router.include_router(readiness_regel.router, tags=["readiness_regel"])
|
||||
api_router.include_router(einsatz.router, tags=["einsatz"])
|
||||
api_router.include_router(sollmengen_gruppe.router, tags=["sollmengen_gruppe"])
|
||||
api_router.include_router(inventur.router, tags=["inventur"])
|
||||
@@ -0,0 +1,180 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
|
||||
from app.models.inventur import Inventur, InventurPosition, InventurStatus
|
||||
from app.schemas.inventur import (
|
||||
InventurAbschlussErgebnis,
|
||||
InventurCreate,
|
||||
InventurPositionErfassen,
|
||||
InventurPositionRead,
|
||||
InventurRead,
|
||||
)
|
||||
from app.services.inventur import (
|
||||
InventurBereitsLaufendError,
|
||||
InventurNichtLaufendError,
|
||||
breche_inventur_ab,
|
||||
erfasse_position,
|
||||
schliesse_inventur,
|
||||
starte_inventur,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_mitarbeiter_plus = require_roles(
|
||||
RolleTyp.mitarbeiter,
|
||||
RolleTyp.materialverantwortlicher,
|
||||
RolleTyp.leitungsverantwortlicher,
|
||||
RolleTyp.administration,
|
||||
)
|
||||
_verantwortliche = require_roles(
|
||||
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
|
||||
)
|
||||
|
||||
|
||||
async def _hauptserver_id(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _lade_positionen(db: AsyncSession, inventur_id: uuid.UUID) -> list[InventurPosition]:
|
||||
result = await db.execute(
|
||||
select(InventurPosition).where(InventurPosition.inventur_id == inventur_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _zu_read(db: AsyncSession, inventur: Inventur) -> InventurRead:
|
||||
positionen = await _lade_positionen(db, inventur.id)
|
||||
daten = InventurRead.model_validate(inventur).model_dump()
|
||||
daten["positionen"] = [InventurPositionRead.model_validate(p) for p in positionen]
|
||||
return InventurRead(**daten)
|
||||
|
||||
|
||||
async def _get_inventur_or_404(db: AsyncSession, inventur_id: uuid.UUID) -> Inventur:
|
||||
inventur = await db.get(Inventur, inventur_id)
|
||||
if inventur is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Inventur nicht gefunden")
|
||||
return inventur
|
||||
|
||||
|
||||
@router.post("/inventuren", response_model=InventurRead, status_code=status.HTTP_201_CREATED)
|
||||
async def erstelle_inventur(
|
||||
payload: InventurCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_verantwortliche),
|
||||
) -> InventurRead:
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
try:
|
||||
inventur = await starte_inventur(
|
||||
db,
|
||||
lagerort_id=payload.lagerort_id,
|
||||
benutzer_id=current_user.id,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
except InventurBereitsLaufendError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Für diesen Lagerort läuft bereits eine Inventur",
|
||||
) from exc
|
||||
return await _zu_read(db, inventur)
|
||||
|
||||
|
||||
@router.get("/inventuren", response_model=list[InventurRead])
|
||||
async def liste_inventuren(
|
||||
lagerort_id: int | None = None,
|
||||
status_filter: InventurStatus | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_mitarbeiter_plus),
|
||||
) -> list[InventurRead]:
|
||||
stmt = select(Inventur)
|
||||
if lagerort_id is not None:
|
||||
stmt = stmt.where(Inventur.lagerort_id == lagerort_id)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Inventur.status == status_filter)
|
||||
stmt = stmt.order_by(Inventur.gestartet_am.desc())
|
||||
result = await db.execute(stmt)
|
||||
inventuren = list(result.scalars().all())
|
||||
return [await _zu_read(db, i) for i in inventuren]
|
||||
|
||||
|
||||
@router.get("/inventuren/{inventur_id}", response_model=InventurRead)
|
||||
async def hole_inventur(
|
||||
inventur_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_mitarbeiter_plus)
|
||||
) -> InventurRead:
|
||||
inventur = await _get_inventur_or_404(db, inventur_id)
|
||||
return await _zu_read(db, inventur)
|
||||
|
||||
|
||||
@router.patch("/inventuren/{inventur_id}/positionen/{position_id}", response_model=InventurPositionRead)
|
||||
async def erfasse_inventur_position(
|
||||
inventur_id: uuid.UUID,
|
||||
position_id: uuid.UUID,
|
||||
payload: InventurPositionErfassen,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_mitarbeiter_plus),
|
||||
) -> InventurPosition:
|
||||
inventur = await _get_inventur_or_404(db, inventur_id)
|
||||
position = await db.get(InventurPosition, position_id)
|
||||
if position is None or position.inventur_id != inventur_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Inventurposition nicht gefunden")
|
||||
|
||||
try:
|
||||
return await erfasse_position(
|
||||
db,
|
||||
inventur=inventur,
|
||||
position=position,
|
||||
ist_menge=payload.ist_menge,
|
||||
benutzer_id=current_user.id,
|
||||
)
|
||||
except InventurNichtLaufendError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Inventur ist nicht mehr laufend"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/inventuren/{inventur_id}/abschliessen", response_model=InventurAbschlussErgebnis)
|
||||
async def schliesse_inventur_ab(
|
||||
inventur_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_verantwortliche),
|
||||
) -> InventurAbschlussErgebnis:
|
||||
inventur = await _get_inventur_or_404(db, inventur_id)
|
||||
if inventur.status != InventurStatus.laufend:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Inventur ist nicht mehr laufend")
|
||||
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
inventur, anzahl_korrekturen, anzahl_nicht_gezaehlt = await schliesse_inventur(
|
||||
db,
|
||||
inventur=inventur,
|
||||
benutzer_id=current_user.id,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
inventur_read = await _zu_read(db, inventur)
|
||||
return InventurAbschlussErgebnis(
|
||||
inventur=inventur_read,
|
||||
anzahl_korrekturen=anzahl_korrekturen,
|
||||
anzahl_nicht_gezaehlt=anzahl_nicht_gezaehlt,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/inventuren/{inventur_id}/abbrechen", response_model=InventurRead)
|
||||
async def breche_inventur_ab_route(
|
||||
inventur_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_verantwortliche),
|
||||
) -> InventurRead:
|
||||
inventur = await _get_inventur_or_404(db, inventur_id)
|
||||
if inventur.status != InventurStatus.laufend:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Inventur ist nicht mehr laufend")
|
||||
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
inventur = await breche_inventur_ab(
|
||||
db, inventur=inventur, benutzer_id=current_user.id, zustaendiger_server_id=zustaendiger_server_id
|
||||
)
|
||||
return await _zu_read(db, inventur)
|
||||
@@ -8,6 +8,7 @@ from app.models.fahrzeugdetails import Fahrzeugdetails
|
||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
|
||||
from app.models.historie import Historie
|
||||
from app.models.inventur import Inventur, InventurPosition, InventurStatus
|
||||
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
|
||||
from app.models.lager import Bestand, Lagerort, LagerortTyp, Lagerplatz, Materialbewegung
|
||||
from app.models.lagerbewegung import Lagerbewegung
|
||||
@@ -68,6 +69,9 @@ __all__ = [
|
||||
"GeraetInstanz",
|
||||
"GeraetStatus",
|
||||
"Historie",
|
||||
"Inventur",
|
||||
"InventurPosition",
|
||||
"InventurStatus",
|
||||
"Kontrolle",
|
||||
"KontrollStatus",
|
||||
"Kontrollposition",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import ForeignKey, Numeric, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InventurStatus(str, enum.Enum):
|
||||
laufend = "laufend"
|
||||
abgeschlossen = "abgeschlossen"
|
||||
abgebrochen = "abgebrochen"
|
||||
|
||||
|
||||
inventur_status_pg = PgEnum(InventurStatus, name="inventur_status", create_type=False)
|
||||
|
||||
|
||||
class Inventur(Base):
|
||||
"""WH-006: periodischer Inventurlauf je Lagerort (inkl. aller darunterliegenden
|
||||
Lagerplätze). `InventurPosition.soll_menge` wird beim Start einmalig aus
|
||||
`Bestand.menge` gesnapshotted, nicht live nachgezogen (analog Fehlbestand.sollmenge)."""
|
||||
|
||||
__tablename__ = "inventur"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
lagerort_id: Mapped[int] = mapped_column(ForeignKey("lagerort.id"), nullable=False)
|
||||
gestartet_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||
gestartet_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
status: Mapped[InventurStatus] = mapped_column(
|
||||
inventur_status_pg, nullable=False, default=InventurStatus.laufend
|
||||
)
|
||||
abgeschlossen_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
abgeschlossen_von: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
|
||||
|
||||
|
||||
class InventurPosition(Base):
|
||||
"""WH-006: eine Zeile je (Lagerplatz, Material), die bei Inventur-Start aus
|
||||
dem aktuellen `Bestand` übernommen wurde. `ist_menge` bleibt NULL bis zur
|
||||
Zählung - eine ungezählte Position blockiert den Abschluss der Inventur nicht."""
|
||||
|
||||
__tablename__ = "inventur_position"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("inventur_id", "lagerplatz_id", "material_id", name="inventur_position_uq"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
inventur_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("inventur.id"), nullable=False)
|
||||
lagerplatz_id: Mapped[int] = mapped_column(ForeignKey("lagerplatz.id"), nullable=False)
|
||||
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
|
||||
soll_menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
|
||||
ist_menge: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
erfasst_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
erfasst_von: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
|
||||
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.inventur import InventurStatus
|
||||
|
||||
|
||||
class InventurCreate(BaseModel):
|
||||
lagerort_id: int
|
||||
|
||||
|
||||
class InventurPositionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
inventur_id: uuid.UUID
|
||||
lagerplatz_id: int
|
||||
material_id: int
|
||||
soll_menge: Decimal
|
||||
ist_menge: Decimal | None
|
||||
erfasst_am: datetime | None
|
||||
erfasst_von: int | None
|
||||
|
||||
|
||||
class InventurRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
lagerort_id: int
|
||||
gestartet_von: int
|
||||
gestartet_am: datetime
|
||||
status: InventurStatus
|
||||
abgeschlossen_am: datetime | None
|
||||
abgeschlossen_von: int | None
|
||||
positionen: list[InventurPositionRead] = []
|
||||
|
||||
|
||||
class InventurPositionErfassen(BaseModel):
|
||||
ist_menge: Decimal
|
||||
|
||||
|
||||
class InventurAbschlussErgebnis(BaseModel):
|
||||
inventur: InventurRead
|
||||
anzahl_korrekturen: int
|
||||
anzahl_nicht_gezaehlt: int
|
||||
@@ -0,0 +1,209 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.lager import Bestand, Lagerort, Lagerplatz, Materialbewegung
|
||||
from app.models.inventur import Inventur, InventurPosition, InventurStatus
|
||||
from app.services import historie as historie_service
|
||||
|
||||
|
||||
class InventurBereitsLaufendError(Exception):
|
||||
"""Für denselben Lagerort läuft bereits eine Inventur mit status=laufend."""
|
||||
|
||||
|
||||
class InventurNichtLaufendError(Exception):
|
||||
"""Positionen dürfen nur erfasst werden, solange die Inventur noch läuft."""
|
||||
|
||||
|
||||
async def _lagerort_ids_unterhalb(db: AsyncSession, lagerort_id: int) -> list[int]:
|
||||
"""Ermittelt den übergebenen Lagerort plus alle Lagerorte, deren
|
||||
`parent_id`-Kette (rekursiv) zu ihm führt - iterative Traversierung analog
|
||||
zur Baumstruktur aus `app/models/lager.py` (keine bestehende Hilfsfunktion
|
||||
dafür gefunden, `Lagerort.parent_id` ist selbstreferenzierend)."""
|
||||
result = await db.execute(select(Lagerort.id, Lagerort.parent_id))
|
||||
alle = list(result.all())
|
||||
kinder_map: dict[int, list[int]] = {}
|
||||
for id_, parent_id in alle:
|
||||
if parent_id is not None:
|
||||
kinder_map.setdefault(parent_id, []).append(id_)
|
||||
|
||||
gefundene_ids = [lagerort_id]
|
||||
warteschlange = [lagerort_id]
|
||||
while warteschlange:
|
||||
aktuell = warteschlange.pop()
|
||||
for kind_id in kinder_map.get(aktuell, []):
|
||||
gefundene_ids.append(kind_id)
|
||||
warteschlange.append(kind_id)
|
||||
return gefundene_ids
|
||||
|
||||
|
||||
async def starte_inventur(
|
||||
db: AsyncSession, *, lagerort_id: int, benutzer_id: int, zustaendiger_server_id: int
|
||||
) -> Inventur:
|
||||
vorhandene = await db.execute(
|
||||
select(Inventur).where(
|
||||
Inventur.lagerort_id == lagerort_id, Inventur.status == InventurStatus.laufend
|
||||
)
|
||||
)
|
||||
if vorhandene.scalar_one_or_none() is not None:
|
||||
raise InventurBereitsLaufendError()
|
||||
|
||||
lagerort_ids = await _lagerort_ids_unterhalb(db, lagerort_id)
|
||||
|
||||
lagerplaetze_result = await db.execute(
|
||||
select(Lagerplatz.id).where(Lagerplatz.lagerort_id.in_(lagerort_ids))
|
||||
)
|
||||
lagerplatz_ids = [row[0] for row in lagerplaetze_result.all()]
|
||||
|
||||
inventur = Inventur(
|
||||
lagerort_id=lagerort_id,
|
||||
gestartet_von=benutzer_id,
|
||||
gestartet_am=datetime.now(timezone.utc),
|
||||
status=InventurStatus.laufend,
|
||||
)
|
||||
db.add(inventur)
|
||||
await db.flush()
|
||||
|
||||
anzahl_positionen = 0
|
||||
if lagerplatz_ids:
|
||||
bestaende_result = await db.execute(
|
||||
select(Bestand).where(Bestand.lagerplatz_id.in_(lagerplatz_ids))
|
||||
)
|
||||
for bestand in bestaende_result.scalars().all():
|
||||
db.add(
|
||||
InventurPosition(
|
||||
inventur_id=inventur.id,
|
||||
lagerplatz_id=bestand.lagerplatz_id,
|
||||
material_id=bestand.material_id,
|
||||
soll_menge=bestand.menge,
|
||||
)
|
||||
)
|
||||
anzahl_positionen += 1
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="inventur_gestartet",
|
||||
entitaet_typ="inventur",
|
||||
entitaet_id=inventur.id,
|
||||
neuer_wert={"lagerort_id": lagerort_id, "anzahl_positionen": anzahl_positionen},
|
||||
)
|
||||
return inventur
|
||||
|
||||
|
||||
async def erfasse_position(
|
||||
db: AsyncSession, *, inventur: Inventur, position: InventurPosition, ist_menge: Decimal, benutzer_id: int
|
||||
) -> InventurPosition:
|
||||
if inventur.status != InventurStatus.laufend:
|
||||
raise InventurNichtLaufendError()
|
||||
|
||||
position.ist_menge = ist_menge
|
||||
position.erfasst_am = datetime.now(timezone.utc)
|
||||
position.erfasst_von = benutzer_id
|
||||
await db.flush()
|
||||
return position
|
||||
|
||||
|
||||
async def schliesse_inventur(
|
||||
db: AsyncSession, *, inventur: Inventur, benutzer_id: int, zustaendiger_server_id: int
|
||||
) -> tuple[Inventur, int, int]:
|
||||
result = await db.execute(
|
||||
select(InventurPosition).where(InventurPosition.inventur_id == inventur.id)
|
||||
)
|
||||
positionen = list(result.scalars().all())
|
||||
|
||||
anzahl_korrekturen = 0
|
||||
anzahl_nicht_gezaehlt = 0
|
||||
|
||||
for position in positionen:
|
||||
if position.ist_menge is None:
|
||||
anzahl_nicht_gezaehlt += 1
|
||||
continue
|
||||
if position.ist_menge == position.soll_menge:
|
||||
continue
|
||||
|
||||
bestand_result = await db.execute(
|
||||
select(Bestand)
|
||||
.where(Bestand.lagerplatz_id == position.lagerplatz_id, Bestand.material_id == position.material_id)
|
||||
.with_for_update()
|
||||
)
|
||||
bestand = bestand_result.scalar_one_or_none()
|
||||
if bestand is None:
|
||||
bestand = Bestand(
|
||||
lagerplatz_id=position.lagerplatz_id,
|
||||
material_id=position.material_id,
|
||||
menge=Decimal(0),
|
||||
)
|
||||
db.add(bestand)
|
||||
await db.flush()
|
||||
|
||||
differenz = position.ist_menge - position.soll_menge
|
||||
bestand.menge = position.ist_menge
|
||||
|
||||
if differenz > 0:
|
||||
von_lagerplatz_id = None
|
||||
nach_lagerplatz_id = position.lagerplatz_id
|
||||
else:
|
||||
von_lagerplatz_id = position.lagerplatz_id
|
||||
nach_lagerplatz_id = None
|
||||
|
||||
bewegung = Materialbewegung(
|
||||
material_id=position.material_id,
|
||||
menge=abs(differenz),
|
||||
von_lagerplatz_id=von_lagerplatz_id,
|
||||
nach_lagerplatz_id=nach_lagerplatz_id,
|
||||
benutzer_id=benutzer_id,
|
||||
zeitpunkt=datetime.now(timezone.utc),
|
||||
grund="inventur_korrektur",
|
||||
)
|
||||
db.add(bewegung)
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="inventur_korrektur",
|
||||
entitaet_typ="bestand",
|
||||
entitaet_id=bestand.id,
|
||||
alter_wert={"menge": str(position.soll_menge)},
|
||||
neuer_wert={"menge": str(position.ist_menge)},
|
||||
begruendung=f"Inventur {inventur.id}",
|
||||
)
|
||||
anzahl_korrekturen += 1
|
||||
|
||||
inventur.status = InventurStatus.abgeschlossen
|
||||
inventur.abgeschlossen_am = datetime.now(timezone.utc)
|
||||
inventur.abgeschlossen_von = benutzer_id
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="inventur_abgeschlossen",
|
||||
entitaet_typ="inventur",
|
||||
entitaet_id=inventur.id,
|
||||
neuer_wert={"anzahl_korrekturen": anzahl_korrekturen, "anzahl_nicht_gezaehlt": anzahl_nicht_gezaehlt},
|
||||
)
|
||||
return inventur, anzahl_korrekturen, anzahl_nicht_gezaehlt
|
||||
|
||||
|
||||
async def breche_inventur_ab(db: AsyncSession, *, inventur: Inventur, benutzer_id: int, zustaendiger_server_id: int) -> Inventur:
|
||||
inventur.status = InventurStatus.abgebrochen
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="inventur_abgebrochen",
|
||||
entitaet_typ="inventur",
|
||||
entitaet_id=inventur.id,
|
||||
neuer_wert={"status": "abgebrochen"},
|
||||
)
|
||||
return inventur
|
||||
@@ -0,0 +1,254 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.lager import Bestand, Lagerort, LagerortTyp, Lagerplatz
|
||||
from app.models.stammdaten import Material, MaterialTyp
|
||||
from tests.conftest import auth_header, login
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lagerort_factory(db_session):
|
||||
async def _make(name: str, parent_id: int | None = None) -> Lagerort:
|
||||
lagerort = Lagerort(name=name, parent_id=parent_id, typ=LagerortTyp.lager)
|
||||
db_session.add(lagerort)
|
||||
await db_session.flush()
|
||||
return lagerort
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
async def _setup_lager_mit_bestand(db_session, lagerort_factory, menge: Decimal = Decimal(10)):
|
||||
"""Baut Lagerort (mit Unter-Lagerort) -> Lagerplatz -> Bestand für einen
|
||||
Inventur-Testlauf auf; die Inventur muss auch den Unter-Lagerort erfassen."""
|
||||
lagerort = await lagerort_factory("Hauptlager")
|
||||
unter_lagerort = await lagerort_factory("Regal A", parent_id=lagerort.id)
|
||||
|
||||
lagerplatz = Lagerplatz(lagerort_id=unter_lagerort.id, code="WH006-LP-1", bezeichnung="Fach 1")
|
||||
db_session.add(lagerplatz)
|
||||
await db_session.flush()
|
||||
|
||||
material = Material(name="WH006-Verbandmaterial", einheit="Stück", materialtyp=MaterialTyp.standard)
|
||||
db_session.add(material)
|
||||
await db_session.flush()
|
||||
|
||||
bestand = Bestand(lagerplatz_id=lagerplatz.id, material_id=material.id, menge=menge)
|
||||
db_session.add(bestand)
|
||||
await db_session.flush()
|
||||
|
||||
return lagerort, lagerplatz, material, bestand
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventur_starten_snapshotet_sollmenge(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user
|
||||
):
|
||||
lagerort, lagerplatz, material, bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "laufend"
|
||||
assert len(body["positionen"]) == 1
|
||||
position = body["positionen"][0]
|
||||
assert position["lagerplatz_id"] == lagerplatz.id
|
||||
assert position["material_id"] == material.id
|
||||
assert Decimal(position["soll_menge"]) == Decimal(10)
|
||||
assert position["ist_menge"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zweite_inventur_fuer_gleichen_lagerort_ist_409(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user
|
||||
):
|
||||
lagerort, _lp, _mat, _bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
erste = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
assert erste.status_code == 201
|
||||
|
||||
zweite = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
assert zweite.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abweichende_ist_menge_erzeugt_korrekturbuchung(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user
|
||||
):
|
||||
lagerort, lagerplatz, material, bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
start = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
inventur_id = start.json()["id"]
|
||||
position_id = start.json()["positionen"][0]["id"]
|
||||
|
||||
erfasst = await client.patch(
|
||||
f"/api/v1/inventuren/{inventur_id}/positionen/{position_id}",
|
||||
json={"ist_menge": "7"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert erfasst.status_code == 200
|
||||
|
||||
abschluss = await client.post(
|
||||
f"/api/v1/inventuren/{inventur_id}/abschliessen", headers=auth_header(token)
|
||||
)
|
||||
assert abschluss.status_code == 200, abschluss.text
|
||||
ergebnis = abschluss.json()
|
||||
assert ergebnis["anzahl_korrekturen"] == 1
|
||||
assert ergebnis["anzahl_nicht_gezaehlt"] == 0
|
||||
assert ergebnis["inventur"]["status"] == "abgeschlossen"
|
||||
|
||||
bewegungen = await client.get(
|
||||
f"/api/v1/materialbewegungen?lagerplatz_id={lagerplatz.id}", headers=auth_header(token)
|
||||
)
|
||||
treffer = [b for b in bewegungen.json() if b["grund"] == "inventur_korrektur"]
|
||||
assert len(treffer) == 1
|
||||
assert Decimal(treffer[0]["menge"]) == Decimal(3)
|
||||
assert treffer[0]["von_lagerplatz_id"] == lagerplatz.id
|
||||
assert treffer[0]["nach_lagerplatz_id"] is None
|
||||
|
||||
bestand_neu = await client.get(
|
||||
f"/api/v1/lagerplaetze/{lagerplatz.id}/bestand", headers=auth_header(token)
|
||||
)
|
||||
eintrag = next(b for b in bestand_neu.json() if b["material_id"] == material.id)
|
||||
assert Decimal(eintrag["menge"]) == Decimal(7)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gleiche_ist_menge_erzeugt_keine_korrekturbuchung(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user
|
||||
):
|
||||
lagerort, lagerplatz, material, bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
start = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
inventur_id = start.json()["id"]
|
||||
position_id = start.json()["positionen"][0]["id"]
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/inventuren/{inventur_id}/positionen/{position_id}",
|
||||
json={"ist_menge": "10"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
abschluss = await client.post(
|
||||
f"/api/v1/inventuren/{inventur_id}/abschliessen", headers=auth_header(token)
|
||||
)
|
||||
assert abschluss.status_code == 200
|
||||
ergebnis = abschluss.json()
|
||||
assert ergebnis["anzahl_korrekturen"] == 0
|
||||
assert ergebnis["anzahl_nicht_gezaehlt"] == 0
|
||||
|
||||
bewegungen = await client.get(
|
||||
f"/api/v1/materialbewegungen?lagerplatz_id={lagerplatz.id}", headers=auth_header(token)
|
||||
)
|
||||
treffer = [b for b in bewegungen.json() if b["grund"] == "inventur_korrektur"]
|
||||
assert len(treffer) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nicht_gezaehlte_position_blockiert_abschluss_nicht(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user
|
||||
):
|
||||
lagerort, _lp, _mat, _bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
start = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
inventur_id = start.json()["id"]
|
||||
|
||||
abschluss = await client.post(
|
||||
f"/api/v1/inventuren/{inventur_id}/abschliessen", headers=auth_header(token)
|
||||
)
|
||||
assert abschluss.status_code == 200
|
||||
ergebnis = abschluss.json()
|
||||
assert ergebnis["anzahl_korrekturen"] == 0
|
||||
assert ergebnis["anzahl_nicht_gezaehlt"] == 1
|
||||
assert ergebnis["inventur"]["status"] == "abgeschlossen"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventur_abbrechen_aendert_bestand_nicht(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user
|
||||
):
|
||||
lagerort, lagerplatz, material, bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
start = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(token)
|
||||
)
|
||||
inventur_id = start.json()["id"]
|
||||
position_id = start.json()["positionen"][0]["id"]
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/inventuren/{inventur_id}/positionen/{position_id}",
|
||||
json={"ist_menge": "999"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
abbruch = await client.post(
|
||||
f"/api/v1/inventuren/{inventur_id}/abbrechen", headers=auth_header(token)
|
||||
)
|
||||
assert abbruch.status_code == 200
|
||||
assert abbruch.json()["status"] == "abgebrochen"
|
||||
|
||||
bestand_neu = await client.get(
|
||||
f"/api/v1/lagerplaetze/{lagerplatz.id}/bestand", headers=auth_header(token)
|
||||
)
|
||||
eintrag = next(b for b in bestand_neu.json() if b["material_id"] == material.id)
|
||||
assert Decimal(eintrag["menge"]) == Decimal(10)
|
||||
|
||||
bewegungen = await client.get(
|
||||
f"/api/v1/materialbewegungen?lagerplatz_id={lagerplatz.id}", headers=auth_header(token)
|
||||
)
|
||||
treffer = [b for b in bewegungen.json() if b["grund"] == "inventur_korrektur"]
|
||||
assert len(treffer) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mitarbeiter_darf_nur_erfassen_nicht_starten_oder_abschliessen(
|
||||
client, db_session, lagerort_factory, materialverantwortlicher_user, mitarbeiter_user
|
||||
):
|
||||
lagerort, _lp, _mat, _bestand = await _setup_lager_mit_bestand(db_session, lagerort_factory)
|
||||
mv_token = await login(client, "materialverantwortlicher1")
|
||||
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||
|
||||
start_als_mitarbeiter = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(mitarbeiter_token)
|
||||
)
|
||||
assert start_als_mitarbeiter.status_code == 403
|
||||
|
||||
start = await client.post(
|
||||
"/api/v1/inventuren", json={"lagerort_id": lagerort.id}, headers=auth_header(mv_token)
|
||||
)
|
||||
inventur_id = start.json()["id"]
|
||||
position_id = start.json()["positionen"][0]["id"]
|
||||
|
||||
erfasst = await client.patch(
|
||||
f"/api/v1/inventuren/{inventur_id}/positionen/{position_id}",
|
||||
json={"ist_menge": "5"},
|
||||
headers=auth_header(mitarbeiter_token),
|
||||
)
|
||||
assert erfasst.status_code == 200
|
||||
|
||||
abschluss_als_mitarbeiter = await client.post(
|
||||
f"/api/v1/inventuren/{inventur_id}/abschliessen", headers=auth_header(mitarbeiter_token)
|
||||
)
|
||||
assert abschluss_als_mitarbeiter.status_code == 403
|
||||
|
||||
abbruch_als_mitarbeiter = await client.post(
|
||||
f"/api/v1/inventuren/{inventur_id}/abbrechen", headers=auth_header(mitarbeiter_token)
|
||||
)
|
||||
assert abbruch_als_mitarbeiter.status_code == 403
|
||||
@@ -240,6 +240,34 @@ export interface Bestand {
|
||||
menge: string;
|
||||
}
|
||||
|
||||
export interface InventurPosition {
|
||||
id: string;
|
||||
inventur_id: string;
|
||||
lagerplatz_id: number;
|
||||
material_id: number;
|
||||
soll_menge: string;
|
||||
ist_menge: string | null;
|
||||
erfasst_am: string | null;
|
||||
erfasst_von: number | null;
|
||||
}
|
||||
|
||||
export interface Inventur {
|
||||
id: string;
|
||||
lagerort_id: number;
|
||||
gestartet_von: number;
|
||||
gestartet_am: string;
|
||||
status: "laufend" | "abgeschlossen" | "abgebrochen";
|
||||
abgeschlossen_am: string | null;
|
||||
abgeschlossen_von: number | null;
|
||||
positionen: InventurPosition[];
|
||||
}
|
||||
|
||||
export interface InventurAbschlussErgebnis {
|
||||
inventur: Inventur;
|
||||
anzahl_korrekturen: number;
|
||||
anzahl_nicht_gezaehlt: number;
|
||||
}
|
||||
|
||||
export interface Materialbewegung {
|
||||
id: string;
|
||||
material_id: number;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ReadinessRegelSection } from "./admin/ReadinessRegelSection";
|
||||
import { EinsatzSection } from "./admin/EinsatzSection";
|
||||
import { MaterialSetSection } from "./admin/MaterialSetSection";
|
||||
import { SollmengenGruppeSection } from "./admin/SollmengenGruppeSection";
|
||||
import { InventurSection } from "./admin/InventurSection";
|
||||
import { VerleihSection } from "./admin/VerleihSection";
|
||||
|
||||
type Tab =
|
||||
@@ -54,7 +55,8 @@ type Tab =
|
||||
| "einsaetze"
|
||||
| "materialSets"
|
||||
| "verleih"
|
||||
| "sollmengenGruppen";
|
||||
| "sollmengenGruppen"
|
||||
| "inventur";
|
||||
|
||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
|
||||
@@ -106,6 +108,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
||||
label: "Sollmengen-Gruppen",
|
||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||
},
|
||||
{ key: "inventur", label: "Inventur", rollen: ["administration", "materialverantwortlicher"] },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -329,6 +332,7 @@ export function AdminPage() {
|
||||
{aktiverTab === "sollmengenGruppen" && (
|
||||
<SollmengenGruppeSection materialien={materialien} onFehler={setFehler} />
|
||||
)}
|
||||
{aktiverTab === "inventur" && <InventurSection materialien={materialien} onFehler={setFehler} />}
|
||||
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
|
||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||
{aktiverTab === "personal" && (
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { Inventur, InventurAbschlussErgebnis, Lagerort, Material } from "../../api/types";
|
||||
|
||||
interface Props {
|
||||
materialien: Material[];
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<Inventur["status"], string> = {
|
||||
laufend: "Läuft",
|
||||
abgeschlossen: "Abgeschlossen",
|
||||
abgebrochen: "Abgebrochen",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<Inventur["status"], string> = {
|
||||
laufend: "badge-warning",
|
||||
abgeschlossen: "badge-success",
|
||||
abgebrochen: "badge-neutral",
|
||||
};
|
||||
|
||||
// WH-006: periodischer Inventurlauf je Lagerort - Ist-Mengen erfassen,
|
||||
// Abweichungen erzeugen beim Abschluss automatisch Korrekturbuchungen.
|
||||
export function InventurSection({ materialien, onFehler }: Props) {
|
||||
const [lagerorte, setLagerorte] = useState<Lagerort[]>([]);
|
||||
const [inventuren, setInventuren] = useState<Inventur[]>([]);
|
||||
const [neuerLagerort, setNeuerLagerort] = useState<number | "">("");
|
||||
const [ausgeklapptId, setAusgeklapptId] = useState<string | null>(null);
|
||||
const [erfassungWerte, setErfassungWerte] = useState<Record<string, string>>({});
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [orte, liste] = await Promise.all([
|
||||
apiRequest<Lagerort[]>("/lagerorte"),
|
||||
apiRequest<Inventur[]>("/inventuren"),
|
||||
]);
|
||||
setLagerorte(orte);
|
||||
setInventuren(liste);
|
||||
} catch {
|
||||
onFehler("Inventuren konnten nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
}, []);
|
||||
|
||||
function lagerortName(id: number) {
|
||||
return lagerorte.find((o) => o.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
function materialName(id: number) {
|
||||
return materialien.find((m) => m.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
async function starten() {
|
||||
if (neuerLagerort === "") return;
|
||||
try {
|
||||
const inventur = await apiRequest<Inventur>("/inventuren", {
|
||||
method: "POST",
|
||||
body: { lagerort_id: neuerLagerort },
|
||||
});
|
||||
setNeuerLagerort("");
|
||||
await laden();
|
||||
setAusgeklapptId(inventur.id);
|
||||
} catch {
|
||||
onFehler("Inventur konnte nicht gestartet werden (läuft evtl. schon eine für diesen Lagerort).");
|
||||
}
|
||||
}
|
||||
|
||||
async function erfassen(inventur: Inventur, positionId: string) {
|
||||
const wert = erfassungWerte[positionId];
|
||||
if (!wert) return;
|
||||
try {
|
||||
await apiRequest(`/inventuren/${inventur.id}/positionen/${positionId}`, {
|
||||
method: "PATCH",
|
||||
body: { ist_menge: wert },
|
||||
});
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Menge konnte nicht erfasst werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function abschliessen(inventur: Inventur) {
|
||||
try {
|
||||
const ergebnis = await apiRequest<InventurAbschlussErgebnis>(`/inventuren/${inventur.id}/abschliessen`, {
|
||||
method: "POST",
|
||||
});
|
||||
await laden();
|
||||
onFehler(
|
||||
`Inventur abgeschlossen: ${ergebnis.anzahl_korrekturen} Korrektur(en), ${ergebnis.anzahl_nicht_gezaehlt} nicht gezählt.`
|
||||
);
|
||||
} catch {
|
||||
onFehler("Inventur konnte nicht abgeschlossen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function abbrechen(inventur: Inventur) {
|
||||
try {
|
||||
await apiRequest(`/inventuren/${inventur.id}/abbrechen`, { method: "POST" });
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Inventur konnte nicht abgebrochen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>Inventur</h2>
|
||||
<div className="card">
|
||||
<div className="row" style={{ flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={neuerLagerort}
|
||||
onChange={(e) => setNeuerLagerort(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Lagerort wählen…</option>
|
||||
{lagerorte.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={starten} disabled={neuerLagerort === ""}>
|
||||
Inventur starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{inventuren.length === 0 && <p className="text-muted">Noch keine Inventuren.</p>}
|
||||
{inventuren.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{inventuren.map((inv) => (
|
||||
<li key={inv.id} style={{ padding: "0.4rem 0" }}>
|
||||
<div className="row-between">
|
||||
<span>
|
||||
<strong>{lagerortName(inv.lagerort_id)}</strong>{" "}
|
||||
<span className="text-muted">
|
||||
gestartet {new Date(inv.gestartet_am).toLocaleString("de-DE")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="row" style={{ gap: "0.4rem" }}>
|
||||
<span className={`badge ${STATUS_BADGE[inv.status]}`}>{STATUS_LABEL[inv.status]}</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setAusgeklapptId(ausgeklapptId === inv.id ? null : inv.id)}
|
||||
>
|
||||
{ausgeklapptId === inv.id ? "Schließen" : "Positionen"}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{ausgeklapptId === inv.id && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<ul className="card-list">
|
||||
{inv.positionen.map((p) => (
|
||||
<li key={p.id} className="row-between" style={{ padding: "0.2rem 0" }}>
|
||||
<span>
|
||||
{materialName(p.material_id)} — Soll: {p.soll_menge}
|
||||
{p.ist_menge !== null && <> · Ist: {p.ist_menge}</>}
|
||||
</span>
|
||||
{inv.status === "laufend" && (
|
||||
<span className="row" style={{ gap: "0.3rem" }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: "6rem" }}
|
||||
placeholder="Ist-Menge"
|
||||
value={erfassungWerte[p.id] ?? p.ist_menge ?? ""}
|
||||
onChange={(e) =>
|
||||
setErfassungWerte((v) => ({ ...v, [p.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={() => erfassen(inv, p.id)}>
|
||||
Erfassen
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{inv.status === "laufend" && (
|
||||
<div className="row" style={{ gap: "0.4rem", marginTop: "0.5rem" }}>
|
||||
<button className="btn btn-primary" onClick={() => abschliessen(inv)}>
|
||||
Abschließen
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => abbrechen(inv)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user