diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index 8e89ccf..ec661ea 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -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) | diff --git a/arbeitskacheln/07_warehouse.md b/arbeitskacheln/07_warehouse.md index 7a58352..145eac0 100644 --- a/arbeitskacheln/07_warehouse.md +++ b/arbeitskacheln/07_warehouse.md @@ -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 diff --git a/backend/alembic/versions/0052_inventur.py b/backend/alembic/versions/0052_inventur.py new file mode 100644 index 0000000..41ec3f4 --- /dev/null +++ b/backend/alembic/versions/0052_inventur.py @@ -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") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index be664cd..f9f3e24 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/inventur.py b/backend/app/api/v1/endpoints/inventur.py new file mode 100644 index 0000000..c52accb --- /dev/null +++ b/backend/app/api/v1/endpoints/inventur.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b2de5dc..668878c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/inventur.py b/backend/app/models/inventur.py new file mode 100644 index 0000000..d708220 --- /dev/null +++ b/backend/app/models/inventur.py @@ -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")) diff --git a/backend/app/schemas/inventur.py b/backend/app/schemas/inventur.py new file mode 100644 index 0000000..a9f5bbd --- /dev/null +++ b/backend/app/schemas/inventur.py @@ -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 diff --git a/backend/app/services/inventur.py b/backend/app/services/inventur.py new file mode 100644 index 0000000..305c396 --- /dev/null +++ b/backend/app/services/inventur.py @@ -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 diff --git a/backend/tests/test_wh006_inventur.py b/backend/tests/test_wh006_inventur.py new file mode 100644 index 0000000..a6ac6a4 --- /dev/null +++ b/backend/tests/test_wh006_inventur.py @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ec04f60..8fe0667 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 3d90cf6..b9e7453 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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" && ( )} + {aktiverTab === "inventur" && } {aktiverTab === "verleih" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/admin/InventurSection.tsx b/frontend/src/pages/admin/InventurSection.tsx new file mode 100644 index 0000000..168473d --- /dev/null +++ b/frontend/src/pages/admin/InventurSection.tsx @@ -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 = { + laufend: "Läuft", + abgeschlossen: "Abgeschlossen", + abgebrochen: "Abgebrochen", +}; + +const STATUS_BADGE: Record = { + 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([]); + const [inventuren, setInventuren] = useState([]); + const [neuerLagerort, setNeuerLagerort] = useState(""); + const [ausgeklapptId, setAusgeklapptId] = useState(null); + const [erfassungWerte, setErfassungWerte] = useState>({}); + + async function laden() { + try { + const [orte, liste] = await Promise.all([ + apiRequest("/lagerorte"), + apiRequest("/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("/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(`/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 ( + + Inventur + + + setNeuerLagerort(Number(e.target.value) || "")} + > + Lagerort wählen… + {lagerorte.map((o) => ( + + {o.name} + + ))} + + + Inventur starten + + + + {inventuren.length === 0 && Noch keine Inventuren.} + {inventuren.length > 0 && ( + + {inventuren.map((inv) => ( + + + + {lagerortName(inv.lagerort_id)}{" "} + + gestartet {new Date(inv.gestartet_am).toLocaleString("de-DE")} + + + + {STATUS_LABEL[inv.status]} + setAusgeklapptId(ausgeklapptId === inv.id ? null : inv.id)} + > + {ausgeklapptId === inv.id ? "Schließen" : "Positionen"} + + + + {ausgeklapptId === inv.id && ( + + + {inv.positionen.map((p) => ( + + + {materialName(p.material_id)} — Soll: {p.soll_menge} + {p.ist_menge !== null && <> · Ist: {p.ist_menge}>} + + {inv.status === "laufend" && ( + + + setErfassungWerte((v) => ({ ...v, [p.id]: e.target.value })) + } + /> + erfassen(inv, p.id)}> + Erfassen + + + )} + + ))} + + {inv.status === "laufend" && ( + + abschliessen(inv)}> + Abschließen + + abbrechen(inv)}> + Abbrechen + + + )} + + )} + + ))} + + )} + + + ); +}
Noch keine Inventuren.