Chapter 5 Business Decision Making

Finally, we discuss searching for the best drive-through design, where a design corresponds to a set of decisions, including the staffing level at each station and whether or not to implement a large-scale renovation project. Because the “best” design depends on the definitions of business objective, we investigate two alternatives.

  1. Minimize operational cost while meeting performance constraints.

    • One way to resolve issues of drive-through congestion (as mentioned in Chapter 1) is to utilize minimal operational resouces while bounding key performance metrics such as customers’ waiting time and length of the car line. A survey of drive-through customers suggests that a waiting time of 13 minutes is acceptable, similar to the stats reported in QSR Magazine. Moreover, a system carline of 8 or less can keep the cars a sufficient distance from the main road of the Marketplace.
  2. Minimize total cost consisting of operational cost and lost sales due to inferior performance (e.g., long car line).

    • Alternatively, one may quantify the cost of violating performance constraints by counting lost sales (i.e., customer abandonment) and minimize the sum of operational cost and cost of lost sales, which implicitly considers service quality when trading off various costs. Specifically, a customer, upon arrival at the drive-through, decides to balk if the system carline exceeds 8.

To represent the decision problems above, we introduce a few mathematical notations. All cost numbers are in thousands.

  • \(I^{reno}=1\) if rebuilding the facility (with a one-time cost of one million dollars: \(c^{reno}=1000\)), and 0 otherwise.

  • \(N^{cashier}\) and \(N^{cook}\) are the number of workers at the ordering station and the pickup station, respectively.

  • \(c^{cashier}\) and \(c^{cook}\) are the costs of one cashier and one cook per year, respectively. Recall that the staffing decisions are made specifically for rush hours (11 a.m. to 1 p.m. and 5 p.m. to 7 p.m.), and the focal Chick-fil-A operates six days a week (roughly \(6\textrm{days/week} \times 52\textrm{weeks}=312\textrm{days}\) per year). At an hourly rate of $15 per worker, the estimated annual cost per worker is \(c^{cashier}=c^{cook}=4\textrm{rush hours/day}\times 312\textrm{days} \times \$15/1000=18.7\).

  • A design of the drive-through corresponds to a set of values for the decision variables concerning the renovation (\(I^{reno}\)) and the staffing level (\(N^{cashier}\), \(N^{cook}\)). Based on the previous bottleneck analysis, we propose the following designs for consideration (i.e., we perform a grid search):

    • \(I^{reno}=0,\ N^{cashier}=2\), \(N^{cook}\in\{3,4,5\}\).

    • \(I^{reno}=1,\ N^{cashier}=2\), \(N^{cook}\in\{3,4,5\}\).

5.1 Operational cost with performance constraints

In this example, the best design minimizes 10-year operational cost subject to performance constraints. Note that whether or not renovation takes place will affect which simulator we apply to evaluate system performance. If \(I^{reno}=0\) (1), we shall run the simulator of the “old” (“new”) Chick-fil-A.

\[\begin{align} \min_{I^{reno}, N^{cashier},N^{cook}}\quad &I^{reno}\cdot c^{reno}+(N^{cashier}\cdot c^{cashier}+N^{cook}\cdot c^{cook})\cdot 10\\ \text{subject to: } &\text{Average flow time}\leq 13 \text{ minutes}\\ &\text{Average system carline}\leq 8\text{ cars} \end{align}\]

We next evaluate various designs with simulation.

Designs=data.frame(I=rep(0,3),Ncashier=rep(2,3),Ncook=3:5,
                   flow_time=rep(0,3),system_carline=rep(0,3))

Designs=rbind(Designs,Designs)
Designs[4:6,'I']=1

for(ii in 1:nrow(Designs)){
  if(Designs[ii,'I']==0){ # before renovation
    out=DES_before(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                   space_pickup = 6)
  }else{ # after renovation
    out=DES_after(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                   space_pickup = 10)
  }
  
  Designs[ii,'flow_time']=out$flow_time
  Designs[ii,'system_carline']=out$system_carline
}

write.csv(Designs, "Designs.csv",row.names = FALSE)

The following table summarizes all the designs and their performance metrics.

Designs = read.csv("Designs.csv")

## cost parameters (in thousands)
c_reno=1000 # the cost of the renovation project 
c_worker=15*4*52*6/1000 # the annual cost of the each worker during rush hours

## compute cost function
Designs$Meet_Constraints=1*(Designs$flow_time<=15)*(Designs$system_carline<=8)
Designs$Cost=c_reno*Designs$I+c_worker*(Designs$Ncashier+Designs$Ncook)*10

Designs
##   I Ncashier Ncook flow_time system_carline Meet_Constraints   Cost
## 1 0        2     3 18.939296      10.587536                0  936.0
## 2 0        2     4  6.365675       4.460407                1 1123.2
## 3 0        2     5  5.461123       3.894831                1 1310.4
## 4 1        2     3 18.462271       8.499721                0 1936.0
## 5 1        2     4  6.368084       3.326079                1 2123.2
## 6 1        2     5  5.471719       2.902712                1 2310.4

5.1.1 Discussion

  • If the objective is to minimize ten-year operational cost while meeting the flow time and carline constraints, the best design is given by \(I^{reno}=0,N^{cashier}=2,N^{cook}=4\), that is, there is no need to rebuild Chick-fil-A with considerable cost. This conclusion highlights the value of using DES to predict system performance and prescribe the best cost-effective decision.

  • The previous bottleneck analysis provides important insights that guide the selection of potential designs. In other words, with an understanding of constraining resources, we can restrict the search space of the optimization problem, which in practice would significantly reduce the computational cost and is a fundamental technique in operations research.

  • The conclusion we draw depends on the optimization problem defined by the objective function and the constraints. If we only require the flow time to be under 20 and the system carline to be under 9, then the design \(I^{reno}=1,N^{cashier}=2,N^{cook}=3\) also meets the constraints. This particular design can be optimal if the unit staffing cost \(c^{cook}\) is sufficiently high. Hence, collaborating with practitioners and identifying objectives and constraints play a key role in deriving relevant solutions based on a comprehensive understanding of the business operations.

5.2 Tota cost including lost sales

To account for lost sales, we first refine our simulators to include customer balking upon arrival. We introduce a new parameter reflecting consumer tolerance of the car line.

# Upon arrival, a customer balks (decides not to join the drive-through) 
# if the system carline exceeds MAX_line
MAX_line = 8

The simulator of Chick-fil-A before renovation with customer abandonment is as follows.

################################################################################
########### Simulator (with abandonment) before renovation ########################################
################################################################################
DES_AB_before=function(num_cashier,num_cook,space_pickup, MAX_line){
  customer = trajectory("Customer's path") %>%
    branch(function() (max(get_server_count(env,"lane1"),get_server_count(env,"lane2"))+
             get_server_count(env,"lane_pickup")>=MAX_line)*1+1, continue = c(TRUE,FALSE),
           trajectory("Join car line"),
           trajectory("Customer balks") %>% 
             seize("main_road",amount=1) %>% # Try to seize a cashier for placing the order
             timeout(function() 0.001) %>%
             release("main_road",amount=1)
    ) %>%
    set_attribute("lane", function() {
      (get_server_count(env, "lane1")>=get_server_count(env, "lane2"))*1+1}
    ) %>% 
    select(function() {
      paste0("lane",get_attribute(env, "lane"))
    }) %>% 
    seize_selected(1) %>% # Choose the shortest ordering lane
    
    seize("cashier",amount=1) %>% # Try to seize a cashier for placing the order
    timeout(function() {rexp(1, mu_cashier)}) %>%
    release("cashier",amount=1) %>%
    
    seize("lane_pickup",amount=1) %>% # Try to seize a car space in pickup lane
    release_selected(1) %>% # Once going to the pickup lane, the customer releases the ordering lane
    
    seize("cook",amount=1) %>% # Try to seize a cook for preparing the order
    timeout(function() {rexp(1, mu_cook)}) %>%
    release("cook",amount=1) %>%
    release("lane_pickup") # After the order is ready, the car immediately leaves the system
  
  dummy = trajectory() %>% # A dummy trajectory for recording the system carline
    set_attribute("carline_ordering",function() {
      carline1 = max(get_server_count(env,"lane1"),get_server_count(env,"lane2"))}) %>%
    set_attribute("carline_pickup",function() {
      carline2 = get_server_count(env,"lane_pickup")})  
  
  env = simmer()
  
  num_rep = 500
  Data_before=data.frame(repetition=1:num_rep,system_carline=1:num_rep,flow_time=1:num_rep,
                         served_arrivals=1:num_rep, balked_arrivals=1:num_rep)
  
  for(ii in 1:num_rep){
    print(paste0("Repetition ",ii))
    env = simmer()
    env %>%
      add_resource("main_road", 20) %>% # Enough spaces on the Main road for cars to balk
      add_resource("cashier", num_cashier) %>% # 2 Cashiers for taking orders
      add_resource("lane1", capacity=space_ordering) %>% # Ample car spaces in ordering lane1
      add_resource("lane2", capacity=space_ordering) %>% # Ample car spaces in ordering lane2
      add_resource("lane_pickup", capacity=space_pickup) %>% # 6 car spaces in pick up lane
      add_resource("cook", num_cook) %>% # 3 Cooks for preparing the orders
      add_generator("Customer", customer, function() rexp(1, arrival_rate), mon=2) %>% # Customer's arrival process
      add_generator("Dummy recorder", dummy, function() 1, mon=2) %>% # Dummy trajectory records every 1 minute
      run(simTime)
    
    ## average system carline length
    df_att = get_mon_attributes(env)
    df_att = df_att[substr(df_att$name,1,1)=='D',] # only look at dummy recorder
    ordering = df_att[df_att$key=="carline_ordering",'value']
    
    pickup = df_att[df_att$key=="carline_pickup",'value']
    
    system_carline = ordering + pickup
    
    Data_before[ii,'system_carline']=mean(system_carline[500:length(system_carline)])
    
    ## average flow time
    df_arr = get_mon_arrivals(env)
    df_arr = df_arr[substr(df_arr$name,1,1)=='C',] # picking customer's data only
    
    df_arr_abandon = df_arr[df_arr$activity_time==0.001,] # customers who balk upon arrival
    df_arr = df_arr[df_arr$activity_time!=0.001,] # customers who enter the drive-through
    
    df_arr$flow_time = df_arr$end_time - df_arr$start_time
    
    Data_before[ii,'flow_time']=mean(df_arr$flow_time[200:nrow(df_arr)]) 
    
    Data_before[ii, 'served_arrivals']=nrow(df_arr)/simTime
    Data_before[ii, 'balked_arrivals']=nrow(df_arr_abandon)/simTime # lost sales per minute
  }
  return(list(flow_time=mean(Data_before$flow_time),system_carline=mean(Data_before$system_carline),
              served_arrivals=mean(Data_before$served_arrivals),balked_arrivals=mean(Data_before$balked_arrivals)))
}

The simulator of Chick-fil-A after renovation with customer abandonment is as follows.

################################################################################
########### Simulator after renovation ########################################
################################################################################
DES_AB_after=function(num_cashier,num_cook,space_pickup, MAX_line){
  customer = trajectory("Customer's path") %>%
    branch(function() (max(get_server_count(env,"lane1")+get_server_count(env,"lane1pickup"),
                           get_server_count(env,"lane2")+get_server_count(env,"lane2pickup"))>=MAX_line)*1+1, 
           continue = c(TRUE,FALSE),
           trajectory("Join car line"),
           trajectory("Customer balks") %>% 
             seize("main_road",amount=1) %>% # Try to seize a cashier for placing the order
             timeout(function() 0.001) %>%
             release("main_road",amount=1)
    ) %>%
    set_attribute("lane", function() {
      (get_server_count(env, "lane1")>=get_server_count(env, "lane2"))*1+1}
    ) %>% 
    select(function() {
      paste0("lane",get_attribute(env, "lane"))
    }) %>% 
    seize_selected(1) %>% # Choose the shortest ordering lane
    
    seize("cashier",amount=1) %>% # Try to seize a cashier for placing the order
    timeout(function() {rexp(1, mu_cashier)}) %>%
    release("cashier",amount=1) %>%
    
    select(function() {ifelse(get_attribute(env, "lane")==1,"lane1pickup","lane2pickup")}, id=2) %>% 
    seize_selected(amount=1, id=2) %>% # Try to go on to the corresponding pickup lane
    release_selected(1) %>% # Once going to the pickup lane, release the ordering lane
    
    seize("cook",amount=1) %>% # Try to seize a cook for preparing the order
    timeout(function() {rexp(1, mu_cook)}) %>%
    release("cook",amount=1) %>%
    release_selected(amount=1, id=2) # After the order is ready, the car immediately leaves the system
  
  dummy = trajectory() %>% # A dummy trajectory for recording the system carline of lane1 and lane2
    set_attribute("carline_lane1",function() {
      carline1 = get_server_count(env,"lane1")+get_server_count(env,"lane1pickup")}) %>%
    set_attribute("carline_lane2",function() {
      carline2 = get_server_count(env,"lane2")+get_server_count(env,"lane2pickup")})  
  
  env = simmer()
  
  num_rep = 500
  Data_after=data.frame(repetition=1:num_rep,system_carline=1:num_rep,flow_time=1:num_rep,
                        served_arrivals=1:num_rep, balked_arrivals=1:num_rep)
  
  for(ii in 1:num_rep){
    print(paste0("Repetition ",ii))
    env = simmer()
    env %>%
      add_resource("main_road", 20) %>% # Enough spaces on the Main road for cars to balk
      add_resource("cashier", num_cashier) %>% # Cashiers for taking orders
      add_resource("lane1", capacity=space_ordering) %>% # Ample car spaces in ordering lane1
      add_resource("lane2", capacity=space_ordering) %>% # Ample car spaces in ordering lane2
      add_resource("lane1pickup", capacity=space_pickup) %>% # Car spaces in pick up lane1
      add_resource("lane2pickup", capacity=space_pickup) %>% # Car spaces in pick up lane2
      add_resource("cook", num_cook) %>% # 3 Cooks for preparing the orders
      add_generator("Customer", customer, function() rexp(1, arrival_rate), mon=2) %>% # Customer's arrival process
      add_generator("Dummy recorder", dummy, function() 1, mon=2) %>% # Dummy trajectory records every 1 minute
      run(simTime)
    
    ## average system carline length
    df_att = get_mon_attributes(env)
    df_att = df_att[substr(df_att$name,1,1)=='D',] # Extract values for the dummy recorder
    carline1 = df_att[df_att$key=='carline_lane1',] 
    carline2 = df_att[df_att$key=='carline_lane2',] 
    system_carline = pmax(carline1$value,carline2$value)
    
    Data_after[ii,'system_carline']=mean(system_carline[500:length(system_carline)])
    
    ## average flow time
    df_arr = get_mon_arrivals(env)
    df_arr = df_arr[substr(df_arr$name,1,1)=='C',] # picking customer's data only
    
    df_arr_abandon = df_arr[df_arr$activity_time==0.001,] # customers who balk upon arrival
    df_arr = df_arr[df_arr$activity_time!=0.001,] # customers who enter the drive-through
    
    df_arr$flow_time = df_arr$end_time - df_arr$start_time
    
    Data_after[ii,'flow_time']=mean(df_arr$flow_time[200:nrow(df_arr)]) 
    Data_after[ii, 'served_arrivals']=nrow(df_arr)/simTime
    Data_after[ii, 'balked_arrivals']=nrow(df_arr_abandon)/simTime  # lost sales per minute
  }
  return(list(flow_time=mean(Data_after$flow_time),system_carline=mean(Data_after$system_carline),
              served_arrivals=mean(Data_after$served_arrivals),balked_arrivals=mean(Data_after$balked_arrivals)))
}

For both simulators, the output includes

  1. customer’ average flow time (“flow_time”),

  2. average system carline (“system_carline”),

  3. sales per minute (“served_arrivals”),

  4. lost sales per minute (“balked_arrivals”).

Using refined simulators, we solve the following with the objective being the sum of 10-year operational cost and 10-year cost of lost sales. Suppose each lost sale on average is worth $15

\[\begin{align} \min_{I^{reno}, N^{cashier},N^{cook}}\quad &I^{reno}\cdot c^{reno}+(N^{cashier}\cdot c^{cashier}+N^{cook}\cdot c^{cook})\cdot 10 +\\ &A(I^{reno})\cdot 240\text{min/day} \cdot 312\text{days/year} \cdot 10\text{years} \cdot \$15/1000 \end{align}\]

where \(A(I^{reno})\) is lost sales per minute, one of the output from simulators “DES_AB_before” (if \(I^{reno}=0\)) or “DES_AB_after” (if \(I^{reno}=1\)).

We compute the total cost of all candidate designs.

################################################################################
########### Business decision making 2 ##########################
################################################################################
Designs=data.frame(I=rep(0,3),Ncashier=rep(2,3),Ncook=3:5,
                   flow_time=rep(0,3),system_carline=rep(0,3),
                   served_arrivals=rep(0,3),balked_arrivals=rep(0,3))

Designs=rbind(Designs,Designs)
Designs[4:6,'I']=1

for(ii in 1:nrow(Designs)){
  if(Designs[ii,'I']==0){ # before renovation
    out=DES_AB_before(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                   space_pickup = 6, MAX_line = 8)
  }else{ # after renovation
    out=DES_AB_after(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                  space_pickup = 10, MAX_line = 8)
  }
  
  Designs[ii,'flow_time']=out$flow_time
  Designs[ii,'system_carline']=out$system_carline
  Designs[ii,'served_arrivals']=out$served_arrivals
  Designs[ii,'balked_arrivals']=out$balked_arrivals
}

## cost parameters (in thousands)
c_reno=1000 # the cost of the renovation project 
c_worker=15*4*52*6/1000 # the annual cost of the each worker during rush hours
c_lost=15/1000 # the average cost of each lost sale

## compute cost function
Designs$Operational_cost = c_reno*Designs$I+c_worker*10*(Designs$Ncashier+Designs$Ncook)
Designs$Lost_sales = Designs$balked_arrivals*4*60*52*6*10
Designs$Total_cost = Designs$Operational_cost + Designs$Lost_sales*c_lost

write.csv(Designs, "Performance metrics with abandonments.csv", row.names = FALSE)

The following table summarizes all the designs and their performance metrics, where we generate the number of sales and lost sales per minute, and costs over 10 years.

Designs = read.csv("Performance metrics with abandonments.csv")
Designs[,c(1:3,6:10)]
##   I Ncashier Ncook served_arrivals balked_arrivals Operational_cost Lost_sales Total_cost
## 1 0        2     3        0.711217        0.086102            936.0  64473.178   1903.098
## 2 0        2     4        0.763184        0.035915           1123.2  26893.152   1526.597
## 3 0        2     5        0.775952        0.021346           1310.4  15983.885   1550.158
## 4 1        2     3        0.741019        0.056531           1936.0  42330.413   2570.956
## 5 1        2     4        0.786695        0.011232           2123.2   8410.522   2249.358
## 6 1        2     5        0.793101        0.003675           2310.4   2751.840   2351.678

Interestingly, the best design, \(I^{reno}=0,N^{cashier}=2,N^{cook}=4\), is identical to the one given by the previous problem in 5.1, though the objective functions are formulated differently. We can think of the design problem with hard performance constraints (5.1) as a simplified approximation of a problem minimizing total cost (5.2). Because it is generally challenging to calculate abandonment and estimate its economic value, optimizing service operations with target performances can be relatively easier to implement and, hence, practically appealing.