feat(warehouse): WH-006 Inventur & Bestandskorrektur
CI / backend-tests (push) Successful in 3m8s
CI / frontend-build (push) Successful in 19s

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:
patrickandClaude Sonnet 5 committed 2026-09-10 18:39:46 +02:00
1 parent bc1542a5e9
commit 11e6ed4332
13 files changed
+1064 -13

No files matched your search

+9 -9
View File
@@ -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) |
+14 -3
View File
@@ -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
+57
View File
@@ -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")
+2
View File
@@ -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"])
+180
View File
@@ -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)
+4
View File
@@ -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",
+57
View File
@@ -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"))
+45
View File
@@ -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
+209
View File
@@ -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
+254
View File
@@ -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
+28
View File
@@ -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;
+5 -1
View File
@@ -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>
);
}