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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class FCSY < CarrierLogistics
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Frontline Freight'
9
+ @@scac = 'FCSY'
10
+
11
+ # Documents
12
+
13
+ # Rates
14
+ def build_calculated_accessorials(*); end
15
+
16
+ # Tracking
17
+
18
+ # protected
19
+
20
+ # Documents
21
+
22
+ # Rates
23
+ end
24
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class FWDA < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Forward Air'
9
+ @@scac = 'FWDA'
10
+
11
+ JSON_HEADERS = {
12
+ 'Accept': 'application/json',
13
+ 'charset': 'utf-8',
14
+ 'Content-Type' => 'application/json'
15
+ }.freeze
16
+
17
+ # Override Carrier#serviceable_accessorials? since we have separate delivery/pickup accessorials
18
+ def serviceable_accessorials?(accessorials)
19
+ return true if accessorials.blank?
20
+
21
+ if !self.class::REACTIVE_FREIGHT_CARRIER ||
22
+ !@conf.dig(:accessorials, :mappable) ||
23
+ !@conf.dig(:accessorials, :unquotable) ||
24
+ !@conf.dig(:accessorials, :unserviceable)
25
+ raise NotImplementedError, "#{self.class.name}: #serviceable_accessorials? not supported"
26
+ end
27
+
28
+ serviceable_accessorials = @conf.dig(:accessorials, :mappable, :delivery).keys +
29
+ @conf.dig(:accessorials, :mappable, :pickup).keys +
30
+ @conf.dig(:accessorials, :unquotable)
31
+ serviceable_count = (serviceable_accessorials & accessorials).size
32
+
33
+ unserviceable_accessorials = @conf.dig(:accessorials, :unserviceable)
34
+ unserviceable_count = (unserviceable_accessorials & accessorials).size
35
+
36
+ if serviceable_count != accessorials.size || !unserviceable_count.zero?
37
+ raise ArgumentError, "#{self.class.name}: Some accessorials unserviceable"
38
+ end
39
+
40
+ true
41
+ end
42
+
43
+ # Documents
44
+
45
+ # Rates
46
+ def find_rates(origin, destination, packages, options = {})
47
+ options = @options.merge(options)
48
+ origin = Location.from(origin)
49
+ destination = Location.from(destination)
50
+ packages = Array(packages)
51
+
52
+ request = build_rate_request(origin, destination, packages, options)
53
+ parse_rate_response(origin, destination, commit(request))
54
+ end
55
+
56
+ # Tracking
57
+
58
+ protected
59
+
60
+ def build_url(action, options = {})
61
+ options = @options.merge(options)
62
+ "#{base_url}#{@conf.dig(:api, :endpoints, action)}"
63
+ end
64
+
65
+ def base_url
66
+ "https://#{@conf.dig(:api, :domain)}"
67
+ end
68
+
69
+ def build_headers(options = {})
70
+ options = @options.merge(options)
71
+ if !options[:username].blank? && !options[:password].blank? && !options[:account].blank?
72
+ return JSON_HEADERS.merge(
73
+ 'user': options[:username],
74
+ 'password': options[:password],
75
+ 'customerId': options[:account]
76
+ )
77
+ end
78
+
79
+ JSON_HEADERS
80
+ end
81
+
82
+ def build_request(action, options = {})
83
+ options = @options.merge(options)
84
+ headers = JSON_HEADERS
85
+ headers = headers.merge(options[:headers]) unless options[:headers].blank?
86
+ body = options[:body].to_json unless options[:body].blank?
87
+
88
+ request = {
89
+ url: build_url(action, options),
90
+ headers: headers,
91
+ method: @conf.dig(:api, :methods, action),
92
+ body: body
93
+ }
94
+
95
+ save_request(request)
96
+ request
97
+ end
98
+
99
+ def commit(request)
100
+ url = request[:url]
101
+ headers = request[:headers]
102
+ method = request[:method]
103
+ body = request[:body]
104
+
105
+ response = case method
106
+ when :post
107
+ HTTParty.post(url, headers: headers, body: body)
108
+ else
109
+ HTTParty.get(url, headers: headers)
110
+ end
111
+
112
+ JSON.parse(response.body)
113
+ end
114
+
115
+ # Documents
116
+
117
+ # Rates
118
+ def build_rate_request(origin, destination, packages, options = {})
119
+ options = @options.merge(options)
120
+
121
+ delivery_accessorials = []
122
+ pickup_accessorials = []
123
+ unless options[:accessorials].blank?
124
+ serviceable_accessorials?(options[:accessorials])
125
+ options[:accessorials].each do |a|
126
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
127
+ if @conf.dig(:accessorials, :mappable, :pickup).include?(a)
128
+ pickup_accessorials << @conf.dig(:accessorials, :mappable, :pickup)[a]
129
+ elsif delivery_accessorials << @conf.dig(:accessorials, :mappable, :delivery)[a]
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ unless delivery_accessorials.blank?
136
+ # Remove duplicate delivery appointment accessorial when residential delivery (included with RDE)
137
+ delivery_accessorials -= ['ADE'] if delivery_accessorials.include?('RDE')
138
+ end
139
+
140
+ unless pickup_accessorials.blank?
141
+ # Remove duplicate pickup appointment accessorial when residential pickup (included with RPU)
142
+ pickup_accessorials -= ['APP'] if pickup_accessorials.include?('RPU')
143
+ end
144
+
145
+ delivery_accessorials = delivery_accessorials.uniq
146
+ pickup_accessorials = pickup_accessorials.uniq
147
+
148
+ # API doesn't like empty arrays
149
+ delivery_accessorials = nil if delivery_accessorials.blank?
150
+ pickup_accessorials = nil if pickup_accessorials.blank?
151
+
152
+ freight_details = []
153
+ packages.each do |package|
154
+ freight_details << {
155
+ description: 'Freight',
156
+ freightClass: package.freight_class.to_s,
157
+ pieces: '1',
158
+ weightType: 'L',
159
+ weight: package.pounds.ceil.to_s
160
+ }
161
+ end
162
+
163
+ request = {
164
+ url: build_url(:rates, options),
165
+ headers: build_headers(options),
166
+ method: @conf.dig(:api, :methods, :rates),
167
+ body: {
168
+ billToCustomerNumber: options[:account],
169
+ origin: {
170
+ originZipCode: origin.to_hash[:postal_code].to_s.upcase,
171
+ pickup: {
172
+ airportPickup: pickup_accessorials&.include?('ALP') ? 'Y' : 'N',
173
+ pickupAccessorials: { pickupAccessorial: pickup_accessorials }
174
+ }
175
+ },
176
+ destination: {
177
+ destinationZipCode: destination.to_hash[:postal_code].to_s.upcase,
178
+ delivery: {
179
+ airportDelivery: delivery_accessorials&.include?('ALD') ? 'Y' : 'N',
180
+ deliveryAccessorials: { deliveryAccessorial: delivery_accessorials }
181
+ }
182
+ },
183
+ freightDetails: { freightDetail: freight_details },
184
+ hazmat: 'N',
185
+ inBondShipment: 'N',
186
+ declaredValue: '0.00',
187
+ shipmentDate: Date.current.strftime('%Y-%m-%d')
188
+ }.to_json
189
+ }
190
+
191
+ save_request(request)
192
+ request
193
+ end
194
+
195
+ def parse_rate_response(origin, destination, response)
196
+ success = true
197
+ message = ''
198
+
199
+ if !response
200
+ success = false
201
+ message = 'API Error: Unknown response'
202
+ elsif response.key?('errorMessage')
203
+ success = false
204
+ message = response.dig('errorMessage')
205
+ else
206
+ cost = response.dig('quoteTotal')
207
+ if cost
208
+ cost = (cost.to_f * 100).to_i
209
+ transit_days = response.dig('transitDaysTotal')
210
+
211
+ rate_estimates = [
212
+ RateEstimate.new(
213
+ origin,
214
+ destination,
215
+ self.class,
216
+ :standard,
217
+ transit_days: transit_days,
218
+ estimate_reference: nil,
219
+ total_cost: cost,
220
+ total_price: cost,
221
+ currency: 'USD',
222
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
223
+ )
224
+ ]
225
+ else
226
+ success = false
227
+ message = 'API Error: Cost is emtpy'
228
+ end
229
+ end
230
+
231
+ RateResponse.new(
232
+ success,
233
+ message,
234
+ response.to_hash,
235
+ rates: rate_estimates,
236
+ response: response,
237
+ request: last_request
238
+ )
239
+ end
240
+
241
+ # Tracking
242
+ end
243
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class JFJTransportation < Liftoff
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'JFJ Transportation'
9
+ @@scac = nil
10
+ end
11
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class PENS < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Peninsula Truck Lines'
9
+ @@scac = 'PENS'
10
+
11
+ def requirements
12
+ %i[username password account]
13
+ end
14
+
15
+ # Documents
16
+
17
+ # Rates
18
+ def find_rates(origin, destination, packages, options = {})
19
+ options = @options.merge(options)
20
+ origin = Location.from(origin)
21
+ destination = Location.from(destination)
22
+ packages = Array(packages)
23
+
24
+ request = build_rate_request(origin, destination, packages, options)
25
+ parse_rate_response(origin, destination, commit_soap(:rates, request))
26
+ end
27
+
28
+ # Tracking
29
+
30
+ protected
31
+
32
+ def commit_soap(action, request)
33
+ Savon.client(
34
+ wsdl: request_url(action),
35
+ convert_request_keys_to: :lower_camelcase,
36
+ env_namespace: :soap,
37
+ element_form_default: :qualified
38
+ ).call(
39
+ @conf.dig(:api, :actions, action),
40
+ message: request
41
+ ).body.to_json
42
+ end
43
+
44
+ def request_url(action)
45
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
46
+ "#{scheme}#{@conf.dig(:api, :domains, action)}#{@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
+ request = {
56
+ user_id: @options[:username],
57
+ password: @options[:password],
58
+ account: @options[:account],
59
+ customer_type: @options[:customer_type].blank? ? 'B' : @options[:customer_type],
60
+ origin_zip: origin.to_hash[:postal_code].to_s,
61
+ destination_zip: destination.to_hash[:postal_code].to_s,
62
+ accessorial_list: '', # TODO: Fix this!
63
+ class_list: packages.map(&:freight_class).join(','),
64
+ weight_list: packages.map(&:lbs).inject([]) { |weights, lbs| weights << lbs.ceil }.join(','),
65
+ none_palletized_mode: 'N',
66
+ plt_count_list: Array.new(packages.size, 1).join(','),
67
+ plt_length_list: packages.map(&:inches).inject([]) { |lengths, inches| lengths << length(:in).ceil }.join(','),
68
+ plt_total_weight: packages.map(&:lbs).inject(0) { |sum, lbs| sum += lbs }.ceil,
69
+ plt_width_list: packages.map(&:inches).inject([]) { |lengths, inches| lengths << width(:in).ceil }.join(',')
70
+ }
71
+
72
+ save_request(request)
73
+ request
74
+ end
75
+
76
+ def parse_rate_response(origin, destination, response)
77
+ success = true
78
+ message = ''
79
+
80
+ if !response
81
+ success = false
82
+ message = 'API Error: Unknown response'
83
+ else
84
+ response = JSON.parse(response)
85
+ error = response.dig('create_pens_rate_quote_response', 'create_pens_rate_quote_result', 'errors', 'message')
86
+ if !error.blank?
87
+ success = false
88
+ message = error
89
+ else
90
+ result = response.dig('create_pens_rate_quote_response', 'create_pens_rate_quote_result')
91
+
92
+ service_type = :standard
93
+ api_service_type = result.dig('quote', 'transit_type')
94
+ @conf.dig(:services, :mappable).each do |key, val|
95
+ service_type = key if api_service_type.downcase.include?(val)
96
+ end
97
+
98
+ cost = result.dig('quote', 'gross_charge').sub(',', '').sub('.', '').to_i
99
+ transit_days = service_type == :next_day_ltl ? 1 : nil # TODO: Detect correctly
100
+ estimate_reference = result.dig('quote', 'quote_number')
101
+ if cost
102
+ rate_estimates = [
103
+ RateEstimate.new(
104
+ origin,
105
+ destination,
106
+ { scac: self.class.scac.upcase, name: self.class.name },
107
+ service_type,
108
+ transit_days: transit_days,
109
+ estimate_reference: estimate_reference,
110
+ total_cost: cost,
111
+ total_price: cost,
112
+ currency: 'USD',
113
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
114
+ )
115
+ ]
116
+ else
117
+ success = false
118
+ message = 'API Error: Cost is emtpy'
119
+ end
120
+ end
121
+ end
122
+
123
+ RateResponse.new(
124
+ success,
125
+ message,
126
+ response.to_hash,
127
+ rates: rate_estimates,
128
+ response: response,
129
+ request: last_request
130
+ )
131
+ end
132
+
133
+ # Tracking
134
+ end
135
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class RDFS < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Roadrunner Transportation Services'
9
+ @@scac = 'RRDS'
10
+
11
+ def requirements
12
+ %i[username password account]
13
+ end
14
+
15
+ # Documents
16
+ def find_bol(tracking_number, options = {})
17
+ options = @options.merge(options)
18
+ parse_document_response(:bol, tracking_number, options)
19
+ end
20
+
21
+ def find_pod(tracking_number, options = {})
22
+ options = @options.merge(options)
23
+ parse_document_response(:pod, tracking_number, options)
24
+ end
25
+
26
+ # Rates
27
+ def find_rates(origin, destination, packages, options = {})
28
+ options = @options.merge(options)
29
+ origin = Location.from(origin)
30
+ destination = Location.from(destination)
31
+ packages = Array(packages)
32
+
33
+ request = build_rate_request(origin, destination, packages, options)
34
+ parse_rate_response(origin, destination, commit_soap(:rates, request))
35
+ end
36
+
37
+ # Tracking
38
+ def find_tracking_info(tracking_number)
39
+ tracking_request = build_tracking_request(tracking_number)
40
+ parse_tracking_response(tracking_request)
41
+ end
42
+
43
+ protected
44
+
45
+ def build_soap_header(action)
46
+ {
47
+ authentication_header: {
48
+ :@xmlns => @conf.dig(:api, :soap, :namespaces, action),
49
+ :user_name => @options[:username],
50
+ :password => @options[:password]
51
+ }
52
+ }
53
+ end
54
+
55
+ def commit_soap(action, request)
56
+ Savon.client(
57
+ wsdl: request_url(action),
58
+ convert_request_keys_to: :camelcase,
59
+ env_namespace: :soap,
60
+ element_form_default: :qualified
61
+ ).call(
62
+ @conf.dig(:api, :actions, action),
63
+ soap_header: build_soap_header(action),
64
+ message: request
65
+ ).body.to_json
66
+ end
67
+
68
+ def parse_date(date)
69
+ date ? DateTime.strptime(date, '%Y-%m-%dT%H:%M:%S').to_s(:db) : nil
70
+ end
71
+
72
+ def request_url(action)
73
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
74
+ "#{scheme}#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
75
+ end
76
+
77
+ def strip_date(str)
78
+ str ? str.split(/[A|P]M /)[1] : nil
79
+ end
80
+
81
+ # Documents
82
+ def parse_document_response(type, tracking_number, options = {})
83
+ url = request_url(type).sub('%%TRACKING_NUMBER%%', tracking_number.to_s)
84
+
85
+ begin
86
+ doc = Nokogiri::HTML(URI.parse(url).open)
87
+ rescue OpenURI::HTTPError
88
+ raise ReactiveShipping::ResponseError, "API Error: #{@@name}: Document not found"
89
+ end
90
+
91
+ data = Base64.decode64(doc.css('img').first['src'].split('data:image/jpg;base64,').last)
92
+ path = if options[:path].blank?
93
+ File.join(Dir.tmpdir, "#{@@name} #{tracking_number} #{type.to_s.upcase}.pdf")
94
+ else
95
+ options[:path]
96
+ end
97
+
98
+ file = Tempfile.new
99
+ file.write(data)
100
+ file = Magick::ImageList.new(file.path)
101
+ file.write(path)
102
+ File.exist?(path) ? path : false
103
+ end
104
+
105
+ # Rates
106
+ def build_rate_request(origin, destination, packages, options = {})
107
+ options = @options.merge(options)
108
+
109
+ service_deliveryoptions = [
110
+ serviceoptions: { service_code: 'SS' }
111
+ ]
112
+
113
+ unless options[:accessorials].blank?
114
+ serviceable_accessorials?(options[:accessorials])
115
+ options[:accessorials].each do |a|
116
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
117
+ service_deliveryoptions << { serviceoptions: { service_code: @conf.dig(:accessorials, :mappable)[a] } }
118
+ end
119
+ end
120
+ end
121
+
122
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
123
+ if longest_dimension > 144
124
+ service_deliveryoptions << { serviceoptions: { service_code: 'EXL' } }
125
+ elsif longest_dimension > 96
126
+ service_deliveryoptions << { serviceoptions: { service_code: 'EXM' } }
127
+ end
128
+
129
+ service_deliveryoptions = service_deliveryoptions.uniq.to_a
130
+
131
+ request = {
132
+ 'request' => {
133
+ origin_zip: origin.to_hash[:postal_code].to_s,
134
+ destination_zip: destination.to_hash[:postal_code].to_s,
135
+ shipment_details: {
136
+ shipment_detail: packages.inject([]) do |arr, package|
137
+ arr << {
138
+ 'ActualClass' => package.freight_class,
139
+ 'Weight' => package.pounds.ceil
140
+ }
141
+ end
142
+ },
143
+ service_deliveryoptions: service_deliveryoptions,
144
+ origin_type: options[:origin_type] || 'B', # O for shipper, I for consignee, B for third party
145
+ payment_type: options[:payment_type] || 'P', # Prepaid
146
+ pallet_count: packages.size,
147
+ # :linear_feet => linear_ft(packages),
148
+ pieces: packages.size,
149
+ account: options[:account]
150
+ }
151
+ }
152
+
153
+ save_request(request)
154
+ request
155
+ end
156
+
157
+ def parse_rate_response(origin, destination, response)
158
+ success = true
159
+ message = ''
160
+
161
+ if !response
162
+ success = false
163
+ message = 'API Error: Unknown response'
164
+ else
165
+ response = JSON.parse(response)
166
+ if response[:error]
167
+ success = false
168
+ message = response[:error]
169
+ else
170
+ cost = response.dig('rate_quote_by_account_response', 'rate_quote_by_account_result', 'net_charge')
171
+ transit_days = response.dig(
172
+ 'rate_quote_by_account_response',
173
+ 'rate_quote_by_account_result',
174
+ 'routing_info',
175
+ 'estimated_transit_days'
176
+ ).to_i
177
+ estimate_reference = response.dig(
178
+ 'rate_quote_by_account_response',
179
+ 'rate_quote_by_account_result',
180
+ 'quote_number'
181
+ )
182
+ if cost
183
+ rate_estimates = [
184
+ RateEstimate.new(
185
+ origin,
186
+ destination,
187
+ { scac: self.class.scac.upcase, name: self.class.name },
188
+ :standard,
189
+ transit_days: transit_days,
190
+ estimate_reference: estimate_reference,
191
+ total_cost: cost,
192
+ total_price: cost,
193
+ currency: 'USD',
194
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
195
+ )
196
+ ]
197
+ else
198
+ success = false
199
+ message = 'API Error: Cost is emtpy'
200
+ end
201
+ end
202
+ end
203
+
204
+ RateResponse.new(
205
+ success,
206
+ message,
207
+ response.to_hash,
208
+ rates: rate_estimates,
209
+ response: response,
210
+ request: last_request
211
+ )
212
+ end
213
+
214
+ # Tracking
215
+ def build_tracking_request(tracking_number)
216
+ URI.parse("#{request_url(:track)}/#{tracking_number}").open
217
+ end
218
+
219
+ def parse_location(comment, delimiters)
220
+ city = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[0].titleize
221
+ state = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[1].upcase
222
+
223
+ Location.new(
224
+ city: city,
225
+ province: state,
226
+ state: state,
227
+ country: ActiveUtils::Country.find('USA')
228
+ )
229
+ end
230
+
231
+ def parse_tracking_response(response)
232
+ json = JSON.parse(response.read)
233
+
234
+ if (response.status[0] != '200') || !json.dig('SearchResults')
235
+ status = json.dig('error') || "API Error: HTTP #{response.status[0]}"
236
+ return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
237
+ end
238
+
239
+ search_result = json.dig('SearchResults')[0]
240
+ if search_result.dig('Shipment', 'ProNumber').downcase.include?('not available')
241
+ status = "API Error: #{@@name} tracking number not found"
242
+ return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
243
+ end
244
+
245
+ receiver_address = Location.new(
246
+ city: search_result.dig('Shipment', 'Consignee', 'City').titleize,
247
+ province: search_result.dig('Shipment', 'Consignee', 'State').upcase,
248
+ state: search_result.dig('Shipment', 'Consignee', 'State').upcase,
249
+ country: ActiveUtils::Country.find('USA')
250
+ )
251
+
252
+ shipper_address = Location.new(
253
+ city: search_result.dig('Shipment', 'Origin', 'City').titleize,
254
+ province: search_result.dig('Shipment', 'Origin', 'State').upcase,
255
+ state: search_result.dig('Shipment', 'Origin', 'State').upcase,
256
+ country: ActiveUtils::Country.find('USA')
257
+ )
258
+
259
+ actual_delivery_date = parse_date(search_result.dig('Shipment', 'DeliveredDateTime'))
260
+ scheduled_delivery_date = parse_date(search_result.dig('Shipment', 'ApptDateTime'))
261
+ tracking_number = search_result.dig('Shipment', 'SearchItem')
262
+
263
+ last_location = nil
264
+ shipment_events = []
265
+ search_result.dig('Shipment', 'Comments').each do |api_event|
266
+ type_code = api_event.dig('ActivityCode')
267
+ next if !type_code || type_code == 'ARQ'
268
+
269
+ event = @conf.dig(:events, :types).key(type_code)
270
+ next if event.blank?
271
+
272
+ datetime_without_time_zone = parse_date(api_event.dig('StatusDateTime'))
273
+ comment = strip_date(api_event.dig('StatusComment'))
274
+
275
+ case event
276
+ when :arrived_at_terminal
277
+ location = parse_location(comment, [' to ', 'in '])
278
+ when :delivered
279
+ location = receiver_address
280
+ when :departed
281
+ location = parse_location(comment, [' to ', 'from '])
282
+ when :out_for_delivery
283
+ location = parse_location(comment, [' to ', 'from '])
284
+ when :picked_up
285
+ location = shipper_address
286
+ when :trailer_closed
287
+ location = last_location
288
+ when :trailer_unloaded
289
+ location = parse_location(comment, [' to ', 'in '])
290
+ end
291
+ last_location = location
292
+
293
+ # status and type_code set automatically by ActiveFreight based on event
294
+ shipment_events << ShipmentEvent.new(event, datetime_without_time_zone, location)
295
+ end
296
+
297
+ shipment_events = shipment_events.sort_by(&:time)
298
+
299
+ TrackingResponse.new(
300
+ true,
301
+ shipment_events.last.status,
302
+ json,
303
+ carrier: "#{@@scac}, #{@@name}",
304
+ json: json,
305
+ response: response,
306
+ status: status,
307
+ type_code: shipment_events.last.status,
308
+ ship_time: parse_date(search_result.dig('Shipment', 'ProDateTime')),
309
+ scheduled_delivery_date: scheduled_delivery_date,
310
+ actual_delivery_date: actual_delivery_date,
311
+ delivery_signature: nil,
312
+ shipment_events: shipment_events,
313
+ shipper_address: shipper_address,
314
+ origin: shipper_address,
315
+ destination: receiver_address,
316
+ tracking_number: tracking_number
317
+ )
318
+ end
319
+ end
320
+ end