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,744 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class FWDA < 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(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]
32
+ end
33
+
34
+ def requirements
35
+ %i[credentials]
36
+ end
37
+ end
38
+
39
+ REACTIVE_FREIGHT_CARRIER = true
40
+
41
+ include FreightKit::Rateable
42
+ include FreightKit::Trackable
43
+ include FreightKit::Pickupable
44
+
45
+ class << self
46
+ attr_reader :name, :scac
47
+ end
48
+ @name = 'Forward Air'
49
+ @scac = 'FWDA'
50
+
51
+ JSON_HEADERS = {
52
+ Accept: 'application/json',
53
+ charset: 'utf-8',
54
+ 'Content-Type' => 'application/json'
55
+ }.freeze
56
+
57
+ # Override Carrier#serviceable_accessorials? since we have separate delivery/pickup accessorials
58
+ def serviceable_accessorials?(accessorials)
59
+ return true if accessorials.blank?
60
+
61
+ if !self.class::REACTIVE_FREIGHT_CARRIER ||
62
+ !@conf.dig(:accessorials, :mappable) ||
63
+ !@conf.dig(:accessorials, :unquotable) ||
64
+ !@conf.dig(:accessorials, :unserviceable)
65
+ raise NotImplementedError, "#{self.class.name}: #serviceable_accessorials? not supported"
66
+ end
67
+
68
+ serviceable_accessorials = @conf.dig(:accessorials, :mappable, :delivery).keys +
69
+ @conf.dig(:accessorials, :mappable, :pickup).keys +
70
+ @conf.dig(:accessorials, :unquotable)
71
+
72
+ unsupported_accessorials = accessorials - serviceable_accessorials
73
+
74
+ if unsupported_accessorials.any?
75
+ raise FreightKit::UnserviceableError, "#{self.class.name}: #{unsupported_accessorials.join(", ")} not supported"
76
+ end
77
+
78
+ true
79
+ end
80
+
81
+ # Documents
82
+
83
+ def pod(tracking_number)
84
+ # Retrieve list of available documents first
85
+ begin
86
+ documents = commit(build_documents_request(tracking_number))
87
+ rescue FreightKit::ResponseError => e
88
+ if e.message.downcase.include?('no airbills found')
89
+ return DocumentResponse.new(error: FreightKit::DocumentNotFoundError)
90
+ end
91
+
92
+ return DocumentResponse.new(error: e)
93
+ end
94
+
95
+ begin
96
+ doc_id = get_doc_id(documents:, tracking_number:, type: :pod)
97
+ rescue StandardError => e
98
+ return DocumentResponse.new(error: e)
99
+ end
100
+
101
+ request = build_document_request(doc_id:, tracking_number:)
102
+ response = commit(request)
103
+
104
+ parse_document_response(:pod, tracking_number, response)
105
+ end
106
+
107
+ def scanned_bol(tracking_number, _options = {})
108
+ # Retrieve list of available documents first
109
+ begin
110
+ documents = commit(build_documents_request(tracking_number))
111
+ rescue FreightKit::ResponseError => e
112
+ if e.message.downcase.include?('no airbills found')
113
+ return DocumentResponse.new(error: FreightKit::DocumentNotFoundError)
114
+ end
115
+
116
+ return DocumentResponse.new(error: e)
117
+ end
118
+
119
+ begin
120
+ doc_id = get_doc_id(documents:, tracking_number:, type: :bol)
121
+ rescue StandardError => e
122
+ return DocumentResponse.new(e:)
123
+ end
124
+
125
+ request = build_document_request(doc_id:, tracking_number:)
126
+ response = commit(request)
127
+
128
+ parse_document_response(:bol, tracking_number, response)
129
+ end
130
+
131
+ # Locations
132
+
133
+ def find_locations(country)
134
+ raise ArgumentError, 'country must be a ActiveUtils::Country' unless country.is_a?(ActiveUtils::Country)
135
+
136
+ request = build_locations_request
137
+ parse_locations_response(country:, response: commit(request))
138
+ end
139
+
140
+ # Rates
141
+
142
+ def find_rates(shipment:)
143
+ if shipment.packages.map { |package| package.height(:in) }.any?(&:blank?) ||
144
+ shipment.packages.map { |package| package.length(:in) }.any?(&:blank?) ||
145
+ shipment.packages.map { |package| package.width(:in) }.any?(&:blank?)
146
+
147
+ raise UnserviceableError, 'Dimensions required for quoting'
148
+ end
149
+
150
+ packages = shipment.packages.select { |package| package.height(:in) > 89 }
151
+ if packages.any?
152
+ message = <<~MESSAGE.squish
153
+ #{"Height".pluralize(packages)}
154
+ #{packages.map { |package| "#{package.height(:in)} inches" }.join(", ")}
155
+ greater than maximum allowed of 89 inches.
156
+ MESSAGE
157
+ raise UnserviceableError, message
158
+ end
159
+
160
+ packages = shipment.packages.select { |package| package.length(:in) > 240 }
161
+ if packages.any?
162
+ message = <<~MESSAGE.squish
163
+ #{"Length".pluralize(packages)}
164
+ #{packages.map { |package| "#{package.length(:in)} inches" }.join(", ")}
165
+ greater than maximum allowed of 240 inches.
166
+ MESSAGE
167
+ raise UnserviceableError, message
168
+ end
169
+
170
+ packages = shipment.packages.select { |package| package.width(:in) > 82 }
171
+ if packages.any?
172
+ message = <<~MESSAGE.squish
173
+ #{"Width".pluralize(packages)}
174
+ #{packages.map { |package| "#{package.width(:in)} inches" }.join(", ")}
175
+ greater than maximum allowed of 82 inches.
176
+ MESSAGE
177
+ raise UnserviceableError, message
178
+ end
179
+
180
+ super
181
+ end
182
+
183
+ protected
184
+
185
+ def build_accessorials(accessorials)
186
+ delivery_accessorials = []
187
+ pickup_accessorials = []
188
+
189
+ if accessorials.present?
190
+ serviceable_accessorials?(accessorials)
191
+
192
+ accessorials.each do |a|
193
+ if @conf.dig(:accessorials, :unserviceable).exclude?(a) &&
194
+ @conf.dig(:accessorials, :mappable, :pickup).include?(a)
195
+ pickup_accessorials << @conf.dig(:accessorials, :mappable, :pickup)[a]
196
+ end
197
+ end
198
+
199
+ accessorials.each do |a|
200
+ if @conf.dig(:accessorials, :unserviceable).exclude?(a) &&
201
+ @conf.dig(:accessorials, :mappable, :delivery).include?(a)
202
+ delivery_accessorials << @conf.dig(:accessorials, :mappable, :delivery)[a]
203
+ end
204
+ end
205
+ end
206
+
207
+ if delivery_accessorials.present? && delivery_accessorials.include?('RDE')
208
+ # Remove duplicate delivery appointment accessorial when residential delivery (included with RDE)
209
+ delivery_accessorials -= ['ADE']
210
+ end
211
+
212
+ if pickup_accessorials.present? && pickup_accessorials.include?('RPU')
213
+ # Remove duplicate pickup appointment accessorial when residential pickup (included with RPU)
214
+ pickup_accessorials -= ['APP']
215
+ end
216
+
217
+ # API doesn't like empty arrays
218
+ delivery_accessorials = nil if delivery_accessorials.blank?
219
+ pickup_accessorials = nil if pickup_accessorials.blank?
220
+
221
+ [pickup_accessorials&.uniq, delivery_accessorials&.uniq]
222
+ end
223
+
224
+ def build_dimensions(packages)
225
+ packages.map do |package|
226
+ {
227
+ height: package.height(:in).ceil,
228
+ length: package.length(:in).ceil,
229
+ pieces: package.quantity,
230
+ width: package.width(:in).ceil
231
+ }
232
+ end
233
+ end
234
+
235
+ def build_freight_details(packages)
236
+ packages.map do |package|
237
+ {
238
+ description: package.description || 'Freight',
239
+ freightClass: package.freight_class.to_s,
240
+ pieces: package.quantity.to_s,
241
+ weightType: 'L',
242
+ weight: package.pounds(:total).ceil.to_s
243
+ }
244
+ end
245
+ end
246
+
247
+ def build_url(action, options = {})
248
+ url = "#{base_url}#{@conf.dig(:api, :endpoints, action)}"
249
+ url = url.gsub('%TRACKING_NUMBER%', options[:tracking_number]) if options[:tracking_number]
250
+ url = url.gsub('%DOC_ID%', options[:doc_id]) if options[:doc_id]
251
+
252
+ url
253
+ end
254
+
255
+ def base_url
256
+ "https://#{@conf.dig(:api, :domains, :production)}"
257
+ end
258
+
259
+ def build_headers
260
+ api_credentials = fetch_credential(:api)
261
+
262
+ JSON_HEADERS.merge(
263
+ {
264
+ billToAccountNumber: api_credentials.account,
265
+ customerId: api_credentials.username.upcase,
266
+ password: api_credentials.password,
267
+ user: api_credentials.username
268
+ },
269
+ )
270
+ end
271
+
272
+ def build_request(action, options = {})
273
+ headers = JSON_HEADERS
274
+ headers = headers.merge(options[:headers]) if options[:headers].present?
275
+ body = options[:body].to_json if options[:body].present?
276
+
277
+ request = {
278
+ url: build_url(action, options),
279
+ headers:,
280
+ method: @conf.dig(:api, :methods, action),
281
+ body:
282
+ }
283
+
284
+ save_request(request)
285
+ request
286
+ end
287
+
288
+ def commit(request)
289
+ url = request[:url]
290
+ headers = request[:headers]
291
+ method = request[:method]
292
+ body = request[:body]
293
+
294
+ response = case method
295
+ when :post
296
+ HTTParty.post(url, headers:, body:, debug_output: $stdout)
297
+ else
298
+ HTTParty.get(url, headers:, debug_output: $stdout)
299
+ end
300
+
301
+ if (200..299).exclude?(response.code)
302
+ message = begin
303
+ JSON.parse(response.body)['errorMessage'] || "HTTP #{response.code}"
304
+ rescue JSON::ParserError
305
+ "HTTP #{response.code}"
306
+ end
307
+
308
+ raise FreightKit::ResponseError, message
309
+ end
310
+
311
+ return response if response.headers.content_type != 'application/json'
312
+
313
+ json = JSON.parse(response.body)
314
+ error = json.is_a?(Array) ? nil : json['errorMessage']
315
+
316
+ return json if error.blank?
317
+
318
+ raise FreightKit::InvalidCredentialsError, error if error.downcase.include?('not authorized')
319
+ raise FreightKit::InvalidCredentialsError, error if error.downcase.include?('shipper client does not exist')
320
+ raise FreightKit::ShipmentNotFoundError, error if error.downcase.include?('no history found')
321
+ raise FreightKit::UnserviceableError, error if error.downcase.include?('not serviced')
322
+
323
+ raise FreightKit::ResponseError, error
324
+ end
325
+
326
+ # Documents
327
+
328
+ def get_doc_id(documents:, tracking_number:, type:)
329
+ type = type.to_s.upcase
330
+ link = nil
331
+
332
+ documents.each do |document|
333
+ next unless document['documentType'] == type
334
+
335
+ link = document['link']
336
+ end
337
+
338
+ raise FreightKit::DocumentNotFoundError, "API Error: #{self.class.name}: Document not found" unless link
339
+
340
+ query = URI.parse(link).query
341
+ doc_id = URI.decode_www_form(query).assoc('docId')&.last
342
+
343
+ raise FreightKit::DocumentNotFoundError, "API Error: #{self.class.name}: Document not found" unless doc_id
344
+
345
+ doc_id
346
+ end
347
+
348
+ def build_document_request(doc_id:, tracking_number:)
349
+ request = {
350
+ url: build_url(:document, doc_id:, tracking_number:),
351
+ headers: build_headers,
352
+ method: @conf.dig(:api, :methods, :documents)
353
+ }
354
+
355
+ save_request(request)
356
+ request
357
+ end
358
+
359
+ def build_documents_request(tracking_number)
360
+ request = {
361
+ url: build_url(:documents, tracking_number:),
362
+ headers: build_headers,
363
+ method: @conf.dig(:api, :methods, :documents)
364
+ }
365
+
366
+ save_request(request)
367
+ request
368
+ end
369
+
370
+ def parse_document_response(_type, _tracking_number, response)
371
+ DocumentResponse.new(content_type: response.headers['content-type'], data: response.body, request: last_request)
372
+ end
373
+
374
+ # Locations
375
+
376
+ def build_locations_request
377
+ request = {
378
+ url: build_url(:locations),
379
+ headers: build_headers,
380
+ method: @conf.dig(:api, :methods, :locations)
381
+ }
382
+
383
+ save_request(request)
384
+ request
385
+ end
386
+
387
+ def parse_locations_response(country:, response:)
388
+ raise ResponseError, 'API Error: Unknown response' if response.blank?
389
+
390
+ raise ResponseError, 'API Error: Unknown response' unless response.is_a?(Array)
391
+
392
+ locations = response
393
+
394
+ locations = locations.map do |location|
395
+ Location.new(
396
+ address1: location['address1'],
397
+ city: location['city'],
398
+ province: location['state'],
399
+ country: ActiveUtils::Country.find(location['countrycd']),
400
+ contact: Contact.new(fax: location['fax'], phone: location['phone']),
401
+ )
402
+ end
403
+
404
+ locations.select { |location| location.country == country }
405
+ end
406
+
407
+ # Pickups
408
+
409
+ def build_pickup_request(
410
+ delivery_from:,
411
+ delivery_to:,
412
+ dispatcher:,
413
+ pickup_from:,
414
+ pickup_to:,
415
+ scac:,
416
+ service:,
417
+ shipment:
418
+ )
419
+ pickup_accessorials, delivery_accessorials = build_accessorials(shipment.accessorials)
420
+
421
+ dispatcher_phone = dispatcher.phone.delete('^0-9')
422
+ shipper_phone = shipment.origin.contact.phone.delete('^0-9')
423
+ receiver_phone = shipment.destination.contact.phone.delete('^0-9')
424
+
425
+ api_credentials = fetch_credential(:api)
426
+
427
+ declared_value = if shipment.declared_value_cents.blank?
428
+ '0'
429
+ else
430
+ format('%.2f', (shipment.declared_value_cents.to_f / 100).ceil)
431
+ end
432
+
433
+ delivery = {
434
+ airportDelivery: delivery_accessorials&.include?('ALD') ? 'Y' : 'N',
435
+ deliveryAccessorials: { deliveryAccessorial: delivery_accessorials }
436
+ }
437
+ description = shipment.packages.map(&:description).reject(&:blank?).uniq.join(', ')
438
+
439
+ request = {
440
+ headers: build_headers,
441
+ method: @conf.dig(:api, :methods, :pickup),
442
+ url: build_url(:pickup),
443
+ body: {
444
+ testmode: 'N',
445
+ consignee: {
446
+ consigneeAddress1: shipment.destination.address1,
447
+ consigneeAddress2: '',
448
+ consigneeCity: shipment.destination.city,
449
+ consigneeCloseTime: delivery_to.strftime('%H:%M:00'),
450
+ consigneeContactEmail: '',
451
+ consigneeContactName: shipment.destination.contact.name || 'Receiving',
452
+ consigneeContactPhone: receiver_phone || '',
453
+ consigneeCountry: shipment.destination.country.code(:alpha2).value,
454
+ consigneeLocationName: shipment.destination.contact.name,
455
+ consigneeOpenTime: delivery_from.strftime('%H:%M:00'),
456
+ consigneeState: shipment.destination.province,
457
+ consigneeZipCode: shipment.destination.postal_code.to_s
458
+ },
459
+ shipper: {
460
+ shipperAddress1: shipment.origin.address1,
461
+ shipperAddress2: '',
462
+ shipperCity: shipment.origin.city,
463
+ shipperCloseTime: pickup_to.strftime('%H:%M:00'),
464
+ shipperContactEmail: '',
465
+ shipperContactName: shipment.origin.contact.name || 'Shipping',
466
+ shipperContactPhone: shipper_phone || '',
467
+ shipperCountry: shipment.origin.country.code(:alpha2).value,
468
+ shipperLocationName: shipment.origin.contact.name,
469
+ shipperOpenTime: pickup_from.strftime('%H:%M:00'),
470
+ shipperState: shipment.origin.province,
471
+ shipperZipCode: shipment.origin.postal_code.to_s
472
+ },
473
+ orderDetails: {
474
+ airbillNumber: '00000000',
475
+ billToCustomerNumber: api_credentials.account&.to_s || '',
476
+ customerReferenceNumber: shipment.po_number,
477
+ declaredValue: declared_value,
478
+ description:,
479
+ destinationAirportCode: '',
480
+ guaranteedService: 'N',
481
+ hazmat: shipment.hazmat? ? 'Y' : 'N',
482
+ inBondShipment: declared_value.to_f.positive? ? 'Y' : 'N',
483
+ orderAction: 'CREATE',
484
+ originAirportCode: '',
485
+ shippingDate: pickup_from.strftime('%Y-%m-%d'),
486
+ shipperCustomerNumber: api_credentials.account&.to_s || '',
487
+ specialInstructions: '',
488
+ dimensions: {
489
+ dimension: shipment.packages.map do |package|
490
+ {
491
+ height: package.inches(:height).ceil.to_s,
492
+ length: package.inches(:length).ceil.to_s,
493
+ width: package.inches(:width).ceil.to_s,
494
+ pieces: package.quantity.to_s
495
+ }
496
+ end
497
+ },
498
+ freightDetails: { freightDetail: build_freight_details(shipment.packages) },
499
+ delivery:,
500
+ emergencyContact: {
501
+ email: dispatcher.email,
502
+ name: dispatcher.name,
503
+ phone: dispatcher_phone
504
+ },
505
+ pickup: {
506
+ airportPickup: pickup_accessorials&.include?('ALP') ? 'Y' : 'N',
507
+ pickupAccessorials: { pickupAccessorial: pickup_accessorials },
508
+ pickupReadyTime: pickup_from.strftime('%H:%M:00')
509
+ },
510
+ referenceNumbers: {
511
+ referenceNumber: [
512
+ shipment.order_number,
513
+ shipment.po_number,
514
+ '',
515
+ ]
516
+ }
517
+ }
518
+ }.to_json
519
+ }
520
+
521
+ save_request(request)
522
+ request
523
+ end
524
+
525
+ def parse_pickup_response(response)
526
+ pickup_response = PickupResponse.new(request: last_request, response:)
527
+ pickup_number = response['airbillNumber']
528
+
529
+ if pickup_number.blank?
530
+ pickup_response.error = FreightKit::ResponseError.new('Unknown response')
531
+ return pickup_response
532
+ end
533
+
534
+ pickup_response.pickup_number = pickup_number
535
+ pickup_response
536
+ end
537
+
538
+ # Rates
539
+
540
+ def build_rate_request(shipment:)
541
+ api_credentials = fetch_credential(:api)
542
+
543
+ freight_details = build_freight_details(shipment.packages)
544
+ dimensions = build_dimensions(shipment.packages)
545
+ declared_value = if shipment.declared_value_cents.blank?
546
+ '0'
547
+ else
548
+ format('%.2f', (shipment.declared_value_cents.to_f / 100).ceil)
549
+ end
550
+ pickup_accessorials, delivery_accessorials = build_accessorials(shipment.accessorials)
551
+
552
+ delivery = {
553
+ airportDelivery: delivery_accessorials&.include?('ALD') ? 'Y' : 'N',
554
+ deliveryAccessorials: { deliveryAccessorial: delivery_accessorials }
555
+ }
556
+
557
+ request = {
558
+ url: build_url(:rates),
559
+ headers: build_headers,
560
+ method: @conf.dig(:api, :methods, :rates),
561
+ body: {
562
+ billToCustomerNumber: api_credentials.account,
563
+ origin: {
564
+ originZipCode: shipment.origin.postal_code.to_s.upcase,
565
+ pickup: {
566
+ airportPickup: pickup_accessorials&.include?('ALP') ? 'Y' : 'N',
567
+ pickupAccessorials: { pickupAccessorial: pickup_accessorials }
568
+ }
569
+ },
570
+ destination: {
571
+ destinationZipCode: shipment.destination.postal_code.to_s.upcase,
572
+ delivery:
573
+ },
574
+ dimensions: { dimension: dimensions },
575
+ freightDetails: { freightDetail: freight_details },
576
+ hazmat: shipment.hazmat? ? 'Y' : 'N',
577
+ inBondShipment: 'N',
578
+ declaredValue: declared_value,
579
+ shippingDate: Date.current.strftime('%Y-%m-%d')
580
+ }.to_json
581
+ }
582
+
583
+ save_request(request)
584
+ request
585
+ end
586
+
587
+ def parse_rate_response(shipment:, response:)
588
+ rate_response = RateResponse.new(request: last_request, response:)
589
+
590
+ if response.blank?
591
+ rate_response.error = ResponseError.new('API Error: Unknown response')
592
+ return rate_response
593
+ end
594
+
595
+ error = response.key?('errorMessage')
596
+
597
+ if error.present?
598
+ rate_response.error = ResponseError.new(error)
599
+ return rate_response
600
+ end
601
+
602
+ if response['quoteTotal'].blank?
603
+ rate_response.error = ResponseError.new('Cost is blank')
604
+ return rate_response
605
+ end
606
+
607
+ transit_days = response['transitDaysTotal']
608
+
609
+ charge_line_items = response.dig('chargeLineItems', 'chargeLineItem')
610
+ prices = []
611
+
612
+ charge_line_items.each do |charge_line_item|
613
+ cents = (charge_line_item['amount'] * 100).to_i
614
+ next if cents.zero?
615
+
616
+ description = charge_line_item_description(charge_line_item)
617
+
618
+ prices << Price.new(blame: :api, cents:, description:)
619
+ end
620
+
621
+ rate = Rate.new(
622
+ carrier: self,
623
+ carrier_name: self.class.name,
624
+ currency: 'USD',
625
+ scac: self.class.scac.upcase,
626
+ service_name: :standard,
627
+ shipment:,
628
+ prices:,
629
+ transit_days:,
630
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
631
+ )
632
+
633
+ rate_response.rates = [rate]
634
+ rate_response
635
+ end
636
+
637
+ def charge_line_item_description(charge_line_item)
638
+ description = charge_line_item['description'] || ''
639
+ description = description.gsub('-', '')
640
+ description = description.capitalize
641
+
642
+ code = charge_line_item['code']&.upcase || ''
643
+ description = "#{description} (#{code})" if code.present?
644
+ description = description.gsub('Fsc', 'FSC') if description.include?('Fsc')
645
+
646
+ description.squish
647
+ end
648
+
649
+ # Tracking
650
+
651
+ def build_tracking_request(tracking_number)
652
+ request = {
653
+ url: build_url(:track, tracking_number:),
654
+ headers: build_headers,
655
+ method: @conf.dig(:api, :methods, :track),
656
+ body: {
657
+ billToCustomerNumber: fetch_credential(:api).account,
658
+ referenceNumber: tracking_number.to_s
659
+ }.to_json
660
+ }
661
+
662
+ save_request(request)
663
+ request
664
+ end
665
+
666
+ def parse_api_date_time(date_time, location)
667
+ return if date_time.blank?
668
+
669
+ local_date_time = ::Time.strptime(date_time, '%m/%d/%y %H:%M').to_fs(:db)
670
+ ::FreightKit::DateTime.new(local_date_time:, location:)
671
+ end
672
+
673
+ def parse_tracking_response(response)
674
+ tracking_response = TrackingResponse.new(carrier: self, request: last_request, response:)
675
+
676
+ actual_delivery_date = nil
677
+ estimated_delivery_date = nil
678
+ receiver_location = nil
679
+ scheduled_delivery_date = nil
680
+ ship_time = nil
681
+ shipper_location = nil
682
+
683
+ shipment_events = []
684
+
685
+ api_events = response
686
+ api_events.each do |api_event|
687
+ event = nil
688
+ @conf.dig(:events, :types).each do |key, val|
689
+ if api_event['statusCode'].upcase == val
690
+ event = key
691
+ break
692
+ end
693
+ end
694
+ next if event.blank?
695
+
696
+ location = Location.new(
697
+ city: api_event['city'].titleize,
698
+ province: api_event['state'].upcase,
699
+ postal_code: api_event['zip'].upcase,
700
+ country: ActiveUtils::Country.find(api_event['country']),
701
+ )
702
+
703
+ date_time = parse_api_date_time(api_event['recordDate'], location)
704
+
705
+ api_estimated_delivery_date = api_event['estimatedArrivalDate']
706
+ estimated_delivery_date = parse_api_date_time(api_estimated_delivery_date, nil)
707
+
708
+ case event
709
+ when :delivered
710
+ actual_delivery_date = date_time
711
+ receiver_location = location
712
+ when :delivery_appointment_scheduled
713
+ api_date_time = api_event['scheduledDeliveryFromDate']
714
+ scheduled_delivery_date = parse_api_date_time(api_date_time, location)
715
+ when :picked_up
716
+ ship_time = date_time
717
+ shipper_location = location
718
+ end
719
+
720
+ shipment_events << ShipmentEvent.new(date_time:, location:, type_code: event)
721
+ end
722
+
723
+ estimated_delivery_date = scheduled_delivery_date if scheduled_delivery_date.present?
724
+
725
+ status = shipment_events.last&.type_code
726
+
727
+ tracking_number = api_events.last['airbillNumber']
728
+
729
+ tracking_response.assign_attributes(
730
+ actual_delivery_date:,
731
+ destination: receiver_location,
732
+ estimated_delivery_date:,
733
+ origin: shipper_location,
734
+ scheduled_delivery_date:,
735
+ ship_time:,
736
+ shipment_events:,
737
+ status:,
738
+ tracking_number:,
739
+ )
740
+
741
+ tracking_response
742
+ end
743
+ end
744
+ end