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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class CLNI < CarrierLogistics
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Clear Lane Freight Systems'
9
+ @@scac = 'CLNI'
10
+
11
+ # Documents
12
+
13
+ # Rates
14
+ def build_calculated_accessorials(packages, origin, destination)
15
+ accessorials = []
16
+
17
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
18
+ if longest_dimension > 48
19
+ if longest_dimension < 240
20
+ accessorials << '&HHG=yes' # standard overlength fee
21
+ elsif longest_dimension >= 240
22
+ accessorials << '&OVER20=yes'
23
+ elsif longest_dimension >= 192 && longest_dimension < 240
24
+ accessorials << '&OVER16=yes'
25
+ elsif longest_dimension >= 132 && longest_dimension < 192
26
+ accessorials << '&OVER11=yes'
27
+ elsif longest_dimension >= 96 && longest_dimension < 132
28
+ accessorials << '&OVER11=yes'
29
+ end
30
+ end
31
+
32
+ accessorials << '&BOSP=yes' if destination.city == 'Boston' && destination.state == 'MA'
33
+ accessorials << '&BOSD=yes' if origin.city == 'Boston' && origin.state == 'MA'
34
+
35
+ accessorials << '&SDDLY=yes' if destination.state == 'SD'
36
+ accessorials << '&SDPU=yes' if origin.state == 'SD'
37
+
38
+ # TODO: Add support for:
39
+ # NYBDY, NYC BUROUGH DELY
40
+ # NYBPU, NYC BUROUGH PU
41
+ # NYLID, NYC LONG ISLAND DELY
42
+ # NYLIP, NYC LONG ISLAND PU
43
+ # NYMDY, NYC MANHATTAN DELY
44
+ # NYMPU, NYC MANHATTAN PU
45
+ # TXWDY, TXWST DELY
46
+ # TXWPU, TXWST PU SURCHARGE
47
+
48
+ accessorials
49
+ end
50
+
51
+ # Tracking
52
+
53
+ # protected
54
+
55
+ # Documents
56
+
57
+ # Rates
58
+ end
59
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class CTBV < CarrierLogistics
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'The Custom Companies'
9
+ @@scac = 'CTBV'
10
+
11
+ # Documents
12
+
13
+ # Rates
14
+ def build_calculated_accessorials(packages, *)
15
+ accessorials = []
16
+
17
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
18
+ if longest_dimension > 144
19
+ accessorials << '&OL=yes'
20
+ elsif longest_dimension >= 96 && longest_dimension <= 144
21
+ accessorials << '&OL1=yes'
22
+ end
23
+
24
+ accessorials
25
+ end
26
+
27
+ # Tracking
28
+
29
+ # protected
30
+
31
+ # Documents
32
+
33
+ # Rates
34
+ end
35
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class DPHE < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Dependable Highway Express'
9
+ @@scac = 'DPHE'
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 build_soap_header(_action)
33
+ {
34
+ authentication_header: {
35
+ user_name: @options[:username],
36
+ password: @options[:password]
37
+ }
38
+ }
39
+ end
40
+
41
+ def commit_soap(action, request)
42
+ Savon.client(
43
+ wsdl: request_url(action),
44
+ convert_request_keys_to: :camelcase,
45
+ env_namespace: :soap,
46
+ element_form_default: :qualified
47
+ ).call(
48
+ @conf.dig(:api, :actions, action),
49
+ message: request
50
+ ).body.to_hash
51
+ end
52
+
53
+ def request_url(action)
54
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
55
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
56
+ end
57
+
58
+ # Documents
59
+
60
+ # Rates
61
+ def build_rate_request(origin, destination, packages, options = {})
62
+ options = @options.merge(options)
63
+
64
+ accessorials = []
65
+ unless options[:accessorials].blank?
66
+ serviceable_accessorials?(options[:accessorials])
67
+ options[:accessorials].each do |a|
68
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
69
+ accessorials << @conf.dig(:accessorials, :mappable)[a]
70
+ end
71
+ end
72
+ end
73
+
74
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
75
+ if longest_dimension >= 336
76
+ accessorials << 'X29'
77
+ elsif longest_dimension >= 240 && longest_dimension < 336
78
+ accessorials << 'X28'
79
+ elsif longest_dimension >= 144 && longest_dimension < 240
80
+ accessorials << 'X20'
81
+ elsif longest_dimension >= 96 && longest_dimension < 144
82
+ accessorials << 'X12'
83
+ end
84
+
85
+ accessorials = accessorials.uniq.join(',')
86
+
87
+ shipment_detail = []
88
+ packages.each do |package|
89
+ shipment_detail << "1|#{package.freight_class}|#{package.pounds.ceil}"
90
+ end
91
+ shipment_detail = shipment_detail.join('|')
92
+
93
+ request = {
94
+ customer_code: @options[:account],
95
+ origin_zip: origin.to_hash[:postal_code].to_s.upcase,
96
+ destination_zip: destination.to_hash[:postal_code].to_s.upcase,
97
+ shipment_detail: shipment_detail,
98
+ rating_type: '', # per API documentation
99
+ accessorials: accessorials
100
+ }
101
+
102
+ save_request(request)
103
+ request
104
+ end
105
+
106
+ def parse_rate_response(origin, destination, response)
107
+ success = true
108
+ message = ''
109
+
110
+ if !response
111
+ success = false
112
+ message = 'API Error: Unknown response'
113
+ else
114
+ error = response.dig(:get_rates_response, :get_rates_result, :rate_error)
115
+ quote_number = response.dig(:get_rates_response, :get_rates_result, :rate_quote_number).blank?
116
+
117
+ # error on its own isn't reliable indicator of error - returns false on error
118
+ if !error.blank? || quote_number
119
+ success = false
120
+ message = response.dig(:get_rates_response, :get_rates_result, :return_line)
121
+ else
122
+ cost = response.dig(:get_rates_response, :get_rates_result, :totals)
123
+ if cost
124
+ cost = cost.sub('$', '').sub(',', '').sub('.', '').to_i
125
+ transit_days = response.dig(:get_rates_response, :get_rates_result, :transit_days).to_i
126
+ estimate_reference = response.dig(:get_rates_response, :get_rates_result, :rate_quote_number)
127
+
128
+ rate_estimates = [
129
+ RateEstimate.new(
130
+ origin,
131
+ destination,
132
+ { scac: self.class.scac.upcase, name: self.class.name },
133
+ :standard,
134
+ transit_days: transit_days,
135
+ estimate_reference: estimate_reference,
136
+ total_cost: cost,
137
+ total_price: cost,
138
+ currency: 'USD',
139
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
140
+ )
141
+ ]
142
+ else
143
+ success = false
144
+ message = 'API Error: Cost is emtpy'
145
+ end
146
+ end
147
+ end
148
+
149
+ RateResponse.new(
150
+ success,
151
+ message,
152
+ response.to_hash,
153
+ rates: rate_estimates,
154
+ response: response,
155
+ request: last_request
156
+ )
157
+ end
158
+
159
+ # Tracking
160
+ def build_tracking_request(tracking_number)
161
+ request = { pro_number: tracking_number }
162
+ save_request(request)
163
+ request
164
+ end
165
+
166
+ def parse_city_state(str)
167
+ return nil if str.blank?
168
+
169
+ Location.new(
170
+ city: str.split(', ')[0].titleize,
171
+ state: str.split(', ')[1].split(' ')[0].upcase,
172
+ country: ActiveUtils::Country.find('USA')
173
+ )
174
+ end
175
+
176
+ def parse_city(str)
177
+ return nil if str.blank?
178
+
179
+ Location.new(
180
+ city: str.squeeze.strip.titleize,
181
+ state: nil,
182
+ country: ActiveUtils::Country.find('USA')
183
+ )
184
+ end
185
+
186
+ def parse_date(date)
187
+ date ? DateTime.strptime(date, '%m/%d/%Y %l:%M:%S %p').to_s(:db) : nil
188
+ end
189
+
190
+ def parse_location(comment, delimiters)
191
+ city = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[0].titleize
192
+ state = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[1].upcase
193
+
194
+ Location.new(
195
+ city: city,
196
+ province: state,
197
+ state: state,
198
+ country: ActiveUtils::Country.find('USA')
199
+ )
200
+ end
201
+
202
+ def parse_tracking_response(response)
203
+ unless response.dig(:get_tracking_response, :get_tracking_result, :tracking_status_response)
204
+ status = json.dig('error') || "API Error: HTTP #{response.status[0]}"
205
+ return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
206
+ end
207
+
208
+ search_result = response.dig(:get_tracking_response, :get_tracking_result)
209
+
210
+ shipper_address = Location.new(
211
+ street: search_result.dig(:shipperaddress).squeeze.strip.titleize,
212
+ city: search_result.dig(:shipper_city).squeeze.strip.titleize,
213
+ state: search_result.dig(:shipper_state).strip.upcase,
214
+ postal_code: search_result.dig(:shipper_zip).strip,
215
+ country: ActiveUtils::Country.find('USA')
216
+ )
217
+
218
+ receiver_address = Location.new(
219
+ street: search_result.dig(:consaddress).squeeze.strip.titleize,
220
+ city: search_result.dig(:cons_city).squeeze.strip.titleize,
221
+ state: search_result.dig(:cons_state).strip.upcase,
222
+ postal_code: search_result.dig(:cons_zip).strip,
223
+ country: ActiveUtils::Country.find('USA')
224
+ )
225
+
226
+ actual_delivery_date = parse_date(search_result.dig('Shipment', 'DeliveredDateTime'))
227
+ pickup_date = parse_date(search_result.dig(:pickup_date))
228
+ scheduled_delivery_date = parse_date(search_result.dig('Shipment', 'ApptDateTime'))
229
+ tracking_number = search_result.dig('Shipment', 'SearchItem')
230
+
231
+ shipment_events = []
232
+ shipment_events << ShipmentEvent.new(
233
+ :picked_up,
234
+ pickup_date,
235
+ shipper_address
236
+ )
237
+
238
+ api_events = search_result.dig(:tracking_status_response, :tracking_status_row).reverse
239
+ api_events.each do |api_event|
240
+ event_key = nil
241
+ comment = api_event.dig(:tracking_status)
242
+
243
+ @conf.dig(:events, :types).each do |key, val|
244
+ if comment.downcase.include?(val)
245
+ event_key = key
246
+ break
247
+ end
248
+ end
249
+ next if event_key.blank?
250
+
251
+ case event_key
252
+ when :arrived_at_terminal
253
+ location = parse_city(comment.split('arrived')[1].upcase.split('SERVICE CENTER')[0])
254
+ when :delivered
255
+ location = parse_city_state(comment.split('in ')[1].split('completed')[0])
256
+ when :departed
257
+ location = parse_city(comment.split('departed')[1].upcase.split('SERVICE CENTER')[0])
258
+ when :out_for_delivery
259
+ location = receiver_address
260
+ when :trailer_closed
261
+ location = parse_city(comment.split('Location:')[1])
262
+ when :trailer_unloaded
263
+ location = parse_city(comment.split('Location:')[1])
264
+ end
265
+
266
+ datetime_without_time_zone = parse_date(api_event.dig(:tracking_date))
267
+
268
+ # status and type_code set automatically by ActiveFreight based on event
269
+ shipment_events << ShipmentEvent.new(event_key, datetime_without_time_zone, location)
270
+ end
271
+
272
+ shipment_events = shipment_events.sort_by(&:time)
273
+
274
+ TrackingResponse.new(
275
+ true,
276
+ shipment_events.last.status,
277
+ response,
278
+ carrier: "#{@@scac}, #{@@name}",
279
+ hash: response,
280
+ response: response,
281
+ status: status,
282
+ type_code: shipment_events.last.status,
283
+ ship_time: parse_date(search_result.dig('Shipment', 'ProDateTime')),
284
+ scheduled_delivery_date: scheduled_delivery_date,
285
+ actual_delivery_date: actual_delivery_date,
286
+ delivery_signature: nil,
287
+ shipment_events: shipment_events,
288
+ shipper_address: shipper_address,
289
+ origin: shipper_address,
290
+ destination: receiver_address,
291
+ tracking_number: tracking_number,
292
+ request: last_request
293
+ )
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class DRRQ < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ JSON_HEADERS = {
8
+ 'Accept': 'application/json',
9
+ 'Content-Type': 'application/json',
10
+ 'charset': 'utf-8'
11
+ }.freeze
12
+
13
+ cattr_reader :name, :scac
14
+ @@name = 'TForce Worldwide'
15
+ @@scac = 'DRRQ'
16
+
17
+ def available_services
18
+ nil
19
+ end
20
+
21
+ def requirements
22
+ %i[username password]
23
+ end
24
+
25
+ # Documents
26
+ def find_pod(tracking_number, options = {})
27
+ options = @options.merge(options)
28
+ parse_pod_response(tracking_number, options)
29
+ end
30
+
31
+ # Rates
32
+ def find_rates(origin, destination, packages, options = {})
33
+ options = @options.merge(options)
34
+ origin = Location.from(origin)
35
+ destination = Location.from(destination)
36
+ packages = Array(packages)
37
+
38
+ request = build_rate_request(origin, destination, packages, options)
39
+ parse_rate_response(origin, destination, commit(request))
40
+ end
41
+
42
+ # Tracking
43
+
44
+ protected
45
+
46
+ def build_headers(action, options = {})
47
+ options = @options.merge(options)
48
+
49
+ case action
50
+ when :quote
51
+ JSON_HEADERS.merge(
52
+ {
53
+ 'UserName' => options[:username],
54
+ 'ApiKey' => options[:password]
55
+ }
56
+ )
57
+ else
58
+ {}
59
+ end
60
+ end
61
+
62
+ def build_url(action)
63
+ "#{@conf.dig(:api, :use_ssl, action) ? 'https' : 'http'}://#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
64
+ end
65
+
66
+ def commit(request)
67
+ url = request[:url]
68
+ headers = request[:headers]
69
+ method = request[:method]
70
+ body = request[:body]
71
+
72
+ response = case method
73
+ when :post
74
+ HTTParty.post(url, headers: headers, body: body)
75
+ else
76
+ HTTParty.get(url, headers: headers)
77
+ end
78
+
79
+ JSON.parse(response.body) if response&.body
80
+ end
81
+
82
+ def request_url(action)
83
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
84
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
85
+ end
86
+
87
+ # Documents
88
+
89
+ def parse_document_response(type, tracking_number, url, options = {})
90
+ options = @options.merge(options)
91
+
92
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Document not found" if url.blank?
93
+
94
+ path = if options[:path].blank?
95
+ File.join(Dir.tmpdir, "#{self.class.name} #{tracking_number} #{type.to_s.upcase}.pdf")
96
+ else
97
+ options[:path]
98
+ end
99
+ file = File.new(path, 'w')
100
+
101
+ File.open(file.path, 'wb') do |file|
102
+ URI.parse(url).open do |input|
103
+ file.write(input.read)
104
+ end
105
+ rescue OpenURI::HTTPError
106
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Document not found"
107
+ end
108
+
109
+ unless url.end_with?('.pdf')
110
+ file = Magick::ImageList.new(file.path)
111
+ file.write(path)
112
+ end
113
+
114
+ File.exist?(path) ? path : false
115
+ end
116
+
117
+ def parse_pod_response(tracking_number, options = {})
118
+ options = @options.merge(options)
119
+ browser = Watir::Browser.new(:chrome, headless: !@debug)
120
+ browser.goto(build_url(:pod))
121
+
122
+ browser.text_field(name: 'UserId').set(options[:username])
123
+ browser.text_field(name: 'Password').set(options[:password])
124
+ browser.button(name: 'submitbutton').click
125
+
126
+ browser
127
+ .element(xpath: '//*[@id="__AppFrameBaseTable"]/tbody/tr[2]/td/div[4]')
128
+ .click
129
+
130
+ browser.iframes(src: '../mainframe/MainFrame.jsp?bRedirect=true')
131
+ browser.iframe(name: 'AppBody').frame(id: 'Header')
132
+ .text_field(name: 'filter')
133
+ .set(tracking_number)
134
+ browser.iframe(name: 'AppBody').frame(id: 'Header').button(value: 'Find')
135
+ .click
136
+
137
+ begin
138
+ browser.iframe(name: 'AppBody').frame(id: 'Detail')
139
+ .iframe(id: 'transportsWin')
140
+ .element(xpath: '/html/body/div/table/tbody/tr[2]/td[1]/span/a[2]')
141
+ .click
142
+ rescue StandardError
143
+ # POD not yet available
144
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Document not found"
145
+ end
146
+
147
+ browser.iframe(name: 'AppBody').frame(id: 'Detail')
148
+ .element(xpath: '/html/body/div[1]/div/div/div[1]/div[1]/div[2]/div/a[5]')
149
+ .click
150
+
151
+ html = browser.iframe(name: 'AppBody').frame(id: 'Detail').iframes[1]
152
+ .element(xpath: '/html/body/table[3]')
153
+ .html
154
+ html = Nokogiri::HTML(html)
155
+
156
+ browser.close
157
+
158
+ url = nil
159
+ html.css('tr').each do |tr|
160
+ tds = tr.css('td')
161
+ next if tds.size <= 1 || tds.blank?
162
+
163
+ text = tds[1].text
164
+ next unless text&.include?('http')
165
+
166
+ url = text if url.blank? || !url.include?('hubtran') # Prefer HubTran
167
+ end
168
+
169
+ parse_document_response(:pod, tracking_number, url, options)
170
+ end
171
+
172
+ # Rates
173
+ def build_rate_request(origin, destination, packages, options = {})
174
+ options = @options.merge(options)
175
+
176
+ accessorials = []
177
+
178
+ unless options[:accessorials].blank?
179
+ serviceable_accessorials?(options[:accessorials])
180
+ options[:accessorials].each do |a|
181
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
182
+ accessorials << { ServiceCode: @conf.dig(:accessorials, :mappable)[a] }
183
+ end
184
+ end
185
+ end
186
+
187
+ longest_dimension_ft = (packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil.to_f / 12).ceil.to_i
188
+ if longest_dimension_ft >= 8 && longest_dimension_ft < 30
189
+ accessorials << { ServiceCode: "OVL#{longest_dimension_ft}" }
190
+ end
191
+
192
+ accessorials = accessorials.uniq.to_a
193
+
194
+ items = []
195
+ packages.each do |package|
196
+ items << {
197
+ Name: 'Freight',
198
+ FreightClass: package.freight_class.to_s,
199
+ Weight: package.pounds.ceil.to_s,
200
+ WeightUnits: 'lb',
201
+ Width: package.width(:in).ceil,
202
+ Length: package.length(:in).ceil,
203
+ Height: package.height(:in).ceil,
204
+ DimensionUnits: 'in',
205
+ Quantity: 1,
206
+ QuantityUnits: 'PLT' # Check this
207
+ }
208
+ end
209
+
210
+ body = {
211
+ Constraints: {
212
+ ServiceFlags: accessorials
213
+ },
214
+ Items: items,
215
+ PickupEvent: {
216
+ Date: DateTime.now.strftime('%m/%d/%Y %I:%M:00 %p'),
217
+ LocationCode: 'PLocationCode',
218
+ City: origin.to_hash[:city].upcase,
219
+ State: origin.to_hash[:province].upcase,
220
+ Zip: origin.to_hash[:postal_code].upcase,
221
+ Country: 'USA'
222
+ },
223
+ DropEvent: {
224
+ Date: (DateTime.now + 5.days).strftime('%m/%d/%Y %I:%M:00 %p'),
225
+ LocationCode: 'DLocationCode',
226
+ City: destination.to_hash[:city].upcase,
227
+ State: destination.to_hash[:province].upcase,
228
+ Zip: destination.to_hash[:postal_code].upcase,
229
+ Country: 'USA',
230
+ MaxPriceSheet: 6,
231
+ ShowInsurance: false
232
+ }
233
+ }.to_json
234
+
235
+ request = {
236
+ url: build_url(:quote),
237
+ headers: build_headers(:quote, options),
238
+ method: @conf.dig(:api, :methods, :quote),
239
+ body: body
240
+ }
241
+
242
+ save_request(request)
243
+ request
244
+ end
245
+
246
+ def parse_rate_response(origin, destination, response)
247
+ success = true
248
+ message = ''
249
+ rate_estimates = []
250
+
251
+ if !response
252
+ success = false
253
+ message = 'API Error: Unknown response'
254
+ else
255
+ response.each do |response_line|
256
+ next if response_line.dig('Message') # Signifies error
257
+
258
+ cost = response_line.dig('Total')
259
+ if cost
260
+ cost = (cost.to_f * 100).to_i
261
+ service = response_line.dig('Charges').map { |charges| charges.dig('Description') }
262
+ service = case service
263
+ when service.any?('Standard LTL Guarantee')
264
+ :guaranteed
265
+ when service.any?('Guaranteed LTL Service AM')
266
+ :guaranteed_am
267
+ when service.any?('Guaranteed LTL Service PM')
268
+ :guaranteed_pm
269
+ else
270
+ :standard
271
+ end
272
+ transit_days = response_line.dig('ServiceDays').to_i
273
+ rate_estimates << RateEstimate.new(
274
+ origin,
275
+ destination,
276
+ { scac: response_line.dig('Scac'), name: response_line.dig('CarrierName') },
277
+ service,
278
+ transit_days: transit_days,
279
+ estimate_reference: nil,
280
+ total_cost: cost,
281
+ total_price: cost,
282
+ currency: 'USD',
283
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
284
+ )
285
+ else
286
+ next
287
+ end
288
+ end
289
+ end
290
+
291
+ RateResponse.new(
292
+ success,
293
+ message,
294
+ { response: response },
295
+ rates: rate_estimates,
296
+ response: response,
297
+ request: last_request
298
+ )
299
+ end
300
+
301
+ # Tracking
302
+ end
303
+ end