reactive_freight 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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