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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ module Trackable
5
+ def find_tracking_info(tracking_number, *)
6
+ request = build_tracking_request(tracking_number)
7
+ begin
8
+ # For SOAP APIs, the :action parameter is required
9
+ response = commit(:track, request) if method(:commit).parameters.count == 2
10
+ response ||= commit(request)
11
+ rescue StandardError => e
12
+ return TrackingResponse.new(error: e, request:)
13
+ end
14
+
15
+ return response if response.is_a?(TrackingResponse)
16
+
17
+ if method(:parse_tracking_response).parameters.count == 1
18
+ parse_tracking_response(response)
19
+ else
20
+ # Carrier Logistics requires tracking number argument
21
+ parse_tracking_response(tracking_number, response:)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'freight_kit/helpers/rateable'
4
+ require 'freight_kit/helpers/trackable'
5
+ require 'freight_kit/helpers/pickupable'
6
+ require 'freight_kit/helpers/documentable'
@@ -0,0 +1,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class CarrierLogistics < Platform
5
+ class << self
6
+ def required_credential_types
7
+ %i[api]
8
+ end
9
+
10
+ def requirements
11
+ return %i[credentials tariff] if overlength_fees_require_tariff?
12
+
13
+ %i[credentials]
14
+ end
15
+ end
16
+
17
+ REACTIVE_FREIGHT_PLATFORM = true
18
+
19
+ EXPIRED_CREDENTIALS_MESSAGES = [
20
+ 'Your password has expired',
21
+ ].freeze
22
+ INVALID_CREDENTIALS_MESSAGES = [
23
+ 'Unable to log in',
24
+ 'Your Username or Password is Incorrect',
25
+ ].freeze
26
+
27
+ include FreightKit::Trackable
28
+ include FreightKit::Rateable
29
+
30
+ # Documents
31
+
32
+ def pod(tracking_number)
33
+ query = build_tracking_request(tracking_number)
34
+ response = commit(:track, query)
35
+ parse_document_response(response, :pod)
36
+ end
37
+
38
+ def scanned_bol(tracking_number)
39
+ query = build_tracking_request(tracking_number)
40
+ response = commit(:track, query)
41
+ parse_document_response(response, :bol)
42
+ end
43
+
44
+ # protected
45
+
46
+ def build_url(action, query:)
47
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
48
+
49
+ uri = URI.parse("#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}")
50
+ uri.query = query.to_query
51
+ url = uri.to_s
52
+ return url if url.exclude?('@CARRIER_CODE@')
53
+
54
+ url.sub('@CARRIER_CODE@', @conf.dig(:api, :carrier_code))
55
+ end
56
+
57
+ def commit(action, query)
58
+ url = build_url(action, query:)
59
+ save_request(url)
60
+
61
+ HTTParty.get(url, logger: Logger.new($stdout))
62
+ end
63
+
64
+ def map_response_errors(response, not_found_error: DocumentNotFoundError)
65
+ return ResponseError.new('Unknown response') if response.blank?
66
+
67
+ webspeed_error = (response.is_a?(String) || response.is_a?(HTTParty::Response)) &&
68
+ response.include?('WebSpeed error')
69
+ return ResponseError.new('Temporary error (WebSpeed error)') if webspeed_error
70
+
71
+ return if response.code == 200
72
+
73
+ response.code == 400 ? not_found_error.new : ResponseError.new("HTTP #{response.code}")
74
+ end
75
+
76
+ # Documents
77
+
78
+ def parse_document_response(tracking_response, document_type)
79
+ document_response = DocumentResponse.new
80
+ document_response.error = map_response_errors(tracking_response)
81
+ return document_response if document_response.error.present?
82
+
83
+ tracking_response.deep_symbolize_keys!
84
+
85
+ image_type_code = case document_type
86
+ when :bol then 'B'
87
+ when :pod then 'P'
88
+ end
89
+
90
+ api_images = tracking_response.dig(:protrace, :images, :image)
91
+ api_images = [api_images] if api_images.is_a?(Hash)
92
+
93
+ image = api_images&.find { |image| image[:imagetypecode] == image_type_code }
94
+ url = image.blank? ? nil : (image[:directurl].presence || image[:imageurl])
95
+
96
+ if url.blank?
97
+ document_response.error = DocumentNotFoundError.new
98
+ return document_response
99
+ end
100
+
101
+ begin
102
+ response = HTTParty.get(url)
103
+ rescue StandardError => e
104
+ document_response.error = e
105
+ return document_response
106
+ end
107
+
108
+ unless response.code == 200
109
+ document_response.error = DocumentNotFoundError.new
110
+ return document_response
111
+ end
112
+
113
+ document_response.assign_attributes(content_type: response.headers['content-type'], data: response.body)
114
+ document_response
115
+ end
116
+
117
+ # Tracking
118
+
119
+ def build_tracking_request(tracking_number)
120
+ api_credentials = fetch_credential(:api)
121
+
122
+ { pronum: tracking_number, xmlpass: api_credentials.password, xmluser: api_credentials.username }
123
+ end
124
+
125
+ def parse_api_city_state(str)
126
+ return if str.blank?
127
+
128
+ city = str.split(', ')[0].titleize
129
+ province = str.split(', ')[1].upcase
130
+
131
+ if province == '*'
132
+ province = case city
133
+ when 'Albuquerque' then 'NM'
134
+ end
135
+ end
136
+
137
+ Location.new(
138
+ city:,
139
+ province:,
140
+ country: ActiveUtils::Country.find('USA'),
141
+ )
142
+ end
143
+
144
+ def parse_api_city_state_zip(str)
145
+ return if str.blank?
146
+
147
+ parts = str.split(', ')
148
+
149
+ Location.new(
150
+ city: parts.first.titleize,
151
+ province: parts.last.upcase,
152
+ country: ActiveUtils::Country.find('USA'),
153
+ )
154
+ end
155
+
156
+ def parse_api_date(date, location)
157
+ return if date.blank?
158
+
159
+ separator = ['?', '-'].find { |separator| date.include?(separator) }
160
+ return if separator.blank?
161
+
162
+ format = case date
163
+ when /^\d{4}#{separator}/
164
+ ['%Y', '%m', '%d'].join(separator)
165
+ when /^\d{2}#{separator}/
166
+ ['%m', '%d', '%Y'].join(separator)
167
+ end
168
+ return if format.blank?
169
+
170
+ local_date = ::Date.strptime(date, format)
171
+ ::FreightKit::DateTime.new(local_date:, location:)
172
+ end
173
+
174
+ def parse_api_date_time(date_time, location)
175
+ return if date_time.blank?
176
+
177
+ local_date_time = ::Time.strptime(date_time, '%Y-%m-%d %H:%M').to_fs(:db)
178
+ ::FreightKit::DateTime.new(local_date_time:, location:)
179
+ rescue Date::Error
180
+ raise if local_date_time.present?
181
+
182
+ parse_api_date(local_date_time, location)
183
+ end
184
+
185
+ def parse_tracking_response(tracking_number, response:)
186
+ tracking_response = TrackingResponse.new(carrier: self, request: last_request, response:)
187
+ tracking_response.error = map_response_errors(response, not_found_error: ShipmentNotFoundError)
188
+ return tracking_response if tracking_response.error.present?
189
+
190
+ response.deep_symbolize_keys!
191
+
192
+ api_events = response.dig(:protrace, :shiphists, :shiphist)
193
+ if api_events.blank?
194
+ tracking_response.error = ResponseError.new('Empty response')
195
+ return tracking_response
196
+ end
197
+
198
+ origin = Location.new(
199
+ address1: response.dig(:protrace, :shipaddr)&.titleize,
200
+ address2: response.dig(:protrace, :shipaddr2)&.titleize,
201
+ city: response.dig(:protrace, :origcity)&.titleize,
202
+ province: response.dig(:protrace, :origstate)&.upcase,
203
+ country: ActiveUtils::Country.find('USA'),
204
+ )
205
+
206
+ destination = Location.new(
207
+ address1: response.dig(:protrace, :consaddr)&.titleize,
208
+ address2: response.dig(:protrace, :consaddr2)&.titleize,
209
+ city: response.dig(:protrace, :destcity)&.titleize,
210
+ province: response.dig(:protrace, :deststate)&.upcase,
211
+ country: ActiveUtils::Country.find('USA'),
212
+ )
213
+
214
+ deldateiso = response.dig(:protrace, :deldateiso)
215
+ actual_delivery_date = (parse_api_date(deldateiso, destination) if deldateiso.present?)
216
+
217
+ estdeliverydateiso = response.dig(:protrace, :estdeliverydateiso)
218
+ estdeliverytimestart = response.dig(:protrace, :estdeliverytimestart)
219
+ estimated_delivery_date = if estdeliverydateiso.present? && estdeliverytimestart.present?
220
+ parse_api_date_time([estdeliverydateiso, estdeliverytimestart].join(' '), destination)
221
+ elsif estdeliverydateiso.present?
222
+ parse_api_date(estdeliverydateiso, destination)
223
+ end
224
+
225
+ scheduled_delivery_date = nil
226
+ ship_time = nil
227
+
228
+ api_events = response.dig(:protrace, :shiphists, :shiphist)
229
+ api_events = [api_events] if api_events.is_a?(Hash)
230
+
231
+ last_location = origin
232
+
233
+ shipment_events = api_events.reverse.map do |api_event|
234
+ hist_code = api_event[:histcode]&.downcase
235
+ next if hist_code.blank?
236
+
237
+ event = conf.dig(:events, :types).key(hist_code)
238
+ next if event.blank?
239
+
240
+ remarks = api_event[:histremarks]
241
+
242
+ location = if remarks.present? && remarks.match?(/, \w{2}/) # ends in state abbreviation
243
+ parse_api_city_state(api_event[:histremarks])
244
+ end
245
+
246
+ location ||= case event
247
+ when :delivered then destination
248
+ when :departed then last_location
249
+ when :picked_up, :pickup_scheduled then origin
250
+ end
251
+
252
+ date = api_event[:histdate]
253
+ time = api_event[:histtime]
254
+ # Some api_event[:histtime] returns a string with missing hours and minutes like ' : '
255
+ time = nil if (time =~ /\d/).blank?
256
+
257
+ date_time = if [date, time].all?(&:present?)
258
+ parse_api_date_time([date, time].compact.join(' '), location)
259
+ elsif date.present?
260
+ parse_api_date(date, location)
261
+ end
262
+
263
+ last_location = location
264
+
265
+ ShipmentEvent.new(location:, date_time:, type_code: event)
266
+ end
267
+
268
+ shipment_events.compact!
269
+
270
+ status = shipment_events.last&.type_code
271
+
272
+ tracking_response.assign_attributes(
273
+ actual_delivery_date:,
274
+ destination:,
275
+ estimated_delivery_date:,
276
+ origin:,
277
+ scheduled_delivery_date:,
278
+ ship_time:,
279
+ shipment_events:,
280
+ status:,
281
+ tracking_number:,
282
+ )
283
+
284
+ tracking_response
285
+ end
286
+
287
+ # Rates
288
+
289
+ def build_calculated_accessorials(shipment)
290
+ []
291
+ end
292
+
293
+ def parse_amount(amount)
294
+ negative = amount.include?('-')
295
+
296
+ ['$', ',', '-'].each do |char|
297
+ amount = amount.sub(char, '')
298
+ end
299
+
300
+ return 0 if amount.blank?
301
+
302
+ amount = (amount.to_f * 100).to_i
303
+ return amount unless negative
304
+
305
+ amount * -1
306
+ end
307
+
308
+ def ratequote_line_description(ratequote_line)
309
+ description = ratequote_line['chargedesc'] || ''
310
+ description = description.capitalize
311
+
312
+ code = ratequote_line['chargecode']&.upcase || ''
313
+ description = "#{description} (#{code})" if code.present?
314
+
315
+ description.squish
316
+ end
317
+
318
+ def build_rate_request(shipment:)
319
+ api_credentials = fetch_credential(:api)
320
+
321
+ query = {
322
+ xmlv: 'yes', # must be first
323
+ quotenumber: 'YES',
324
+ vdzip: shipment.destination.postal_code,
325
+ vozip: shipment.origin.postal_code,
326
+ xmlpass: api_credentials.password,
327
+ xmluser: api_credentials.username
328
+ }
329
+
330
+ i = 0
331
+ shipment.packages.each do |package|
332
+ i += 1 # API starts at 1 (not 0)
333
+
334
+ query["vclass[#{i}]"] = package.freight_class
335
+ query["wheight[#{i}]"] = package.height(:in).ceil
336
+ query["wlength[#{i}]"] = package.length(:in).ceil
337
+ query["wpallets[#{i}]"] = package.quantity
338
+ query["wpieces[#{i}]"] = package.quantity
339
+ query["wweight[#{i}]"] = package.pounds(:total).ceil
340
+ query["wwidth[#{i}]"] = package.width(:in).ceil
341
+ end
342
+
343
+ accessorials = []
344
+
345
+ if shipment.accessorials.present?
346
+ serviceable_accessorials?(shipment.accessorials)
347
+
348
+ shipment
349
+ .accessorials
350
+ .reject { |accessorial| conf.dig(:accessorials, :unquotable).include?(accessorial) }
351
+ .each do |shipment_accessorial|
352
+ conf_accessorial = conf.dig(:accessorials, :mappable, shipment_accessorial)
353
+
354
+ case conf_accessorial
355
+ when Array then accessorials += conf_accessorial
356
+ when String then accessorials << conf_accessorial
357
+ end
358
+ end
359
+ end
360
+
361
+ accessorials += build_calculated_accessorials(shipment)
362
+
363
+ accessorials.uniq.compact.each { |accessorial| query[accessorial] = 'Yes' } if accessorials.any?
364
+
365
+ query
366
+ end
367
+
368
+ def parse_rate_response(shipment:, response:)
369
+ rate_response = RateResponse.new(request: last_request, response:)
370
+
371
+ rate_response.error = map_response_errors(response)
372
+ return rate_response if rate_response.error.present?
373
+
374
+ error = response.dig('error', 'errormessage')
375
+
376
+ if error.present?
377
+ rate_response.error = InvalidCredentialsError.new if error.downcase.include?('invalid username/password')
378
+
379
+ if error.downcase.include?('is not available') || error.downcase.include?('out of the serviceable area')
380
+ rate_response.error = UnserviceableError.new(error)
381
+ end
382
+
383
+ rate_response.error = ResponseError.new(error) if rate_response.error.blank?
384
+
385
+ return rate_response
386
+ end
387
+
388
+ if response.dig('ratequote', 'quotetotal').blank?
389
+ rate_response.error = ResponseError.new('Cost is blank')
390
+ return rate_response
391
+ end
392
+
393
+ total_cents = parse_amount(response.dig('ratequote', 'quotetotal'))
394
+
395
+ transit_days = response.dig('ratequote', 'busdays').to_i
396
+ estimate_reference = response.dig('ratequote', 'quotenumber')
397
+
398
+ ratequote_lines = response.dig('ratequote', 'ratequoteline')
399
+ prices = []
400
+
401
+ ratequote_lines.each do |ratequote_line|
402
+ next if ratequote_line['chrg'].blank?
403
+ next if ratequote_line['chargedesc'] == 'FREIGHT'
404
+
405
+ cents = parse_amount(ratequote_line['chrg'])
406
+ next if cents.zero?
407
+
408
+ prices << Price.new(
409
+ blame: :api,
410
+ cents:,
411
+ description: ratequote_line_description(ratequote_line),
412
+ )
413
+ end
414
+
415
+ prices = [
416
+ Price.new(
417
+ blame: :api,
418
+ cents: total_cents - prices.sum(&:cents),
419
+ description: 'Freight',
420
+ ),
421
+ ] + prices
422
+
423
+ if self.class.overlength_fees_require_tariff?
424
+ cents = 0
425
+
426
+ shipment.packages.each do |package|
427
+ cents += overlength_fee(tariff, package)
428
+ end
429
+
430
+ prices << Price.new(blame: :tariff, cents:, description: 'Overlength fees') if cents.nonzero?
431
+ end
432
+
433
+ rate = Rate.new(
434
+ carrier: self,
435
+ carrier_name: self.class.name,
436
+ currency: 'USD',
437
+ estimate_reference:,
438
+ scac: self.class.scac.upcase,
439
+ service_name: :standard,
440
+ shipment:,
441
+ prices:,
442
+ transit_days:,
443
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
444
+ )
445
+
446
+ rate_response.rates = [rate]
447
+ rate_response
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class Next < Platform
5
+ class << self
6
+ def required_credential_types
7
+ %i[api]
8
+ end
9
+ end
10
+
11
+ REACTIVE_FREIGHT_PLATFORM = true
12
+
13
+ JSON_HEADERS = {
14
+ Accept: 'application/json',
15
+ 'Content-Type': 'application/json',
16
+ charset: 'utf-8'
17
+ }.freeze
18
+
19
+ # Documents
20
+
21
+ # Rates
22
+
23
+ def show(id)
24
+ request = build_request(:show, params: "/#{id}")
25
+ commit(request)
26
+ end
27
+
28
+ # Tracking
29
+
30
+ # protected
31
+
32
+ def build_url(action, options = {})
33
+ url = ''.dup
34
+ url += "#{base_url}#{@conf.dig(:api, :scopes, options[:scope])}#{@conf.dig(:api, :endpoints, action)}"
35
+ url = url.sub(@conf.dig(:api, :scopes, options[:scope]), '') if action == :authenticate
36
+ url += options[:params] if options[:params].present?
37
+ url
38
+ end
39
+
40
+ def build_request(action, options = {})
41
+ headers = JSON_HEADERS
42
+ headers = headers.merge(options[:headers]) if options[:headers].present?
43
+ body = options[:body].to_json if options[:body].present?
44
+
45
+ unless action == :authenticate
46
+ set_auth_token
47
+ headers = headers.merge(token)
48
+ end
49
+
50
+ request = {
51
+ url: build_url(action, options),
52
+ headers:,
53
+ method: @conf.dig(:api, :methods, action),
54
+ body:
55
+ }
56
+
57
+ save_request(request)
58
+ request
59
+ end
60
+
61
+ def commit(request)
62
+ url = request[:url]
63
+ headers = request[:headers]
64
+ method = request[:method]
65
+ body = request[:body]
66
+
67
+ response = case method
68
+ when :post
69
+ HTTParty.post(url, headers:, body:)
70
+ else
71
+ HTTParty.get(url, headers:)
72
+ end
73
+
74
+ JSON.parse(response.body) if response&.body
75
+ end
76
+
77
+ def base_url
78
+ "https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :prefix)}#{@conf.dig(:api, :scope, @options[:scope])}"
79
+ end
80
+
81
+ def set_auth_token
82
+ return @auth_token if @auth_token.present?
83
+
84
+ api_credentials = fetch_credential(:api)
85
+
86
+ request = build_request(
87
+ :authenticate,
88
+ body: { email: api_credentials.username, password: api_credentials.password },
89
+ )
90
+
91
+ response = commit(request)
92
+ @auth_token = response['auth_token']
93
+ end
94
+
95
+ def token
96
+ { Authorization: "Bearer #{@auth_token}" }
97
+ end
98
+
99
+ # Show
100
+ end
101
+ end