feat(inventory): add spare parts management

This commit is contained in:
Schubert Ferenc 2026-07-05 11:32:06 +02:00
parent abc6e308dd
commit fb5c2cc26a
43 changed files with 2950 additions and 2 deletions

View file

@ -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

View file

@ -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")

View file

@ -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."):

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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 ""

View file

@ -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",

View file

@ -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)
)
)

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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