freight_kit 0.1.15 → 0.1.16

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +96 -43
  3. data/README.md +28 -6
  4. data/VERSION +1 -1
  5. data/configuration/carriers/abfs.yml +124 -0
  6. data/configuration/carriers/btvp.yml +84 -0
  7. data/configuration/carriers/ccyq.yml +121 -0
  8. data/configuration/carriers/clni.yml +113 -0
  9. data/configuration/carriers/cnwy.yml +113 -0
  10. data/configuration/carriers/ctbv.yml +117 -0
  11. data/configuration/carriers/dcha.yml +105 -0
  12. data/configuration/carriers/dlds.yml +111 -0
  13. data/configuration/carriers/dphe.yml +130 -0
  14. data/configuration/carriers/drrq.yml +131 -0
  15. data/configuration/carriers/fcsy.yml +102 -0
  16. data/configuration/carriers/fwda.yml +137 -0
  17. data/configuration/carriers/jfj_transportation.yml +2 -0
  18. data/configuration/carriers/mtvl.yml +12 -0
  19. data/configuration/carriers/numk.yml +14 -0
  20. data/configuration/carriers/otcl.yml +124 -0
  21. data/configuration/carriers/pens.yml +22 -0
  22. data/configuration/carriers/rdfs.yml +142 -0
  23. data/configuration/carriers/saia.yml +129 -0
  24. data/configuration/carriers/sefl.yml +115 -0
  25. data/configuration/carriers/totl.yml +111 -0
  26. data/configuration/carriers/tqyl.yml +28 -0
  27. data/configuration/carriers/wrds.yml +20 -0
  28. data/configuration/platforms/carrier_logistics.yml +25 -0
  29. data/configuration/platforms/next.yml +12 -0
  30. data/configuration/platforms/the_great_information_factory.yml +122 -0
  31. data/freight_kit.gemspec +9 -7
  32. data/lib/freight_kit/api_clients/soap_client.rb +70 -0
  33. data/lib/freight_kit/api_clients.rb +3 -0
  34. data/lib/freight_kit/carriers/abfs.rb +421 -0
  35. data/lib/freight_kit/carriers/btvp.rb +29 -0
  36. data/lib/freight_kit/carriers/ccyq.rb +317 -0
  37. data/lib/freight_kit/carriers/clni.rb +396 -0
  38. data/lib/freight_kit/carriers/cnwy.rb +327 -0
  39. data/lib/freight_kit/carriers/ctbv.rb +53 -0
  40. data/lib/freight_kit/carriers/dcha.rb +76 -0
  41. data/lib/freight_kit/carriers/dlds.rb +49 -0
  42. data/lib/freight_kit/carriers/dphe.rb +474 -0
  43. data/lib/freight_kit/carriers/drrq.rb +580 -0
  44. data/lib/freight_kit/carriers/fcsy.rb +57 -0
  45. data/lib/freight_kit/carriers/fwda.rb +744 -0
  46. data/lib/freight_kit/carriers/jfj_transportation.rb +13 -0
  47. data/lib/freight_kit/carriers/mtvl.rb +34 -0
  48. data/lib/freight_kit/carriers/numk.rb +58 -0
  49. data/lib/freight_kit/carriers/otcl.rb +528 -0
  50. data/lib/freight_kit/carriers/pens.rb +204 -0
  51. data/lib/freight_kit/carriers/rdfs.rb +521 -0
  52. data/lib/freight_kit/carriers/saia.rb +438 -0
  53. data/lib/freight_kit/carriers/sefl.rb +342 -0
  54. data/lib/freight_kit/carriers/totl.rb +172 -0
  55. data/lib/freight_kit/carriers/tqyl.rb +339 -0
  56. data/lib/freight_kit/carriers/wrds.rb +246 -0
  57. data/lib/freight_kit/carriers.rb +26 -0
  58. data/lib/freight_kit/helpers/documentable.rb +13 -0
  59. data/lib/freight_kit/helpers/pickupable.rb +39 -0
  60. data/lib/freight_kit/helpers/rateable.rb +28 -0
  61. data/lib/freight_kit/helpers/trackable.rb +25 -0
  62. data/lib/freight_kit/helpers.rb +6 -0
  63. data/lib/freight_kit/platforms/carrier_logistics.rb +450 -0
  64. data/lib/freight_kit/platforms/next.rb +101 -0
  65. data/lib/freight_kit/platforms/the_great_information_factory.rb +528 -0
  66. data/lib/freight_kit/platforms.rb +5 -0
  67. data/lib/freight_kit.rb +20 -1
  68. metadata +94 -14
@@ -0,0 +1,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class DPHE < FreightKit::Carrier
5
+ class << self
6
+ def maximum_height
7
+ Measured::Length.new(105, :inches)
8
+ end
9
+
10
+ def maximum_weight
11
+ Measured::Weight.new(10_000, :pounds)
12
+ end
13
+
14
+ def minimum_length_for_overlength_fees
15
+ Measured::Length.new(8, :feet)
16
+ end
17
+
18
+ def overlength_fees_require_tariff?
19
+ false
20
+ end
21
+
22
+ def required_credential_types
23
+ %i[api selenoid website]
24
+ end
25
+
26
+ def requirements
27
+ %i[credentials]
28
+ end
29
+ end
30
+
31
+ CITY_STATES = {
32
+ ca: ['Los Angeles', 'Mammoth Lakes', 'Sacramento', 'Redding', 'West Sacramento'],
33
+ nv: ['Reno', 'Sparks']
34
+ }
35
+
36
+ REACTIVE_FREIGHT_CARRIER = true
37
+
38
+ include FreightKit::Rateable
39
+ include FreightKit::Trackable
40
+
41
+ class << self
42
+ attr_reader :name, :scac
43
+ end
44
+ @name = 'Dependable Highway Express'
45
+ @scac = 'DPHE'
46
+
47
+ # Documents
48
+
49
+ def pod(tracking_number)
50
+ parse_document_response(:pod, tracking_number)
51
+ end
52
+
53
+ def scanned_bol(tracking_number)
54
+ parse_document_response(:bol, tracking_number)
55
+ end
56
+
57
+ protected
58
+
59
+ def build_soap_header(_action)
60
+ api_credentials = fetch_credential(:api)
61
+
62
+ { authentication_header: { user_name: api_credentials.username, password: api_credentials.password } }
63
+ end
64
+
65
+ def commit(action, request)
66
+ client_args = {
67
+ wsdl: request_url(action),
68
+ convert_request_keys_to: :camelcase,
69
+ env_namespace: :soap,
70
+ element_form_default: :qualified
71
+ }
72
+
73
+ call_args = { message: request }
74
+
75
+ response = ::FreightKit::SoapClient.new(
76
+ carrier: self,
77
+ action:,
78
+ client_args:,
79
+ call_args:,
80
+ soap_operation: @conf.dig(:api, :actions, action),
81
+ ).call
82
+
83
+ return response if response.is_a?(TrackingResponse) || response.is_a?(RateResponse)
84
+
85
+ response.to_hash
86
+ end
87
+
88
+ def request_url(action)
89
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
90
+ "#{scheme}#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
91
+ end
92
+
93
+ def parse_amount(amount)
94
+ negative = amount.include?('(') && amount.include?(')')
95
+
96
+ ['$', ',', '(', ')'].each do |char|
97
+ amount = amount.sub(char, '')
98
+ end
99
+
100
+ return 0 if amount.blank?
101
+
102
+ amount = (amount.to_f * 100).to_i
103
+ return amount unless negative
104
+
105
+ amount * -1
106
+ end
107
+
108
+ # Documents
109
+
110
+ def parse_document_response(action, tracking_number)
111
+ document_response = DocumentResponse.new
112
+
113
+ selenoid_credential = fetch_credential(:selenoid)
114
+ website_credentials = fetch_credential(:website)
115
+
116
+ browser = Watir::Browser.new(*selenoid_credential.watir_args)
117
+
118
+ browser.goto(request_url(action))
119
+
120
+ browser.text_field(name: 'dnn$ctr1914$View$TextBox1').set(website_credentials.username)
121
+ browser.text_field(name: 'dnn$ctr1914$View$TextBox2').set(website_credentials.password)
122
+ browser.button(name: 'dnn$ctr1914$View$Button1').click
123
+
124
+ if browser.html.downcase.include?('invalid username or password')
125
+ browser.close
126
+
127
+ document_response.error = InvalidCredentialsError.new
128
+ return document_response
129
+ end
130
+
131
+ browser.text_field(name: 'ctl00$ContentPlaceHolder1$txtProNumber').set(tracking_number)
132
+ browser.button(name: 'ctl00$ContentPlaceHolder1$btnSubmit').click
133
+
134
+ begin
135
+ browser
136
+ .element(xpath: '//*[@id="ContentPlaceHolder1_GridView1"]/tbody/tr[2]/td[2]/a')
137
+ .click
138
+ rescue Watir::Exception::UnknownObjectException
139
+ document_response.error = DocumentNotFoundError.new
140
+ return document_response
141
+ end
142
+
143
+ browser.switch_window
144
+ button_xpath = case action
145
+ when :bol then '//*[@id="ContentPlaceHolder1_btnDocs"]'
146
+ when :pod then '//*[@id="ContentPlaceHolder1_btnPOD"]'
147
+ end
148
+
149
+ if !button_xpath || !browser.element(xpath: button_xpath).exists?
150
+ browser.close
151
+
152
+ document_response.error = DocumentNotFoundError.new
153
+ return document_response
154
+ end
155
+
156
+ browser.element(xpath: button_xpath).click
157
+
158
+ if !button_xpath || browser.element(xpath: button_xpath).innertext.downcase.include?('unavailable')
159
+ browser.close
160
+
161
+ document_response.error = DocumentNotFoundError.new
162
+ return document_response
163
+ end
164
+
165
+ sleep(10) # so Chrome can finish downloading
166
+
167
+ download_url = "#{selenoid_credential.selenoid_options[:download_url]}/#{browser.driver.session_id}"
168
+ response = HTTParty.get("#{download_url}/?json")
169
+
170
+ filename = URI.encode_www_form_component(JSON.parse(response.body)&.last)
171
+ url = "#{download_url}/#{filename}"
172
+
173
+ document_response.request = URI.parse(url)
174
+
175
+ begin
176
+ response = HTTParty.get(url)
177
+ rescue StandardError => e
178
+ document_response.error = e
179
+ return document_response
180
+ end
181
+
182
+ browser.close
183
+
184
+ unless response.code == 200
185
+ document_response.error = DocumentNotFoundError.new
186
+ return document_response
187
+ end
188
+
189
+ document_response.assign_attributes(content_type: response.headers['content-type'], data: response.body)
190
+ document_response
191
+ end
192
+
193
+ # Rates
194
+
195
+ def build_rate_request(shipment:)
196
+ country_codes = [shipment.destination.country.code(:alpha2).value, shipment.origin.country.code(:alpha2).value]
197
+
198
+ if country_codes.reject { |c| c.upcase == 'US' }.any?
199
+ raise UnserviceableError,
200
+ "No service from #{shipment.origin.postal_code} to #{shipment.destination.postal_code}"
201
+ end
202
+
203
+ accessorials = []
204
+ if shipment.accessorials.present?
205
+ serviceable_accessorials?(shipment.accessorials)
206
+ shipment.accessorials.each do |a|
207
+ if @conf.dig(:accessorials, :unserviceable).exclude?(a)
208
+ accessorials << @conf.dig(:accessorials, :mappable)[a]
209
+ end
210
+ end
211
+ end
212
+
213
+ longest_dimension = shipment.packages.map { |p| [p.length(:in), p.width(:in)].max }.max.ceil
214
+ if longest_dimension >= 336
215
+ accessorials << 'X29'
216
+ elsif longest_dimension >= 240 && longest_dimension < 336
217
+ accessorials << 'X28'
218
+ elsif longest_dimension >= 144 && longest_dimension < 240
219
+ accessorials << 'X20'
220
+ elsif longest_dimension >= 96 && longest_dimension < 144
221
+ accessorials << 'X12'
222
+ end
223
+
224
+ accessorials = accessorials.uniq.join(',')
225
+
226
+ shipment_detail = []
227
+ shipment.packages.each do |package|
228
+ shipment_detail << [package.quantity, package.freight_class, package.pounds(:total)].join('|')
229
+ end
230
+ shipment_detail = shipment_detail.join('|')
231
+
232
+ api_credentials = fetch_credential(:api)
233
+
234
+ request = {
235
+ customer_code: api_credentials.account,
236
+ origin_zip: shipment.origin.postal_code.to_s.upcase,
237
+ destination_zip: shipment.destination.postal_code.to_s.upcase,
238
+ shipment_detail:,
239
+ rating_type: '', # per API documentation
240
+ accessorials:
241
+ }
242
+
243
+ save_request(request)
244
+ request
245
+ end
246
+
247
+ def parse_rate_response(shipment:, response:)
248
+ rate_response = RateResponse.new(request: last_request, response:)
249
+
250
+ if response.blank?
251
+ rate_response.error = ResponseError.new('Unknown response')
252
+ return rate_response
253
+ end
254
+
255
+ error = response.dig(:get_rates_response, :get_rates_result, :return_line)
256
+ error ||= response.dig(:get_rates_response, :get_rates_result, :rate_error)
257
+
258
+ if error
259
+ rate_response.error = InvalidCredentialsError.new(error) if error.downcase.include?('not a valid customer code')
260
+
261
+ if error.downcase.include?('not a direct service point') || error.downcase.include?('lanes not serviced')
262
+ rate_response.error = UnserviceableError.new(error)
263
+ end
264
+
265
+ rate_response.error = ResponseError.new(error) if rate_response.error.blank?
266
+ return rate_response
267
+ end
268
+
269
+ quote_number = response.dig(:get_rates_response, :get_rates_result, :rate_quote_number)
270
+ raise FreightKit::UnserviceableError if quote_number.blank?
271
+
272
+ if response.dig(:get_rates_response, :get_rates_result, :totals).blank?
273
+ rate_response.error = ResponseError.new('Cost is empty')
274
+ return rate_response
275
+ end
276
+
277
+ transit_days = response.dig(:get_rates_response, :get_rates_result, :transit_days)&.to_i
278
+ estimate_reference = response.dig(:get_rates_response, :get_rates_result, :rate_quote_number)
279
+
280
+ prices = []
281
+
282
+ shipment_details = response.dig(
283
+ :get_rates_response,
284
+ :get_rates_result,
285
+ :shipment_detail_response,
286
+ :shipment_detail_row,
287
+ )
288
+
289
+ shipment_details.each do |shipment_detail|
290
+ next if shipment_detail[:charge].blank?
291
+ next if shipment_detail[:description] == 'Totals'
292
+
293
+ cents = parse_amount(shipment_detail[:charge])
294
+ description = shipment_detail_description(shipment_detail)
295
+
296
+ prices << Price.new(blame: :api, cents:, description:)
297
+ end
298
+
299
+ rate = Rate.new(
300
+ carrier_name: self.class.name,
301
+ carrier: self,
302
+ currency: 'USD',
303
+ estimate_reference:,
304
+ prices:,
305
+ scac: self.class.scac.upcase,
306
+ service_name: :standard,
307
+ shipment:,
308
+ transit_days:,
309
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
310
+ )
311
+
312
+ rate_response.rates = [rate]
313
+ rate_response
314
+ end
315
+
316
+ def shipment_detail_description(shipment_detail)
317
+ return '' if shipment_detail[:description].blank?
318
+
319
+ shipment_detail[:description].capitalize.squish
320
+ end
321
+
322
+ # Tracking
323
+
324
+ def build_tracking_request(tracking_number)
325
+ request = { pro_number: tracking_number }
326
+ save_request(request)
327
+ request
328
+ end
329
+
330
+ def parse_api_city_state(str)
331
+ return if str.blank?
332
+
333
+ Location.new(
334
+ city: str.split(', ')[0].titleize,
335
+ province: str.split(', ')[1].split[0].upcase,
336
+ country: ActiveUtils::Country.find('USA'),
337
+ )
338
+ end
339
+
340
+ def parse_api_city(str)
341
+ return if str.blank?
342
+
343
+ city = str.squish.strip.titleize
344
+ province = nil
345
+ country = ActiveUtils::Country.find('USA')
346
+
347
+ CITY_STATES.each do |state, cities|
348
+ if cities.include?(city)
349
+ province = state.to_s.upcase
350
+ break
351
+ end
352
+ end
353
+
354
+ Location.new(city:, province:, country:)
355
+ end
356
+
357
+ def parse_api_date_time(date_time, location)
358
+ return if date_time.blank?
359
+
360
+ local_date_time = ::Time.strptime(date_time, '%m/%d/%Y %l:%M:%S %p').to_fs(:db)
361
+ ::FreightKit::DateTime.new(local_date_time:, location:)
362
+ end
363
+
364
+ def parse_location(comment, delimiters)
365
+ return if comment.blank? || !comment.include?(delimiters[0]) || !comment.include?(delimiters[1])
366
+
367
+ parts = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(',')
368
+
369
+ city = parts[0].squish.strip.titleize
370
+ province = parts[1].squish.strip.upcase
371
+ country = ActiveUtils::Country.find('USA')
372
+
373
+ Location.new(city:, province:, country:)
374
+ end
375
+
376
+ def parse_tracking_response(response)
377
+ tracking_response = TrackingResponse.new(carrier: self, request: last_request, response:)
378
+
379
+ if response.dig(:get_tracking_response, :get_tracking_result, :tracking_status_response).blank?
380
+ tracking_response.error = ShipmentNotFoundError.new
381
+ return tracking_response
382
+ end
383
+
384
+ search_result = response.dig(:get_tracking_response, :get_tracking_result)
385
+
386
+ country = ActiveUtils::Country.find('USA')
387
+
388
+ shipper_location = Location.new(
389
+ address1: search_result[:shipperaddress]&.squish&.strip&.titleize,
390
+ city: search_result[:shipper_city].squish.strip.titleize,
391
+ province: search_result[:shipper_state].strip.upcase,
392
+ postal_code: search_result[:shipper_zip].strip,
393
+ country:,
394
+ )
395
+
396
+ receiver_location = Location.new(
397
+ address1: search_result[:consaddress]&.squish&.strip&.titleize,
398
+ city: search_result[:cons_city].squish.strip.titleize,
399
+ province: search_result[:cons_state].strip.upcase,
400
+ postal_code: search_result[:cons_zip].strip,
401
+ country:,
402
+ )
403
+
404
+ api_date_time = search_result.dig('Shipment', 'DeliveredDateTime')
405
+ actual_delivery_date = parse_api_date_time(api_date_time, receiver_location)
406
+
407
+ api_date_time = search_result[:pickup_date]
408
+ pickup_date = parse_api_date_time(api_date_time, shipper_location)
409
+
410
+ api_date_time = search_result.dig('Shipment', 'ApptDateTime')
411
+ scheduled_delivery_date = parse_api_date_time(api_date_time, receiver_location)
412
+
413
+ ship_time = pickup_date
414
+ tracking_number = search_result.dig('Shipment', 'SearchItem')
415
+
416
+ shipment_events = []
417
+ shipment_events << ShipmentEvent.new(location: shipper_location, date_time: pickup_date, type_code: :picked_up)
418
+
419
+ api_events = search_result.dig(:tracking_status_response, :tracking_status_row).reverse
420
+ api_events.each do |api_event|
421
+ event_key = nil
422
+ comment = api_event[:tracking_status]
423
+
424
+ @conf.dig(:events, :types).each do |key, val|
425
+ if comment.downcase.include?(val)
426
+ event_key = key
427
+ else
428
+ ['signed by', 'partner delivery'].each { |val| event_key = :delivered if comment.downcase.include?(val) }
429
+ end
430
+
431
+ break if event_key
432
+ end
433
+
434
+ next if event_key.blank?
435
+
436
+ location = case event_key
437
+ when :arrived_at_terminal
438
+ parse_api_city(comment.split('arrived')[1].upcase.split('SERVICE CENTER')[0])
439
+ when :delivered
440
+ receiver_location
441
+ when :departed
442
+ parse_api_city(comment.split('departed')[1].upcase.split('SERVICE CENTER')[0])
443
+ when :out_for_delivery
444
+ receiver_location
445
+ when :trailer_closed
446
+ parse_api_city(comment.split('Location:')[1])
447
+ when :trailer_unloaded
448
+ parse_api_city(comment.split('Location:')[1])
449
+ end
450
+
451
+ date_time = parse_api_date_time(api_event[:tracking_date], location)
452
+
453
+ actual_delivery_date = date_time if event_key == :delivered
454
+
455
+ shipment_events << ShipmentEvent.new(date_time:, location:, type_code: event_key)
456
+ end
457
+
458
+ status = shipment_events.last&.type_code
459
+
460
+ tracking_response.assign_attributes(
461
+ actual_delivery_date:,
462
+ destination: receiver_location,
463
+ origin: shipper_location,
464
+ scheduled_delivery_date:,
465
+ ship_time:,
466
+ shipment_events:,
467
+ status:,
468
+ tracking_number:,
469
+ )
470
+
471
+ tracking_response
472
+ end
473
+ end
474
+ end