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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class JFJTransportation < Next
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ class << self
8
+ attr_reader :name, :scac
9
+ end
10
+ @name = 'JFJ Transportation'
11
+ @scac = nil
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class MTVL < 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
+ REACTIVE_FREIGHT_CARRIER = true
20
+
21
+ class << self
22
+ attr_reader :name, :scac
23
+ end
24
+ @name = 'GLS Freight'
25
+ @scac = 'MTVL'
26
+
27
+ def build_soap_header
28
+ soap_header = super
29
+ soap_header[:password] = soap_header[:password]&.downcase
30
+
31
+ soap_header
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class NUMK < 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(4, :ft)
16
+ end
17
+
18
+ def overlength_fees_require_tariff?
19
+ false
20
+ end
21
+
22
+ def requirements
23
+ %i[credentials]
24
+ end
25
+ end
26
+
27
+ include FreightKit::Documentable
28
+
29
+ REACTIVE_FREIGHT_CARRIER = true
30
+
31
+ class << self
32
+ attr_reader :name, :scac
33
+ end
34
+ @name = 'Numark Transportation'
35
+ @scac = 'NUMK'
36
+
37
+ def build_calculated_accessorials(packages)
38
+ longest_dimension = packages.map { |package| [package.length(:in), package.width(:in)].max }.max.ceil
39
+
40
+ return ['OVER'] if longest_dimension > 48
41
+
42
+ []
43
+ end
44
+
45
+ def build_soap_header
46
+ soap_header = super
47
+ soap_header[:password] = soap_header[:password]&.downcase
48
+
49
+ soap_header
50
+ end
51
+
52
+ protected
53
+
54
+ def wrap_request(request)
55
+ { 'args0' => request }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,528 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class OTCL < 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_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 = 'OnTrac'
45
+ @scac = 'OTCL'
46
+
47
+ XML_HEADERS = {
48
+ Accept: 'application/xml',
49
+ charset: 'utf-8',
50
+ 'Content-Type': 'application/xml'
51
+ }.freeze
52
+
53
+ # Override Carrier#serviceable_accessorials? since we have separate delivery/pickup accessorials
54
+ def serviceable_accessorials?(accessorials)
55
+ return true if accessorials.blank?
56
+
57
+ if !self.class::REACTIVE_FREIGHT_CARRIER ||
58
+ !@conf.dig(:accessorials, :mappable) ||
59
+ !@conf.dig(:accessorials, :unquotable) ||
60
+ !@conf.dig(:accessorials, :unserviceable)
61
+ raise NotImplementedError, "#{self.class.name}: #serviceable_accessorials? not supported"
62
+ end
63
+
64
+ serviceable_accessorials = @conf.dig(:accessorials, :mappable).keys +
65
+ @conf.dig(:accessorials, :unquotable)
66
+ serviceable_count = (serviceable_accessorials & accessorials).size
67
+
68
+ unserviceable_accessorials = @conf.dig(:accessorials, :unserviceable)
69
+ unserviceable_count = (unserviceable_accessorials & accessorials).size
70
+
71
+ if serviceable_count != accessorials.size || !unserviceable_count.zero?
72
+ raise FreightKit::UnserviceableError, "#{self.class.name}: Some accessorials unserviceable"
73
+ end
74
+
75
+ true
76
+ end
77
+
78
+ # Documents
79
+
80
+ # Pickups
81
+
82
+ def create_pickup(
83
+ delivery_from:,
84
+ delivery_to:,
85
+ dispatcher:,
86
+ pickup_from:,
87
+ pickup_to:,
88
+ scac:,
89
+ service:,
90
+ shipment:
91
+ )
92
+ request = build_shipment_request(
93
+ delivery_from:,
94
+ delivery_to:,
95
+ dispatcher:,
96
+ pickup_from:,
97
+ pickup_to:,
98
+ scac:,
99
+ service:,
100
+ shipment:,
101
+ )
102
+
103
+ labels = parse_shipment_response(commit(request))
104
+
105
+ request = build_pickup_request(
106
+ delivery_from:,
107
+ delivery_to:,
108
+ dispatcher:,
109
+ pickup_from:,
110
+ pickup_to:,
111
+ scac:,
112
+ service:,
113
+ shipment:,
114
+ )
115
+
116
+ parse_pickup_response(response: commit(request), labels:)
117
+ end
118
+
119
+ # Rates
120
+
121
+ def find_rates(shipment:)
122
+ begin
123
+ validate_packages(shipment.packages)
124
+ rescue UnserviceableError => e
125
+ return RateResponse.new(error: e)
126
+ end
127
+
128
+ request = build_rate_request(shipment:)
129
+ parse_rate_response(shipment:, response: commit(request))
130
+ end
131
+
132
+ # Tracking
133
+
134
+ protected
135
+
136
+ def build_url(action, options = {})
137
+ api_credentials = fetch_credential(:api_key)
138
+
139
+ env = @test_mode ? :test : :production
140
+
141
+ url = "https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, env, action)}"
142
+ url = url.gsub('%ACCOUNT_NUMBER%', api_credentials.account)
143
+
144
+ url += "?pw=#{api_credentials.api_key}"
145
+ url << "&#{options[:params]}" if options[:params].present?
146
+
147
+ url
148
+ end
149
+
150
+ def build_request(action, options = {})
151
+ request = {
152
+ url: build_url(action, options),
153
+ headers: XML_HEADERS,
154
+ method: @conf.dig(:api, :methods, action)
155
+ }
156
+
157
+ save_request(request)
158
+ request
159
+ end
160
+
161
+ def commit(request)
162
+ body = request[:body]
163
+ headers = request[:headers]
164
+ method = request[:method]
165
+ url = request[:url]
166
+
167
+ response = case method
168
+ when :post
169
+ HTTParty.post(url, headers:, body:, debug_output: $stdout)
170
+ else
171
+ HTTParty.get(url, headers:, debug_output: $stdout)
172
+ end
173
+
174
+ response.parsed_response if response&.parsed_response
175
+ end
176
+
177
+ def serviceable_states?(states)
178
+ valid_states = ['AZ', 'CA', 'CO', 'ID', 'NV', 'OR', 'UT', 'WA']
179
+
180
+ invalid_states = []
181
+ states.each do |state|
182
+ invalid_states << state if valid_states.exclude?(state)
183
+ end
184
+
185
+ return true if invalid_states.blank?
186
+
187
+ raise FreightKit::UnserviceableError, "No service to #{invalid_states.join(", ")}"
188
+ end
189
+
190
+ # Documents
191
+
192
+ # Pickups
193
+
194
+ def build_pickup_request(
195
+ delivery_from:,
196
+ delivery_to:,
197
+ dispatcher:,
198
+ pickup_from:,
199
+ pickup_to:,
200
+ scac:,
201
+ service:,
202
+ shipment:
203
+ )
204
+
205
+ dispatcher_phone = dispatcher.phone.sub('+1', '').delete('^0-9')
206
+
207
+ raise UnserviceableError, 'Palletized freight unsupported' unless shipment.loose?
208
+
209
+ request_body = {
210
+ Address: shipment.origin.address1,
211
+ City: shipment.origin.city,
212
+ CloseAt: pickup_to.strftime('%H:%M:00'),
213
+ Contact: shipment.origin.contact.name || 'Shipping',
214
+ Date: pickup_from.to_date,
215
+ DelZip: shipment.destination.postal_code,
216
+ Instructions: '',
217
+ Name: shipment.origin.contact.company_name,
218
+ Phone: dispatcher_phone,
219
+ ReadyAt: pickup_from.strftime('%H:%M:00'),
220
+ State: shipment.origin.province,
221
+ Zip: shipment.origin.postal_code
222
+ }.freeze
223
+
224
+ request = {
225
+ headers: XML_HEADERS,
226
+ method: @conf.dig(:api, :methods, :pickups),
227
+ url: build_url(:pickups),
228
+ body: request_body.to_xml(root: 'OnTracPickupRequest', skip_types: true)
229
+ }
230
+
231
+ save_request(request)
232
+ request
233
+ end
234
+
235
+ def build_shipment_request(
236
+ delivery_from:,
237
+ delivery_to:,
238
+ dispatcher:,
239
+ pickup_from:,
240
+ pickup_to:,
241
+ scac:,
242
+ service:,
243
+ shipment:
244
+ )
245
+
246
+ dispatcher_phone = dispatcher.phone.sub('+1', '').delete('^0-9')
247
+ receiver_phone = shipment.destination.contact.phone.sub('+1', '').delete('^0-9')
248
+
249
+ declared_value = if shipment.declared_value_cents.blank?
250
+ '0'
251
+ else
252
+ format('%.2f', (shipment.declared_value_cents.to_f / 100).ceil)
253
+ end
254
+
255
+ raise UnserviceableError, 'Palletized freight unsupported' unless shipment.loose?
256
+
257
+ base_api_shipment = {
258
+ consignee: {
259
+ Name: shipment.destination.contact.company_name,
260
+ Addr1: shipment.destination.address1,
261
+ Addr2: '',
262
+ Addr3: '',
263
+ City: shipment.destination.city,
264
+ Contact: shipment.destination.contact.name || 'Shipping',
265
+ Phone: receiver_phone || '',
266
+ State: shipment.destination.province,
267
+ Zip: shipment.destination.postal_code.to_s
268
+ },
269
+ shipper: {
270
+ Name: shipment.origin.contact.company_name,
271
+ Addr1: shipment.origin.address1,
272
+ City: shipment.origin.city,
273
+ State: shipment.origin.province,
274
+ Zip: shipment.origin.postal_code,
275
+ Contact: shipment.origin.contact.name || 'Shipping',
276
+ Phone: dispatcher_phone
277
+ },
278
+ BillTo: '0',
279
+ CargoType: '0',
280
+ COD: '0',
281
+ CODType: 'NONE',
282
+ Declared: declared_value,
283
+ DelEmail: dispatcher.email,
284
+ Instructions: '',
285
+ LabelType: '1', # PDF label
286
+ Reference: shipment.order_number,
287
+ Reference2: shipment.po_number,
288
+ Reference3: '',
289
+ Residential: shipment.accessorials.include?(:residential_delivery) ? 'true' : 'false',
290
+ SaturdayDel: 'false',
291
+ Service: 'C',
292
+ ShipDate: pickup_from.to_date.to_s,
293
+ ShipEmail: dispatcher.email,
294
+ SignatureRequired: 'true',
295
+ Tracking: ''
296
+ }.freeze
297
+
298
+ api_shipments = []
299
+
300
+ shipment.packages.each do |package|
301
+ package.quantity.times do
302
+ api_shipments << base_api_shipment.merge(
303
+ {
304
+ DIM: {
305
+ Length: package.length(:inches).ceil,
306
+ Width: package.width(:inches).ceil,
307
+ Height: package.height(:inches).ceil
308
+ },
309
+ UID: SecureRandom.uuid,
310
+ Weight: package.pounds(:each).ceil
311
+ },
312
+ )
313
+ end
314
+ end
315
+
316
+ request = {
317
+ headers: XML_HEADERS,
318
+ method: @conf.dig(:api, :methods, :shipments),
319
+ url: build_url(:shipments),
320
+ body: {
321
+ Shipments: api_shipments
322
+ }.to_xml(root: 'OnTracShipmentRequest', skip_types: true)
323
+ }
324
+
325
+ save_request(request)
326
+ request
327
+ end
328
+
329
+ def parse_pickup_response(response:, labels:)
330
+ pickup_response = PickupResponse.new(request: last_request, response:)
331
+
332
+ if response.blank?
333
+ pickup_response.error = FreightKit::ResponseError.new('API Error: Blank response')
334
+ return pickup_response
335
+ end
336
+
337
+ error = response.dig('OnTracPickupResponse', 'Error')
338
+
339
+ if error.present?
340
+ pickup_response.error = FreightKit::ResponseError.new(error.capitalize)
341
+ return pickup_response
342
+ end
343
+
344
+ pickup_number = response.dig('OnTracPickupResponse', 'Tracking')
345
+
346
+ if pickup_number.blank?
347
+ pickup_response.error = FreightKit::ResponseError.new('Blank pickup number')
348
+ return pickup_response
349
+ end
350
+
351
+ pickup_response.pickup_number = pickup_number
352
+ pickup_response
353
+ end
354
+
355
+ def parse_shipment_response(response)
356
+ raise FreightKit::ResponseError, 'API Error: Blank response' if response.blank?
357
+
358
+ error = response.dig('OnTracShipmentResponse', 'Shipments', 'Error')
359
+
360
+ if error.blank? && response.dig('OnTracShipmentResponse', 'Shipments', 'Shipment').is_a?(Hash)
361
+ error = response.dig('OnTracShipmentResponse', 'Shipments', 'Shipment', 'Error')
362
+ end
363
+
364
+ if error.present?
365
+ error = error.capitalize
366
+
367
+ raise FreightKit::InvalidCredentialsError, error if error.downcase.include?('invalid username')
368
+
369
+ raise FreightKit::UnserviceableError, error if error.downcase.include?('no valid service')
370
+
371
+ raise FreightKit::UnserviceableError, error if error.downcase.include?('not serviced')
372
+
373
+ raise FreightKit::ResponseError, "API Error: #{error}"
374
+ end
375
+
376
+ api_shipments = response.dig('OnTracShipmentResponse', 'Shipments', 'Shipment')
377
+ api_shipments = [api_shipments] unless api_shipments.is_a?(Array)
378
+
379
+ base64_labels = api_shipments&.map { |s| s['Label'] }
380
+ raise FreightKit::ResponseError, 'API Error: Blank label' if base64_labels.blank?
381
+
382
+ labels = []
383
+
384
+ base64_labels.each do |base64_label|
385
+ labels << Label.new(data: base64_label)
386
+ end
387
+
388
+ labels
389
+ end
390
+
391
+ # Rates
392
+
393
+ def build_rate_request(shipment:)
394
+ serviceable_accessorials?(shipment.accessorials)
395
+ serviceable_states?([shipment.origin.province, shipment.destination.province])
396
+
397
+ # API supports non-loose items (see below) but per OnTrac it shouldn't be quoted. We'll raise an error here but
398
+ # leave the support baked-in below anyway.
399
+ raise FreightKit::UnserviceableError, 'Palletized freight unsupported' unless shipment.loose?
400
+
401
+ dim_weights_too_heavy = shipment.packages.map(&:dim_weight).select { |w| w > self.class.maximum_weight.value }
402
+
403
+ if dim_weights_too_heavy.any?
404
+ message = <<~MESSAGE.squish
405
+ Dimensional weight(s) of #{dim_weights_too_heavy.map(&:round).join("lbs, ")} lbs more than maximum of
406
+ #{self.class.maximum_weight.value.round} lbs
407
+ MESSAGE
408
+ raise FreightKit::UnserviceableError, message
409
+ end
410
+
411
+ params = ''.dup
412
+ params << 'packages='
413
+
414
+ total_weight = shipment.packages.map { |p| p.pounds(:total) }.sum
415
+
416
+ i = 1
417
+ package_param_parts = []
418
+
419
+ shipment.packages.each do |package|
420
+ package.quantity.times do
421
+ declared_value = if shipment.declared_value_cents.blank?
422
+ 0
423
+ else
424
+ shipment.declared_value_cents.to_f * (package.pounds(:each) / total_weight)
425
+ end
426
+
427
+ declared_value = declared_value.to_s
428
+
429
+ parts = []
430
+
431
+ parts << "ID#{i}"
432
+ parts << shipment.origin.postal_code
433
+ parts << shipment.destination.postal_code
434
+ parts << shipment.accessorials.include?(:residential_delivery) ? 'true' : 'false'
435
+ parts << '0'
436
+ parts << 'false' # Staurday delivery
437
+ parts << declared_value
438
+ parts << package.pounds(:each).ceil
439
+ parts << "#{package.inches(:length).ceil}X#{package.inches(:width).ceil}X#{package.inches(:height).ceil}"
440
+ parts << 'C'
441
+ parts << '0' # not a letter
442
+ parts << '0' # always 0 per documentation
443
+
444
+ package_param_parts << parts.join(';')
445
+ end
446
+ end
447
+
448
+ params << package_param_parts.join(',')
449
+
450
+ build_request(:rates, { params: })
451
+ end
452
+
453
+ def parse_rate_response(shipment:, response:)
454
+ rate_response = RateResponse.new(request: last_request, response:)
455
+
456
+ if response.blank?
457
+ rate_response.error = ResponseError.new('Blank response')
458
+ return rate_response
459
+ end
460
+
461
+ error = response.dig('OnTracRateResponse', 'Shipments', 'Shipment', 'Error') ||
462
+ response.dig('OnTracRateResponse', 'Shipments', 'Error') ||
463
+ response.dig('OnTracRateResponse', 'Error')
464
+
465
+ if error.blank? && response.dig('OnTracRateResponse', 'Shipments', 'Shipment').is_a?(Hash)
466
+ error = response.dig('OnTracRateResponse', 'Shipments', 'Shipment', 'Rates', 'Rate', 'Error')
467
+ end
468
+
469
+ if error.present?
470
+ case error.downcase
471
+ when ->(value) { value.include?('not serviced') }
472
+ rate_response.error = UnserviceableError.new(error)
473
+ return rate_response
474
+ when ->(value) { value.include?('invalid username') }
475
+ rate_response.error = InvalidCredentialsError.new(error)
476
+ return rate_response
477
+ when ->(value) { value.include?('no valid service') }
478
+ rate_response.error = UnserviceableError.new(error)
479
+ return rate_response
480
+ end
481
+
482
+ rate_response.error = ResponseError.new(error)
483
+ return rate_response
484
+ end
485
+
486
+ prices = []
487
+ transit_days = nil
488
+
489
+ api_shipments = response.dig('OnTracRateResponse', 'Shipments', 'Shipment')
490
+ api_shipments = [api_shipments] unless api_shipments.is_a?(Array)
491
+
492
+ if api_shipments.any? { |api_shipment| api_shipment['Error']&.downcase&.include?('not serviced') }
493
+ rate_response.error = UnserviceableError.new
494
+ return rate_response
495
+ end
496
+
497
+ api_shipments.each do |api_shipment|
498
+ api_rate = api_shipment.dig('Rates', 'Rate')
499
+ api_transit_days = api_rate['TransitDays'].to_i
500
+
501
+ transit_days = api_transit_days if transit_days.blank? || transit_days < api_transit_days
502
+
503
+ cents = (api_rate['ServiceCharge'].to_f * 100).to_i
504
+ prices << Price.new(blame: :api, cents:, description: 'Service charge')
505
+
506
+ cents = (api_rate['FuelCharge'].to_f * 100).to_i
507
+ prices << Price.new(blame: :api, cents:, description: 'Fuel charge')
508
+ end
509
+
510
+ rate = Rate.new(
511
+ carrier_name: self.class.name,
512
+ carrier: self,
513
+ currency: 'USD',
514
+ prices:,
515
+ scac: self.class.scac.upcase,
516
+ service_name: :standard,
517
+ shipment:,
518
+ transit_days:,
519
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
520
+ )
521
+
522
+ rate_response.rates = [rate]
523
+ rate_response
524
+ end
525
+
526
+ # Tracking
527
+ end
528
+ end