erjieyong commited on
Commit
944961d
·
1 Parent(s): f0a595a
pages/1_📈Detailed_Analysis.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import scipy.stats as stats
4
+ import plotly.express as px
5
+ from datetime import datetime
6
+ import pydeck as pdk
7
+ import numpy as np
8
+
9
+ #########################
10
+ ## STREAMLIT STRUCTURE ##
11
+ #########################
12
+
13
+ st.set_page_config(layout="wide")
14
+
15
+ # Title of the page
16
+ st.title("Safety stock, EOQ, ROP Recommender")
17
+ st.write("Recommend safety stock, economic order quantity (EOQ), reorder point (ROP) based on established supply chain algorithm. Assumptions made that demand and lead time are independent variables")
18
+
19
+ # Create placement containers in sequence
20
+ stats_col1, stats_col2, stats_col3 = st.columns(3)
21
+
22
+
23
+ ###############
24
+ ## FUNCTIONS ##
25
+ ###############
26
+
27
+ average_days_per_month = 30.437
28
+
29
+ # to add emoji to the transport mode
30
+ transport_emoji = {
31
+ "Air":"Air 🛩️",
32
+ "Land": "Land 🚚",
33
+ "Ocean": "Ocean 🚢"
34
+ }
35
+
36
+ # we need the following so that streamlit will only run the following once
37
+ # in this case, it will cache our df and not rerun it everytime we change something on the app
38
+ # https://discuss.streamlit.io/t/not-to-run-entire-script-when-a-widget-value-is-changed/502/5
39
+ @st.experimental_memo
40
+ def read_df_combined():
41
+ df_combined = pd.read_csv('https://generalassemblydsi32.s3.ap-southeast-1.amazonaws.com/ABC_Analysis-221125/df_combined.csv')
42
+ # df_combined = pd.read_csv("df_combined.csv")
43
+ df_combined["stock_month_2dp"] = df_combined["stock_month"].round(2)
44
+ df_combined["stock_days_2dp"] = (df_combined["stock_month"]*average_days_per_month).round(2)
45
+ df_combined["current_stock_formatted"] = df_combined["current_stock"].apply(lambda d: f"{int(d):,}")
46
+ df_combined["avg_shipped_qty_2dp"] = df_combined["avg_shipped_qty"].round(2)
47
+ df_combined["rop_date"] = pd.to_datetime(df_combined["rop_date"], format=r"%Y-%m-%d").dt.date
48
+ st.session_state.df = df_combined
49
+ return df_combined
50
+
51
+
52
+
53
+ if "df" not in st.session_state:
54
+ st.session_state.df = read_df_combined()
55
+
56
+ # Function to get and display preset values in data_form based on historical data
57
+ def preset(hub, sku, mode):
58
+ try:
59
+ target_row_no = st.session_state.df[(st.session_state.df['hub']==st.session_state.hub) & (st.session_state.df['sku'] == st.session_state.sku) & (st.session_state.df['mode'] == st.session_state.mode)].index[0]
60
+
61
+ preset_service_level = st.session_state.df.loc[target_row_no, 'service_level']*100
62
+ preset_demand_mean = st.session_state.df.loc[target_row_no, 'avg_shipped_qty']
63
+ preset_demand_sd = st.session_state.df.loc[target_row_no, 'sd_shipped_qty']
64
+ preset_leadtime_mean = st.session_state.df.loc[target_row_no, 'leadtime_days']
65
+ preset_leadtime_sd = st.session_state.df.loc[target_row_no, 'leadtime_days_sd']
66
+ preset_holding_cost_per_unit_per_month = st.session_state.df.loc[target_row_no, 'holding_cost_per_unit_year']/12
67
+ preset_handling_cost = st.session_state.df.loc[target_row_no, 'handling_cost']
68
+ preset_today_inventory = st.session_state.df.loc[target_row_no, 'current_stock']
69
+
70
+
71
+ st.session_state.service_level = preset_service_level
72
+ st.session_state.demand_mean = round(preset_demand_mean,2)
73
+ st.session_state.demand_sd = round(preset_demand_sd,2)
74
+ st.session_state.leadtime_mean = round(preset_leadtime_mean,2)
75
+ st.session_state.leadtime_sd = round(preset_leadtime_sd,2)
76
+ st.session_state.holding_cost_per_unit_per_month = round(preset_holding_cost_per_unit_per_month,2)
77
+ st.session_state.handling_cost = round(preset_handling_cost,2)
78
+ st.session_state.today_inventory = round(preset_today_inventory,2)
79
+
80
+ st.session_state.preset = True
81
+
82
+ safety_stock = st.session_state.df.loc[target_row_no, 'safety_stock']
83
+ eoq = st.session_state.df.loc[target_row_no, 'eoq']
84
+ rop = st.session_state.df.loc[target_row_no, 'rop']
85
+ # display result in 3 col metrics
86
+ result_col1.metric("Safety Stock", f"{safety_stock:,}")
87
+ result_col2.metric("Economic Order Quantiy", f"{eoq:,}")
88
+ result_col3.metric("Reorder Point", f"{rop:,}")
89
+
90
+ # Display re-order date
91
+ with result_container:
92
+ # get target row for the chosen SKU and transport mode in CONSOLE HUB
93
+ target_row_no_console = st.session_state.df[(st.session_state.df['hub']=="R.HUB") & (st.session_state.df['sku'] == sku) & (st.session_state.df['mode'] == mode)].index[0]
94
+ console_support = st.session_state.df.loc[target_row_no_console, "console_support"]
95
+
96
+ if np.isnan(console_support):
97
+ background_color = "rgba(250, 202, 43, 0.2)"
98
+ text_color = "rgb(148, 124, 45)"
99
+ RHUB_support_text = None
100
+ elif console_support == False:
101
+ background_color = "rgba(255, 43, 43, 0.09)"
102
+ text_color = "rgb(125, 53, 59)"
103
+ RHUB_support_text = 'R.HUB support within next 90 days for this SKU in all hubs: <span style = "font-weight: 800;">NO 🚨</span><br>'
104
+ elif console_support == True:
105
+ background_color = "rgba(33, 195, 84, 0.1)"
106
+ text_color = "rgb(23, 114, 51)"
107
+ RHUB_support_text = 'R.HUB support within next 90 days for this SKU in all hubs: <span style = "font-weight: 800;">YES ✅</span><br>'
108
+ tab1, tab2, = st.tabs(["Status", "Pull Quantity Details"])
109
+ with tab1:
110
+ st.markdown(f"""
111
+ <div style="background-color:{background_color};padding:16px;color:{text_color};border-radius:2%;margin:1rem 0 1rem 0">
112
+ Next re-order date: <span style = "font-weight: 800;">{st.session_state.df.loc[target_row_no, "rop_date"]}</span><br>
113
+ Recommended Transport Mode from R.HUB to Hubs: <span style = "font-weight: 800;">{transport_emoji[st.session_state.df.loc[target_row_no, "recommended_mode"]]}</span>
114
+ <br><br>
115
+ {RHUB_support_text}
116
+ Current stock in R.HUB: {int(st.session_state.df.loc[target_row_no_console, "current_stock"])} <br>
117
+ Expected total pull quantity from R.HUB within next 90 days: {int(st.session_state.df.loc[target_row_no_console, "expected_pull_next_90d"])}
118
+ <br><br>
119
+ <font size = "2">* Calculation is based on monthly actual demand (constantly refresh using shipment order detail report) & transit time (transport mode) assuming that all hubs will use the selected transport mode</font>
120
+ </div>
121
+ """, unsafe_allow_html=True)
122
+ with tab2:
123
+ pull_qty_breakdown = st.session_state.df[["hub","actual_eoq", "rop_date"]][(st.session_state.df["hub"] != "R.HUB") & (st.session_state.df["sku"] == st.session_state.sku) & (st.session_state.df["mode"] == st.session_state.mode) & (st.session_state.df["days_to_rop"] < 90)].set_index("hub")
124
+ pull_qty_breakdown['actual_eoq'] = pull_qty_breakdown['actual_eoq'].apply(lambda d: f"{int(d):,}")
125
+ pull_qty_breakdown["rop_date"] = pd.to_datetime(pull_qty_breakdown["rop_date"], format=r"%Y-%m-%d").dt.date
126
+ st.dataframe(pull_qty_breakdown.sort_values(by=["rop_date"]), use_container_width= True)
127
+
128
+ # display warning if rop is higher than eoq
129
+ if rop > eoq:
130
+ result_container.info("Since EOQ is less than ROP due to low handling cost, demand or holding cost, we will plot the inventory level assuming that we order in multiple of EOQ until it exceed ROP. In actual situation, combine them with other skus to yield much better overall costs")
131
+
132
+ # calculate inventory level over 365 days
133
+ data = calc_365_inventory(safety_stock, eoq, rop, st.session_state.demand_mean, st.session_state.demand_sd, st.session_state.leadtime_mean, st.session_state.leadtime_sd, random_demand_leadtime_boolean = False)
134
+
135
+ # display matplotlib chart based on data
136
+ fig = px.line(data, x="day", y="inventory", title='Inventory levels over a year', labels={"day":"Number of days in a year", "inventory": "Inventory Level"})
137
+ fig.add_hline(y=rop, line_dash="dash", line_color="orange", annotation_text="reorder point")
138
+ fig.add_hline(y=safety_stock, line_dash="dash", line_color="green", annotation_text="safety stock")
139
+ result_container.plotly_chart(fig, theme = 'streamlit')
140
+
141
+ except:
142
+ preset_container.write(f"This SKU is not a cat b item at {st.session_state.hub}. \n **NO PRESET LOADED**")
143
+
144
+ # Function to return nan, false or true
145
+ # for use when determine whether console can support based on expected pull in next 90 days
146
+ def pos_neg_nan(val):
147
+ if np.isnan(val):
148
+ return np.nan
149
+ elif val > 0:
150
+ return True
151
+ elif val <= 0:
152
+ return False
153
+
154
+ # Calculate the expected total pull from console hub from all hubs, specific sku and mode
155
+ # and evaluate if console hub can support the expected pull
156
+ def console_support():
157
+ date_90days_later = pd.to_datetime('today').normalize() + pd.Timedelta(days=90)
158
+ # df_expected_pull = st.session_state.df[["sku", "mode", "actual_eoq"]][(st.session_state.df["hub"]!="R.HUB") & (pd.to_datetime(st.session_state.df["rop_date"], format=r"%Y-%m-%d") < date_90days_later)].groupby(["sku", "mode"]).sum("actual_eoq").reset_index()
159
+ df_expected_pull = st.session_state.df[["sku", "mode", "actual_eoq"]][(st.session_state.df["hub"]!="R.HUB") & (st.session_state.df["days_to_rop"] < 90)].groupby(["sku", "mode"]).sum("actual_eoq").reset_index()
160
+ df_expected_pull.columns = ["sku","mode","expected_pull_next_90d"]
161
+ st.session_state.df.drop(["expected_pull_next_90d","console_support"], axis = 1, inplace = True)
162
+ st.session_state.df = st.session_state.df.merge(df_expected_pull, how="left", on=["sku","mode"])
163
+ st.session_state.df = st.session_state.df.fillna(0)
164
+
165
+ st.session_state.df["console_support"] = st.session_state.df["current_stock"] - st.session_state.df["expected_pull_next_90d"]
166
+ st.session_state.df["console_support"] = st.session_state.df["console_support"].apply(pos_neg_nan)
167
+
168
+ def recommend_transport_mode(hub, sku, df):
169
+ target_rows = df[["hub","sku","mode","handling_cost", "leadtime_days", 'days_to_rop']][(df["hub"]==hub)&(df["sku"]==sku)].index
170
+ # if any of the days_to_rop is more than 0, we will take the one that is the lowest handling cost
171
+ if any(df.loc[target_rows, "days_to_rop"]>0):
172
+ temp_df = df[["mode","handling_cost"]][(df["hub"]==hub)&(df["sku"]==sku)&(df["days_to_rop"]>0)]
173
+ recommended_mode = temp_df["mode"][temp_df["handling_cost"] == temp_df["handling_cost"].min()].iloc[0]
174
+
175
+ # if all the days_to_rop is 0, it means we are already behind time, hence, we take the fastest leadtime, ignoring cost
176
+ elif all(df.loc[target_rows, "days_to_rop"]) == 0:
177
+ temp_df = df[["mode","leadtime_days"]][(df["hub"]==hub)&(df["sku"]==sku)&(df["leadtime_days"]>0)]
178
+ recommended_mode = temp_df["mode"][temp_df["leadtime_days"] == temp_df["leadtime_days"].min()].iloc[0]
179
+ return recommended_mode
180
+
181
+
182
+ # Function to calculate SS, EOQ, ROP
183
+ def recommender(service_level, demand_mean, demand_sd, leadtime_mean, leadtime_sd, holding_cost_per_unit_per_month, handling_cost, inventory, hub , sku, mode):
184
+ st.session_state.df = st.session_state.df
185
+ # calculate safety stock
186
+ # assume that demand and leadtime are independent
187
+ z_score = stats.norm.ppf(service_level/100)
188
+ safety_stock = z_score * ((demand_mean * (leadtime_sd/average_days_per_month))**2 + ((leadtime_mean/average_days_per_month) * demand_sd**2))**0.5
189
+ safety_stock = round(safety_stock, 0)
190
+
191
+ # calculate economic order quantity
192
+ # (we look at it in a yearly time period because we may only order every few months. Hence, holding cost is on a yearly basis)
193
+ eoq = ((2*handling_cost*demand_mean*12)/(holding_cost_per_unit_per_month*12))**0.5
194
+ eoq = round(eoq, 0)
195
+
196
+ # calculate reorder point
197
+ rop = safety_stock + demand_mean * (leadtime_mean / average_days_per_month)
198
+ rop = round(rop, 0)
199
+
200
+ # calculate ROP date
201
+ days_to_rop = max((inventory - rop) / (demand_mean / average_days_per_month),0)
202
+ rop_date = (pd.to_datetime('today').normalize() + pd.Timedelta(days=int(days_to_rop))).date()
203
+
204
+ # update these calculated values to the df so that it can be downloaded later
205
+ target_row_no = st.session_state.df[(st.session_state.df['hub']==hub) & (st.session_state.df['sku'] == sku) & (st.session_state.df['mode'] == mode)].index[0]
206
+
207
+ if st.session_state.preset:
208
+ print("save")
209
+ st.session_state.df.loc[target_row_no, "service_level"] = service_level/100
210
+ st.session_state.df.loc[target_row_no, "avg_shipped_qty"] = demand_mean
211
+ st.session_state.df.loc[target_row_no, "sd_shipped_qty"] = demand_sd
212
+ st.session_state.df.loc[target_row_no, "leadtime_days"] = leadtime_mean
213
+ st.session_state.df.loc[target_row_no, "leadtime_days_sd"] = leadtime_sd
214
+ st.session_state.df.loc[target_row_no, "holding_cost_per_unit_year"] = holding_cost_per_unit_per_month * 12
215
+ st.session_state.df.loc[target_row_no, "handling_cost"] = handling_cost
216
+ st.session_state.df.loc[target_row_no, "safety_stock"] = safety_stock
217
+ st.session_state.df.loc[target_row_no, "eoq"] = eoq
218
+ st.session_state.df.loc[target_row_no, "rop"] = rop
219
+ st.session_state.df.loc[target_row_no, "days_to_rop"] = days_to_rop
220
+ st.session_state.df.loc[target_row_no, "rop_date"] = rop_date
221
+
222
+ # calculate recommended transport mode
223
+ # we have to calculate this after rop dates are updated
224
+ recommended_mode = recommend_transport_mode(st.session_state.hub, st.session_state.sku, st.session_state.df)
225
+
226
+ if st.session_state.preset:
227
+ # we don't use target_row_no as we do not need the mode
228
+ st.session_state.df.loc[st.session_state.df[(st.session_state.df["hub"]==hub)&(st.session_state.df["sku"]==sku)].index, "recommended_mode"] = recommended_mode
229
+
230
+
231
+ # recalculate expected pull and whether console hub can support
232
+ console_support()
233
+
234
+ return safety_stock, eoq, rop, rop_date, recommended_mode
235
+
236
+ # function to calculate the stock level per day taking into consideration all the variables
237
+ def calc_365_inventory(safety_stock, eoq, rop, demand_mean, demand_sd, leadtime_mean, leadtime_sd, random_demand_leadtime_boolean):
238
+ safety_stock = round(safety_stock,0)
239
+ eoq = round(eoq,0)
240
+ rop = round(rop,0)
241
+ # we do the following in case rop is higher than eoq. if that happens, then we order multiple times of eoq
242
+ actual_eoq = eoq * np.ceil(rop/eoq)
243
+ demand_per_day = demand_mean / average_days_per_month
244
+ demand_sd_per_day = demand_mean / average_days_per_month
245
+
246
+ data = {'day':[], 'inventory':[], 'order':None}
247
+ for day in range(365):
248
+ # track day count
249
+ data['day'].append(day)
250
+
251
+ # if day 0, assume we have eoq inventory on hand
252
+ if day == 0:
253
+ data['inventory'].append(actual_eoq)
254
+ else:
255
+ # randomised demand based on normal distribution and defined mean and standarad deviation
256
+ if random_demand_leadtime_boolean:
257
+ randomised_demand = round(stats.norm(loc=demand_per_day,scale=demand_sd_per_day).rvs(size=1)[0],0)
258
+ else:
259
+ randomised_demand = demand_per_day
260
+ # deduct inventory per day based on demand
261
+ data['inventory'].append(data['inventory'][-1]-randomised_demand)
262
+
263
+ # check if stock after deduct demand is lower than rop. If yes, then raise order
264
+ if data['inventory'][-1] < rop and data['order'] == None:
265
+ # randomised demand based on normal distribution and defined mean and standarad deviation
266
+ if random_demand_leadtime_boolean:
267
+ randomised_leadtime = round(stats.norm(loc=leadtime_mean,scale=leadtime_sd).rvs(size=1)[0],0)
268
+ else:
269
+ randomised_leadtime = leadtime_mean
270
+ # deduct inventory per day based on demand
271
+ data['order'] = randomised_leadtime
272
+ elif data['inventory'][-1] < rop and data['order'] > 0:
273
+ data['order'] -= 1
274
+ elif data['inventory'][-1] < rop and data['order'] == 0:
275
+ data['order'] = None
276
+ data['inventory'][day] += actual_eoq
277
+ return data
278
+
279
+ # Function to change the result output upon pressing submit button
280
+ def display_recommendations():
281
+ # we have to do this rather than passing it to the function using arg because streamlit only update the args after 1 pass
282
+ # to get the most updated value immediately, we need to use session state instead
283
+ service_level = st.session_state.service_level
284
+ demand_mean = st.session_state.demand_mean
285
+ demand_sd = st.session_state.demand_sd
286
+ leadtime_mean = st.session_state.leadtime_mean
287
+ leadtime_sd = st.session_state.leadtime_sd
288
+ holding_cost_per_unit_per_month = st.session_state.holding_cost_per_unit_per_month
289
+ handling_cost = st.session_state.handling_cost
290
+ random_demand_leadtime_boolean = st.session_state.random_demand_leadtime
291
+ inventory = st.session_state.today_inventory
292
+ hub = st.session_state.hub
293
+ sku = st.session_state.sku
294
+ mode = st.session_state.mode
295
+
296
+ # Run recommender function
297
+ safety_stock, eoq, rop, rop_date, recommended_mode = recommender(service_level, demand_mean, demand_sd, leadtime_mean, leadtime_sd, holding_cost_per_unit_per_month, handling_cost, inventory, hub , sku, mode)
298
+
299
+
300
+ # display result in 3 col metrics
301
+ result_col1.metric("Safety Stock", f"{safety_stock:,}")
302
+ result_col2.metric("Economic Order Quantiy", f"{eoq:,}")
303
+ result_col3.metric("Reorder Point", f"{rop:,}")
304
+
305
+ # Display re-order date
306
+ with result_container:
307
+ # get target row for the chosen SKU and transport mode in CONSOLE HUB
308
+ target_row_no = st.session_state.df[(st.session_state.df['hub']=="R.HUB") & (st.session_state.df['sku'] == sku) & (st.session_state.df['mode'] == mode)].index[0]
309
+ console_support = st.session_state.df.loc[target_row_no, "console_support"]
310
+
311
+ if np.isnan(console_support):
312
+ background_color = "rgba(250, 202, 43, 0.2)"
313
+ text_color = "rgb(148, 124, 45)"
314
+ RHUB_support_text = None
315
+ elif console_support == False:
316
+ background_color = "rgba(255, 43, 43, 0.09)"
317
+ text_color = "rgb(125, 53, 59)"
318
+ RHUB_support_text = 'R.HUB support within next 90 days for this SKU in all hubs: <span style = "font-weight: 800;">NO 🚨</span><br>'
319
+ elif console_support == True:
320
+ background_color = "rgba(33, 195, 84, 0.1)"
321
+ text_color = "rgb(23, 114, 51)"
322
+ RHUB_support_text = 'R.HUB support within next 90 days for this SKU in all hubs: <span style = "font-weight: 800;">YES ✅</span><br>'
323
+
324
+ tab1, tab2, = st.tabs(["Status", "Pull Quantity Details"])
325
+ with tab1:
326
+ st.markdown(f"""
327
+ <div style="background-color:{background_color};padding:16px;color:{text_color};border-radius:2%;margin:1rem 0 1rem 0">
328
+ Next re-order date: <span style = "font-weight: 800;">{rop_date}</span><br>
329
+ Recommended Transport Mode from R.HUB to Hubs: <span style = "font-weight: 800;">{transport_emoji[recommended_mode]}</span>
330
+ <br><br>
331
+ {RHUB_support_text}
332
+ Current stock in R.HUB: {int(st.session_state.df.loc[target_row_no, "current_stock"])} <br>
333
+ Expected total pull quantity from R.HUB within next 90 days: {int(st.session_state.df.loc[target_row_no, "expected_pull_next_90d"])}
334
+ <br><br>
335
+ <font size = "2">* Calculation is based on monthly actual demand (constantly refresh using shipment order detail report) & transit time (transport mode) assuming that all hubs will use the selected transport mode</font>
336
+ </div>
337
+ """, unsafe_allow_html=True)
338
+ with tab2:
339
+ pull_qty_breakdown = st.session_state.df[["hub","actual_eoq", "rop_date"]][(st.session_state.df["hub"] != "R.HUB") & (st.session_state.df["sku"] == st.session_state.sku) & (st.session_state.df["mode"] == st.session_state.mode) & (st.session_state.df["days_to_rop"] < 90)].set_index("hub")
340
+ pull_qty_breakdown['actual_eoq'] = pull_qty_breakdown['actual_eoq'].apply(lambda d: f"{int(d):,}")
341
+ pull_qty_breakdown["rop_date"] = pd.to_datetime(pull_qty_breakdown["rop_date"], format=r"%Y-%m-%d").dt.date
342
+ st.dataframe(pull_qty_breakdown.sort_values(by=["rop_date"]), use_container_width= True)
343
+
344
+ # display warning if rop is higher than eoq
345
+ if rop > eoq:
346
+ result_container.info("Since EOQ is less than ROP due to low handling cost, demand or holding cost, we will plot the inventory level assuming that we order in multiple of EOQ until it exceed ROP. In actual situation, combine them with other skus to yield much better overall costs")
347
+
348
+ # calculate inventory level over 365 days
349
+ data = calc_365_inventory(safety_stock, eoq, rop, demand_mean, demand_sd, leadtime_mean, leadtime_sd, random_demand_leadtime_boolean)
350
+
351
+ # display matplotlib chart based on data
352
+ fig = px.line(data, x="day", y="inventory", title='Inventory levels over a year', labels={"day":"Number of days in a year", "inventory": "Inventory Level"})
353
+ fig.add_hline(y=rop, line_dash="dash", line_color="orange", annotation_text="reorder point")
354
+ fig.add_hline(y=safety_stock, line_dash="dash", line_color="green", annotation_text="safety stock")
355
+ result_container.plotly_chart(fig, theme = 'streamlit')
356
+
357
+ ###############
358
+ ## STREAMLIT ##
359
+ ###############
360
+
361
+ # Create placement containers in sequence
362
+ with st.sidebar:
363
+ st.subheader("Preset values based on historical data")
364
+ # Preset Form
365
+ hub_val = st.selectbox('Hub', sorted(st.session_state.df['hub'].unique()), key='hub')
366
+ sku_val = st.selectbox('SKU', sorted(st.session_state.df['sku'][(st.session_state.df["hub"]==hub_val)].unique()), key='sku')
367
+ mode_val = st.selectbox('Transport Mode', sorted(st.session_state.df['mode'][(st.session_state.df["hub"]==hub_val)&(st.session_state.df["sku"]==sku_val)].unique()), help ="Mode selected would be the same for all hubs and chosen SKU. This would also affect the expected demand from R.HUB within the next 90 days", key='mode')
368
+ preset_container = st.container()
369
+ preset_container.empty()
370
+ preset_submit = st.button(label='Get Preset', on_click=preset, args = (hub_val, sku_val, mode_val))
371
+
372
+ # download_df = st.session_state.df[['hub','sku','mode','current_stock', 'service_level', 'avg_shipped_qty', 'sd_shipped_qty', 'leadtime_days', 'leadtime_days_sd', 'holding_cost_per_unit_year', 'handling_cost','safety_stock','eoq','actual_eoq','rop', 'rop_date', 'expected_pull_next_90d', 'console_support']].copy()
373
+ # download_df.columns = ['hub','sku','mode','current_stock','service_level', 'demand_mean', 'demand_sd', 'leadtime_days', 'leadtime_sd', 'holding_cost_per_unit_year', 'handling_cost_per_order','safety_stock','economic_order_qty','adjusted_economic_order_qty','reorder_point', 'reorder_date', 'expected_pull_next_90d', 'R.HUB_support']
374
+ # download_csv = download_df.to_csv().encode('utf-8')
375
+ # st.download_button('Download Historical Data with calculated SS, EOQ, ROP', data = download_csv, file_name = 'download_csv.csv', mime='text/csv')
376
+
377
+
378
+ # Important statistics
379
+ if "today_inventory" not in st.session_state:
380
+ st.session_state.today_inventory = 0
381
+ st.session_state.preset = False
382
+ stats_col1.metric("Today's Inventory", 0)
383
+ stats_col2.metric("Average Demand / Mnth", 0)
384
+ stats_col3.metric("Stock Month", 0)
385
+ else:
386
+ target_row_no = st.session_state.df[(st.session_state.df['hub']==st.session_state.hub) & (st.session_state.df['sku'] == st.session_state.sku) & (st.session_state.df['mode'] == st.session_state.mode)].index[0]
387
+ stats_col1.metric("Today's Inventory", f"{st.session_state.df.loc[target_row_no, 'current_stock']:,}")
388
+ stats_col2.metric("Average Demand / Mnth", f"{round(st.session_state.df.loc[target_row_no, 'avg_shipped_qty'],2):,}")
389
+ stats_col3.metric("Stock Month", f"{round(st.session_state.df.loc[target_row_no, 'current_stock']/st.session_state.df.loc[target_row_no, 'avg_shipped_qty'], 2):,}")
390
+
391
+ # display inventory across all hubs
392
+ try:
393
+ with st.expander("Expand to see inventory across all hubs", expanded=False):
394
+ # Display Inventory Map across hub
395
+ st.subheader(f"Stock months across all hubs: {int(st.session_state.df['current_stock'][(st.session_state.df['sku']==st.session_state.sku)&(st.session_state.df['mode']==st.session_state.mode)].sum()):,}")
396
+ layer = pdk.Layer(
397
+ "ColumnLayer",
398
+ data=st.session_state.df[["hub","current_stock_formatted","stock_month_2dp","lat", "lng", "color"]][(st.session_state.df['sku'] == st.session_state.sku) & (st.session_state.df['mode'] == "Air")],
399
+ get_position="[lng, lat]",
400
+ auto_highlight=True,
401
+ get_elevation="stock_month_2dp",
402
+ elevation_scale=200_000,
403
+ radius=200_000,
404
+ pickable=True,
405
+ # elevation_range=[0, 100],
406
+ extruded=True,
407
+ coverage=1,
408
+ # get_fill_color=[255, 165, 0, 100]
409
+ get_fill_color=["255", "165 + color", "0", 100]
410
+ )
411
+ # Set the viewport location
412
+ view_state = pdk.ViewState(
413
+ longitude=0.0, latitude=10.2323, zoom=1, min_zoom=1, max_zoom=15, pitch=45, bearing=0
414
+ )
415
+ # Combined all of it and render a viewport
416
+ r = pdk.Deck(
417
+ map_style="mapbox://styles/mapbox/light-v9",
418
+ layers=[layer],
419
+ initial_view_state=view_state,
420
+ tooltip={"html":
421
+ """<b>Hub:</b> {hub} <br>
422
+ <b>Stock Month:</b> {stock_month_2dp} <br>
423
+ <b>Stock Level:</b> {current_stock_formatted} <br>
424
+ """,
425
+ "style": {"color": "white"}},
426
+ )
427
+ st.pydeck_chart(r)
428
+ except:
429
+ pass
430
+
431
+
432
+ with st.expander("Expand to see how demand and leadtime affect SS, EOQ, ROP", expanded=False):
433
+ data_form = st.form(key="data_form")
434
+ data_form.write("*Input the variables in the form below and click submit to derive the recommendation*")
435
+ # data_form.write("*Please note that tweaking these variables are only to simulate how different factors will affect the recommendation. These tweaks will not affect the actual reorder date*")
436
+ data_form_no_col = data_form.columns(1)
437
+ data_form_col1, data_form_col2 = data_form.columns(2)
438
+
439
+ result_container = st.container()
440
+ with result_container:
441
+ result_col1, result_col2, result_col3 = st.columns(3)
442
+
443
+
444
+
445
+ # Data Form
446
+ service_level_val = data_form.slider('Desired service level for SKU (%)', 1, 100, 90, help="Higher service level will result in higher safety stock", key='service_level')
447
+ demand_mean_val = data_form_col1.number_input('Average monthly demand for SKU at hub', value = 12.0, min_value = 0.01, step = 1.0, format = "%f", help="Higher demand will result in higher safety stock, economic order quantity and reorder point", key='demand_mean')
448
+ demand_sd_val = data_form_col1.number_input('Standard deviation of monthly demand for SKU at hub', value = 1.0, min_value = 0.01, step = 0.01, format = "%f", help="Higher variability of demand month on month will result in higher safety stock", key='demand_sd')
449
+ leadtime_mean_val = data_form_col2.number_input('Average lead time from SIN to hub (days)', value = 1.0, min_value = 0.01, step = 1.0, format = "%f", help="Longer lead time will result in higher safety stock and reorder point", key='leadtime_mean')
450
+ leadtime_sd_val = data_form_col2.number_input('Standard deviation of lead time from SIN to hub (days)', value = 1.0, min_value = 0.01, step = 0.01, format = "%f", help="Higher variability of lead time will result in higher safety stock", key='leadtime_sd')
451
+ holding_cost_per_unit_per_month_val = data_form_col1.number_input('Holding cost per unit of SKU per month', value = 1.0, min_value = 0.01, step = 0.01, format = "%f", help="Higher holding cost will reduce the economic order quantity per order", key='holding_cost_per_unit_per_month')
452
+ handling_cost_val = data_form_col2.number_input('Handling cost per shipment order', value = 1.0, min_value = 0.01, step = 1.0, format = "%f", help="Higher handling cost per order will increase the economic order quantity per order", key='handling_cost')
453
+ random_demand_leadtime_boolean = data_form.checkbox("Simulate normalised random demand and leadtime", value=False, key='random_demand_leadtime', help="Enabling this will assuming randomised demand and leadtime normalised according to mean and standard deviation input")
454
+ data_submit = data_form.form_submit_button(label = "Submit", on_click = display_recommendations, args=())
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ scipy
2
+ plotly
🗺️Dashboard.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+
4
+ #########################
5
+ ## STREAMLIT STRUCTURE ##
6
+ #########################
7
+
8
+ st.set_page_config(layout="wide")
9
+
10
+ # Title of the page
11
+ st.title("Dashboard")
12
+ st.write("Summary of SKUs where stock days are limited and reorder date is near")
13
+
14
+
15
+
16
+ ###############
17
+ ## FUNCTIONS ##
18
+ ###############
19
+ average_days_per_month = 30.437
20
+
21
+
22
+ # we need the following so that streamlit will only run the following once
23
+ # in this case, it will cache our df and not rerun it everytime we change something on the app
24
+ # https://discuss.streamlit.io/t/not-to-run-entire-script-when-a-widget-value-is-changed/502/5
25
+ @st.experimental_memo
26
+ def read_df_combined():
27
+ df_combined = pd.read_csv('https://generalassemblydsi32.s3.ap-southeast-1.amazonaws.com/ABC_Analysis-221125/df_combined.csv')
28
+ # df_combined = pd.read_csv("df_combined.csv")
29
+ df_combined["stock_month_2dp"] = df_combined["stock_month"].round(2)
30
+ df_combined["stock_days_2dp"] = (df_combined["stock_month"]*average_days_per_month).round(2)
31
+ df_combined["current_stock_formatted"] = df_combined["current_stock"].apply(lambda d: f"{int(d):,}")
32
+ df_combined["avg_shipped_qty_2dp"] = df_combined["avg_shipped_qty"].round(2)
33
+ df_combined["rop_date"] = pd.to_datetime(df_combined["rop_date"], format=r"%Y-%m-%d").dt.date
34
+ st.session_state.df = df_combined
35
+ return df_combined
36
+
37
+
38
+
39
+ if "df" not in st.session_state:
40
+ st.session_state.df = read_df_combined()
41
+ read_df_combined()
42
+
43
+ def display_stock_warn_reorder(hub):
44
+ stock_days_keys = "stock_days"+hub
45
+ reorder_days_keys = "reorder_days"+hub
46
+
47
+ # Stock Warning
48
+ st.subheader(f"Stock Warning for {hub}")
49
+ st.caption("Display stocks where remaining stock days is less than specified")
50
+ st.slider("Stock Warning", 1, 90, 30, label_visibility = "collapsed", help = "Specify the stock days to filter", key = stock_days_keys)
51
+ df_stock_warning = st.session_state.df[["sku","current_stock_formatted","avg_shipped_qty_2dp", "stock_days_2dp"]][(st.session_state.df["hub"]==hub) & (st.session_state.df["stock_days_2dp"] <= st.session_state[stock_days_keys])].groupby("sku").min().reset_index()
52
+ df_stock_warning.columns = ["SKU","Stocks on hand", "Average monthly demand", "Stock days"]
53
+ st.dataframe(df_stock_warning.sort_values(by=["Stock days"]), use_container_width = True)
54
+
55
+ # Reorder stocks. For CONSOLE hub only, remove the column "console_support"
56
+ if hub == "R.HUB":
57
+ st.subheader(f"Upcoming Reorder for {hub}")
58
+ st.caption("Display stocks where the reorder date is within the specified time period")
59
+ st.caption("Recommended transport mode would always be based on the lowest estimated handling cost. If we have reorder date is marked as 0 (meaning we have already pass the optimum reorder date), recommended transport mode would be based on the shortest lead time")
60
+ st.slider("Stock Warning", 1, 90, 30, label_visibility = "collapsed", help = "Specify the time period to filter upcoming reorder point", key = reorder_days_keys)
61
+ df_reorder = st.session_state.df[["sku","current_stock_formatted", "avg_shipped_qty_2dp", "stock_days_2dp", "rop", "rop_date", "recommended_mode"]][(st.session_state.df["hub"]==hub) & (st.session_state.df["days_to_rop"] <= st.session_state[reorder_days_keys])].groupby("sku").min().reset_index()
62
+ df_reorder.columns = ["SKU","Stocks on hand", "Average monthly demand", "Stock days", "Reorder Point", "Reorder Date", "Recommended Transport Mode"]
63
+ st.dataframe(df_reorder.sort_values(by=["Reorder Date", "Stock days"]), use_container_width = True)
64
+ else:
65
+ st.subheader(f"Upcoming Reorder for {hub}")
66
+ st.caption("Display stocks where the reorder date is within the specified time period")
67
+ st.caption("Recommended transport mode would always be based on the lowest estimated handling cost. If we have reorder date is marked as 0 (meaning we have already pass the optimum reorder date), recommended transport mode would be based on the shortest lead time")
68
+ st.slider("Stock Warning", 1, 90, 30, label_visibility = "collapsed", help = "Specify the time period to filter upcoming reorder point", key = reorder_days_keys)
69
+ df_reorder = st.session_state.df[["sku","current_stock_formatted", "avg_shipped_qty_2dp", "stock_days_2dp", "rop","rop_date", "recommended_mode", "console_support"]][(st.session_state.df["hub"]==hub) & (st.session_state.df["days_to_rop"] <= st.session_state[reorder_days_keys])].groupby("sku").min().reset_index()
70
+ df_reorder.columns = ["SKU","Stocks on hand", "Average monthly demand", "Stock days", "Reorder Point", "Reorder Date", "Recommended Transport Mode", "Support from R.Hub"]
71
+ st.dataframe(df_reorder.sort_values(by=["Reorder Date", "Stock days"]), use_container_width = True)
72
+
73
+
74
+ ###############
75
+ ## STREAMLIT ##
76
+ ###############
77
+ rhub_tab, cvg_tab, dub_tab, sin_tab, slc_tab = st.tabs(["R.HUB", "CVG", "DUB", "SIN", "SLC"])
78
+
79
+ with rhub_tab:
80
+ display_stock_warn_reorder("R.HUB")
81
+
82
+ with cvg_tab:
83
+ display_stock_warn_reorder("CVG")
84
+
85
+ with dub_tab:
86
+ display_stock_warn_reorder("DUB")
87
+
88
+ with sin_tab:
89
+ display_stock_warn_reorder("SIN")
90
+
91
+ with slc_tab:
92
+ display_stock_warn_reorder("SLC")