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,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class ABFS < 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(150, :pounds)
16
+ end
17
+
18
+ def minimum_length_for_overlength_fees
19
+ Measured::Length.new(6, :feet)
20
+ end
21
+
22
+ def overlength_fees_require_tariff?
23
+ false
24
+ end
25
+
26
+ def pickup_number_is_tracking_number?
27
+ true
28
+ end
29
+
30
+ def required_credential_types
31
+ %i[api api_key]
32
+ end
33
+
34
+ def requirements
35
+ %i[credentials]
36
+ end
37
+ end
38
+
39
+ REACTIVE_FREIGHT_CARRIER = true
40
+
41
+ class << self
42
+ attr_reader :name, :scac
43
+ end
44
+ @name = 'ABF Freight System'
45
+ @scac = 'ABFS'
46
+
47
+ XML_HEADERS = {
48
+ 'Accept' => 'application/xml',
49
+ 'charset' => 'utf-8',
50
+ 'Content-Type' => 'application/xml'
51
+ }.freeze
52
+
53
+ def serviceable_accessorials?(accessorials)
54
+ unsupported_accessorials = conf.dig(:accessorials, :unserviceable).select do |key|
55
+ accessorials.include?(key)
56
+ end.sort!
57
+
58
+ message = "#{unsupported_accessorials.join(", ")} unserviceable"
59
+ raise FreightKit::UnserviceableError, message if unsupported_accessorials.any?
60
+
61
+ true
62
+ end
63
+
64
+ def validate_packages(packages)
65
+ # @note This doesn't refer to package quantities, instead it referes to maximum number of package-related URL
66
+ # query params
67
+ raise FreightKit::UnserviceableError, 'Too many packages' if packages.count > 15
68
+
69
+ unsupported_packaging_types = packages.map(&:packaging).map(&:type).select do |type|
70
+ conf.dig(:package_types).keys.exclude?(type)
71
+ end.sort!
72
+
73
+ raise FreightKit::UnserviceableError,
74
+ "#{unsupported_packaging_types.join(", ").upcase_first} unserviceable" if unsupported_packaging_types.any?
75
+
76
+ true
77
+ end
78
+
79
+ # Documents
80
+
81
+ # Pickups
82
+
83
+ # Rates
84
+
85
+ def find_rates(shipment:)
86
+ begin
87
+ serviceable_accessorials?(shipment.accessorials)
88
+ validate_packages(shipment.packages)
89
+ rescue UnserviceableError => e
90
+ return RateResponse.new(error: e)
91
+ end
92
+
93
+ request = build_rate_request(shipment:)
94
+ # commit(request)
95
+ parse_rate_response(shipment:, response: commit(request))
96
+ end
97
+
98
+ # Tracking
99
+
100
+ protected
101
+
102
+ def build_url(action, options = {})
103
+ uri = URI.parse("https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}")
104
+ uri.query = options[:params].to_query if options[:params].present?
105
+
106
+ uri.to_s
107
+ end
108
+
109
+ def build_request(action, options = {})
110
+ request = {
111
+ url: build_url(action, options),
112
+ headers: XML_HEADERS,
113
+ method: @conf.dig(:api, :methods, action)
114
+ }
115
+
116
+ save_request(request)
117
+ request
118
+ end
119
+
120
+ def commit(request)
121
+ body = request[:body]
122
+ headers = request[:headers]
123
+ method = request[:method]
124
+ url = request[:url]
125
+
126
+ response = if method == :post
127
+ HTTParty.post(url, headers:, body:, debug_output: $stdout)
128
+ else
129
+ HTTParty.get(url, headers:, debug_output: $stdout)
130
+ end
131
+
132
+ response.deep_transform_keys! { |key| key.downcase.to_sym }[:abf] if response&.parsed_response
133
+ end
134
+
135
+ # Documents
136
+
137
+ # Pickups
138
+
139
+ # Rates
140
+
141
+ def build_rate_request(shipment:)
142
+ broker_credential = fetch_credential(:api)
143
+ tms_credential = fetch_credential(:api_key)
144
+
145
+ account = { 'ID' => broker_credential.username }
146
+ account['APP_ID'] = tms_credential.api_key
147
+
148
+ delivery = {}.tap do |builder|
149
+ delivery_limited_access_type = shipment.accessorials.map do |key|
150
+ conf.dig(:accessorials, :delivery_limited_access_types, key)
151
+ end[0]
152
+
153
+ if delivery_limited_access_type.present?
154
+ builder['Acc_LAD'] = 'Y'
155
+ builder['LADType'] = delivery_limited_access_type
156
+ end
157
+
158
+ builder['Acc_CSD'] = 'Y' if shipment.accessorials.include?(:construction_site_delivery)
159
+ builder['Acc_GRD_DEL'] = 'Y' if shipment.accessorials.include?(:liftgate_delivery)
160
+ builder['Acc_IDEL'] = 'Y' if shipment.accessorials.include?(:inside_delivery)
161
+ builder['Acc_RDEL'] = 'Y' if shipment.accessorials.include?(:residential_delivery)
162
+
163
+ if shipment.accessorials.include?(:convention_delivery)
164
+ builder['Acc_TRDSHWD'] = 'Y'
165
+ builder['TRDSHWDType'] = 'DTS' if shipment.accessorials.include?(:convention_delivery)
166
+ end
167
+ end
168
+
169
+ pickup = {}.tap do |builder|
170
+ pickup_limited_access_type = conf
171
+ .dig(:accessorials, :pickup_limited_access_types)
172
+ .values_at(*shipment.accessorials)
173
+ .first
174
+
175
+ if pickup_limited_access_type.present?
176
+ builder['Acc_LAP'] = 'Y'
177
+ builder['LAPType'] = pickup_limited_access_type
178
+ end
179
+
180
+ builder['Acc_GRD_PU'] = 'Y' if shipment.accessorials.include?(:liftgate_pickup)
181
+ builder['Acc_IPU'] = 'Y' if shipment.accessorials.include?(:inside_pickup)
182
+ builder['Acc_RPU'] = 'Y' if shipment.accessorials.include?(:residential_pickup)
183
+ builder['Acc_TRDSHWO'] = 'DTS' if shipment.accessorials.include?(:convention_pickup)
184
+ end
185
+
186
+ shipper = {
187
+ 'ShipCity' => shipment.origin.city,
188
+ 'ShipState' => shipment.origin.province,
189
+ 'ShipZip' => shipment.origin.postal_code,
190
+ 'ShipCountry' => shipment.origin.country.code(:alpha2).value
191
+ }
192
+
193
+ consignee = {
194
+ 'ConsCity' => shipment.destination.city,
195
+ 'ConsState' => shipment.destination.province,
196
+ 'ConsZip' => shipment.destination.postal_code,
197
+ 'ConsCountry' => shipment.destination.country.code(:alpha2).value
198
+ }
199
+
200
+ third_party = {
201
+ 'TPBAcct' => broker_credential.account,
202
+ 'TPBAddr' => [customer_location.address1, customer_location.address2].compact.join(', '),
203
+ 'TPBAff' => 'Y',
204
+ 'TPBCity' => customer_location.city,
205
+ 'TPBCountry' => customer_location.country.code(:alpha2).value,
206
+ 'TPBName' => customer_location.contact&.company_name,
207
+ 'TPBPay' => 'Y',
208
+ 'TPBState' => customer_location.province,
209
+ 'TPBZip' => customer_location.postal_code
210
+ }
211
+ .compact_blank!
212
+
213
+ shipment.packages.map { |p| p.pounds(:total).ceil }.sum
214
+
215
+ commodities = shipment.packages.map.with_index do |package, i|
216
+ {
217
+ "Class#{i + 1}" => package.freight_class,
218
+ "FrtHght#{i + 1}" => package.inches(:height),
219
+ "FrtLng#{i + 1}" => package.inches(:length),
220
+ "FrtWdth#{i + 1}" => package.inches(:width),
221
+ "UnitNo#{i + 1}" => package.quantity,
222
+ "UnitType#{i + 1}" => conf.dig(:package_types, package.packaging.type),
223
+ "Wgt#{i + 1}" => package.pounds(:total)
224
+ }
225
+ end
226
+ commodities = commodities.reduce({}, :merge)
227
+
228
+ # @note API won't accept any other date
229
+ time = Time.current.in_time_zone('America/Chicago')
230
+
231
+ specifics = {
232
+ 'FrtLWHType' => 'IN',
233
+ 'ShipMonth' => time.strftime('%m'),
234
+ 'ShipDay' => time.strftime('%d'),
235
+ 'ShipYear' => time.strftime('%Y')
236
+ }
237
+
238
+ if shipment.packages.map { |package| package.cubic_ft(:each) }.all?(&:present?)
239
+ specifics['Cube'] = shipment.packages.sum { |package| package.cubic_ft(:each) }.ceil
240
+ end
241
+
242
+ other_options = {}.tap do |builder|
243
+ alpha2_codes = [shipment.destination.country, shipment.origin.country].map do |country|
244
+ country.code(:alpha2).value
245
+ end
246
+
247
+ builder['Acc_ARR'] = 'Y' if shipment.accessorials.include?(:appointment_delivery)
248
+
249
+ if alpha2_codes.any? { |alpha2_code| alpha2_code == 'US' } &&
250
+ alpha2_codes.any? { |alpha2_code| alpha2_code != 'US' }
251
+ builder['Acc_BOND'] = 'Y'
252
+ end
253
+
254
+ unless shipment.accessorials.intersect?(%i[
255
+ church_delivery
256
+ inside_delivery
257
+ liftgate_delivery
258
+ residential_delivery
259
+ restaurant_delivery
260
+ ])
261
+ builder['Acc_CUL'] = 'Y'
262
+ end
263
+
264
+ builder['Acc_HAZ'] = 'Y' if shipment.packages.any?(&:hazmat?)
265
+ builder['Acc_PALLET'] = 'Y' if shipment.packages.map(&:packaging).all?(&:pallet?)
266
+
267
+ unless shipment.accessorials.intersect?(%i[
268
+ church_pickup
269
+ inside_pickup
270
+ liftgate_pickup
271
+ residential_pickup
272
+ restaurant_pickup
273
+ ])
274
+ builder['Acc_SL'] = 'Y'
275
+ end
276
+
277
+ builder['Acc_SS'] = 'Y'
278
+
279
+ longest_dimension_in = if shipment.packages.all? do |package|
280
+ package.length(:inch).present? && package.width(:inch).present?
281
+ end
282
+
283
+ shipment.packages.map do |package|
284
+ [package.length(:in), package.width(:in)].max
285
+ end.max.ceil
286
+ end
287
+
288
+ if longest_dimension_in.present?
289
+ builder['ODLongestSide'] = longest_dimension_in
290
+
291
+ if longest_dimension_in >= 96
292
+ builder['Acc_OD'] = 'Y'
293
+
294
+ if longest_dimension_in >= 336
295
+ builder['Acc_CAP'] = 'Y'
296
+ end
297
+ end
298
+ end
299
+
300
+ if shipment.declared_value_cents.present? && shipment.declared_value_cents.positive?
301
+ builder['Acc_ELC'] = 'Y'
302
+ builder['DeclaredType'] = 'N'
303
+ builder['DeclaredValue'] = (shipment.declared_value_cents / 100).ceil
304
+ end
305
+ end
306
+
307
+ params = [
308
+ account,
309
+ commodities,
310
+ consignee,
311
+ delivery,
312
+ other_options,
313
+ pickup,
314
+ shipper,
315
+ specifics,
316
+ third_party,
317
+ ]
318
+ .reduce({}, :merge)
319
+
320
+ build_request(:rates, { params: })
321
+ end
322
+
323
+ def parse_rate_response(shipment:, response:)
324
+ rate_response = RateResponse.new(request: last_request, response:)
325
+
326
+ error_count = response[:numerrors].to_i
327
+ if error_count.positive?
328
+ # response.dig(:error, :errorcode).to_i
329
+
330
+ message = response.dig(:error, :errormessage)
331
+ rate_response.error = ResponseError.new(message)
332
+ return rate_response
333
+ end
334
+
335
+ if response[:charge].blank?
336
+ rate_response.error = ResponseError.new('Cost is blank')
337
+ return rate_response
338
+ end
339
+
340
+ estimate_reference = response[:quoteid]
341
+ included_charges = response[:includedcharges].compact_blank!.keys
342
+ transit_days = response[:standard_service_days].to_i
343
+
344
+ rates = [].tap do |builder|
345
+ cents = (response[:charge].to_f * 100).to_i
346
+ transit_days = parse_transit_duration(response[:shipdate], response[:advertisedduedate])
347
+ with_excessive_length_fees = included_charges.include?(:overdimension)
348
+
349
+ builder << Rate.new(
350
+ carrier: self,
351
+ carrier_name: self.class.name,
352
+ currency: 'USD',
353
+ estimate_reference:,
354
+ scac: self.class.scac.upcase,
355
+ service_name: :standard,
356
+ shipment:,
357
+ prices: [
358
+ Price.new(
359
+ blame: :api,
360
+ cents:,
361
+ description: 'Freight',
362
+ ),
363
+ ],
364
+ transit_days:,
365
+ with_excessive_length_fees:,
366
+ )
367
+
368
+ guaranteed_options = response.dig(:guaranteedoptions, :option)
369
+
370
+ if guaranteed_options.is_a?(Array)
371
+ guaranteed_options.each do |guaranteed_option|
372
+ cents = (guaranteed_option[:guaranteedcharge].to_f * 100).to_i
373
+ service_name = guaranteed_ltl_service(guaranteed_option[:guaranteedbytime])
374
+ transit_days = parse_transit_duration(response[:shipdate], guaranteed_option[:guaranteeddeldate])
375
+
376
+ builder << Rate.new(
377
+ carrier: self,
378
+ carrier_name: self.class.name,
379
+ currency: 'USD',
380
+ estimate_reference:,
381
+ scac: self.class.scac.upcase,
382
+ service_name:,
383
+ shipment:,
384
+ prices: [
385
+ Price.new(
386
+ blame: :api,
387
+ cents:,
388
+ description: 'Freight',
389
+ ),
390
+ ],
391
+ transit_days:,
392
+ with_excessive_length_fees:,
393
+ )
394
+ end
395
+ end
396
+ end
397
+
398
+ rate_response.rates = rates
399
+ rate_response
400
+ end
401
+
402
+ # Tracking
403
+
404
+ private
405
+
406
+ def guaranteed_ltl_service(hours)
407
+ raise ArgumentError, 'Invalid hours' unless hours.match?(/^\d{4}$/)
408
+
409
+ return :guaranteed_ltl_am if hours[0..1].to_i <= 12
410
+
411
+ :guaranteed_ltl
412
+ end
413
+
414
+ def parse_transit_duration(from, to)
415
+ from = ::Time.parse(from).in_time_zone('America/Chicago')
416
+ to = ::Time.parse(to).in_time_zone('America/Chicago')
417
+
418
+ (from.business_time_until(to) / 28_800.0).days
419
+ end
420
+ end
421
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class BTVP < TheGreatInformationFactory
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
+ end
18
+
19
+ include FreightKit::Documentable
20
+
21
+ REACTIVE_FREIGHT_CARRIER = true
22
+
23
+ class << self
24
+ attr_reader :name, :scac
25
+ end
26
+ @name = 'Best Overnite Express'
27
+ @scac = 'BTVP'
28
+ end
29
+ end