Files
bakery-ia/services/inventory/app/api/stock_entries.py

335 lines
11 KiB
Python

# services/inventory/app/api/stock_entries.py
"""
Stock Entries API - ATOMIC CRUD operations on Stock model
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.core.database import get_db
from app.services.inventory_service import InventoryService
from app.schemas.inventory import (
StockCreate,
StockUpdate,
StockResponse,
StockMovementCreate,
StockMovementResponse,
BulkStockCreate,
BulkStockResponse
)
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role, admin_role_required
from shared.routing import RouteBuilder
logger = structlog.get_logger()
route_builder = RouteBuilder('inventory')
router = APIRouter(tags=["stock-entries"])
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:
if current_user.get('type') == 'service':
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not found in context"
)
try:
return UUID(user_id)
except (ValueError, TypeError):
return None
@router.post(
route_builder.build_base_route("stock"),
response_model=StockResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def add_stock(
stock_data: StockCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Add new stock entry"""
try:
user_id = get_current_user_id(current_user)
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"
)
@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"
)
@router.get(
route_builder.build_base_route("stock"),
response_model=List[StockResponse]
)
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"),
current_user: dict = Depends(get_current_user_dep),
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"
)
# ===== 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) =====
@router.get(
route_builder.build_resource_detail_route("stock", "stock_id"),
response_model=StockResponse
)
async def get_stock_entry(
stock_id: UUID = Path(..., description="Stock entry ID"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get specific stock entry"""
try:
service = InventoryService()
stock = await service.get_stock_entry(stock_id, tenant_id)
if not stock:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Stock entry not found"
)
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"
)
@router.put(
route_builder.build_resource_detail_route("stock", "stock_id"),
response_model=StockResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def update_stock(
stock_data: StockUpdate,
stock_id: UUID = Path(..., description="Stock entry ID"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update stock entry"""
try:
service = InventoryService()
stock = await service.update_stock(stock_id, stock_data, tenant_id)
if not stock:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Stock entry not found"
)
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"
)
@router.delete(
route_builder.build_resource_detail_route("stock", "stock_id"),
status_code=status.HTTP_204_NO_CONTENT
)
@admin_role_required
async def delete_stock(
stock_id: UUID = Path(..., description="Stock entry ID"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete stock entry"""
try:
service = InventoryService()
deleted = await service.delete_stock(stock_id, tenant_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Stock entry not found"
)
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"
)