reactive_freight 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +166 -0
  5. data/Rakefile +8 -0
  6. data/accessorial_symbols.txt +95 -0
  7. data/lib/reactive_freight.rb +21 -0
  8. data/lib/reactive_freight/carrier.rb +62 -0
  9. data/lib/reactive_freight/carriers.rb +18 -0
  10. data/lib/reactive_freight/carriers/btvp.rb +384 -0
  11. data/lib/reactive_freight/carriers/clni.rb +59 -0
  12. data/lib/reactive_freight/carriers/ctbv.rb +35 -0
  13. data/lib/reactive_freight/carriers/dphe.rb +296 -0
  14. data/lib/reactive_freight/carriers/drrq.rb +303 -0
  15. data/lib/reactive_freight/carriers/fcsy.rb +24 -0
  16. data/lib/reactive_freight/carriers/fwda.rb +243 -0
  17. data/lib/reactive_freight/carriers/jfj_transportation.rb +11 -0
  18. data/lib/reactive_freight/carriers/pens.rb +135 -0
  19. data/lib/reactive_freight/carriers/rdfs.rb +320 -0
  20. data/lib/reactive_freight/carriers/saia.rb +336 -0
  21. data/lib/reactive_freight/carriers/sefl.rb +234 -0
  22. data/lib/reactive_freight/carriers/totl.rb +96 -0
  23. data/lib/reactive_freight/carriers/wrds.rb +218 -0
  24. data/lib/reactive_freight/configuration/carriers/btvp.yml +139 -0
  25. data/lib/reactive_freight/configuration/carriers/clni.yml +107 -0
  26. data/lib/reactive_freight/configuration/carriers/ctbv.yml +117 -0
  27. data/lib/reactive_freight/configuration/carriers/dphe.yml +124 -0
  28. data/lib/reactive_freight/configuration/carriers/drrq.yml +115 -0
  29. data/lib/reactive_freight/configuration/carriers/fcsy.yml +104 -0
  30. data/lib/reactive_freight/configuration/carriers/fwda.yml +117 -0
  31. data/lib/reactive_freight/configuration/carriers/jfj_transportation.yml +2 -0
  32. data/lib/reactive_freight/configuration/carriers/pens.yml +22 -0
  33. data/lib/reactive_freight/configuration/carriers/rdfs.yml +135 -0
  34. data/lib/reactive_freight/configuration/carriers/saia.yml +117 -0
  35. data/lib/reactive_freight/configuration/carriers/sefl.yml +115 -0
  36. data/lib/reactive_freight/configuration/carriers/totl.yml +107 -0
  37. data/lib/reactive_freight/configuration/carriers/wrds.yml +19 -0
  38. data/lib/reactive_freight/configuration/platforms/carrier_logistics.yml +25 -0
  39. data/lib/reactive_freight/configuration/platforms/liftoff.yml +12 -0
  40. data/lib/reactive_freight/package.rb +137 -0
  41. data/lib/reactive_freight/platform.rb +36 -0
  42. data/lib/reactive_freight/platforms.rb +4 -0
  43. data/lib/reactive_freight/platforms/carrier_logistics.rb +317 -0
  44. data/lib/reactive_freight/platforms/liftoff.rb +102 -0
  45. data/lib/reactive_freight/rate_estimate.rb +113 -0
  46. data/lib/reactive_freight/shipment_event.rb +10 -0
  47. data/reactive_freight.gemspec +39 -0
  48. data/service_type_symbols.txt +4 -0
  49. metadata +198 -0
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class SAIA < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Saia'
9
+ @@scac = 'SAIA'
10
+
11
+ # Documents
12
+
13
+ # Rates
14
+ def find_rates(origin, destination, packages, options = {})
15
+ options = @options.merge(options)
16
+ origin = Location.from(origin)
17
+ destination = Location.from(destination)
18
+ packages = Array(packages)
19
+
20
+ request = build_rate_request(origin, destination, packages, options)
21
+ parse_rate_response(origin, destination, commit_soap(:rates, request))
22
+ end
23
+
24
+ # Tracking
25
+ def find_tracking_info(tracking_number)
26
+ request = build_tracking_request(tracking_number)
27
+ parse_tracking_response(commit_soap(:track, request))
28
+ end
29
+
30
+ protected
31
+
32
+ def commit_soap(action, request)
33
+ Savon.client(
34
+ wsdl: request_url(action),
35
+ convert_request_keys_to: :none,
36
+ env_namespace: :soap,
37
+ element_form_default: :qualified
38
+ ).call(
39
+ @conf.dig(:api, :actions, action),
40
+ message: request
41
+ ).body.to_hash
42
+ end
43
+
44
+ def request_url(action)
45
+ scheme = @conf.dig(:api, :use_ssl) ? 'https://' : 'http://'
46
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
47
+ end
48
+
49
+ # Documents
50
+
51
+ # Rates
52
+ def build_rate_request(origin, destination, packages, options = {})
53
+ options = @options.merge(options)
54
+
55
+ accessorials = []
56
+ unless options[:accessorials].blank?
57
+ serviceable_accessorials?(options[:accessorials])
58
+ options[:accessorials].each do |a|
59
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
60
+ accessorials << { 'AccessorialItem': { 'Code': @conf.dig(:accessorials, :mappable)[a] } }
61
+ end
62
+ end
63
+ end
64
+
65
+ excessive_length_total_inches = 0
66
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
67
+ if longest_dimension >= 96
68
+ accessorials << { 'AccessorialItem': { 'Code': 'ExcessiveLength' } }
69
+ excessive_length_total_inches += longest_dimension
70
+ end
71
+ excessive_length_total_inches = excessive_length_total_inches.ceil.to_s
72
+
73
+ accessorials = accessorials.uniq
74
+
75
+ details = []
76
+ dimensions = []
77
+ packages.each do |package|
78
+ details << {
79
+ 'DetailItem': {
80
+ 'Weight': package.pounds.ceil,
81
+ 'Class': package.freight_class.to_s,
82
+ 'Length': package.length(:in).ceil,
83
+ 'Width': package.width(:in).ceil,
84
+ 'Height': package.height(:in).ceil
85
+ }
86
+ }
87
+ dimensions << {
88
+ 'DimensionItem': {
89
+ 'Units': 1,
90
+ 'Length': package.length(:in).round(2),
91
+ 'Width': package.width(:in).round(2),
92
+ 'Height': package.height(:in).round(2),
93
+ 'Type': 'IN' # inches
94
+ }
95
+ }
96
+ end
97
+ request = {
98
+ 'request': {
99
+ 'Application': 'ThirdParty',
100
+ 'AccountNumber': options[:account],
101
+ 'UserID': options[:username],
102
+ 'Password': options[:password],
103
+ 'TestMode': options[:debug].blank? ? 'N' : 'Y',
104
+ 'BillingTerms': 'Prepaid',
105
+ 'OriginCity': origin.city,
106
+ 'OriginState': origin.state,
107
+ 'OriginZipcode': origin.to_hash[:postal_code].to_s.upcase,
108
+ 'DestinationCity': destination.city,
109
+ 'DestinationState': destination.state,
110
+ 'DestinationZipcode': destination.to_hash[:postal_code].to_s.upcase,
111
+ 'WeightUnits': 'LBS',
112
+ 'TotalCube': packages.inject(0) { |_sum, p| _sum += p.cubic_ft }.to_f.round(2),
113
+ 'TotalCubeUnits': 'CUFT', # cubic ft
114
+ 'ExcessiveLengthTotalInches': excessive_length_total_inches,
115
+ 'Details': details,
116
+ 'Dimensions': dimensions,
117
+ 'Accessorials': accessorials
118
+ }
119
+ }
120
+
121
+ save_request(request)
122
+ request
123
+ end
124
+
125
+ def parse_rate_response(origin, destination, response)
126
+ success = true
127
+ message = ''
128
+
129
+ if !response
130
+ success = false
131
+ message = 'API Error: Unknown response'
132
+ else
133
+ error = response.dig(:create_response, :create_result, :code)
134
+
135
+ if !error.blank?
136
+ success = false
137
+ message = response.dig(:create_response, :create_result, :message)
138
+ else
139
+ response = response.dig(:create_response, :create_result)
140
+ cost = response.dig(:total_invoice)
141
+ if cost
142
+ cost = cost.sub('.', '').to_i
143
+ transit_days = response.dig(:standard_service_days).to_i
144
+ estimate_reference = response.dig(:quote_number)
145
+
146
+ rate_estimates = []
147
+ rate_estimates << RateEstimate.new(
148
+ origin,
149
+ destination,
150
+ { scac: self.class.scac.upcase, name: self.class.name },
151
+ :standard,
152
+ transit_days: transit_days,
153
+ estimate_reference: estimate_reference,
154
+ total_cost: cost,
155
+ total_price: cost,
156
+ currency: 'USD',
157
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
158
+ )
159
+
160
+ [
161
+ { guaranteed_ltl: response.dig(:guarantee_amount) },
162
+ { guaranteed_ltl_am: response.dig(:guarantee_amount12pm) },
163
+ { guaranteed_ltl_pm: response.dig(:guarantee_amount2pm) }
164
+ ].each do |service|
165
+ if !service.values[0] == '0' && !service.values[0].blank?
166
+ cost = service.values[0].sub('.', '').to_i
167
+ rate_estimates << RateEstimate.new(
168
+ origin,
169
+ destination,
170
+ { scac: self.class.scac.upcase, name: self.class.name },
171
+ service.keys[0],
172
+ delivery_range: delivery_range,
173
+ estimate_reference: estimate_reference,
174
+ total_cost: cost,
175
+ total_price: cost,
176
+ currency: 'USD',
177
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
178
+ )
179
+ end
180
+ rate_estimates
181
+ end
182
+ else
183
+ success = false
184
+ message = 'API Error: Cost is emtpy'
185
+ end
186
+ end
187
+ end
188
+
189
+ RateResponse.new(
190
+ success,
191
+ message,
192
+ response.to_hash,
193
+ rates: rate_estimates,
194
+ response: response,
195
+ request: last_request
196
+ )
197
+ end
198
+
199
+ # Tracking
200
+ def build_tracking_request(tracking_number)
201
+ request = { pro_number: tracking_number }
202
+ save_request(request)
203
+ request
204
+ end
205
+
206
+ def parse_city_state(str)
207
+ return nil if str.blank?
208
+
209
+ Location.new(
210
+ city: str.split(', ')[0].titleize,
211
+ state: str.split(', ')[1].split(' ')[0].upcase,
212
+ country: ActiveUtils::Country.find('USA')
213
+ )
214
+ end
215
+
216
+ def parse_city(str)
217
+ return nil if str.blank?
218
+
219
+ Location.new(
220
+ city: str.squeeze.strip.titleize,
221
+ state: nil,
222
+ country: ActiveUtils::Country.find('USA')
223
+ )
224
+ end
225
+
226
+ def parse_date(date)
227
+ date ? DateTime.strptime(date, '%m/%d/%Y %l:%M:%S %p').to_s(:db) : nil
228
+ end
229
+
230
+ def parse_location(comment, delimiters)
231
+ city = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[0].titleize
232
+ state = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[1].upcase
233
+
234
+ Location.new(
235
+ city: city,
236
+ province: state,
237
+ state: state,
238
+ country: ActiveUtils::Country.find('USA')
239
+ )
240
+ end
241
+
242
+ def parse_tracking_response(response)
243
+ unless response.dig(:get_tracking_response, :get_tracking_result, :tracking_status_response)
244
+ status = json.dig('error') || "API Error: HTTP #{response.status[0]}"
245
+ return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
246
+ end
247
+
248
+ search_result = response.dig(:get_tracking_response, :get_tracking_result)
249
+
250
+ shipper_address = Location.new(
251
+ street: search_result.dig(:shipperaddress).squeeze.strip.titleize,
252
+ city: search_result.dig(:shipper_city).squeeze.strip.titleize,
253
+ state: search_result.dig(:shipper_state).strip.upcase,
254
+ postal_code: search_result.dig(:shipper_zip).strip,
255
+ country: ActiveUtils::Country.find('USA')
256
+ )
257
+
258
+ receiver_address = Location.new(
259
+ street: search_result.dig(:consaddress).squeeze.strip.titleize,
260
+ city: search_result.dig(:cons_city).squeeze.strip.titleize,
261
+ state: search_result.dig(:cons_state).strip.upcase,
262
+ postal_code: search_result.dig(:cons_zip).strip,
263
+ country: ActiveUtils::Country.find('USA')
264
+ )
265
+
266
+ actual_delivery_date = parse_date(search_result.dig('Shipment', 'DeliveredDateTime'))
267
+ pickup_date = parse_date(search_result.dig(:pickup_date))
268
+ scheduled_delivery_date = parse_date(search_result.dig('Shipment', 'ApptDateTime'))
269
+ tracking_number = search_result.dig('Shipment', 'SearchItem')
270
+
271
+ shipment_events = []
272
+ shipment_events << ShipmentEvent.new(
273
+ :picked_up,
274
+ pickup_date,
275
+ shipper_address
276
+ )
277
+
278
+ api_events = search_result.dig(:tracking_status_response, :tracking_status_row).reverse
279
+ api_events.each do |api_event|
280
+ event_key = nil
281
+ comment = api_event.dig(:tracking_status)
282
+
283
+ @conf.dig(:events, :types).each do |key, val|
284
+ if comment.downcase.include?(val)
285
+ event_key = key
286
+ break
287
+ end
288
+ end
289
+ next if event_key.blank?
290
+
291
+ case event_key
292
+ when :arrived_at_terminal
293
+ location = parse_city(comment.split('arrived')[1].upcase.split('SERVICE CENTER')[0])
294
+ when :delivered
295
+ location = parse_city_state(comment.split('in ')[1].split('completed')[0])
296
+ when :departed
297
+ location = parse_city(comment.split('departed')[1].upcase.split('SERVICE CENTER')[0])
298
+ when :out_for_delivery
299
+ location = receiver_address
300
+ when :trailer_closed
301
+ location = parse_city(comment.split('Location:')[1])
302
+ when :trailer_unloaded
303
+ location = parse_city(comment.split('Location:')[1])
304
+ end
305
+
306
+ datetime_without_time_zone = parse_date(api_event.dig(:tracking_date))
307
+
308
+ # status and type_code set automatically by ActiveFreight based on event
309
+ shipment_events << ShipmentEvent.new(event_key, datetime_without_time_zone, location)
310
+ end
311
+
312
+ shipment_events = shipment_events.sort_by(&:time)
313
+
314
+ TrackingResponse.new(
315
+ true,
316
+ shipment_events.last.status,
317
+ response,
318
+ carrier: "#{@@scac}, #{@@name}",
319
+ hash: response,
320
+ response: response,
321
+ status: status,
322
+ type_code: shipment_events.last.status,
323
+ ship_time: parse_date(search_result.dig('Shipment', 'ProDateTime')),
324
+ scheduled_delivery_date: scheduled_delivery_date,
325
+ actual_delivery_date: actual_delivery_date,
326
+ delivery_signature: nil,
327
+ shipment_events: shipment_events,
328
+ shipper_address: shipper_address,
329
+ origin: shipper_address,
330
+ destination: receiver_address,
331
+ tracking_number: tracking_number,
332
+ request: last_request
333
+ )
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class SEFL < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Southeastern Freight Lines'
9
+ @@scac = 'SEFL'
10
+
11
+ JSON_HEADERS = {
12
+ 'Accept': 'application/json',
13
+ 'charset': 'utf-8',
14
+ 'Content-Type' => 'application/x-www-form-urlencoded'
15
+ }.freeze
16
+
17
+ # Documents
18
+
19
+ # Rates
20
+ def find_rates(origin, destination, packages, options = {})
21
+ options = @options.merge(options)
22
+ origin = Location.from(origin)
23
+ destination = Location.from(destination)
24
+ packages = Array(packages)
25
+
26
+ request = build_rate_request(origin, destination, packages, options)
27
+ parse_rate_response(origin, destination, commit(request))
28
+ end
29
+
30
+ # Tracking
31
+
32
+ protected
33
+
34
+ def build_url(action, options = {})
35
+ options = @options.merge(options)
36
+ "#{base_url}#{@conf.dig(:api, :endpoints, action)}"
37
+ end
38
+
39
+ def base_url
40
+ "https://#{@conf.dig(:api, :domain)}"
41
+ end
42
+
43
+ def auth_header(options = {})
44
+ options = @options.merge(options)
45
+ if !options[:username].blank? && !options[:password].blank?
46
+ auth = Base64.strict_encode64("#{options[:username]}:#{options[:password]}")
47
+ return { 'Authorization': "Basic #{auth}" }
48
+ end
49
+
50
+ {}
51
+ end
52
+
53
+ def build_request(action, options = {})
54
+ options = @options.merge(options)
55
+ headers = JSON_HEADERS
56
+ headers = headers.merge(auth_header)
57
+ headers = headers.merge(options[:headers]) unless options[:headers].blank?
58
+ body = URI.encode_www_form(options[:body]) unless options[:body].blank?
59
+
60
+ request = {
61
+ url: options[:url].blank? ? build_url(action, options) : options[:url],
62
+ headers: headers,
63
+ method: @conf.dig(:api, :methods, action),
64
+ body: body
65
+ }
66
+
67
+ save_request(request)
68
+ request
69
+ end
70
+
71
+ def commit(request)
72
+ url = request[:url]
73
+ headers = request[:headers]
74
+ method = request[:method]
75
+ body = request[:body]
76
+
77
+ response = case method
78
+ when :post
79
+ HTTParty.post(url, headers: headers, body: body)
80
+ else
81
+ HTTParty.get(url, headers: headers)
82
+ end
83
+
84
+ JSON.parse(response.body) if response&.body
85
+ end
86
+
87
+ # Documents
88
+
89
+ # Rates
90
+ def build_rate_request(origin, destination, packages, options = {})
91
+ options = @options.merge(options)
92
+
93
+ accessorials = []
94
+ unless options[:accessorials].blank?
95
+ serviceable_accessorials?(options[:accessorials])
96
+ options[:accessorials].each do |a|
97
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
98
+ accessorials << @conf.dig(:accessorials, :mappable)[a]
99
+ end
100
+ end
101
+ end
102
+
103
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
104
+ accessorials << 'chkOD' if longest_dimension >= 96
105
+
106
+ accessorials = accessorials.uniq
107
+
108
+ pickup_on = options[:pickup_on].blank? ? Date.current : options[:pickup_on]
109
+
110
+ body = {
111
+ returnX: 'Y',
112
+ rateXML: 'Y',
113
+ CustomerAccount: options[:account].to_i.to_s.rjust(9, '0'),
114
+ CustomerName: options[:customer_name],
115
+ CustomerStreet: options.dig(:customer_address, :street),
116
+ CustomerCity: options.dig(:customer_address, :city),
117
+ CustomerState: options.dig(:customer_address, :state),
118
+ CustomerZip: options.dig(:customer_address, :zip_code),
119
+ Description: 'Freight All Kinds',
120
+ Option: 'T',
121
+ Terms: 'P',
122
+ allowSpot: packages.inject(0) { |_sum, p| _sum += [p.length(:in), p.width(:in)].max.ceil } >= 120 ? 'Y' : 'N',
123
+ DimsOption: 'I',
124
+ EmailAddress: options[:customer_email].blank? ? 'unknown@fake.fake' : options[:customer_email],
125
+ PickupMonth: pickup_on.strftime('%_m'),
126
+ PickupDay: pickup_on.strftime('%_d'),
127
+ PickupYear: pickup_on.strftime('%Y'),
128
+ OriginCity: origin.to_hash[:city],
129
+ OriginState: origin.to_hash[:province],
130
+ OriginZip: origin.to_hash[:postal_code],
131
+ OrigCountry: 'U',
132
+ DestinationCity: destination.to_hash[:city],
133
+ DestinationState: destination.to_hash[:province],
134
+ DestinationZip: destination.to_hash[:postal_code],
135
+ DestCountry: 'U'
136
+ }
137
+
138
+ if longest_dimension >= 96
139
+ body = body.deep_merge(
140
+ {
141
+ ODLength: longest_dimension,
142
+ ODLengthUnit: 'I'
143
+ }
144
+ )
145
+ end
146
+
147
+ i = 0
148
+ packages.each do |package|
149
+ i += 1
150
+ body = body.deep_merge({ "Class#{i}": package.freight_class.to_s.sub('.', '').to_i })
151
+ body = body.deep_merge({ "CubicFt#{i}": package.cubic_ft }) if destination.to_hash[:province].upcase == 'PR'
152
+ body = body.deep_merge({ "Description#{i}": 'Freight All Kinds' })
153
+ body = body.deep_merge({ "PieceLength#{i}": package.length(:in).ceil })
154
+ body = body.deep_merge({ "PieceWidth#{i}": package.width(:in).ceil })
155
+ body = body.deep_merge({ "PieceHeight#{i}": package.height(:in).ceil })
156
+ body = body.deep_merge({ "Weight#{i}": package.pounds.ceil })
157
+ end
158
+
159
+ unless accessorials.blank?
160
+ accessorials.each do |_accessorial|
161
+ body = body.deep_merge({ accessorial: 'on' })
162
+ end
163
+ end
164
+
165
+ request = build_request(:rates, body: body)
166
+
167
+ save_request(request)
168
+ request
169
+ end
170
+
171
+ def parse_rate_response(origin, destination, response)
172
+ success = true
173
+ message = ''
174
+
175
+ if !response
176
+ success = false
177
+ message = 'API Error: Unknown response'
178
+ elsif !response.dig('errorMessage').blank?
179
+ success = false
180
+ message = response.dig('errorMessage')
181
+ else
182
+ sleep(5) # TODO: Maybe improve this?
183
+ url = response.dig('detailQuoteLocation').gsub('\\', '')
184
+ request = build_request(:get_rate, url: url)
185
+ save_request(request)
186
+ response = commit(request)
187
+
188
+ if !response
189
+ success = false
190
+ message = 'API Error: Unknown response'
191
+ elsif !response.dig('errorMessage').blank?
192
+ success = false
193
+ message = response.dig('errorMessage')
194
+ else
195
+ cost = response.dig('rateQuote')
196
+ if cost
197
+ cost = cost.sub('.', '').to_i
198
+ estimate_reference = response.dig('quoteNumber')
199
+ transit_days = response.dig('transitTime').to_i
200
+
201
+ rate_estimates = [
202
+ RateEstimate.new(
203
+ origin,
204
+ destination,
205
+ { scac: self.class.scac.upcase, name: self.class.name },
206
+ :standard,
207
+ transit_days: transit_days,
208
+ estimate_reference: estimate_reference,
209
+ total_cost: cost,
210
+ total_price: cost,
211
+ currency: 'USD',
212
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
213
+ )
214
+ ]
215
+ else
216
+ success = false
217
+ message = 'API Error: Cost is emtpy'
218
+ end
219
+ end
220
+ end
221
+
222
+ RateResponse.new(
223
+ success,
224
+ message,
225
+ response.to_hash,
226
+ rates: rate_estimates,
227
+ response: response,
228
+ request: last_request
229
+ )
230
+ end
231
+
232
+ # Tracking
233
+ end
234
+ end