feat(inventory): add spare parts management
This commit is contained in:
parent
abc6e308dd
commit
fb5c2cc26a
43 changed files with 2950 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal 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")
|
||||
Binary file not shown.
|
|
@ -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."):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
307
backend/hermes/app/api/inventory.py
Normal file
307
backend/hermes/app/api/inventory.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
110
backend/hermes/app/models/inventory.py
Normal file
110
backend/hermes/app/models/inventory.py
Normal 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 ""
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
187
backend/hermes/app/repositories/inventory_repository.py
Normal file
187
backend/hermes/app/repositories/inventory_repository.py
Normal 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)
|
||||
)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
231
backend/hermes/app/schemas/inventory.py
Normal file
231
backend/hermes/app/schemas/inventory.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
318
backend/hermes/app/services/inventory_service.py
Normal file
318
backend/hermes/app/services/inventory_service.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue