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,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class CNWY < 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 api_key]
24
+ end
25
+
26
+ def requirements
27
+ %i[credentials]
28
+ end
29
+ end
30
+
31
+ REACTIVE_FREIGHT_CARRIER = true
32
+
33
+ class << self
34
+ attr_reader :name, :scac
35
+ end
36
+ @name = 'XPO Logistics'
37
+ @scac = 'CNWY'
38
+
39
+ # Documents
40
+
41
+ # Pickups
42
+
43
+ # Rates
44
+
45
+ def find_rates(shipment:)
46
+ begin
47
+ validate_packages(shipment.packages)
48
+ rescue UnserviceableError => e
49
+ return RateResponse.new(error: e)
50
+ end
51
+
52
+ request = build_rate_request(shipment:)
53
+ parse_rate_response(shipment:, response: commit(request))
54
+ end
55
+
56
+ # Tracking
57
+
58
+ protected
59
+
60
+ def build_headers
61
+ {
62
+ accept: 'application/json',
63
+ authorization: "Bearer #{bearer_token}",
64
+ 'Content-Type': 'application/json'
65
+ }
66
+ end
67
+
68
+ def bearer_token
69
+ @bearer_token ||= commit(build_bearer_token_request)[:access_token]
70
+ end
71
+
72
+ def build_bearer_token_request
73
+ api_credentials = fetch_credential(:api)
74
+
75
+ body = URI.encode_www_form(
76
+ grant_type: 'password',
77
+ password: api_credentials.password,
78
+ username: api_credentials.username,
79
+ )
80
+
81
+ {
82
+ body:,
83
+ headers: {
84
+ authorization: "Basic #{api_credentials.api_key}",
85
+ content_type: 'application/x-www-form-urlencoded'
86
+ },
87
+ method: :post,
88
+ url: build_url(:token)
89
+ }
90
+ end
91
+
92
+ def build_url(action)
93
+ scheme = @conf.dig(:api, :use_ssl) ? 'https://' : 'http://'
94
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
95
+ end
96
+
97
+ def commit(request)
98
+ url = request[:url]
99
+ headers = request[:headers] || build_headers
100
+ method = request[:method]
101
+ body = request[:body]
102
+
103
+ response = case method
104
+ when :post
105
+ HTTParty.post(url, headers:, body:, debug_output: $stdout)
106
+ else
107
+ HTTParty.get(url, headers:, debug_output: $stdout)
108
+ end
109
+
110
+ begin
111
+ json = JSON.parse(response.body).deep_symbolize_keys
112
+ rescue JSON::ParserError => e
113
+ # CNWY returns a string during runtime/server error
114
+ raise FreightKit::ResponseError, 'Runtime Error' if response.body.include?('Runtime Error')
115
+
116
+ raise e
117
+ end
118
+
119
+ error = if json.is_a?(Hash)
120
+ json[:error_description] || json.dig(:fault, :description) || json.dig(:error, :message)
121
+ end
122
+
123
+ if error.blank?
124
+ return json if (200..299).include?(response.code)
125
+ else
126
+ case response.code
127
+ when 401
128
+ raise FreightKit::InvalidCredentialsError
129
+ end
130
+ end
131
+
132
+ raise FreightKit::ResponseError, error
133
+ end
134
+
135
+ def request_url(action)
136
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
137
+ "#{scheme}#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
138
+ end
139
+
140
+ # Documents
141
+
142
+ # Rates
143
+
144
+ def build_accessorials(shipment:)
145
+ serviceable_accessorials?(shipment.accessorials)
146
+
147
+ accessorial_codes = []
148
+ accessorial_codes << 'SSC'
149
+ accessorial_codes << 'ZHM' if shipment.hazmat?
150
+
151
+ if shipment.destination.province.upcase == 'HI'
152
+ accessorial_codes = accessorial_codes.map { |code| ['DID', 'OIP'].include?(code) ? 'WHN' : code }.uniq
153
+ end
154
+
155
+ longest_dimension_in = shipment.packages.map { |p| [p.width(:inch), p.length(:inch)].max }.max.ceil
156
+
157
+ # Switch to accessorials rather than accessorial_codes since now we need more complex structures
158
+
159
+ accessorials = accessorial_codes.map { |accessorial_code| { accessorial_cd: accessorial_code, quantity: 0 } }
160
+
161
+ return accessorials if longest_dimension_in < 96 && shipment.accessorials.blank?
162
+
163
+ if longest_dimension_in >= 96
164
+ accessorials << {
165
+ accessorial_cd: 'ELS',
166
+ quantity_uom: 'INCH',
167
+ quantity: longest_dimension_in
168
+ }
169
+ end
170
+
171
+ return accessorials if shipment.accessorials.blank?
172
+
173
+ shipment.accessorials.map do |accessorial|
174
+ next if @conf.dig(:accessorials, :unquotable)&.include?(accessorial)
175
+
176
+ accessorials << { accessorial_cd: @conf.dig(:accessorials, :mappable, accessorial), quantity: 0 }
177
+ end
178
+
179
+ accessorials
180
+ end
181
+
182
+ def parse_amount(amount)
183
+ (amount.to_f * 100).to_i
184
+ end
185
+
186
+ def build_commodity(shipment:)
187
+ shipment.packages.map do |package|
188
+ {
189
+ dimensions: {
190
+ dimensions_uom: 'INCH',
191
+ height: package.inches(:height).ceil,
192
+ length: package.inches(:length).ceil,
193
+ width: package.inches(:width).ceil
194
+ },
195
+ gross_weight: {
196
+ weight: package.pounds(:total).ceil,
197
+ weight_uom: 'LBS'
198
+ },
199
+ hazmat_ind: package.hazmat?,
200
+ nmfc_class: package.freight_class.to_s,
201
+ nmfc_item_cd: package.nmfc,
202
+ piece_cnt: package.quantity
203
+ }
204
+ end
205
+ end
206
+
207
+ def build_rate_request(shipment:)
208
+ api_credentials = fetch_credential(:api)
209
+ shipment_date = shipment.pickup_at.date_time_with_zone.iso8601
210
+
211
+ accessorials = build_accessorials(shipment:)
212
+ commodity = build_commodity(shipment:)
213
+
214
+ body = {
215
+ shipmentInfo: {
216
+ accessorials:,
217
+ bill_2_party: { acct_inst_id: api_credentials.account },
218
+ commodity:,
219
+ consignee: { address: { postal_cd: shipment.destination.postal_code.to_s } },
220
+ pallet_cnt: shipment.packages.map(&:packaging).map(&:pallet?).count(true),
221
+ payment_term_cd: 'P', # prepaid,
222
+ shipment_date:,
223
+ shipper: { address: { postal_cd: shipment.origin.postal_code.to_s } }
224
+ }
225
+ }.deep_transform_keys! { |key| key.to_s.camelize(:lower) }.to_json
226
+
227
+ request = {
228
+ body:,
229
+ headers: build_headers,
230
+ method: :post,
231
+ url: build_url(:rates)
232
+ }
233
+
234
+ save_request(request)
235
+ request
236
+ end
237
+
238
+ def parse_rate_response(shipment:, response:)
239
+ rate_response = RateResponse.new(request: last_request, response:)
240
+
241
+ if response.blank?
242
+ rate_response.error = ResponseError.new('Unknown response')
243
+ return rate_response
244
+ end
245
+
246
+ if response.dig(:data, :rateQuote, :totCharge, 0, :amt).blank?
247
+ rate_response.error = ResponseError.new('Cost is empty')
248
+ return rate_response
249
+ end
250
+
251
+ accessorials = response.dig(:data, :rateQuote, :shipmentInfo, :accessorials)
252
+ commodities = response.dig(:data, :rateQuote, :shipmentInfo, :commodity)
253
+ deficit_weight = response.dig(:data, :rateQuote, :deficitRatingInfo)
254
+
255
+ prices = []
256
+
257
+ prices << FreightKit::Price.new(
258
+ blame: :api,
259
+ cents: parse_amount(
260
+ commodities.sum { |c| c.dig(:charge, :chargeAmt, :amt) },
261
+ ),
262
+ description: 'Freight',
263
+ )
264
+
265
+ if deficit_weight.present?
266
+ prices << FreightKit::Price.new(
267
+ blame: :api,
268
+ cents: parse_amount(deficit_weight.dig(:deficitAmt, :amt)),
269
+ description: <<~DESC.squish,
270
+ Deficit weight
271
+ #{deficit_weight.dig(:deficitWght, :weight).ceil}
272
+ #{deficit_weight.dig(:deficitWght, :weightUom).downcase}
273
+ DESC
274
+ )
275
+ end
276
+
277
+ prices << FreightKit::Price.new(
278
+ blame: :api,
279
+ cents: parse_amount(response.dig(:data, :rateQuote, :totDiscountAmt, :amt)) * -1,
280
+ description: "Discount #{response.dig(:data, :rateQuote, :actlDiscountPct)}%",
281
+ )
282
+
283
+ prices += accessorials.map do |accessorial|
284
+ FreightKit::Price.new(
285
+ blame: :api,
286
+ cents: parse_amount(accessorial.dig(:chargeAmt, :amt)),
287
+ description: accessorial[:accessorialDesc].squish.capitalize.gsub('Xpo', 'XPO'),
288
+ )
289
+ end
290
+
291
+ comment = response.dig(:data, :rateQuote, :shipmentInfo, :comment)
292
+ days = if comment.blank?
293
+ nil
294
+ else
295
+ comment.match(/\d+ days/)&.to_s&.split(' days')&.first&.to_i
296
+ end
297
+
298
+ expires_at = if days.is_a?(Integer) && days.positive?
299
+ days.days.from_now
300
+ else
301
+ 2.days.from_now
302
+ end
303
+
304
+ estimate_reference = response.dig(:data, :rateQuote, :confirmationNbr)
305
+ transit_days = response.dig(:data, :transitTime, :transitDays)
306
+
307
+ rate = Rate.new(
308
+ carrier: self,
309
+ carrier_name: self.class.name,
310
+ currency: 'USD',
311
+ estimate_reference:,
312
+ expires_at:,
313
+ scac: self.class.scac.upcase,
314
+ service_name: :standard,
315
+ shipment:,
316
+ prices:,
317
+ transit_days:,
318
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
319
+ )
320
+
321
+ rate_response.rates = [rate]
322
+ rate_response
323
+ end
324
+
325
+ # Tracking
326
+ end
327
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class CTBV < 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(6999, :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
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ class << self
26
+ attr_reader :name, :scac
27
+ end
28
+ @name = 'The Custom Companies'
29
+ @scac = 'CTBV'
30
+
31
+ # Documents
32
+
33
+ # Rates
34
+ def build_calculated_accessorials(shipment)
35
+ [].tap do |builder|
36
+ longest_dimension = shipment.packages.map { |package| [package.length(:in), package.width(:in)].max }.max.ceil
37
+
38
+ case longest_dimension
39
+ when (96..143) then builder << 'OL1'
40
+ when (144..) then builder << 'OL'
41
+ end
42
+ end
43
+ end
44
+
45
+ # Tracking
46
+
47
+ # protected
48
+
49
+ # Documents
50
+
51
+ # Rates
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class DCHA < 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(12, :feet)
16
+ end
17
+
18
+ def overlength_fees_require_tariff?
19
+ false
20
+ end
21
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ class << self
26
+ attr_reader :name, :scac
27
+ end
28
+ @name = 'Mountain Valley Express'
29
+ @scac = 'DCHA'
30
+
31
+ # Documents
32
+
33
+ # Rates
34
+ def build_calculated_accessorials(shipment)
35
+ [].tap do |builder|
36
+ builder << 'HAZM' if shipment.packages.any?(&:hazmat?)
37
+
38
+ if shipment.accessorials.present? && %i[
39
+ residential_delivery
40
+ residential_pickup
41
+ ].intersect?(shipment.accessorials)
42
+ builder << 'RES'
43
+ end
44
+
45
+ longest_dimension = shipment.packages.map { |package| [package.length(:in), package.width(:in)].max }.max.ceil
46
+
47
+ case longest_dimension
48
+ when (144..155) then builder << 'XL12'
49
+ when (156..167) then builder << 'XL13'
50
+ when (168..179) then builder << 'XL14'
51
+ when (180..191) then builder << 'XL15'
52
+ when (192..203) then builder << 'XL16'
53
+ when (204..215) then builder << 'XL17'
54
+ when (216..227) then builder << 'XL18'
55
+ when (228..239) then builder << 'XL19'
56
+ when (240..251) then builder << 'XL20'
57
+ when (252..483) then builder << 'XL21'
58
+ when (484..275) then builder << 'XL22'
59
+ when (276..287) then builder << 'XL23'
60
+ when (288..299) then builder << 'XL24'
61
+ when (300..311) then builder << 'XL25'
62
+ when (312..323) then builder << 'XL26'
63
+ when (324..) then builder << 'XL27'
64
+ end
65
+ end
66
+ end
67
+
68
+ # Tracking
69
+
70
+ # protected
71
+
72
+ # Documents
73
+
74
+ # Rates
75
+ end
76
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class DLDS < 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(8, :feet)
16
+ end
17
+
18
+ def overlength_fees_require_tariff?
19
+ false
20
+ end
21
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ class << self
26
+ attr_reader :name, :scac
27
+ end
28
+ @name = 'Diamond Line Delivery'
29
+ @scac = 'DLDS'
30
+
31
+ # Documents
32
+
33
+ # Rates
34
+ def build_calculated_accessorials(shipment)
35
+ [].tap do |builder|
36
+ builder << 'SS'
37
+ builder << 'HAZ' if shipment.packages.any?(&:hazmat?)
38
+ end
39
+ end
40
+
41
+ # Tracking
42
+
43
+ # protected
44
+
45
+ # Documents
46
+
47
+ # Rates
48
+ end
49
+ end