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,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class RDFS < Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ include FreightKit::Rateable
8
+
9
+ class << self
10
+ attr_reader :name, :scac
11
+ end
12
+ @name = 'Roadrunner Transportation Services'
13
+ @scac = 'RDFS'
14
+
15
+ class << self
16
+ def maximum_height
17
+ Measured::Length.new(105, :inches)
18
+ end
19
+
20
+ def maximum_weight
21
+ Measured::Weight.new(10_000, :pounds)
22
+ end
23
+
24
+ def minimum_length_for_overlength_fees
25
+ Measured::Length.new(8, :feet)
26
+ end
27
+
28
+ def overlength_fees_require_tariff?
29
+ false
30
+ end
31
+
32
+ def required_credential_types
33
+ %i[website]
34
+ end
35
+
36
+ def requirements
37
+ %i[credentials]
38
+ end
39
+ end
40
+
41
+ # Documents
42
+
43
+ def pod(tracking_number)
44
+ parse_document_response(:pod, tracking_number)
45
+ end
46
+
47
+ def scanned_bol(tracking_number)
48
+ parse_document_response(:bol, tracking_number)
49
+ end
50
+
51
+ # Pickups
52
+
53
+ def pickup_number_is_tracking_number?
54
+ false
55
+ end
56
+
57
+ # Tracking
58
+
59
+ def find_tracking_info(tracking_number)
60
+ response = commit_tracking_request(tracking_number)
61
+
62
+ parse_tracking_response(response)
63
+ end
64
+
65
+ def find_tracking_number_from_pickup_number(pickup_number, _date)
66
+ parse_tracking_number_from_pickup_number_response(pickup_number)
67
+ end
68
+
69
+ protected
70
+
71
+ def build_soap_header(action)
72
+ api_credential = fetch_credential(:website)
73
+ validate_api_credential!(api_credential)
74
+
75
+ {
76
+ authentication_header: {
77
+ :@xmlns => @conf.dig(:api, :soap, :namespaces, action),
78
+ password: api_credential.password,
79
+ user_name: api_credential.username
80
+ }
81
+ }
82
+ end
83
+
84
+ def commit(action, request)
85
+ client_args = {
86
+ wsdl: request_url(action),
87
+ convert_request_keys_to: :camelcase,
88
+ env_namespace: :soap,
89
+ element_form_default: :qualified
90
+ }
91
+
92
+ call_args = {
93
+ soap_header: build_soap_header(action),
94
+ message: request
95
+ }
96
+
97
+ soap_operation = @conf.dig(:api, :actions, action)
98
+
99
+ response = ::FreightKit::SoapClient
100
+ .new(carrier: self, action:, client_args:, call_args:, soap_operation:)
101
+ .call(handle_soap_fault_error: false)
102
+
103
+ response
104
+ rescue Savon::SOAPFault => e
105
+ raise InvalidCredentialsError, 'Invalid credentials' if e.message.include?('RRTS.Common.BLL.InvalidUserException')
106
+
107
+ error = if e.respond_to?(:to_hash)
108
+ error_hash = e.to_hash
109
+
110
+ {
111
+ message: error_hash.dig(:fault, :detail, :error, :error_message),
112
+ number: error_hash.dig(:fault, :detail, :error, :error_number)
113
+ }
114
+ else
115
+ {}
116
+ end
117
+
118
+ if error[:message].blank?
119
+ erorr[:message] = if error[:number].present?
120
+ "Error #{error[:number]}"
121
+ else
122
+ 'Unknown error'
123
+ end
124
+ end
125
+
126
+ { error: }
127
+ end
128
+
129
+ def parse_amount(amount)
130
+ negative = amount.start_with?('-$') || amount.start_with?('-')
131
+
132
+ ['$', '-', ','].each do |char|
133
+ amount = amount.sub(char, '')
134
+ end
135
+
136
+ return 0 if amount.blank?
137
+
138
+ amount = (amount.to_f * 100).to_i
139
+ return amount unless negative
140
+
141
+ amount * -1
142
+ end
143
+
144
+ def parse_api_date_time(date_time, location)
145
+ return if date_time.blank? || date_time == '0001-01-01T00:00:00'
146
+
147
+ local_date_time = ::Time.strptime(date_time, '%Y-%m-%dT%H:%M:%S').to_fs(:db)
148
+ ::FreightKit::DateTime.new(local_date_time:, location:)
149
+ end
150
+
151
+ def request_url(action)
152
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
153
+ "#{scheme}#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
154
+ end
155
+
156
+ def strip_date(str)
157
+ str ? str.split(/[A|P]M /)[1] : nil
158
+ end
159
+
160
+ def validate_api_credential!(api_credential)
161
+ %i[account password username].each do |attribute|
162
+ if !api_credential.respond_to?(attribute) || api_credential.send(attribute).blank?
163
+ raise InvalidCredentialsError, "Invalid #{attribute}"
164
+ end
165
+
166
+ next unless attribute == :account
167
+
168
+ # Raises vague input exception from API if we don't handle ourselves
169
+ next if api_credential.account.gsub(/\D/, '') == api_credential.account &&
170
+ api_credential.account.length >= 6
171
+
172
+ raise InvalidCredentialsError, 'Invalid account'
173
+ end
174
+ end
175
+
176
+ # Documents
177
+
178
+ def parse_document_response(type, tracking_number)
179
+ url = request_url(type).sub('%%TRACKING_NUMBER%%', tracking_number.to_s)
180
+ document_response = DocumentResponse.new(request: url)
181
+
182
+ begin
183
+ doc = Nokogiri::HTML(URI.parse(url).open)
184
+ rescue OpenURI::HTTPError
185
+ document_response.error = FreightKit::DocumentNotFoundError.new
186
+ return document_response
187
+ end
188
+
189
+ if doc.css('img').blank?
190
+ document_response.error = FreightKit::DocumentNotFoundError.new
191
+ return document_response
192
+ end
193
+
194
+ data = Base64.decode64(doc.css('img').first['src'].split('data:image/jpg;base64,').last)
195
+
196
+ document_response.assign_attributes(content_type: 'image/jpeg', data:)
197
+ document_response
198
+ end
199
+
200
+ # Rates
201
+
202
+ def build_rate_request(shipment:)
203
+ api_credential = fetch_credential(:website)
204
+ validate_api_credential!(api_credential)
205
+
206
+ service_delivery_options = [
207
+ # API calls this invalid now
208
+ # service_options: { service_code: 'SS' }
209
+ ]
210
+
211
+ if shipment.accessorials.present?
212
+ serviceable_accessorials?(shipment.accessorials)
213
+
214
+ service_delivery_options = shipment
215
+ .accessorials
216
+ .reject { |accessorial| conf.dig(:accessorials, :unquotable).include?(accessorial) }
217
+ .map do |shipment_accessorial|
218
+ {
219
+ service_options: {
220
+ service_code: conf.dig(
221
+ :accessorials,
222
+ :mappable,
223
+ shipment_accessorial,
224
+ )
225
+ }
226
+ }
227
+ end
228
+ end
229
+
230
+ shipment.packages.each do |package|
231
+ longest_dimension = [package.width(:inches), package.length(:inches)].max.ceil
232
+
233
+ next if longest_dimension < 96
234
+
235
+ package.quantity.times do
236
+ if longest_dimension >= 240
237
+ service_delivery_options << { service_options: { service_code: 'EXX' } }
238
+ elsif longest_dimension >= 144
239
+ service_delivery_options << { service_options: { service_code: 'EXL' } }
240
+ elsif longest_dimension >= 96
241
+ service_delivery_options << { service_options: { service_code: 'EXM' } }
242
+ end
243
+ end
244
+ end
245
+
246
+ shipment_detail = []
247
+ shipment_box_count = 0
248
+ shipment_pallet_count = 0
249
+
250
+ shipment.packages.each do |package|
251
+ if package.packaging.type == 'pallet'
252
+ shipment_pallet_count += package.quantity
253
+ else
254
+ shipment_box_count += package.quantity
255
+ end
256
+
257
+ package.quantity.times do
258
+ shipment_detail << {
259
+ 'ActualClass' => package.freight_class,
260
+ 'Weight' => package.pounds(:each).ceil
261
+ }
262
+ end
263
+ end
264
+
265
+ cubic_feet = if shipment.packages.map { |package| package.cubic_ft(:each) }.any?(nil)
266
+ nil
267
+ else
268
+ shipment.packages.sum { |package| package.cubic_ft(:total) }.ceil
269
+ end
270
+
271
+ request = {
272
+ 'request' => {
273
+ account: api_credential.account,
274
+ cubic_feet:,
275
+ destination_zip: shipment.destination.postal_code.gsub(/\s+/, '').upcase,
276
+ # linear_feet: linear_ft(packages),
277
+ origin_type: 'B', # O for shipper, I for consignee, B for third party
278
+ origin_zip: shipment.origin.postal_code.gsub(/\s+/, '').upcase,
279
+ pallet_count: shipment_pallet_count,
280
+ payment_type: 'P', # prepaid
281
+ pieces: shipment_box_count,
282
+ service_delivery_options:,
283
+ shipment_details: { shipment_detail: }
284
+ }
285
+ }
286
+
287
+ save_request(request)
288
+ request
289
+ end
290
+
291
+ def parse_rate_response(shipment:, response:)
292
+ rate_response = RateResponse.new(request: last_request, response:)
293
+
294
+ error_number = response.dig(:error, :number)&.to_i
295
+ error_message = response.dig(:error, :message)
296
+
297
+ if error_number.present?
298
+ case error_number
299
+ when 99991
300
+ rate_response.error = UnserviceableError.new(error_message)
301
+ return rate_response
302
+ end
303
+ end
304
+
305
+ # @todo Don't rely on error_message
306
+
307
+ if error_message.present? && error_message.downcase.include?('not in the standard pickup area')
308
+ rate_response.error = UnserviceableError
309
+ return rate_response
310
+ end
311
+
312
+ if error_message.present? || error_number.present?
313
+ parts = [error_message]
314
+ parts << "(#{error_number})" if error_number.present?
315
+
316
+ message = parts.compact_blank.join(' ')
317
+
318
+ rate_response.error = ResponseError.new(message)
319
+ return rate_response
320
+ end
321
+
322
+ result = response.dig(:rate_quote_by_account_response, :rate_quote_by_account_result)
323
+
324
+ if result[:net_charge].blank?
325
+ rate_response.error = ResponseError.new('Cost is empty')
326
+ return rate_response
327
+ end
328
+
329
+ estimate_reference = result[:quote_number]
330
+ rate_details = result.dig(:rate_details, :quote_detail)
331
+ transit_days = result.dig(:routing_info, :estimated_transit_days).to_i
332
+
333
+ prices = []
334
+
335
+ rate_details.each do |rate_detail|
336
+ if rate_detail[:description].blank?
337
+ prices << Price.new(
338
+ blame: :api,
339
+ cents: parse_amount(rate_detail[:charge]),
340
+ description: 'Freight',
341
+ )
342
+
343
+ next
344
+ end
345
+
346
+ prices << FreightKit::Price.new(
347
+ blame: :api,
348
+ cents: parse_amount(rate_detail[:charge]),
349
+ description: rate_detail[:description]&.capitalize,
350
+ )
351
+ end
352
+
353
+ rate = Rate.new(
354
+ carrier: self,
355
+ carrier_name: self.class.name,
356
+ currency: 'USD',
357
+ estimate_reference:,
358
+ scac: self.class.scac.upcase,
359
+ service_name: :standard,
360
+ shipment:,
361
+ prices:,
362
+ transit_days:,
363
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
364
+ )
365
+
366
+ rate_response.rates = [rate]
367
+ rate_response
368
+ end
369
+
370
+ # Tracking
371
+
372
+ def commit_tracking_request(tracking_number)
373
+ uri = URI.parse("#{request_url(:track)}/#{tracking_number}")
374
+ save_request(uri)
375
+
376
+ uri.open
377
+ rescue OpenURI::HTTPError, Errno::EHOSTUNREACH
378
+ nil
379
+ end
380
+
381
+ def parse_api_location(comment, delimiters)
382
+ return if comment.blank? || !comment.include?(delimiters[0])
383
+
384
+ parts = if delimiters.size == 2
385
+ comment.split(delimiters[0])[0].split(delimiters[1])[1].split(',')
386
+ else
387
+ comment.split(delimiters[0])[1].split(',')
388
+ end
389
+
390
+ if parts.size == 1
391
+ str = parts[0].downcase
392
+ if str.include?('long beach')
393
+ return Location.new(
394
+ city: 'Long Beach',
395
+ province: 'CA',
396
+ country: ActiveUtils::Country.find('USA'),
397
+ )
398
+ end
399
+
400
+ return
401
+ end
402
+
403
+ city = parts[0].squish.strip.titleize
404
+ province = parts[1].gsub('.', '').squish.strip.upcase
405
+ country = ActiveUtils::Country.find('USA')
406
+
407
+ Location.new(city:, province:, country:)
408
+ end
409
+
410
+ def parse_tracking_response(response)
411
+ tracking_response = TrackingResponse.new(carrier: self, request: last_request, response:)
412
+
413
+ json = JSON.parse(response&.read || '{}')
414
+
415
+ if json['SearchResults'].blank? || response.status[0] != '200'
416
+ tracking_response.error = ShipmentNotFoundError.new
417
+ return tracking_response
418
+ end
419
+
420
+ search_result = json['SearchResults']&.first
421
+
422
+ pro = search_result.dig('Shipment', 'ProNumber')&.downcase
423
+ if pro.blank? || pro.downcase.include?('not available')
424
+ tracking_response.error = ShipmentNotFoundError.new
425
+ return tracking_response
426
+ end
427
+
428
+ receiver_location = Location.new(
429
+ city: search_result.dig('Shipment', 'Consignee', 'City').titleize,
430
+ province: search_result.dig('Shipment', 'Consignee', 'State').upcase,
431
+ country: ActiveUtils::Country.find('USA'),
432
+ )
433
+
434
+ shipper_location = Location.new(
435
+ city: search_result.dig('Shipment', 'Origin', 'City').titleize,
436
+ province: search_result.dig('Shipment', 'Origin', 'State').upcase,
437
+ country: ActiveUtils::Country.find('USA'),
438
+ )
439
+
440
+ api_date_time = search_result.dig('Shipment', 'DeliveredDateTime')
441
+ actual_delivery_date = parse_api_date_time(api_date_time, receiver_location)
442
+
443
+ api_date_time = search_result.dig('Shipment', 'ApptDateTime')
444
+ scheduled_delivery_date = parse_api_date_time(api_date_time, receiver_location)
445
+
446
+ tracking_number = search_result.dig('Shipment', 'SearchItem')
447
+
448
+ api_date_time = search_result.dig('Shipment', 'ProDateTime')
449
+ ship_time = parse_api_date_time(api_date_time, shipper_location)
450
+
451
+ last_location = nil
452
+ shipment_events = []
453
+
454
+ search_result.dig('Shipment', 'Comments').each do |api_event|
455
+ type_code = api_event['ActivityCode']
456
+ next if !type_code || type_code == 'ARQ'
457
+
458
+ event = @conf.dig(:events, :types).key(type_code)
459
+ next if event.blank?
460
+
461
+ comment = strip_date(api_event['StatusComment'])
462
+
463
+ location = case event
464
+ when :arrived_at_terminal
465
+ parse_api_location(comment, [' terminal in '])
466
+ when :delayed_due_to_weather,
467
+ :delivery_appointment_scheduled,
468
+ :pending_delivery_appointment,
469
+ :trailer_closed,
470
+ :trailer_unloaded
471
+ last_location
472
+ when :delivered
473
+ receiver_location
474
+ when :departed, :out_for_delivery
475
+ parse_api_location(comment, [' to ', 'from '])
476
+ when :located
477
+ parse_api_location(comment, [' currently at '])
478
+ when :picked_up
479
+ shipper_location
480
+ end
481
+
482
+ date_time = parse_api_date_time(api_event['StatusDateTime'], location)
483
+
484
+ last_location = location
485
+
486
+ shipment_events << ShipmentEvent.new(date_time:, location:, type_code: event)
487
+ end
488
+
489
+ status = shipment_events.last&.type_code
490
+
491
+ tracking_response.assign_attributes(
492
+ actual_delivery_date:,
493
+ destination: receiver_location,
494
+ origin: shipper_location,
495
+ scheduled_delivery_date:,
496
+ ship_time:,
497
+ shipment_events:,
498
+ status:,
499
+ tracking_number:,
500
+ )
501
+
502
+ tracking_response
503
+ end
504
+
505
+ def parse_tracking_number_from_pickup_number_response(pickup_number)
506
+ url = request_url(:tracking_number_from_pickup_number).sub('%%PICKUP_NUMBER%%', pickup_number.to_s)
507
+
508
+ begin
509
+ doc = Nokogiri::HTML(URI.parse(url).open)
510
+ rescue OpenURI::HTTPError, Errno::EHOSTUNREACH
511
+ return
512
+ end
513
+
514
+ pro = doc.css('#lblProNumber')&.text
515
+
516
+ return if pro.blank? || pro.downcase.include?('not available')
517
+
518
+ pro
519
+ end
520
+ end
521
+ end