# 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 ) 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.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" )