diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fdac253..8134159 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -219,6 +219,60 @@ Tabellen: - `repair_public_access_tokens` - `repair_notification_events` +## Lager / Ersatzteile + +Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet. + +Datenfluss: + +```text +Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL +``` + +Kernregeln: + +- Browser greifen nie direkt auf Hermes zu. +- Artikelnummern/SKUs sind eindeutig. +- Leere SKU bei Erstellung wird automatisch im Format `ET--000001` vergeben. +- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben. +- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert. +- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation. +- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht. + +Tabellen: + +- `inventory_items` +- `inventory_categories` +- `inventory_locations` +- `inventory_suppliers` +- `inventory_stock_movements` + +Bestandsbewegungen: + +- `initial` +- `purchase` +- `adjustment` +- `reservation` +- `release` +- `consumption` +- `return` +- `correction` + +RBAC-Permissions: + +- `inventory.read` +- `inventory.create` +- `inventory.update` +- `inventory.delete` +- `inventory.stock.adjust` +- `inventory.stock.reserve` +- `inventory.stock.consume` +- `inventory.manage.masterdata` + +Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten. + +Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures. + Hermes-Endpunkte: - `GET /repairs` diff --git a/README-DEV.md b/README-DEV.md index 896ad69..70c613f 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -214,6 +214,48 @@ Kostenvoranschlaege: - Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit. - PDF-Erzeugung, Lexoffice und Rechnungen sind Folgefeatures. +## Lager / Ersatzteile + +Das Lagermodul ist ab v0.8.7 aktiv. + +Migration lokal ausfuehren: + +```bash +cd backend/hermes +SECRET_KEY=local-check uv run alembic upgrade head +``` + +Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen: + +- `/api/inventory/items` +- `/api/inventory/items/[id]` +- `/api/inventory/items/[id]/stock/adjust` +- `/api/inventory/items/[id]/stock/reserve` +- `/api/inventory/items/[id]/stock/release` +- `/api/inventory/items/[id]/stock/consume` +- `/api/inventory/items/[id]/movements` +- `/api/inventory/categories` +- `/api/inventory/categories/[id]` +- `/api/inventory/locations` +- `/api/inventory/locations/[id]` +- `/api/inventory/suppliers` +- `/api/inventory/suppliers/[id]` + +Hermes-Endpunkte liegen unter `/inventory/...` und sind mit `inventory.*` Permissions geschuetzt. + +Bestandslogik: + +- SKU optional; bei leerer SKU erzeugt Hermes `ET--000001`. +- `quantity_available = quantity_on_hand - quantity_reserved`. +- Bestandsanpassungen, Reservierungen, Freigaben und Verbrauch erzeugen `inventory_stock_movements`. +- Artikel mit Bewegungen werden deaktiviert statt hart geloescht. +- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`. + +Folgefeatures: + +- Integration in Kostenvoranschlaege folgt in v0.8.8. +- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv. + Vorbereitete Website-/Portal-Routen fuer spaeter: - `/status/` diff --git a/ROADMAP.md b/ROADMAP.md index 8248ce1..3b10ef2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,7 +147,29 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version - Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen -## v0.8.7 - Kundenportal, geplant +## v0.8.7 - Lager / Ersatzteile + +- Ersatzteil- und Lagerartikelverwaltung +- Kategorien, Lagerorte und Lieferanten +- SKU-Format `ET--000001` +- Bestandslogik mit Lagerbestand, Reservierung und Verfuegbarkeit +- Bestandsbewegungen fuer Anfangsbestand, Anpassung, Reservierung, Freigabe und Verbrauch +- Preise intern in cents, UI-Eingabe in Euro +- RBAC-Permissions fuer Lagerlesen, Artikelpflege, Bestandsbuchungen und Stammdaten +- Dashboard-Kennzahlen fuer aktive Ersatzteile, niedrigen Bestand, Lagerwert und Bewegungen +- Audit Logs und Activity Feed fuer Lageraktionen +- Athena-BFF-Routen und UI fuer `/inventory` und `/inventory/items/[id]` +- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8 +- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion + +## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant + +- Lagerartikel als KV-Position ueber Suche auswaehlen +- Verkaufspreis aus `selling_price_cents` uebernehmen +- Reservierungen aus KV-/Reparaturprozess vorbereiten +- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten + +## v0.8.9 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index 474acef..3217c11 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -14,6 +14,7 @@ import app.models.audit import app.models.rbac import app.models.repair import app.models.repair_estimate +import app.models.inventory import app.models.system_setting config = context.config diff --git a/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py new file mode 100644 index 0000000..e422b5a --- /dev/null +++ b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py @@ -0,0 +1,214 @@ +"""create inventory + +Revision ID: ef8a2d5c9017 +Revises: b4e8f1c7a205 +Create Date: 2026-07-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ef8a2d5c9017" +down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +INVENTORY_PERMISSIONS = [ + ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"), + ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"), + ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"), + ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"), + ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"), + ("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"), + ("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"), + ("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"), +] + + +def upgrade() -> None: + op.create_table( + "inventory_categories", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=180), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("slug"), + ) + op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True) + op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True) + + op.create_table( + "inventory_locations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True) + + op.create_table( + "inventory_suppliers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=180), nullable=False), + sa.Column("contact_name", sa.String(length=180), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=80), nullable=True), + sa.Column("website", sa.String(length=255), nullable=True), + sa.Column("customer_number", sa.String(length=120), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True) + + op.create_table( + "inventory_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("sku", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category_id", sa.Integer(), nullable=True), + sa.Column("manufacturer", sa.String(length=180), nullable=True), + sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True), + sa.Column("supplier_id", sa.Integer(), nullable=True), + sa.Column("supplier_part_number", sa.String(length=180), nullable=True), + sa.Column("compatible_devices", sa.Text(), nullable=True), + sa.Column("location_id", sa.Integer(), nullable=True), + sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False), + sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False), + sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False), + sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False), + sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False), + sa.Column("purchase_price_cents", sa.Integer(), nullable=True), + sa.Column("selling_price_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False), + sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("sku", name="uq_inventory_items_sku"), + ) + op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False) + op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False) + op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False) + op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False) + op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False) + op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False) + op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False) + op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True) + op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False) + op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False) + + op.create_table( + "inventory_stock_movements", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("item_id", sa.Integer(), nullable=False), + sa.Column("movement_type", sa.String(length=40), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=255), server_default="", nullable=False), + sa.Column("reference_type", sa.String(length=80), nullable=True), + sa.Column("reference_id", sa.Integer(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False) + + for name, display_name, description, module in INVENTORY_PERMISSIONS: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES (:name, :display_name, :description, :module) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + role_permissions = { + "administrator": [item[0] for item in INVENTORY_PERMISSIONS], + "management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"], + "technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"], + "support": ["inventory.read"], + "warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"], + } + for role_name, permission_names in role_permissions.items(): + for permission_name in permission_names: + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE module = 'inventory' + ) + """ + ) + ) + op.execute("DELETE FROM permissions WHERE module = 'inventory'") + op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements") + op.drop_table("inventory_stock_movements") + op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items") + op.drop_table("inventory_items") + op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers") + op.drop_table("inventory_suppliers") + op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations") + op.drop_table("inventory_locations") + op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories") + op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories") + op.drop_table("inventory_categories") diff --git a/backend/hermes/app/__pycache__/main.cpython-314.pyc b/backend/hermes/app/__pycache__/main.cpython-314.pyc index 1be2f4b..fe05fec 100644 Binary files a/backend/hermes/app/__pycache__/main.cpython-314.pyc and b/backend/hermes/app/__pycache__/main.cpython-314.pyc differ diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index efa3d8e..3912a67 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -58,6 +58,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: return "knowledge.read" in permissions if action.startswith("repairs."): return "repairs.read" in permissions + if action.startswith("inventory."): + return "inventory.read" in permissions if action.startswith("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 2ef835a..c3201b4 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -10,6 +10,7 @@ from app.models.rbac import Role from app.models.audit import AuditLog from app.models.user import User from app.repositories.customer_repository import CustomerRepository +from app.repositories.inventory_repository import InventoryRepository from app.repositories.repair_repository import RepairRepository from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.user_repository import UserRepository @@ -35,6 +36,7 @@ def get_dashboard_summary( users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + inventory: list[MetricCard] = [] system_status: list[SystemStatusItem] = [] if "customers.read" in permissions: @@ -82,6 +84,14 @@ def get_dashboard_summary( MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)), ]) + if "inventory.read" in permissions: + inventory = [ + MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), + MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)), + MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)), + MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))), + ] + if "system_settings.manage" in permissions: smtp_config = SystemSettingsService.get_smtp_runtime_config(db) public_links = SystemSettingsService.get_public_links_settings(db) @@ -127,6 +137,7 @@ def get_dashboard_summary( users=users, roles=roles, repairs=repairs, + inventory=inventory, system_status=system_status, activities=EmptyWidget( title="Letzte Aktivitäten", diff --git a/backend/hermes/app/api/inventory.py b/backend/hermes/app/api/inventory.py new file mode 100644 index 0000000..b09f534 --- /dev/null +++ b/backend/hermes/app/api/inventory.py @@ -0,0 +1,307 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository +from app.schemas.inventory import ( + InventoryCategoryPayload, + InventoryCategoryResponse, + InventoryItemCreate, + InventoryItemListResponse, + InventoryItemResponse, + InventoryItemUpdate, + InventoryLocationPayload, + InventoryLocationResponse, + InventoryStockAction, + InventoryStockMovementResponse, + InventorySupplierPayload, + InventorySupplierResponse, +) +from app.services.inventory_service import InventoryService + +router = APIRouter(prefix="/inventory", tags=["Inventory"]) + + +def get_item_or_404(db: Session, item_id: int) -> InventoryItem: + item = InventoryRepository.get_item(db, item_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") + return item + + +def get_category_or_404(db: Session, category_id: int) -> InventoryCategory: + category = InventoryRepository.get_category(db, category_id) + if category is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden") + return category + + +def get_location_or_404(db: Session, location_id: int) -> InventoryLocation: + location = InventoryRepository.get_location(db, location_id) + if location is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden") + return location + + +def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier: + supplier = InventoryRepository.get_supplier(db, supplier_id) + if supplier is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden") + return supplier + + +@router.get("/items", response_model=InventoryItemListResponse) +def list_items( + q: str | None = None, + category_id: int | None = None, + supplier_id: int | None = None, + location_id: int | None = None, + low_stock: bool | None = None, + active: bool | None = True, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + items, total = InventoryRepository.list_items( + db, + q=q, + category_id=category_id, + supplier_id=supplier_id, + location_id=location_id, + low_stock=low_stock, + active=active, + limit=limit, + offset=offset, + ) + return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset) + + +@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED) +def create_item( + payload: InventoryItemCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.create")), +): + return InventoryService.create_item(db, payload, actor=current_user, request=request) + + +@router.get("/items/{item_id}", response_model=InventoryItemResponse) +def get_item( + item_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return get_item_or_404(db, item_id) + + +@router.put("/items/{item_id}", response_model=InventoryItemResponse) +def update_item( + item_id: int, + payload: InventoryItemUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.update")), +): + item = get_item_or_404(db, item_id) + return InventoryService.update_item(db, item, payload, actor=current_user, request=request) + + +@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None) +def delete_item( + item_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.delete")), +): + item = get_item_or_404(db, item_id) + return InventoryService.delete_item(db, item, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse) +def adjust_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.adjust")), +): + item = get_item_or_404(db, item_id) + return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse) +def reserve_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.reserve")), +): + item = get_item_or_404(db, item_id) + return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse) +def release_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.reserve")), +): + item = get_item_or_404(db, item_id) + return InventoryService.release_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse) +def consume_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.consume")), +): + item = get_item_or_404(db, item_id) + return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request) + + +@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse]) +def list_movements( + item_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + get_item_or_404(db, item_id) + return InventoryRepository.list_movements(db, item_id) + + +@router.get("/categories", response_model=list[InventoryCategoryResponse]) +def list_categories( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_categories(db) + + +@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED) +def create_category( + payload: InventoryCategoryPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_category(db, payload, actor=current_user, request=request) + + +@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse) +def update_category( + category_id: int, + payload: InventoryCategoryPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + category = get_category_or_404(db, category_id) + return InventoryService.update_category(db, category, payload, actor=current_user, request=request) + + +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_category( + category_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + category = get_category_or_404(db, category_id) + InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request) + return None + + +@router.get("/locations", response_model=list[InventoryLocationResponse]) +def list_locations( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_locations(db) + + +@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED) +def create_location( + payload: InventoryLocationPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_location(db, payload, actor=current_user, request=request) + + +@router.put("/locations/{location_id}", response_model=InventoryLocationResponse) +def update_location( + location_id: int, + payload: InventoryLocationPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + location = get_location_or_404(db, location_id) + return InventoryService.update_location(db, location, payload, actor=current_user, request=request) + + +@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_location( + location_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + location = get_location_or_404(db, location_id) + InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request) + return None + + +@router.get("/suppliers", response_model=list[InventorySupplierResponse]) +def list_suppliers( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_suppliers(db) + + +@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED) +def create_supplier( + payload: InventorySupplierPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_supplier(db, payload, actor=current_user, request=request) + + +@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse) +def update_supplier( + supplier_id: int, + payload: InventorySupplierPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + supplier = get_supplier_or_404(db, supplier_id) + return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request) + + +@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_supplier( + supplier_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + supplier = get_supplier_or_404(db, supplier_id) + InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request) + return None diff --git a/backend/hermes/app/core/__pycache__/config.cpython-314.pyc b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc index c828751..c789c9b 100644 Binary files a/backend/hermes/app/core/__pycache__/config.cpython-314.pyc and b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc differ diff --git a/backend/hermes/app/db/__pycache__/database.cpython-314.pyc b/backend/hermes/app/db/__pycache__/database.cpython-314.pyc index 79708cf..a25929a 100644 Binary files a/backend/hermes/app/db/__pycache__/database.cpython-314.pyc and b/backend/hermes/app/db/__pycache__/database.cpython-314.pyc differ diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 9443c1e..3b7c410 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -13,6 +13,7 @@ from app.api.auth import router as auth_router from app.api.audit import router as audit_router from app.api.customers import router as customers_router from app.api.dashboard import router as dashboard_router +from app.api.inventory import router as inventory_router from app.api.knowledge import router as knowledge_router from app.api.permissions import router as permissions_router from app.api.repairs import router as repairs_router @@ -41,6 +42,7 @@ app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) app.include_router(customers_router) +app.include_router(inventory_router) app.include_router(knowledge_router) app.include_router(repairs_router) app.include_router(repair_estimates_router) diff --git a/backend/hermes/app/models/__pycache__/user.cpython-314.pyc b/backend/hermes/app/models/__pycache__/user.cpython-314.pyc index 5a9e9b9..d25fb9d 100644 Binary files a/backend/hermes/app/models/__pycache__/user.cpython-314.pyc and b/backend/hermes/app/models/__pycache__/user.cpython-314.pyc differ diff --git a/backend/hermes/app/models/inventory.py b/backend/hermes/app/models/inventory.py new file mode 100644 index 0000000..9428b9e --- /dev/null +++ b/backend/hermes/app/models/inventory.py @@ -0,0 +1,110 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +class InventoryCategory(Base): + __tablename__ = "inventory_categories" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160), unique=True, index=True) + slug: Mapped[str] = mapped_column(String(180), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + items: Mapped[list["InventoryItem"]] = relationship(back_populates="category") + + +class InventoryLocation(Base): + __tablename__ = "inventory_locations" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + items: Mapped[list["InventoryItem"]] = relationship(back_populates="location") + + +class InventorySupplier(Base): + __tablename__ = "inventory_suppliers" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(180), unique=True, index=True) + contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(80), nullable=True) + website: Mapped[str | None] = mapped_column(String(255), nullable=True) + customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier") + + +class InventoryItem(Base): + __tablename__ = "inventory_items" + __table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),) + + id: Mapped[int] = mapped_column(primary_key=True) + sku: Mapped[str] = mapped_column(String(40), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True) + manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True) + manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True) + supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True) + supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True) + compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True) + location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True) + quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True) + reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.") + purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR") + tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined") + supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined") + location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined") + movements: Mapped[list["InventoryStockMovement"]] = relationship( + back_populates="item", + cascade="all, delete-orphan", + order_by="InventoryStockMovement.created_at.desc()", + ) + + +class InventoryStockMovement(Base): + __tablename__ = "inventory_stock_movements" + + id: Mapped[int] = mapped_column(primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True) + movement_type: Mapped[str] = mapped_column(String(40), index=True) + quantity: Mapped[int] = mapped_column(Integer) + reason: Mapped[str] = mapped_column(String(255), default="", server_default="") + reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + item: Mapped[InventoryItem] = relationship(back_populates="movements") + actor = relationship("User", lazy="joined") + + @property + def actor_username(self) -> str: + return self.actor.username if self.actor is not None else "" diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 12490a9..1191da6 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -90,6 +90,14 @@ STANDARD_PERMISSIONS = [ ("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"), ("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"), ("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"), + ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"), + ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"), + ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"), + ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"), + ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"), + ("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"), + ("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"), + ("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"), ] ROLE_PERMISSION_NAMES = { @@ -111,6 +119,13 @@ ROLE_PERMISSION_NAMES = { "repair_estimates.create", "repair_estimates.update", "repair_estimates.send", + "inventory.read", + "inventory.create", + "inventory.update", + "inventory.stock.adjust", + "inventory.stock.reserve", + "inventory.stock.consume", + "inventory.manage.masterdata", }, "sales": { "dashboard.read", @@ -138,6 +153,9 @@ ROLE_PERMISSION_NAMES = { "repair_estimates.create", "repair_estimates.update", "repair_estimates.send", + "inventory.read", + "inventory.stock.reserve", + "inventory.stock.consume", }, "support": { "dashboard.read", @@ -156,9 +174,17 @@ ROLE_PERMISSION_NAMES = { "repairs.public_link.manage", "repair_estimates.read", "repair_estimates.send", + "inventory.read", }, "warehouse": { "dashboard.read", + "inventory.read", + "inventory.create", + "inventory.update", + "inventory.stock.adjust", + "inventory.stock.reserve", + "inventory.stock.consume", + "inventory.manage.masterdata", }, "guest": { "dashboard.read", diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py new file mode 100644 index 0000000..ff2fe8e --- /dev/null +++ b/backend/hermes/app/repositories/inventory_repository.py @@ -0,0 +1,187 @@ +from datetime import UTC, datetime + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.inventory import ( + InventoryCategory, + InventoryItem, + InventoryLocation, + InventoryStockMovement, + InventorySupplier, +) + + +class InventoryRepository: + @staticmethod + def item_query() -> Select[tuple[InventoryItem]]: + return select(InventoryItem).options( + selectinload(InventoryItem.category), + selectinload(InventoryItem.supplier), + selectinload(InventoryItem.location), + ) + + @staticmethod + def list_items( + db: Session, + *, + q: str | None = None, + category_id: int | None = None, + supplier_id: int | None = None, + location_id: int | None = None, + low_stock: bool | None = None, + active: bool | None = True, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[InventoryItem], int]: + query = InventoryRepository.item_query() + if q: + pattern = f"%{q.strip()}%" + query = query.where(or_( + InventoryItem.sku.ilike(pattern), + InventoryItem.name.ilike(pattern), + InventoryItem.manufacturer.ilike(pattern), + InventoryItem.manufacturer_part_number.ilike(pattern), + InventoryItem.supplier_part_number.ilike(pattern), + )) + if category_id is not None: + query = query.where(InventoryItem.category_id == category_id) + if supplier_id is not None: + query = query.where(InventoryItem.supplier_id == supplier_id) + if location_id is not None: + query = query.where(InventoryItem.location_id == location_id) + if low_stock is not None: + if low_stock: + query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level) + else: + query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level) + if active is not None: + query = query.where(InventoryItem.is_active == active) + + total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 + items = list( + db.scalars( + query + .order_by(InventoryItem.name.asc(), InventoryItem.id.asc()) + .offset(offset) + .limit(limit) + ) + ) + return items, total + + @staticmethod + def get_item(db: Session, item_id: int) -> InventoryItem | None: + return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id)) + + @staticmethod + def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None: + return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku)) + + @staticmethod + def get_next_sku(db: Session, year: int | None = None) -> str: + current_year = year or datetime.now(UTC).year + prefix = f"ET-{current_year}-" + latest = db.scalar( + select(InventoryItem.sku) + .where(InventoryItem.sku.like(f"{prefix}%")) + .order_by(InventoryItem.sku.desc()) + .limit(1) + ) + next_number = 1 + if latest: + next_number = int(latest.split("-")[-1]) + 1 + return f"{prefix}{next_number:06d}" + + @staticmethod + def list_categories(db: Session) -> list[InventoryCategory]: + return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc()))) + + @staticmethod + def get_category(db: Session, category_id: int) -> InventoryCategory | None: + return db.get(InventoryCategory, category_id) + + @staticmethod + def list_locations(db: Session) -> list[InventoryLocation]: + return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc()))) + + @staticmethod + def get_location(db: Session, location_id: int) -> InventoryLocation | None: + return db.get(InventoryLocation, location_id) + + @staticmethod + def list_suppliers(db: Session) -> list[InventorySupplier]: + return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc()))) + + @staticmethod + def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None: + return db.get(InventorySupplier, supplier_id) + + @staticmethod + def has_movements(db: Session, item_id: int) -> bool: + return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1))) + + @staticmethod + def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]: + return list( + db.scalars( + select(InventoryStockMovement) + .where(InventoryStockMovement.item_id == item_id) + .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc()) + ) + ) + + @staticmethod + def create_movement( + db: Session, + *, + item_id: int, + movement_type: str, + quantity: int, + reason: str, + reference_type: str | None, + reference_id: int | None, + note: str | None, + actor_user_id: int | None, + ) -> InventoryStockMovement: + movement = InventoryStockMovement( + item_id=item_id, + movement_type=movement_type, + quantity=quantity, + reason=reason, + reference_type=reference_type, + reference_id=reference_id, + note=note, + actor_user_id=actor_user_id, + ) + db.add(movement) + return movement + + @staticmethod + def count_active_items(db: Session) -> int: + return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0 + + @staticmethod + def count_low_stock_items(db: Session) -> int: + return db.scalar( + select(func.count(InventoryItem.id)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.quantity_available <= InventoryItem.reorder_level) + ) or 0 + + @staticmethod + def total_stock_value_cents(db: Session) -> int: + return db.scalar( + select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.purchase_price_cents.is_not(None)) + ) or 0 + + @staticmethod + def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]: + return list( + db.scalars( + select(InventoryStockMovement) + .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc()) + .limit(limit) + ) + ) diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py index 047f18e..8eae677 100644 --- a/backend/hermes/app/schemas/dashboard.py +++ b/backend/hermes/app/schemas/dashboard.py @@ -25,6 +25,7 @@ class DashboardSummary(BaseModel): users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + inventory: list[MetricCard] = [] system_status: list[SystemStatusItem] = [] activities: EmptyWidget tasks: EmptyWidget diff --git a/backend/hermes/app/schemas/inventory.py b/backend/hermes/app/schemas/inventory.py new file mode 100644 index 0000000..093fb55 --- /dev/null +++ b/backend/hermes/app/schemas/inventory.py @@ -0,0 +1,231 @@ +from datetime import datetime +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +InventoryMovementType = Literal[ + "initial", + "purchase", + "adjustment", + "reservation", + "release", + "consumption", + "return", + "correction", +] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +def normalize_optional_text(value: object) -> str | None: + text = normalize_text(value) + return text or None + + +class InventoryCategoryPayload(BaseModel): + name: str = Field(min_length=1, max_length=160) + description: str | None = None + + @field_validator("name", "description", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventoryLocationPayload(BaseModel): + name: str = Field(min_length=1, max_length=160) + description: str | None = None + + @field_validator("name", "description", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventorySupplierPayload(BaseModel): + name: str = Field(min_length=1, max_length=180) + contact_name: str | None = None + email: str | None = None + phone: str | None = None + website: str | None = None + customer_number: str | None = None + notes: str | None = None + + @field_validator("*", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_optional_text(value) + + +class InventoryItemPayload(BaseModel): + sku: str | None = Field(default=None, max_length=40) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + category_id: int | None = None + manufacturer: str | None = None + manufacturer_part_number: str | None = None + supplier_id: int | None = None + supplier_part_number: str | None = None + compatible_devices: str | None = None + location_id: int | None = None + quantity_on_hand: int = Field(default=0, ge=0) + quantity_reserved: int = Field(default=0, ge=0) + reorder_level: int = Field(default=0, ge=0) + unit: str = Field(default="Stk.", max_length=40) + purchase_price_cents: int | None = Field(default=None, ge=0) + selling_price_cents: int | None = Field(default=None, ge=0) + currency: str = Field(default="EUR", max_length=3) + tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0")) + notes: str | None = None + is_active: bool = True + + @field_validator( + "sku", + "name", + "description", + "manufacturer", + "manufacturer_part_number", + "supplier_part_number", + "compatible_devices", + "unit", + "currency", + "notes", + mode="before", + ) + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + return value.upper() or "EUR" + + @field_validator("tax_rate_percent", mode="before") + @classmethod + def normalize_tax_rate(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + + @model_validator(mode="after") + def validate_reserved_quantity(self): + if self.quantity_reserved > self.quantity_on_hand: + raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten") + return self + + +class InventoryItemCreate(InventoryItemPayload): + pass + + +class InventoryItemUpdate(InventoryItemPayload): + pass + + +class InventoryStockAction(BaseModel): + quantity: int = Field(ge=0) + reason: str = Field(min_length=1, max_length=255) + note: str | None = None + reference_type: str | None = Field(default=None, max_length=80) + reference_id: int | None = None + + @field_validator("reason", "note", "reference_type", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventoryCategoryResponse(InventoryCategoryPayload): + id: int + slug: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventoryLocationResponse(InventoryLocationPayload): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventorySupplierResponse(InventorySupplierPayload): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventoryItemResponse(BaseModel): + id: int + sku: str + name: str + description: str | None + category_id: int | None + manufacturer: str | None + manufacturer_part_number: str | None + supplier_id: int | None + supplier_part_number: str | None + compatible_devices: str | None + location_id: int | None + quantity_on_hand: int + quantity_reserved: int + quantity_available: int + reorder_level: int + unit: str + purchase_price_cents: int | None + selling_price_cents: int | None + currency: str + tax_rate_percent: Decimal + notes: str | None + is_active: bool + created_at: datetime + updated_at: datetime + category: InventoryCategoryResponse | None + supplier: InventorySupplierResponse | None + location: InventoryLocationResponse | None + + model_config = ConfigDict(from_attributes=True) + + +class InventoryItemListResponse(BaseModel): + items: list[InventoryItemResponse] + total: int + limit: int + offset: int + + +class InventoryStockMovementResponse(BaseModel): + id: int + item_id: int + movement_type: str + quantity: int + reason: str + reference_type: str | None + reference_id: int | None + note: str | None + actor_user_id: int | None + actor_username: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 204e17c..d46ef66 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -180,6 +180,23 @@ def action_title(action: str) -> str: "repair_estimates.question": "Rückfrage zum Kostenvoranschlag", "repair_estimates.cancel": "Kostenvoranschlag storniert", "repair_estimates.delete": "Kostenvoranschlag gelöscht", + "inventory.items.create": "Lagerartikel erstellt", + "inventory.items.update": "Lagerartikel geändert", + "inventory.items.deactivate": "Lagerartikel deaktiviert", + "inventory.items.delete": "Lagerartikel gelöscht", + "inventory.stock.adjust": "Bestand angepasst", + "inventory.stock.reserve": "Bestand reserviert", + "inventory.stock.release": "Reservierung aufgehoben", + "inventory.stock.consume": "Bestand verbraucht", + "inventory.categories.create": "Lagerkategorie erstellt", + "inventory.categories.update": "Lagerkategorie geändert", + "inventory.categories.delete": "Lagerkategorie gelöscht", + "inventory.locations.create": "Lagerort erstellt", + "inventory.locations.update": "Lagerort geändert", + "inventory.locations.delete": "Lagerort gelöscht", + "inventory.suppliers.create": "Lieferant erstellt", + "inventory.suppliers.update": "Lieferant geändert", + "inventory.suppliers.delete": "Lieferant gelöscht", "system_settings.smtp.update": "SMTP-Konfiguration geändert", "system_settings.smtp.test_sent": "SMTP-Testmail versendet", "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py new file mode 100644 index 0000000..4960557 --- /dev/null +++ b/backend/hermes/app/services/inventory_service.py @@ -0,0 +1,318 @@ +import re +import unicodedata +from typing import TypeVar + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository +from app.schemas.inventory import ( + InventoryCategoryPayload, + InventoryItemCreate, + InventoryItemUpdate, + InventoryLocationPayload, + InventoryStockAction, + InventorySupplierPayload, +) +from app.services.audit_service import write_audit_log + +MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier) + + +def _slugify(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower() + return slug or "eintrag" + + +def _item_label(item: InventoryItem) -> str: + return f"{item.sku} · {item.name}" + + +def _audit_item_data(item: InventoryItem) -> dict: + return { + "id": item.id, + "sku": item.sku, + "name": item.name, + "category_id": item.category_id, + "supplier_id": item.supplier_id, + "location_id": item.location_id, + "quantity_on_hand": item.quantity_on_hand, + "quantity_reserved": item.quantity_reserved, + "quantity_available": item.quantity_available, + "reorder_level": item.reorder_level, + "selling_price_cents": item.selling_price_cents, + "currency": item.currency, + "is_active": item.is_active, + } + + +class InventoryService: + @staticmethod + def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem: + sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db) + InventoryService._validate_sku_available(db, sku) + item = InventoryItem(sku=sku) + InventoryService._apply_item_payload(item, payload) + try: + db.add(item) + db.flush() + if item.quantity_on_hand > 0: + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="initial", + quantity=item.quantity_on_hand, + reason="Anfangsbestand", + reference_type=None, + reference_id=None, + note=None, + actor_user_id=actor.id, + ) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None + db.refresh(item) + item = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action="inventory.items.create", + entity_type="inventory_items", + entity_id=item.id, + entity_label=_item_label(item), + actor=actor, + request=request, + after_data=_audit_item_data(item), + ) + return item + + @staticmethod + def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem: + before_data = _audit_item_data(item) + sku = (payload.sku or "").upper() or item.sku + InventoryService._validate_sku_available(db, sku, item_id=item.id) + item.sku = sku + InventoryService._apply_item_payload(item, payload) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None + db.refresh(item) + updated = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action="inventory.items.update", + entity_type="inventory_items", + entity_id=updated.id, + entity_label=_item_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(updated), + ) + return updated + + @staticmethod + def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None: + before_data = _audit_item_data(item) + label = _item_label(item) + if InventoryRepository.has_movements(db, item.id): + item.is_active = False + InventoryService._recalculate_available(item) + db.commit() + db.refresh(item) + write_audit_log( + db, + action="inventory.items.deactivate", + entity_type="inventory_items", + entity_id=item.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(item), + ) + return InventoryRepository.get_item(db, item.id) or item + + db.delete(item) + db.commit() + write_audit_log( + db, + action="inventory.items.delete", + entity_type="inventory_items", + entity_id=item.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + ) + return None + + @staticmethod + def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + before_data = _audit_item_data(item) + item.quantity_on_hand = payload.quantity + if item.quantity_reserved > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten") + InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request) + + @staticmethod + def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_available: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand") + before_data = _audit_item_data(item) + item.quantity_reserved += payload.quantity + InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request) + + @staticmethod + def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_reserved: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich") + before_data = _audit_item_data(item) + item.quantity_reserved -= payload.quantity + InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request) + + @staticmethod + def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand") + before_data = _audit_item_data(item) + item.quantity_on_hand -= payload.quantity + item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity) + InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request) + + @staticmethod + def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: + category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description) + return InventoryService._save_master(db, category, "inventory.categories.create", actor, request) + + @staticmethod + def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: + before_data = {"id": category.id, "name": category.name, "slug": category.slug} + category.name = payload.name + category.slug = _slugify(payload.name) + category.description = payload.description + return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data) + + @staticmethod + def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation: + location = InventoryLocation(name=payload.name, description=payload.description) + return InventoryService._save_master(db, location, "inventory.locations.create", actor, request) + + @staticmethod + def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation: + before_data = {"id": location.id, "name": location.name} + location.name = payload.name + location.description = payload.description + return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data) + + @staticmethod + def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier: + supplier = InventorySupplier(**payload.model_dump()) + return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request) + + @staticmethod + def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier: + before_data = {"id": supplier.id, "name": supplier.name} + for key, value in payload.model_dump().items(): + setattr(supplier, key, value) + return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data) + + @staticmethod + def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None: + label = getattr(entity, "name", "") + before_data = {"id": entity.id, "name": label} + db.delete(entity) + db.commit() + write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data) + + @staticmethod + def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None: + for key, value in payload.model_dump(exclude={"sku"}).items(): + setattr(item, key, value) + InventoryService._recalculate_available(item) + + @staticmethod + def _recalculate_available(item: InventoryItem) -> None: + item.quantity_available = item.quantity_on_hand - item.quantity_reserved + + @staticmethod + def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None: + existing = InventoryRepository.get_item_by_sku(db, sku) + if existing is not None and existing.id != item_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") + + @staticmethod + def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None: + if payload.quantity <= 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein") + + @staticmethod + def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None: + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type=movement_type, + quantity=payload.quantity, + reason=payload.reason, + reference_type=payload.reference_type, + reference_id=payload.reference_id, + note=payload.note, + actor_user_id=actor_user_id, + ) + + @staticmethod + def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem: + InventoryService._recalculate_available(item) + db.commit() + db.refresh(item) + updated = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action=action, + entity_type="inventory_items", + entity_id=updated.id, + entity_label=_item_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(updated), + ) + return updated + + @staticmethod + def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel: + try: + db.add(entity) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None + db.refresh(entity) + label = getattr(entity, "name", "") + write_audit_log( + db, + action=action, + entity_type=entity.__tablename__, + entity_id=entity.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + after_data={"id": entity.id, "name": label}, + ) + return entity diff --git a/frontend/athena/app/api/inventory/categories/[id]/route.ts b/frontend/athena/app/api/inventory/categories/[id]/route.ts new file mode 100644 index 0000000..4c1288a --- /dev/null +++ b/frontend/athena/app/api/inventory/categories/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxyCategoryRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/categories/${id}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyCategoryRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyCategoryRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/categories/route.ts b/frontend/athena/app/api/inventory/categories/route.ts new file mode 100644 index 0000000..568b58d --- /dev/null +++ b/frontend/athena/app/api/inventory/categories/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/inventory/categories"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/categories"); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/movements/route.ts b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts new file mode 100644 index 0000000..c3eade1 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/movements`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/route.ts b/frontend/athena/app/api/inventory/items/[id]/route.ts new file mode 100644 index 0000000..0cbc57b --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxyItemRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyItemRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyItemRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyItemRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts new file mode 100644 index 0000000..ccc0243 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts new file mode 100644 index 0000000..8ac6939 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts new file mode 100644 index 0000000..94c1db7 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts new file mode 100644 index 0000000..f7c5752 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`); +} diff --git a/frontend/athena/app/api/inventory/items/route.ts b/frontend/athena/app/api/inventory/items/route.ts new file mode 100644 index 0000000..1000e7f --- /dev/null +++ b/frontend/athena/app/api/inventory/items/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/items"); +} diff --git a/frontend/athena/app/api/inventory/locations/[id]/route.ts b/frontend/athena/app/api/inventory/locations/[id]/route.ts new file mode 100644 index 0000000..6823a7e --- /dev/null +++ b/frontend/athena/app/api/inventory/locations/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxyLocationRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/locations/${id}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyLocationRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyLocationRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/locations/route.ts b/frontend/athena/app/api/inventory/locations/route.ts new file mode 100644 index 0000000..08efc26 --- /dev/null +++ b/frontend/athena/app/api/inventory/locations/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/inventory/locations"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/locations"); +} diff --git a/frontend/athena/app/api/inventory/suppliers/[id]/route.ts b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts new file mode 100644 index 0000000..c296251 --- /dev/null +++ b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxySupplierRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/suppliers/${id}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxySupplierRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxySupplierRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/suppliers/route.ts b/frontend/athena/app/api/inventory/suppliers/route.ts new file mode 100644 index 0000000..6c9f421 --- /dev/null +++ b/frontend/athena/app/api/inventory/suppliers/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/inventory/suppliers"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/suppliers"); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index 4b56102..4f8cb10 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -62,7 +62,7 @@ export default function DashboardPage() { return
{error || "Keine Dashboarddaten verfügbar"}
; } - const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles]; + const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles]; return (
diff --git a/frontend/athena/app/inventory/items/[id]/page.tsx b/frontend/athena/app/inventory/items/[id]/page.tsx new file mode 100644 index 0000000..cb76fe8 --- /dev/null +++ b/frontend/athena/app/inventory/items/[id]/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { use, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; + +import { formatDate, formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { InventoryItem, InventoryStockMovement } from "@/types/inventory"; + +type PageProps = { + params: Promise<{ + id: string; + }>; +}; + +const movementLabels: Record = { + initial: "Anfangsbestand", + purchase: "Einkauf", + adjustment: "Anpassung", + reservation: "Reservierung", + release: "Freigabe", + consumption: "Verbrauch", + return: "Rückgabe", + correction: "Korrektur", +}; + +export default function InventoryItemDetailPage({ params }: PageProps) { + const { id } = use(params); + const [item, setItem] = useState(null); + const [movements, setMovements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const loadItem = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [itemResponse, movementResponse] = await Promise.all([ + api.get(`/inventory/items/${id}`), + api.get(`/inventory/items/${id}/movements`), + ]); + setItem(itemResponse.data); + setMovements(movementResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + queueMicrotask(() => { + void loadItem(); + }); + }, [loadItem]); + + if (loading) { + return
Artikel wird geladen...
; + } + + if (error || !item) { + return
{error || "Artikel nicht gefunden"}
; + } + + return ( +
+
+
+

{item.sku}

+

{item.name}

+

{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}

+
+ +
+ +
+
+

Stammdaten

+
+ + + + + + + + +
+
+ +
+

Bestand

+
+ + + + +
+
+
+ +
+
+

Preise

+
+ + + +
+
+ +
+

Bewegungen

+ {movements.length === 0 ? ( +

Noch keine Bewegungen vorhanden.

+ ) : ( +
+ {movements.map((movement) => ( +
+
+

{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}

+ +
+

{movement.reason}{movement.note ? ` · ${movement.note}` : ""}

+ {movement.actor_username &&

Gebucht von {movement.actor_username}

} +
+ ))} +
+ )} +
+
+
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/athena/app/inventory/page.tsx b/frontend/athena/app/inventory/page.tsx new file mode 100644 index 0000000..ce588c3 --- /dev/null +++ b/frontend/athena/app/inventory/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Edit, Eye, PackagePlus, Plus, RotateCcw, Trash2, Wrench } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import EmptyState from "@/components/common/EmptyState"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import InventoryItemDialog from "@/components/inventory/InventoryItemDialog"; +import MasterDataSection from "@/components/inventory/MasterDataSection"; +import StockActionDialog, { type StockAction } from "@/components/inventory/StockActionDialog"; +import { formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { + InventoryCategory, + InventoryItem, + InventoryItemListResponse, + InventoryItemPayload, + InventoryLocation, + InventoryStockPayload, + InventorySupplier, +} from "@/types/inventory"; +import type { CurrentUser } from "@/types/rbac"; + +const pageSize = 10; + +export default function InventoryPage() { + const { showToast } = useToast(); + const [items, setItems] = useState([]); + const [categories, setCategories] = useState([]); + const [locations, setLocations] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [stockError, setStockError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [pending, setPending] = useState(false); + const [tab, setTab] = useState<"items" | "categories" | "locations" | "suppliers">("items"); + const [search, setSearch] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [locationFilter, setLocationFilter] = useState("all"); + const [supplierFilter, setSupplierFilter] = useState("all"); + const [stockFilter, setStockFilter] = useState<"all" | "low">("all"); + const [activeFilter, setActiveFilter] = useState<"true" | "false" | "all">("true"); + const [page, setPage] = useState(1); + const [sortKey, setSortKey] = useState("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [formOpen, setFormOpen] = useState(false); + const [stockOpen, setStockOpen] = useState(false); + const [stockAction, setStockAction] = useState("adjust"); + const [selectedItem, setSelectedItem] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams(); + params.set("limit", String(pageSize)); + params.set("offset", String((page - 1) * pageSize)); + if (search.trim()) params.set("q", search.trim()); + if (categoryFilter !== "all") params.set("category_id", categoryFilter); + if (locationFilter !== "all") params.set("location_id", locationFilter); + if (supplierFilter !== "all") params.set("supplier_id", supplierFilter); + if (stockFilter === "low") params.set("low_stock", "true"); + if (activeFilter !== "all") params.set("active", activeFilter); + const [itemResponse, categoryResponse, locationResponse, supplierResponse, meResponse] = await Promise.all([ + api.get(`/inventory/items?${params.toString()}`), + api.get("/inventory/categories"), + api.get("/inventory/locations"), + api.get("/inventory/suppliers"), + api.get("/me"), + ]); + setItems(itemResponse.data.items); + setTotal(itemResponse.data.total); + setCategories(categoryResponse.data); + setLocations(locationResponse.data); + setSuppliers(supplierResponse.data); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [activeFilter, categoryFilter, locationFilter, page, search, stockFilter, supplierFilter]); + + useEffect(() => { + queueMicrotask(() => { + void loadData(); + }); + }, [loadData]); + + const canCreate = hasPermission(currentUser, "inventory.create"); + const canUpdate = hasPermission(currentUser, "inventory.update"); + const canDelete = hasPermission(currentUser, "inventory.delete"); + const canAdjust = hasPermission(currentUser, "inventory.stock.adjust"); + const canReserve = hasPermission(currentUser, "inventory.stock.reserve"); + const canConsume = hasPermission(currentUser, "inventory.stock.consume"); + const canManageMasterdata = hasPermission(currentUser, "inventory.manage.masterdata"); + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount); + + const sortedItems = useMemo(() => [...items].sort((first, second) => { + const firstValue = String(first[sortKey as keyof InventoryItem] ?? "").toLowerCase(); + const secondValue = String(second[sortKey as keyof InventoryItem] ?? "").toLowerCase(); + if (firstValue === secondValue) return 0; + const result = firstValue > secondValue ? 1 : -1; + return sortDirection === "asc" ? result : -result; + }), [items, sortDirection, sortKey]); + + const columns: DataTableColumn[] = [ + { key: "sku", label: "SKU", sortable: true, render: (item) => {item.sku} }, + { key: "name", label: "Name", sortable: true, render: (item) => item.name }, + { key: "category", label: "Kategorie", render: (item) => item.category?.name ?? "Keine" }, + { key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer ?? "Keine Angabe" }, + { key: "quantity_on_hand", label: "Bestand", sortable: true, render: (item) => item.quantity_on_hand }, + { key: "quantity_reserved", label: "Reserviert", sortable: true, render: (item) => item.quantity_reserved }, + { key: "quantity_available", label: "Verfügbar", sortable: true, render: (item) => {item.quantity_available} }, + { key: "reorder_level", label: "Mindestbestand", sortable: true, render: (item) => item.reorder_level }, + { key: "location", label: "Lagerort", render: (item) => item.location?.name ?? "Kein Lagerort" }, + { key: "selling_price_cents", label: "VK", sortable: true, render: (item) => formatMoney(item.selling_price_cents, item.currency) }, + { key: "is_active", label: "Status", sortable: true, render: (item) => item.is_active ? "Aktiv" : "Inaktiv" }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + {canUpdate && } + {canAdjust && } + {canReserve && } + {canConsume && } + {canDelete && } +
+ ), + }, + ]; + + function openStock(item: InventoryItem, action: StockAction) { + setSelectedItem(item); + setStockAction(action); + setStockError(""); + setStockOpen(true); + } + + function handleSort(key: string) { + if (sortKey === key) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortDirection("asc"); + } + + async function saveItem(payload: InventoryItemPayload) { + setPending(true); + setFormError(""); + try { + if (selectedItem) { + await api.put(`/inventory/items/${selectedItem.id}`, payload); + showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku }); + } else { + const response = await api.post("/inventory/items", payload); + showToast({ type: "success", title: "Artikel angelegt", description: response.data.sku }); + } + setFormOpen(false); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Artikel konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function saveStock(payload: InventoryStockPayload) { + if (!selectedItem) return; + setPending(true); + setStockError(""); + try { + await api.post(`/inventory/items/${selectedItem.id}/stock/${stockAction}`, payload); + showToast({ type: "success", title: "Bestand gebucht", description: selectedItem.sku }); + setStockOpen(false); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setStockError(message); + showToast({ type: "error", title: "Bestand konnte nicht gebucht werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + setDeleteError(""); + try { + await api.delete(`/inventory/items/${deleteItem.id}`); + showToast({ type: "success", title: "Artikel deaktiviert", description: deleteItem.sku }); + setDeleteItem(null); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Artikel konnte nicht deaktiviert werden", description: message }); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

Lager / Ersatzteile

+

{total} Artikel im aktuellen Filter

+
+ {canCreate && } +
+ +
+ {(["items", "categories", "locations", "suppliers"] as const).map((value) => ( + + ))} +
+ + {tab === "items" ? ( + <> +
+ { setSearch(value); setPage(1); }} placeholder="Artikel suchen" /> + + + + + +
+ + {!loading && !error && total === 0 && !search ? ( + setFormOpen(true)}>Artikel anlegen : undefined} + /> + ) : ( + item.id} + sortKey={sortKey} + sortDirection={sortDirection} + loading={loading} + error={error} + emptyTitle="Keine Artikel gefunden." + emptyDescription="Passe Suche oder Filter an." + onSort={handleSort} + /> + )} + +
+ Seite {currentPage} von {pageCount} +
+ + +
+
+ + ) : ( +
+ {tab === "categories" && void loadData()} />} + {tab === "locations" && void loadData()} />} + {tab === "suppliers" && void loadData()} />} +
+ )} + + + + !open && setDeleteItem(null)} + onConfirm={confirmDelete} + > +
+

{deleteItem?.sku} · {deleteItem?.name}

+ {deleteError &&

{deleteError}

} +
+
+
+ ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 07d85b7..00ed94e 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -66,6 +66,7 @@ const menu = [ icon: Package, name: "Lager", href: "/inventory", + permission: "inventory.read", }, { icon: ShoppingCart, diff --git a/frontend/athena/components/inventory/InventoryItemDialog.tsx b/frontend/athena/components/inventory/InventoryItemDialog.tsx new file mode 100644 index 0000000..76776a2 --- /dev/null +++ b/frontend/athena/components/inventory/InventoryItemDialog.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { + InventoryCategory, + InventoryItem, + InventoryItemPayload, + InventoryLocation, + InventorySupplier, +} from "@/types/inventory"; +import { centsToEuroInput, emptyItemPayload, euroToCents } from "./inventory-utils"; + +type Props = { + open: boolean; + item: InventoryItem | null; + categories: InventoryCategory[]; + locations: InventoryLocation[]; + suppliers: InventorySupplier[]; + pending: boolean; + serverError: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: InventoryItemPayload) => void; +}; + +function payloadFromItem(item: InventoryItem | null): InventoryItemPayload { + if (!item) { + return emptyItemPayload; + } + return { + sku: item.sku, + name: item.name, + description: item.description, + category_id: item.category_id, + manufacturer: item.manufacturer, + manufacturer_part_number: item.manufacturer_part_number, + supplier_id: item.supplier_id, + supplier_part_number: item.supplier_part_number, + compatible_devices: item.compatible_devices, + location_id: item.location_id, + quantity_on_hand: item.quantity_on_hand, + quantity_reserved: item.quantity_reserved, + reorder_level: item.reorder_level, + unit: item.unit, + purchase_price_cents: item.purchase_price_cents, + selling_price_cents: item.selling_price_cents, + currency: item.currency, + tax_rate_percent: item.tax_rate_percent, + notes: item.notes, + is_active: item.is_active, + }; +} + +export default function InventoryItemDialog({ + open, + item, + categories, + locations, + suppliers, + pending, + serverError, + onOpenChange, + onSubmit, +}: Props) { + const initialPayload = payloadFromItem(item); + const [payload, setPayload] = useState(initialPayload); + const [purchasePrice, setPurchasePrice] = useState(centsToEuroInput(initialPayload.purchase_price_cents)); + const [sellingPrice, setSellingPrice] = useState(centsToEuroInput(initialPayload.selling_price_cents)); + const [clientError, setClientError] = useState(""); + + function update(key: K, value: InventoryItemPayload[K]) { + setPayload((current) => ({ ...current, [key]: value })); + } + + function submit() { + const purchaseCents = euroToCents(purchasePrice); + const sellingCents = euroToCents(sellingPrice); + if (!payload.name.trim()) { + setClientError("Bitte einen Artikelnamen angeben."); + return; + } + if (payload.quantity_reserved > payload.quantity_on_hand) { + setClientError("Reservierter Bestand darf den Lagerbestand nicht überschreiten."); + return; + } + if ((purchasePrice.trim() && purchaseCents === null) || (sellingPrice.trim() && sellingCents === null)) { + setClientError("Bitte Preise als Eurobetrag eingeben, z. B. 1,50."); + return; + } + setClientError(""); + onSubmit({ + ...payload, + sku: payload.sku?.trim() || null, + name: payload.name.trim(), + purchase_price_cents: purchaseCents, + selling_price_cents: sellingCents, + description: payload.description?.trim() || null, + manufacturer: payload.manufacturer?.trim() || null, + manufacturer_part_number: payload.manufacturer_part_number?.trim() || null, + supplier_part_number: payload.supplier_part_number?.trim() || null, + compatible_devices: payload.compatible_devices?.trim() || null, + notes: payload.notes?.trim() || null, + }); + } + + return ( + + + + {item ? "Artikel bearbeiten" : "Artikel anlegen"} + Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten. + + +
+
+

Stammdaten

+
+ + +