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,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class SAIA < FreightKit::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ include FreightKit::Rateable
8
+ include FreightKit::Trackable
9
+
10
+ class << self
11
+ attr_reader :name, :scac
12
+ end
13
+ @name = 'Saia'
14
+ @scac = 'SAIA'
15
+
16
+ class << self
17
+ def find_rates_with_declared_value?
18
+ true
19
+ end
20
+
21
+ def maximum_height
22
+ Measured::Length.new(105, :inches)
23
+ end
24
+
25
+ def maximum_weight
26
+ Measured::Weight.new(10_000, :pounds)
27
+ end
28
+
29
+ def minimum_length_for_overlength_fees
30
+ Measured::Length.new(8, :feet)
31
+ end
32
+
33
+ def overlength_fees_require_tariff?
34
+ false
35
+ end
36
+
37
+ def required_credential_types
38
+ %i[api]
39
+ end
40
+
41
+ def requirements
42
+ %i[credentials]
43
+ end
44
+ end
45
+
46
+ # Documents
47
+
48
+ # Rates
49
+
50
+ def validate_packages(packages, _tariff = nil)
51
+ raise UnserviceableError, 'Must be fewer than 10 items altogether' if packages.sum(&:quantity) > 10
52
+
53
+ super
54
+ end
55
+
56
+ protected
57
+
58
+ def commit(action, request)
59
+ client_args = {
60
+ wsdl: request_url(action),
61
+ convert_request_keys_to: :none,
62
+ env_namespace: :soap,
63
+ element_form_default: :qualified
64
+ }
65
+
66
+ call_args = { message: request_blueprint.deep_merge(request) }
67
+
68
+ ::FreightKit::SoapClient.new(
69
+ carrier: self,
70
+ action:,
71
+ client_args:,
72
+ call_args:,
73
+ soap_operation: @conf.dig(:api, :actions, action),
74
+ ).call&.to_hash&.with_indifferent_access
75
+ end
76
+
77
+ def request_blueprint
78
+ api_credentials = fetch_credential(:api)
79
+
80
+ {
81
+ request: {
82
+ AccountNumber: api_credentials.account,
83
+ Application: 'ThirdParty',
84
+ Password: api_credentials.password,
85
+ TestMode: 'N',
86
+ UserID: api_credentials.username
87
+ }
88
+ }
89
+ end
90
+
91
+ def request_url(action)
92
+ scheme = @conf.dig(:api, :use_ssl) ? 'https://' : 'http://'
93
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
94
+ end
95
+
96
+ # Documents
97
+
98
+ # Rates
99
+ def build_rate_request(shipment:)
100
+ accessorials = [{ AccessorialItem: { Code: 'SingleShipment' } }]
101
+ if shipment.accessorials.present?
102
+ serviceable_accessorials?(shipment.accessorials)
103
+ shipment.accessorials.each do |a|
104
+ if @conf.dig(:accessorials, :unserviceable).exclude?(a)
105
+ accessorials << { AccessorialItem: { Code: @conf.dig(:accessorials, :mappable)[a] } }
106
+ end
107
+ end
108
+ end
109
+
110
+ longest_dimension = shipment.packages.map { |p| [p.width(:inches), p.length(:inches)].max }.max.ceil
111
+ accessorials << { AccessorialItem: { Code: 'ExcessiveLength' } } if longest_dimension >= 96
112
+
113
+ accessorials = accessorials.uniq
114
+
115
+ details = []
116
+ dimensions = []
117
+ shipment.packages.each do |package|
118
+ package.quantity.times do
119
+ details << {
120
+ DetailItem: {
121
+ Weight: package.pounds(:each).ceil,
122
+ Class: package.freight_class.to_s,
123
+ Length: package.length(:in).ceil,
124
+ Width: package.width(:in).ceil,
125
+ Height: package.height(:in).ceil
126
+ }
127
+ }
128
+
129
+ # Keeping this one at a time to match with "details"
130
+ dimensions << {
131
+ DimensionItem: {
132
+ Units: 1,
133
+ Length: package.length(:in).round(2),
134
+ Width: package.width(:in).round(2),
135
+ Height: package.height(:in).round(2),
136
+ Type: 'IN' # inches
137
+ }
138
+ }
139
+ end
140
+ end
141
+
142
+ request = {
143
+ request: {
144
+ Application: 'ThirdParty',
145
+ BillingTerms: 'Prepaid',
146
+ OriginCity: shipment.origin.city,
147
+ OriginState: shipment.origin.province,
148
+ OriginZipcode: shipment.origin.postal_code.to_s.upcase,
149
+ DestinationCity: shipment.destination.city,
150
+ DestinationState: shipment.destination.province,
151
+ DestinationZipcode: shipment.destination.postal_code.to_s.upcase,
152
+ WeightUnits: 'LBS',
153
+ TotalCube: shipment.packages.sum { |p| p.cubic_ft(:each) }.round(2),
154
+ TotalCubeUnits: 'CUFT', # cubic ft
155
+ ExcessiveLengthTotalInches: longest_dimension.to_s,
156
+ Details: details,
157
+ Dimensions: dimensions,
158
+ Accessorials: accessorials
159
+ }
160
+ }
161
+
162
+ declared_value = if shipment.declared_value_cents.blank?
163
+ nil
164
+ else
165
+ (shipment.declared_value_cents.to_f / 100).ceil
166
+ end
167
+
168
+ if declared_value.present?
169
+ request = request.deep_merge(
170
+ {
171
+ request: {
172
+ DeclaredValue: declared_value,
173
+ FullValueCoverage: declared_value
174
+ }
175
+ },
176
+ )
177
+ end
178
+
179
+ save_request(request)
180
+ request
181
+ end
182
+
183
+ def parse_rate_response(shipment:, response:)
184
+ rate_response = RateResponse.new(request: last_request, response:)
185
+
186
+ if response.blank?
187
+ rate_response.error = ResponseError.new('Unknown response')
188
+ return rate_response
189
+ end
190
+
191
+ error = response.dig(:create_response, :create_result, :code)
192
+
193
+ if error.present?
194
+ message = response.dig(:create_response, :create_result, :message)
195
+
196
+ case error
197
+ when 'DNF'
198
+ rate_response.error = UnserviceableError.new("#{error}: #{message}")
199
+ return rate_response
200
+ when 'S10'
201
+ rate_response.error = UnserviceableError.new("#{error}: #{message}")
202
+ return rate_response
203
+ end
204
+
205
+ if message.downcase.include?('must not exceed 10 lines')
206
+ rate_response.error = UnserviceableError.new("#{error}: #{message}")
207
+ return rate_response
208
+ end
209
+
210
+ rate_response.error = ResponseError.new("#{error}: #{message}")
211
+ return rate_response
212
+ end
213
+
214
+ result = response.dig(:create_response, :create_result)
215
+
216
+ if result.blank?
217
+ rate_response.error = ResponseError.new('Unknown result')
218
+ return rate_response
219
+ end
220
+
221
+ if result[:total_invoice].blank?
222
+ rate_response.error = ResponseError.new('Cost is blank')
223
+ return rate_response
224
+ end
225
+
226
+ transit_days = result[:standard_service_days].to_i
227
+ estimate_reference = result[:quote_number]
228
+
229
+ rate_accessorial_items = result.dig(:rate_accessorials, :rate_accessorial_item)
230
+ rate_accessorial_items = [rate_accessorial_items] if rate_accessorial_items.is_a?(Hash)
231
+
232
+ prices = []
233
+
234
+ rate_accessorial_items.each do |rate_accessorial_item|
235
+ prices << Price.new(
236
+ blame: :api,
237
+ cents: (rate_accessorial_item[:amount].to_f * 100).to_i,
238
+ description: rate_accessorial_item[:description]&.titleize&.squish,
239
+ )
240
+ end
241
+
242
+ standard_ltl_cents = (result[:total_invoice].to_f * 100).to_i - prices.sum(&:cents)
243
+
244
+ rates = []
245
+
246
+ rates << Rate.new(
247
+ carrier: self,
248
+ carrier_name: self.class.name,
249
+ currency: 'USD',
250
+ estimate_reference:,
251
+ scac: self.class.scac.upcase,
252
+ service_name: :standard,
253
+ shipment:,
254
+ prices: [
255
+ Price.new(
256
+ blame: :api,
257
+ cents: standard_ltl_cents,
258
+ description: 'Freight',
259
+ ),
260
+ ] + prices,
261
+ transit_days:,
262
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
263
+ )
264
+
265
+ [
266
+ { guaranteed_ltl: result[:guarantee_amount] },
267
+ { guaranteed_ltl_am: result[:guarantee_amount12pm] },
268
+ { guaranteed_ltl_pm: result[:guarantee_amount2pm] },
269
+ ].each do |service|
270
+ next if service.values[0] == '0' || service.values[0].blank?
271
+
272
+ cents = (service.values[0].to_f * 100).to_i
273
+
274
+ rates << Rate.new(
275
+ carrier_name: self.class.name,
276
+ carrier: self,
277
+ currency: 'USD',
278
+ estimate_reference:,
279
+ scac: self.class.scac.upcase,
280
+ service_name: service.keys[0],
281
+ shipment:,
282
+ prices: [
283
+ Price.new(
284
+ blame: :api,
285
+ cents: standard_ltl_cents + cents,
286
+ description: 'Freight',
287
+ ),
288
+ ] + prices,
289
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
290
+ )
291
+ end
292
+
293
+ rate_response.rates = rates
294
+ rate_response
295
+ end
296
+
297
+ # Tracking
298
+
299
+ def build_tracking_request(tracking_number)
300
+ request = {
301
+ request: {
302
+ ProNumber: tracking_number
303
+ }
304
+ }
305
+ save_request(request)
306
+ request
307
+ end
308
+
309
+ def parse_api_date_time(date_time, location)
310
+ return if date_time.blank?
311
+
312
+ local_date_time = ::Time.strptime(date_time, '%Y-%m-%d %H:%M:%S').to_fs(:db)
313
+ ::FreightKit::DateTime.new(local_date_time:, location:)
314
+ end
315
+
316
+ def parse_api_location(api_event)
317
+ Location.new(
318
+ city: api_event[:city]&.titleize,
319
+ province: api_event[:state]&.upcase,
320
+ country: ActiveUtils::Country.find('USA'),
321
+ )
322
+ end
323
+
324
+ def parse_tracking_response(response)
325
+ tracking_response = TrackingResponse.new(carrier: self, request: last_request, response:)
326
+
327
+ error = if response
328
+ response.dig(:get_by_pro_number_response, :get_by_pro_number_result, :code)
329
+ else
330
+ 'API Error: Unknown response'
331
+ end
332
+
333
+ if error.present?
334
+ tracking_response.error = ResponseError.new(error)
335
+ return tracking_response
336
+ end
337
+
338
+ search_result = response.dig(:get_by_pro_number_response, :get_by_pro_number_result)
339
+
340
+ if search_result.blank?
341
+ tracking_response.error = ShipmentNotFoundError.new
342
+ return tracking_response
343
+ end
344
+
345
+ address1 = [
346
+ search_result.dig(:shipper, :address1),
347
+ search_result.dig(:shipper, :address2),
348
+ ]
349
+ .select(&:present?)
350
+ .map { |line| line.squish.strip.titleize }
351
+ .join(', ')
352
+
353
+ shipper_location = Location.new(
354
+ address1:,
355
+ city: search_result.dig(:shipper, :city)&.squish&.strip&.titleize,
356
+ province: search_result.dig(:shipper, :state)&.strip&.upcase,
357
+ postal_code: search_result.dig(:shipper, :zipcode)&.strip,
358
+ country: ActiveUtils::Country.find('USA'),
359
+ )
360
+
361
+ address1 = [
362
+ search_result.dig(:consignee, :address1),
363
+ search_result.dig(:consignee, :address2),
364
+ ]
365
+ .select(&:present?)
366
+ .map { |line| line.squish.strip.titleize }
367
+ .join(', ')
368
+
369
+ receiver_location = Location.new(
370
+ address1:,
371
+ city: search_result.dig(:consignee, :city)&.squish&.strip&.titleize,
372
+ province: search_result.dig(:consignee, :state)&.strip&.upcase,
373
+ postal_code: search_result.dig(:consignee, :zipcode)&.strip,
374
+ country: ActiveUtils::Country.find('USA'),
375
+ )
376
+
377
+ api_date_time = search_result[:delivery_date_time_arrive]
378
+ actual_delivery_date = parse_api_date_time(api_date_time, receiver_location)
379
+
380
+ api_date_time = search_result[:pickup_date_time]
381
+ pickup_date = parse_api_date_time(api_date_time, shipper_location)
382
+
383
+ api_date_time = search_result[:delivery_appointment_date_time]
384
+ scheduled_delivery_date = parse_api_date_time(api_date_time, receiver_location)
385
+
386
+ tracking_number = search_result[:pro_number]
387
+
388
+ shipment_events = []
389
+
390
+ api_events = search_result.dig(:history, :history_item)
391
+
392
+ if api_events.blank?
393
+ shipment_events << ShipmentEvent.new(
394
+ date_time: pickup_date,
395
+ location: shipper_location,
396
+ type_code: :picked_up,
397
+ )
398
+ else
399
+ api_events = [api_events] if api_events.is_a?(Hash)
400
+
401
+ api_events.each do |api_event|
402
+ event_key = nil
403
+ comment = api_event[:activity]
404
+
405
+ @conf.dig(:events, :types).each do |key, val|
406
+ if comment.downcase.include?(val)
407
+ event_key = key
408
+ break
409
+ end
410
+ end
411
+ next if event_key.blank?
412
+
413
+ api_date_time = api_event[:activity_date_time]
414
+
415
+ location = parse_api_location(api_event)
416
+ date_time = parse_api_date_time(api_date_time, location)
417
+
418
+ shipment_events << ShipmentEvent.new(date_time:, location:, type_code: event_key)
419
+ end
420
+ end
421
+
422
+ status = shipment_events.last&.type_code
423
+
424
+ tracking_response.assign_attributes(
425
+ actual_delivery_date:,
426
+ destination: receiver_location,
427
+ origin: shipper_location,
428
+ scheduled_delivery_date:,
429
+ ship_time: pickup_date,
430
+ shipment_events:,
431
+ status:,
432
+ tracking_number:,
433
+ )
434
+
435
+ tracking_response
436
+ end
437
+ end
438
+ end