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,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