2025-10-06 15:27:01 +02:00
|
|
|
# services/inventory/app/api/stock_entries.py
|
2025-08-13 17:39:35 +02:00
|
|
|
"""
|
2025-10-06 15:27:01 +02:00
|
|
|
Stock Entries API - ATOMIC CRUD operations on Stock model
|
2025-08-13 17:39:35 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
from uuid import UUID
|
2025-09-09 21:39:12 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
2025-08-13 17:39:35 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-09-16 12:21:15 +02:00
|
|
|
import structlog
|
2025-08-13 17:39:35 +02:00
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
|
|
|
|
from app.services.inventory_service import InventoryService
|
|
|
|
|
from app.schemas.inventory import (
|
2025-10-06 15:27:01 +02:00
|
|
|
StockCreate,
|
|
|
|
|
StockUpdate,
|
2025-08-13 17:39:35 +02:00
|
|
|
StockResponse,
|
|
|
|
|
StockMovementCreate,
|
2026-01-01 19:01:33 +01:00
|
|
|
StockMovementResponse,
|
|
|
|
|
BulkStockCreate,
|
|
|
|
|
BulkStockResponse
|
2025-08-13 17:39:35 +02:00
|
|
|
)
|
|
|
|
|
from shared.auth.decorators import get_current_user_dep
|
2025-10-06 15:27:01 +02:00
|
|
|
from shared.auth.access_control import require_user_role, admin_role_required
|
|
|
|
|
from shared.routing import RouteBuilder
|
2025-08-13 17:39:35 +02:00
|
|
|
|
2025-09-16 12:21:15 +02:00
|
|
|
logger = structlog.get_logger()
|
2025-10-06 15:27:01 +02:00
|
|
|
route_builder = RouteBuilder('inventory')
|
|
|
|
|
router = APIRouter(tags=["stock-entries"])
|
|
|
|
|
|
2025-08-13 17:39:35 +02:00
|
|
|
|
|
|
|
|
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
|
|
|
|
"""Extract user ID from current user context"""
|
|
|
|
|
user_id = current_user.get('user_id')
|
|
|
|
|
if not user_id:
|
2025-09-09 21:39:12 +02:00
|
|
|
if current_user.get('type') == 'service':
|
|
|
|
|
return None
|
2025-08-13 17:39:35 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="User ID not found in context"
|
|
|
|
|
)
|
2025-09-09 21:39:12 +02:00
|
|
|
try:
|
|
|
|
|
return UUID(user_id)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return None
|
2025-08-13 17:39:35 +02:00
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.post(
|
|
|
|
|
route_builder.build_base_route("stock"),
|
|
|
|
|
response_model=StockResponse,
|
|
|
|
|
status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner', 'member'])
|
2025-08-13 17:39:35 +02:00
|
|
|
async def add_stock(
|
|
|
|
|
stock_data: StockCreate,
|
2025-09-09 21:39:12 +02:00
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-08-13 17:39:35 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Add new stock entry"""
|
|
|
|
|
try:
|
2025-09-09 21:39:12 +02:00
|
|
|
user_id = get_current_user_id(current_user)
|
2025-08-13 17:39:35 +02:00
|
|
|
service = InventoryService()
|
|
|
|
|
stock = await service.add_stock(stock_data, tenant_id, user_id)
|
|
|
|
|
return stock
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to add stock"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-01 19:01:33 +01:00
|
|
|
@router.post(
|
|
|
|
|
route_builder.build_base_route("stock/bulk"),
|
|
|
|
|
response_model=BulkStockResponse,
|
|
|
|
|
status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
|
|
|
async def bulk_add_stock(
|
|
|
|
|
bulk_data: BulkStockCreate,
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Bulk add stock entries for efficient batch operations"""
|
|
|
|
|
try:
|
|
|
|
|
user_id = get_current_user_id(current_user)
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
result = await service.bulk_add_stock(bulk_data, tenant_id, user_id)
|
|
|
|
|
return result
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to bulk add stock", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to bulk add stock"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_base_route("stock"),
|
|
|
|
|
response_model=List[StockResponse]
|
|
|
|
|
)
|
2025-09-09 21:39:12 +02:00
|
|
|
async def get_stock(
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
|
|
|
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
|
|
|
|
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
|
|
|
|
available_only: bool = Query(True, description="Show only available stock"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-09-09 21:39:12 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Get stock entries with filtering"""
|
|
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
stock_entries = await service.get_stock(
|
|
|
|
|
tenant_id, skip, limit, ingredient_id, available_only
|
|
|
|
|
)
|
|
|
|
|
return stock_entries
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get stock entries"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-24 13:05:04 +02:00
|
|
|
# ===== STOCK MOVEMENTS ROUTES (must come before stock/{stock_id} route) =====
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_base_route("stock/movements"),
|
|
|
|
|
response_model=List[StockMovementResponse]
|
|
|
|
|
)
|
|
|
|
|
async def get_stock_movements(
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
|
|
|
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
|
|
|
|
ingredient_id: Optional[str] = Query(None, description="Filter by ingredient"),
|
|
|
|
|
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Get stock movements with filtering"""
|
|
|
|
|
logger.info("Stock movements endpoint called",
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
ingredient_id=ingredient_id,
|
|
|
|
|
skip=skip,
|
|
|
|
|
limit=limit,
|
|
|
|
|
movement_type=movement_type)
|
|
|
|
|
|
|
|
|
|
# Validate and convert ingredient_id if provided
|
|
|
|
|
ingredient_uuid = None
|
|
|
|
|
if ingredient_id:
|
|
|
|
|
try:
|
|
|
|
|
ingredient_uuid = UUID(ingredient_id)
|
|
|
|
|
logger.info("Ingredient ID validated", ingredient_id=str(ingredient_uuid))
|
|
|
|
|
except (ValueError, AttributeError) as e:
|
|
|
|
|
logger.error("Invalid ingredient_id format",
|
|
|
|
|
ingredient_id=ingredient_id,
|
|
|
|
|
error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=f"Invalid ingredient_id format: {ingredient_id}. Must be a valid UUID."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
movements = await service.get_stock_movements(
|
|
|
|
|
tenant_id, skip, limit, ingredient_uuid, movement_type
|
|
|
|
|
)
|
|
|
|
|
logger.info("Successfully retrieved stock movements",
|
|
|
|
|
count=len(movements),
|
|
|
|
|
tenant_id=str(tenant_id))
|
|
|
|
|
return movements
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
logger.error("Validation error in stock movements",
|
|
|
|
|
error=str(e),
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
ingredient_id=ingredient_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get stock movements",
|
|
|
|
|
error=str(e),
|
|
|
|
|
error_type=type(e).__name__,
|
|
|
|
|
tenant_id=str(tenant_id),
|
|
|
|
|
ingredient_id=ingredient_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"Failed to get stock movements: {str(e)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
route_builder.build_base_route("stock/movements"),
|
|
|
|
|
response_model=StockMovementResponse,
|
|
|
|
|
status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
|
|
|
async def create_stock_movement(
|
|
|
|
|
movement_data: StockMovementCreate,
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Create stock movement record"""
|
|
|
|
|
try:
|
|
|
|
|
user_id = get_current_user_id(current_user)
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
|
|
|
|
|
return movement
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to create stock movement"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== STOCK DETAIL ROUTES (must come after stock/movements routes) =====
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_resource_detail_route("stock", "stock_id"),
|
|
|
|
|
response_model=StockResponse
|
|
|
|
|
)
|
2025-09-09 21:39:12 +02:00
|
|
|
async def get_stock_entry(
|
|
|
|
|
stock_id: UUID = Path(..., description="Stock entry ID"),
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-09-09 21:39:12 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Get specific stock entry"""
|
|
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
stock = await service.get_stock_entry(stock_id, tenant_id)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-09-09 21:39:12 +02:00
|
|
|
if not stock:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Stock entry not found"
|
|
|
|
|
)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-09-09 21:39:12 +02:00
|
|
|
return stock
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get stock entry"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.put(
|
|
|
|
|
route_builder.build_resource_detail_route("stock", "stock_id"),
|
|
|
|
|
response_model=StockResponse
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner', 'member'])
|
2025-09-09 21:39:12 +02:00
|
|
|
async def update_stock(
|
2025-09-09 22:27:52 +02:00
|
|
|
stock_data: StockUpdate,
|
2025-09-09 21:39:12 +02:00
|
|
|
stock_id: UUID = Path(..., description="Stock entry ID"),
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-09-09 21:39:12 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Update stock entry"""
|
|
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
stock = await service.update_stock(stock_id, stock_data, tenant_id)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-09-09 21:39:12 +02:00
|
|
|
if not stock:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Stock entry not found"
|
|
|
|
|
)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-09-09 21:39:12 +02:00
|
|
|
return stock
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e)
|
|
|
|
|
)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to update stock entry"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-10-06 15:27:01 +02:00
|
|
|
@router.delete(
|
|
|
|
|
route_builder.build_resource_detail_route("stock", "stock_id"),
|
|
|
|
|
status_code=status.HTTP_204_NO_CONTENT
|
|
|
|
|
)
|
|
|
|
|
@admin_role_required
|
2025-09-09 21:39:12 +02:00
|
|
|
async def delete_stock(
|
|
|
|
|
stock_id: UUID = Path(..., description="Stock entry ID"),
|
|
|
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
2025-10-06 15:27:01 +02:00
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
2025-09-09 21:39:12 +02:00
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
2025-10-06 15:27:01 +02:00
|
|
|
"""Delete stock entry"""
|
2025-09-09 21:39:12 +02:00
|
|
|
try:
|
|
|
|
|
service = InventoryService()
|
|
|
|
|
deleted = await service.delete_stock(stock_id, tenant_id)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-09-09 21:39:12 +02:00
|
|
|
if not deleted:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Stock entry not found"
|
|
|
|
|
)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-09-09 21:39:12 +02:00
|
|
|
return None
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to delete stock entry"
|
|
|
|
|
)
|