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,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class SEFL < FreightKit::Carrier
5
+ class << self
6
+ def find_rates_with_declared_value?
7
+ true
8
+ end
9
+
10
+ def maximum_height
11
+ Measured::Length.new(105, :inches)
12
+ end
13
+
14
+ def maximum_weight
15
+ Measured::Weight.new(10_000, :pounds)
16
+ end
17
+
18
+ def minimum_length_for_overlength_fees
19
+ Measured::Length.new(8, :feet)
20
+ end
21
+
22
+ def overlength_fees_require_tariff?
23
+ false
24
+ end
25
+
26
+ def required_credential_types
27
+ %i[api]
28
+ end
29
+
30
+ def requirements
31
+ %i[credentials]
32
+ end
33
+ end
34
+
35
+ REACTIVE_FREIGHT_CARRIER = true
36
+
37
+ class << self
38
+ attr_reader :name, :scac
39
+ end
40
+ @name = 'Southeastern Freight Lines'
41
+ @scac = 'SEFL'
42
+
43
+ JSON_HEADERS = {
44
+ Accept: 'application/json',
45
+ charset: 'utf-8',
46
+ 'Content-Type' => 'application/x-www-form-urlencoded'
47
+ }.freeze
48
+
49
+ # Documents
50
+
51
+ # Rates
52
+ def find_rates(shipment:)
53
+ begin
54
+ validate_packages(shipment.packages)
55
+ rescue UnserviceableError => e
56
+ return RateResponse.new(error: e)
57
+ end
58
+
59
+ request = build_rate_request(shipment:)
60
+ parse_rate_response(shipment:, response: commit(request))
61
+ end
62
+
63
+ # Tracking
64
+
65
+ protected
66
+
67
+ def build_url(action)
68
+ "#{base_url}#{@conf.dig(:api, :endpoints, action)}"
69
+ end
70
+
71
+ def base_url
72
+ "https://#{@conf.dig(:api, :domain)}"
73
+ end
74
+
75
+ def auth_header
76
+ api_credentials = fetch_credential(:api)
77
+ auth = Base64.strict_encode64("#{api_credentials.username}:#{api_credentials.password}")
78
+
79
+ { Authorization: "Basic #{auth}" }
80
+ end
81
+
82
+ def build_request(action, options = {})
83
+ headers = JSON_HEADERS
84
+ headers = headers.merge(auth_header)
85
+ headers = headers.merge(options[:headers]) if options[:headers].present?
86
+ body = URI.encode_www_form(options[:body]) if options[:body].present?
87
+
88
+ request = {
89
+ url: options[:url].presence || build_url(action),
90
+ headers:,
91
+ method: @conf.dig(:api, :methods, action),
92
+ body:
93
+ }
94
+
95
+ save_request(request)
96
+ request
97
+ end
98
+
99
+ def commit(request)
100
+ url = request[:url]
101
+ headers = request[:headers]
102
+ method = request[:method]
103
+ body = request[:body]
104
+
105
+ case method
106
+ when :post
107
+ HTTParty.post(url, headers:, body:)
108
+ else
109
+ HTTParty.get(url, headers:)
110
+ end
111
+ end
112
+
113
+ # Documents
114
+
115
+ # Rates
116
+ def build_rate_request(shipment:)
117
+ accessorials = []
118
+
119
+ if shipment.accessorials.any?
120
+ serviceable_accessorials?(shipment.accessorials)
121
+
122
+ shipment
123
+ .accessorials
124
+ .reject { |accessorial| conf.dig(:accessorials, :unquotable).include?(accessorial) }
125
+ .each do |shipment_accessorial|
126
+ conf_accessorial = conf.dig(:accessorials, :mappable, shipment_accessorial)
127
+
128
+ case conf_accessorial
129
+ when Array then accessorials += conf_accessorial
130
+ when String then accessorials << conf_accessorial
131
+ end
132
+ end
133
+ end
134
+
135
+ longest_dimension = shipment.packages.map { |p| [p.width(:inches), p.length(:inches)].max }.max.ceil
136
+ accessorials << 'chkOD' if longest_dimension >= 96
137
+
138
+ accessorials.uniq!
139
+
140
+ pickup_on = Date.current
141
+ shipment_description = shipment.packages.map(&:description).reject(&:blank?).uniq.join(', ')
142
+ shipment_description = 'Freight All Kinds' if shipment_description.blank?
143
+
144
+ api_credentials = fetch_credential(:api)
145
+
146
+ body = {
147
+ allowSpot: longest_dimension >= 120 ? 'Y' : 'N',
148
+ CustomerAccount: api_credentials.account.to_i.to_s.rjust(9, '0'),
149
+ CustomerCity: customer_location.city,
150
+ CustomerName: customer_location.contact.company_name,
151
+ CustomerState: customer_location.province,
152
+ CustomerStreet: customer_location.address1,
153
+ CustomerZip: customer_location.postal_code,
154
+ Description: shipment_description,
155
+ DestCountry: 'U',
156
+ DestinationCity: shipment.destination.city,
157
+ DestinationState: shipment.destination.province,
158
+ DestinationZip: shipment.destination.postal_code,
159
+ DimsOption: 'I',
160
+ EmailAddress: customer_location.contact.email,
161
+ Option: 'T',
162
+ OrigCountry: 'U',
163
+ OriginCity: shipment.origin.city,
164
+ OriginState: shipment.origin.province,
165
+ OriginZip: shipment.origin.postal_code,
166
+ PickupDay: pickup_on.strftime('%_d'),
167
+ PickupMonth: pickup_on.strftime('%_m'),
168
+ PickupYear: pickup_on.strftime('%Y'),
169
+ rateXML: 'Y',
170
+ returnX: 'Y',
171
+ Terms: 'P'
172
+ }
173
+
174
+ declared_value = if shipment.declared_value_cents.blank?
175
+ 0
176
+ else
177
+ (shipment.declared_value_cents.to_f / 100).ceil
178
+ end
179
+
180
+ if declared_value.positive?
181
+ body.deep_merge!({
182
+ chkIN: 'on',
183
+ FVInsuranceAmount: format('%.2f', declared_value)
184
+ })
185
+ end
186
+ body.deep_merge!({ ODLength: longest_dimension, ODLengthUnit: 'I' }) if longest_dimension >= 96
187
+
188
+ cubic_ft_required = shipment.destination.province.upcase == 'PR'
189
+
190
+ i = 0
191
+ shipment.packages.each do |package|
192
+ package.quantity.times do
193
+ i += 1
194
+
195
+ body = body.deep_merge({ "Class#{i}": package.freight_class.to_s.sub('.', '').to_i })
196
+ body = body.deep_merge({ "Description#{i}": package.description || 'Freight' })
197
+ body = body.deep_merge({ "PieceLength#{i}": package.length(:in).ceil })
198
+ body = body.deep_merge({ "PieceWidth#{i}": package.width(:in).ceil })
199
+ body = body.deep_merge({ "PieceHeight#{i}": package.height(:in).ceil })
200
+ body = body.deep_merge({ "Weight#{i}": package.pounds(:each).ceil })
201
+
202
+ body = body.deep_merge({ "CubicFt#{i}": package.cubic_ft(:each) }) if cubic_ft_required
203
+ end
204
+ end
205
+
206
+ if accessorials.any?
207
+ body[:accessorial] = 'on'
208
+
209
+ accessorials.each { |accessorial| body[accessorial] = 'on' }
210
+ end
211
+
212
+ request = build_request(:rates, body:)
213
+ save_request(request)
214
+ request
215
+ end
216
+
217
+ def parse_rate_response(shipment:, response:, tries: 0)
218
+ rate_response = RateResponse.new(request: last_request, response:)
219
+
220
+ # Used begin rescue block's retry instead.
221
+ # if tries > 10
222
+ # rate_response.error = ResponseError.new("Timeout after #{tries * 5} seconds")
223
+ # return rate_response
224
+ # end
225
+
226
+ if response.body.blank?
227
+ rate_response.error = InvalidCredentialsError if response.code == 401
228
+
229
+ rate_response.error = ResponseError.new('Unknown response') if rate_response.error.blank?
230
+ return rate_response
231
+ end
232
+
233
+ begin
234
+ response = JSON.parse(response.body)
235
+ rescue JSON::ParserError
236
+ sleep(5)
237
+ if tries > 10
238
+ rate_response.error = ResponseError.new("Timeout after #{tries * 5} seconds")
239
+ return rate_response
240
+ end
241
+
242
+ tries += 1
243
+ retry
244
+ end
245
+
246
+ error = response['errorMessage']
247
+
248
+ if error.present?
249
+ if error.include?('one point must be directly serviced')
250
+ rate_response.error = UnserviceableError.new(error.sub(' by SEFL.', ''))
251
+ end
252
+
253
+ rate_response.error = ResponseError.new(error) if rate_response.error.blank?
254
+ return rate_response
255
+ end
256
+
257
+ url = response['detailQuoteLocation'].gsub('\\', '')
258
+ request = build_request(:get_rate, url:)
259
+
260
+ tries = 0
261
+
262
+ until tries > 10
263
+ save_request(request)
264
+ response = commit(request)
265
+
266
+ if response.body.blank?
267
+ rate_response.error = InvalidCredentialsError if response.code == 401
268
+
269
+ rate_response.error = ResponseError.new('Unknown response') if rate_response.error.blank?
270
+ return rate_response
271
+ end
272
+
273
+ response = JSON.parse(response.body)
274
+
275
+ if response.blank?
276
+ rate_response.error = ResponseError.new('Unknown response')
277
+ return rate_response
278
+ end
279
+
280
+ error = response['errorMessage']
281
+
282
+ if error.present?
283
+ if error.downcase.include?('not yet been processed')
284
+ sleep(5)
285
+ tries += 1
286
+ next
287
+ else
288
+ rate_response.error = ResponseError.new(error)
289
+ return rate_response
290
+ end
291
+ end
292
+
293
+ tries = 50
294
+ end
295
+
296
+ if response['rateQuote'].blank?
297
+ rate_response.error = ResponseError.new('Cost is empty')
298
+ return rate_response
299
+ end
300
+
301
+ estimate_reference = response['quoteNumber']
302
+ transit_days = response['transitTime'].to_i
303
+
304
+ details = response['details']
305
+ prices = []
306
+
307
+ # return details
308
+
309
+ details.each do |detail|
310
+ next if detail['typeCharge'].include?('TTL') || detail['typeCharge'].include?('NFC')
311
+
312
+ cents = detail['charges'].squish
313
+ cents = cents.blank? ? 0 : (cents.to_f * 100).to_i
314
+ next if cents.zero?
315
+
316
+ description = detail['description'].squish
317
+
318
+ cents *= -1 if detail['description'].include?('DISCOUNT')
319
+
320
+ prices << Price.new(blame: :api, cents:, description:)
321
+ end
322
+
323
+ rate = Rate.new(
324
+ carrier: self,
325
+ carrier_name: self.class.name,
326
+ currency: 'USD',
327
+ estimate_reference:,
328
+ scac: self.class.scac.upcase,
329
+ service_name: :standard,
330
+ shipment:,
331
+ prices:,
332
+ transit_days:,
333
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
334
+ )
335
+
336
+ rate_response.rates = [rate]
337
+ rate_response
338
+ end
339
+
340
+ # Tracking
341
+ end
342
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class TOTL < CarrierLogistics
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(40, :inches)
16
+ end
17
+
18
+ def overlength_fees_require_tariff?
19
+ false
20
+ end
21
+
22
+ def pickup_number_is_tracking_number?
23
+ true
24
+ end
25
+
26
+ def required_credential_types
27
+ %i[api]
28
+ end
29
+
30
+ def requirements
31
+ %i[credentials]
32
+ end
33
+ end
34
+
35
+ REACTIVE_FREIGHT_CARRIER = true
36
+
37
+ class << self
38
+ attr_reader :name, :scac
39
+ end
40
+ @name = 'Total Transportation'
41
+ @scac = 'TOTL'
42
+
43
+ # Documents
44
+
45
+ # Pickups
46
+
47
+ # Rates
48
+
49
+ # Tracking
50
+
51
+ # protected
52
+
53
+ # Documents
54
+
55
+ # Rates
56
+
57
+ def parse_rate_response(shipment:, response:)
58
+ rate_response = RateResponse.new(request: last_request, response:)
59
+
60
+ if response.blank?
61
+ rate_response.error = ResponseError.new('Unknown response')
62
+ return rate_response
63
+ end
64
+
65
+ if response.is_a?(String) && response.include?('WebSpeed error')
66
+ rate_response.error = ResponseError.new('API Error: Temporary error (CarrierLogistics WebSpeed error)')
67
+ return rate_response
68
+ end
69
+
70
+ error = response.dig('error', 'errormessage')
71
+
72
+ if error.present?
73
+ if error.downcase.include?('invalid username/password')
74
+ rate_response.error = InvalidCredentialsError.new
75
+ return rate_response
76
+ end
77
+
78
+ if error.downcase.include?('is not available') || error.downcase.include?('out of the serviceable area')
79
+ rate_response.error = UnserviceableError.new
80
+ return rate_response
81
+ end
82
+
83
+ rate_response.error = ResponseError.new("API Error: #{error}")
84
+ return rate_response
85
+ end
86
+
87
+ if response.dig('ratequote', 'quotetotal').blank?
88
+ rate_response.error = ResponseError.new('Cost is blank')
89
+ return rate_response
90
+ end
91
+
92
+ total_cents = parse_amount(response.dig('ratequote', 'quotetotal'))
93
+
94
+ transit_days = response.dig('ratequote', 'busdays').to_i
95
+ estimate_reference = response.dig('ratequote', 'quotenumber')
96
+
97
+ ratequote_lines = response.dig('ratequote', 'ratequoteline')
98
+
99
+ prices = []
100
+ ratequote_lines.each do |ratequote_line|
101
+ next if ratequote_line['chrg'].blank?
102
+ next if ratequote_line['chargedesc'] == 'FREIGHT'
103
+
104
+ cents = parse_amount(ratequote_line['chrg'])
105
+ next if cents.zero?
106
+
107
+ prices << Price.new(
108
+ blame: :api,
109
+ cents:,
110
+ description: ratequote_line_description(ratequote_line),
111
+ )
112
+ end
113
+
114
+ prices = [
115
+ Price.new(
116
+ blame: :api,
117
+ cents: total_cents - prices.sum(&:cents),
118
+ description: 'Freight',
119
+ ),
120
+ ] + prices
121
+
122
+ # Carrier-specific pricing structure
123
+ oversized_pallets_cents = 0
124
+
125
+ shipment.packages.each do |package|
126
+ short_side, long_side = nil
127
+ if package.length(:in).present? && package.width(:in).present? && package.height(:in).present?
128
+ long_side = [package.length(:in), package.width(:in)].max
129
+ short_side = [package.length(:in), package.width(:in)].min
130
+ end
131
+
132
+ next unless short_side &&
133
+ long_side &&
134
+ package.height(:in) &&
135
+ (
136
+ short_side > 40 ||
137
+ long_side > 48 ||
138
+ package.height(:in) > 84
139
+ )
140
+
141
+ oversized_pallets_cents += 1500
142
+ end
143
+
144
+ if oversized_pallets_cents.nonzero?
145
+ prices << Price.new(
146
+ blame: :library,
147
+ cents: oversized_pallets_cents,
148
+ description: 'Overlength fees',
149
+ )
150
+ end
151
+
152
+ RateResponse.new(
153
+ rates: [
154
+ Rate.new(
155
+ carrier: self,
156
+ carrier_name: self.class.name,
157
+ currency: 'USD',
158
+ estimate_reference:,
159
+ scac: self.class.scac.upcase,
160
+ service_name: :standard,
161
+ shipment:,
162
+ prices:,
163
+ transit_days:,
164
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
165
+ ),
166
+ ],
167
+ request: last_request,
168
+ response:,
169
+ )
170
+ end
171
+ end
172
+ end