Spaces:
Build error
Build error
version 1
Browse files- pages/1_📈Detailed_Analysis.py +454 -0
- requirements.txt +2 -0
- 🗺️Dashboard.py +92 -0
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")
|