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,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class CCYQ < FreightKit::Carrier
5
+ class << self
6
+ def minimum_length_for_overlength_fees
7
+ Measured::Length.new(6, :feet)
8
+ end
9
+
10
+ def overlength_fees_require_tariff?
11
+ false
12
+ end
13
+
14
+ def required_credential_types
15
+ %i[api_key api_proxy]
16
+ end
17
+
18
+ def requirements
19
+ %i[credentials]
20
+ end
21
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ class << self
26
+ attr_reader :name, :scac
27
+ end
28
+ @name = 'CrossCountry Freight Solutions'
29
+ @scac = 'CCYQ'
30
+
31
+ JSON_HEADERS = {
32
+ Accept: 'application/json',
33
+ charset: 'utf-8',
34
+ 'Content-Type' => 'application/json'
35
+ }.freeze
36
+
37
+ include FreightKit::Rateable
38
+ include FreightKit::Trackable
39
+ include FreightKit::Documentable
40
+ include FreightKit::Pickupable
41
+
42
+ protected
43
+
44
+ def build_url(action)
45
+ "https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
46
+ end
47
+
48
+ def build_request(action, body: {}, query: {})
49
+ api_key = fetch_credential(:api_key).api_key
50
+ proxy_url = fetch_credential(:api_proxy).proxy_url
51
+
52
+ request = {
53
+ url: build_url(action),
54
+ headers: { APIKEY: api_key },
55
+ method: @conf.dig(:api, :methods, action),
56
+ body:,
57
+ query:,
58
+ proxy_url:
59
+ }.compact
60
+
61
+ save_request(request)
62
+ request
63
+ end
64
+
65
+ def commit(_action, request)
66
+ proxy_uri = URI.parse(request[:proxy_url])
67
+
68
+ response = HTTParty.send(
69
+ request[:method],
70
+ request[:url],
71
+ headers: request[:headers].merge(JSON_HEADERS),
72
+ body: request[:body],
73
+ query: request[:query],
74
+ http_proxyaddr: proxy_uri.host,
75
+ http_proxyport: proxy_uri.port.to_s,
76
+ http_proxyuser: proxy_uri.user,
77
+ http_proxypass: proxy_uri.password,
78
+ debug_output: $stdout,
79
+ )
80
+
81
+ unless response.code == 200
82
+ message = begin
83
+ JSON.parse(response.body)['Message'] || "HTTP #{response.code}"
84
+ rescue JSON::ParserError
85
+ "HTTP #{response.code}"
86
+ end
87
+
88
+ if message == 'API key is inactive'
89
+ raise FreightKit::InvalidCredentialsError, message
90
+ end
91
+
92
+ raise FreightKit::ResponseError, message
93
+ end
94
+
95
+ JSON.parse(response.body)
96
+ end
97
+
98
+ # Documents
99
+
100
+ def parse_document_response(type, tracking_number)
101
+ # Tracking Endpoint returns Images for the Shipment
102
+ request = build_request(:track, query: { ReferenceNum: tracking_number })
103
+ response = commit(type, request)
104
+
105
+ document_response = DocumentResponse.new
106
+
107
+ unless response
108
+ document_response.error = DocumentNotFoundError.new
109
+ return document_response
110
+ end
111
+
112
+ # API response sometimes returns an array
113
+ response = response.first if response.is_a?(Array)
114
+
115
+ unless response
116
+ document_response.error = DocumentNotFoundError.new
117
+ return document_response
118
+ end
119
+
120
+ document = response['Images']&.find { |image| image['DocumentType'] == type.upcase }
121
+
122
+ unless document
123
+ document_response.error = DocumentNotFoundError.new
124
+ return document_response
125
+ end
126
+
127
+ decoded_pdf_data = Base64.decode64(document['Content'])
128
+ document_response.assign_attributes(content_type: 'application/pdf', data: decoded_pdf_data)
129
+
130
+ document_response
131
+ end
132
+
133
+ # Tracking
134
+
135
+ def build_tracking_request(tracking_number)
136
+ build_request(:track, query: { ReferenceNum: tracking_number })
137
+ end
138
+
139
+ def parse_tracking_response(response)
140
+ TrackingResponse.new(carrier: self, request: last_request, response:)
141
+
142
+ # TODO
143
+ end
144
+
145
+ # Pickups
146
+
147
+ def build_pickup_request(
148
+ delivery_from:,
149
+ delivery_to:,
150
+ dispatcher:,
151
+ pickup_from:,
152
+ pickup_to:,
153
+ scac:,
154
+ service:,
155
+ shipment:
156
+ )
157
+
158
+ dispatcher_phone = dispatcher.phone.delete('^0-9')
159
+ receiver_phone = shipment.destination.contact.phone.delete('^0-9')
160
+
161
+ build_request(
162
+ :pickup,
163
+ body: {
164
+ PickupAddress: {
165
+ Name: shipment.origin.contact.company_name || shipment.destination.origin.name,
166
+ Address1: shipment.origin.address1,
167
+ Address2: '',
168
+ City: shipment.origin.city,
169
+ State: shipment.origin.province,
170
+ Zip: shipment.origin.postal_code.to_s,
171
+ Phone: dispatcher_phone.presence || '',
172
+ Contact: shipment.origin.contact.name,
173
+ Country: shipment.origin.country.code(:alpha2).value
174
+ },
175
+ DeliveryAddress: {
176
+ Name: shipment.destination.contact.company_name || shipment.destination.contact.name,
177
+ Address1: shipment.destination.address1,
178
+ Address2: '',
179
+ City: shipment.destination.city,
180
+ State: shipment.destination.province,
181
+ Zip: shipment.destination.postal_code.to_s,
182
+ Phone: receiver_phone || '',
183
+ Contact: shipment.destination.contact.name,
184
+ Country: shipment.destination.country.code(:alpha2).value
185
+ },
186
+ PickupSchedule: {
187
+ After: pickup_from.iso8601,
188
+ Before: pickup_to.iso8601,
189
+ AppointmentRequired: false,
190
+ AppointmentMade: false
191
+ },
192
+ TotalWeight: shipment.packages.sum { |p| p.pounds(:total).ceil },
193
+ TotalUnits: shipment.packages.sum(&:quantity),
194
+ TotalBills: 1,
195
+ # TODO: Update with actual value: TotalBills desc =>
196
+ # Total number of freight bills that will be picked up
197
+ TestFlag: false
198
+ }.to_json,
199
+ )
200
+ end
201
+
202
+ def parse_pickup_response(response)
203
+ pickup_response = PickupResponse.new(request: last_request, response:)
204
+ pickup_number = response['FreightBillNum']
205
+
206
+ if pickup_number.blank?
207
+ pickup_response.error = FreightKit::ResponseError.new('Unknown response')
208
+ return pickup_response
209
+ end
210
+
211
+ pickup_response.pickup_number = pickup_number
212
+ pickup_response
213
+ end
214
+
215
+ # Rates
216
+
217
+ def build_accessorials(shipment:)
218
+ accessorials = []
219
+ serviceable_accessorials?(shipment.accessorials)
220
+
221
+ accessorials << { Code: 'HAZMAT', Factor: 1 } if shipment.hazmat?
222
+
223
+ shipment.packages.each do |package|
224
+ longest_dimension = [package.width(:inches), package.length(:inches)].max.ceil
225
+
226
+ next if longest_dimension < 96
227
+
228
+ package.quantity.times do
229
+ accessorials << { Code: 'EXLEN', Factor: longest_dimension }
230
+ end
231
+ end
232
+
233
+ shipment.accessorials.map do |accessorial|
234
+ next if @conf.dig(:accessorials, :unquotable)&.include?(accessorial)
235
+
236
+ accessorials << { Code: @conf.dig(:accessorials, :mappable, accessorial.to_sym), Factor: 1 }
237
+ end
238
+
239
+ accessorials
240
+ end
241
+
242
+ def build_rate_request(shipment:)
243
+ build_request(
244
+ :rates,
245
+ body: {
246
+ Orig: shipment.origin.postal_code,
247
+ Dest: shipment.destination.postal_code,
248
+ Accessorials: build_accessorials(shipment:),
249
+ Details: shipment.packages.map do |package|
250
+ {
251
+ Height: package.inches(:height).ceil.to_f,
252
+ Length: package.inches(:length).ceil.to_f,
253
+ Width: package.inches(:width).ceil.to_f,
254
+ Units: package.quantity.to_i,
255
+ Class: package.freight_class.to_f,
256
+ Weight: package.pounds(:total).ceil.to_f
257
+ }
258
+ end
259
+ }.to_json,
260
+ )
261
+ end
262
+
263
+ def parse_rate_response(shipment:, response:)
264
+ rate_response = RateResponse.new(request: last_request, response:)
265
+
266
+ if response.blank?
267
+ rate_response.error = ResponseError.new('API Error: Unknown response')
268
+ return rate_response
269
+ end
270
+
271
+ if response['Message'].include?('Quotes between these points are not available')
272
+ rate_response.error = UnserviceableError.new(response['Message'])
273
+ return rate_response
274
+ end
275
+
276
+ if response['TotalCharge'].blank?
277
+ rate_response.error = ResponseError.new('Cost is blank')
278
+ return rate_response
279
+ end
280
+
281
+ estimate_reference = response['QuoteNum']
282
+ expires_at = ::Time.iso8601(response['QuoteExpiryDate'])
283
+
284
+ transit_days = (
285
+ ::Time.iso8601(response['EarliestDeliveryDate']).to_date -
286
+ ::Time.iso8601(response['PickupDate']).to_date
287
+ ).to_i
288
+
289
+ prices = []
290
+
291
+ ['AccessorialCharge', 'FuelCharge', 'HighCostCharge', 'MinCharge'].each do |charge_line_key|
292
+ charge_line = response[charge_line_key]
293
+ next unless charge_line
294
+
295
+ cents = (charge_line.to_f * 100).to_i
296
+ prices << Price.new(blame: :api, cents:, description: charge_line_key)
297
+ end
298
+
299
+ rate = Rate.new(
300
+ carrier: self,
301
+ carrier_name: self.class.name,
302
+ currency: 'USD',
303
+ estimate_reference:,
304
+ expires_at:,
305
+ scac: self.class.scac.upcase,
306
+ service_name: :standard,
307
+ shipment:,
308
+ prices:,
309
+ transit_days:,
310
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
311
+ )
312
+
313
+ rate_response.rates = [rate]
314
+ rate_response
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class CLNI < FreightKit::Carrier
5
+ class << self
6
+ # @note Explicitly set to `false` because though API allows this, it "doesn't quote correctly" per customer
7
+ # service
8
+ def find_rates_with_declared_value?
9
+ false
10
+ end
11
+
12
+ def maximum_height
13
+ Measured::Length.new(105, :inches)
14
+ end
15
+
16
+ def maximum_weight
17
+ Measured::Weight.new(10_000, :pounds)
18
+ end
19
+
20
+ def minimum_length_for_overlength_fees
21
+ Measured::Length.new(8, :feet)
22
+ end
23
+
24
+ def overlength_fees_require_tariff?
25
+ false
26
+ end
27
+
28
+ def required_credential_types
29
+ %i[api website]
30
+ end
31
+
32
+ def requirements
33
+ %i[credentials]
34
+ end
35
+ end
36
+
37
+ REACTIVE_FREIGHT_CARRIER = true
38
+
39
+ include FreightKit::Rateable
40
+
41
+ class << self
42
+ attr_reader :name, :scac
43
+ end
44
+ @name = 'Clear Lane Freight Systems'
45
+ @scac = 'CLNI'
46
+
47
+ # Documents
48
+
49
+ def pod(tracking_number)
50
+ parse_document_response(:pod, tracking_number)
51
+ end
52
+
53
+ def scanned_bol(tracking_number)
54
+ parse_document_response(:bol, tracking_number)
55
+ end
56
+
57
+ # Rates
58
+
59
+ def validate_packages(packages, tariff = nil)
60
+ raise UnserviceableError, 'Must be fewer than 10 items altogether' if packages.sum(&:quantity) > 10
61
+
62
+ super
63
+ end
64
+
65
+ # Tracking
66
+
67
+ protected
68
+
69
+ def commit(action, request)
70
+ client_args = {
71
+ wsdl: build_url(:api, action),
72
+ convert_request_keys_to: :none,
73
+ env_namespace: :soap,
74
+ element_form_default: :qualified
75
+ }
76
+
77
+ call_args = { message: request_blueprint.deep_merge(request) }
78
+
79
+ ::FreightKit::SoapClient.new(
80
+ carrier: self,
81
+ action:,
82
+ client_args:,
83
+ call_args:,
84
+ soap_operation: @conf.dig(:api, :actions, action),
85
+ ).call&.to_hash&.with_indifferent_access
86
+ end
87
+
88
+ def request_blueprint
89
+ api_credentials = fetch_credential(:api)
90
+
91
+ {
92
+ request: {
93
+ Application: 'ThirdParty',
94
+ AccountNumber: api_credentials.account,
95
+ UserID: api_credentials.username,
96
+ Password: api_credentials.password,
97
+ TestMode: 'N'
98
+ }
99
+ }
100
+ end
101
+
102
+ def build_url(api_or_website, action)
103
+ case api_or_website
104
+ when :api
105
+ scheme = @conf.dig(:api, :use_ssl) ? 'https://' : 'http://'
106
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
107
+ when :website
108
+ @conf.dig(:website, action)
109
+ end
110
+ end
111
+
112
+ # Documents
113
+
114
+ def parse_document_response(action, tracking_number)
115
+ document_response = DocumentResponse.new
116
+
117
+ selenoid_credential = fetch_credential(:selenoid)
118
+ website_credentials = fetch_credential(:website)
119
+
120
+ browser = Watir::Browser.new(*selenoid_credential.watir_args)
121
+ browser.goto('https://ssworldtrak.com/WebtrakWTNew/')
122
+
123
+ browser.text_field(name: 'txtUserId').set(website_credentials.username)
124
+ browser.text_field(name: 'txtPass').set(website_credentials.password)
125
+ browser.button(name: 'btnSubmit').click
126
+
127
+ if browser.html.include?('Either UserID or Password are incorrect, please try again.')
128
+ browser.close
129
+
130
+ document_response.error = InvalidCredentialsError.new
131
+ return document_response
132
+ end
133
+
134
+ # Bypass the hover menu
135
+ browser.goto('https://ssworldtrak.com/WebtrakWTNew/Main/Reports/POD.aspx')
136
+
137
+ from = 90.days.ago.strftime('%m%d%Y')
138
+
139
+ browser.text_field(name: 'txtFromDate').wait_until(&:present?).focus
140
+
141
+ # Hack to get around JavaScript messing up our input
142
+ sleep(1)
143
+ from.chars.each do |char|
144
+ browser.text_field(name: 'txtFromDate').append(char)
145
+ end
146
+
147
+ browser.text_field(name: 'txtToDate').click
148
+ browser
149
+ .element(xpath: '/html/body/form/div[3]/div[4]/div[3]/div[2]/div/div/div[3]/div')
150
+ .wait_until(&:present?)
151
+ .click
152
+
153
+ browser.button(name: 'btnSubmit').click
154
+
155
+ browser.text_field(id: 'yadcf-filter--grid-2').set(tracking_number)
156
+ browser.send_keys(:enter)
157
+
158
+ if browser.element(id: 'tdPODName0').wait_until(&:present?).text == 'NO POD' && action == :pod
159
+ browser.window.close
160
+ browser.original_window.use
161
+ browser.goto('https://ssworldtrak.com/WebtrakWTNew/logoff.aspx')
162
+ browser.close
163
+
164
+ document_response.error = DocumentNotFoundError.new
165
+ return document_response
166
+ end
167
+
168
+ browser
169
+ .element(xpath: '/html/body/form/div[3]/div[4]/div[8]/div/table/tbody/tr/td[12]/a')
170
+ .wait_until(&:present?)
171
+ .click
172
+
173
+ browser.switch_window
174
+
175
+ sleep(5)
176
+
177
+ html = browser.element(id: 'DataTables_Table_0').wait_until(&:present?).html
178
+ html = Nokogiri::HTML(html)
179
+ link_id = nil
180
+
181
+ html.css('tbody tr').each do |row|
182
+ next unless row.css('td:nth-child(3)').text == action.to_s.upcase
183
+
184
+ link_id = row.css('td:nth-child(1) a').attr('id').value
185
+ end
186
+
187
+ if link_id.blank?
188
+ browser.window.close
189
+ browser.original_window.use
190
+ browser.goto('https://ssworldtrak.com/WebtrakWTNew/logoff.aspx')
191
+ browser.close
192
+
193
+ document_response.error = DocumentNotFoundError.new
194
+ return document_response
195
+ end
196
+
197
+ browser.element(css: "##{link_id}").click
198
+
199
+ sleep(50) # so Chrome can finish downloading, Selenoid default timeout is 60s
200
+
201
+ download_url = "#{selenoid_credential.selenoid_options[:download_url]}/#{browser.driver.session_id}"
202
+ response = HTTParty.get("#{download_url}/?json")
203
+
204
+ filename = URI.encode_www_form_component(JSON.parse(response.body)&.last)
205
+ url = "#{download_url}/#{filename}"
206
+
207
+ document_response.request = URI.parse(url)
208
+
209
+ begin
210
+ response = HTTParty.get(url)
211
+ rescue StandardError => e
212
+ document_response.error = e
213
+ return document_response
214
+ end
215
+
216
+ browser.window.close
217
+ browser.original_window.use
218
+ browser.goto('https://ssworldtrak.com/WebtrakWTNew/logoff.aspx')
219
+ browser.close
220
+
221
+ unless response.code == 200
222
+ document_response.error = DocumentNotFoundError.new
223
+
224
+ return document_response
225
+ end
226
+
227
+ document_response.assign_attributes(content_type: response.headers['content-type'], data: response.body)
228
+ document_response
229
+ end
230
+
231
+ # Rates
232
+
233
+ def build_commodity_input(packages)
234
+ packages.map do |package|
235
+ {
236
+ CommodityInput: {
237
+ CommodityClass: package.freight_class,
238
+ CommodityHazmat: package.hazmat? ? 'Y' : 'N',
239
+ CommodityHeight: package.height(:in).ceil,
240
+ CommodityLength: package.length(:in).ceil,
241
+ CommodityPieces: package.quantity,
242
+ CommodityPieceType: package.packaging.pallet? ? 'pallet' : 'box',
243
+ CommodityWeight: package.pounds(:total).ceil,
244
+ CommodityWeightPerPiece: package.pounds(:each).ceil,
245
+ CommodityWidth: package.width(:in).ceil
246
+ }
247
+ }
248
+ end
249
+ end
250
+
251
+ def build_rate_request(shipment:)
252
+ accessorial_input = []
253
+ if shipment.accessorials.present?
254
+ serviceable_accessorials?(shipment.accessorials)
255
+ shipment.accessorials.each do |a|
256
+ if @conf.dig(:accessorials, :unserviceable).exclude?(a)
257
+ accessorial_input << { AccessorialInput: { AccessorialCode: @conf.dig(:accessorials, :mappable)[a] } }
258
+ end
259
+ end
260
+ end
261
+
262
+ accessorial_input.uniq!
263
+
264
+ commodity_input = build_commodity_input(shipment.packages)
265
+
266
+ palletized = shipment.packages.all? { |package| package.packaging.pallet? } ? 'Y' : 'N'
267
+
268
+ pickup_from = ::Time.current.beginning_of_day + 14.hours
269
+ pickup_from += 1.day if ::Time.current > pickup_from
270
+ pickup_to = pickup_from + 3.hours
271
+
272
+ api_credentials = fetch_credential(:api)
273
+
274
+ request = {
275
+ RatingParam: {
276
+ AccessorialInput: accessorial_input,
277
+ CommodityInput: commodity_input,
278
+ RatingInput: {
279
+ DeclaredValue: 0,
280
+ DestinationCity: shipment.destination.city,
281
+ DestinationCountry: shipment.destination.country.code(:alpha2).value,
282
+ DestinationState: shipment.destination.province,
283
+ DestinationZip: shipment.destination.postal_code,
284
+ LiabilityType: '',
285
+ OriginCity: shipment.origin.city,
286
+ OriginCountry: shipment.origin.country.code(:alpha2).value,
287
+ OriginState: shipment.origin.province,
288
+ OriginZip: shipment.origin.postal_code,
289
+ Palletized: palletized,
290
+ PickupDate: pickup_from.to_date.strftime('%Y-%m-%d'),
291
+ PickupLocationCloseTime: pickup_to.strftime('%H:%M:00'),
292
+ PickupTime: pickup_from.strftime('%H:%M:00'),
293
+ RequestID: rand(0..999_999).to_s,
294
+ ServiceLevelID: '',
295
+ ShipmentTerms: '',
296
+ Stackable: false,
297
+ WebTrakUserID: api_credentials.username
298
+ }
299
+ }
300
+ }
301
+
302
+ save_request(request)
303
+ request
304
+ end
305
+
306
+ def parse_rate_response(shipment:, response:)
307
+ rate_response = RateResponse.new(request: last_request, response:)
308
+
309
+ if response.blank?
310
+ rate_response.error = ResponseError.new('Blank response')
311
+ return rate_response
312
+ end
313
+
314
+ error = response.dig(:get_rating_response, :get_rating_result, :rating_output, :message)
315
+
316
+ if error.present?
317
+ if error.include?('do not service this lane')
318
+ rate_response.error = UnserviceableError.new(
319
+ 'Incorrect ZIP code or no service available at origin and/or destination',
320
+ )
321
+ return rate_response
322
+ end
323
+
324
+ pretty_error = error.strip.gsub('can not', 'cannot')
325
+
326
+ rate_response.error = ResponseError.new(pretty_error)
327
+ return rate_response
328
+ end
329
+
330
+ result = response.dig(:get_rating_response, :get_rating_result, :rating_output)
331
+
332
+ if result.blank?
333
+ rate_response.error = ResponseError.new('Blank response')
334
+ return rate_response
335
+ end
336
+
337
+ cents = parse_amount(result[:standard_total_rate])
338
+
339
+ if cents.blank?
340
+ rate_response.error = ResponseError.new('Cost is blank')
341
+ return rate_response
342
+ end
343
+
344
+ prices = []
345
+ prices << Price.new(blame: :api, cents:, description: 'Freight')
346
+
347
+ accessorial_outputs = result.dig(:accessorial_output, :accessorial_output)
348
+
349
+ accessorial_outputs.each do |accessorial_output|
350
+ prices << Price.new(
351
+ blame: :api,
352
+ cents: 0,
353
+ description: accessorial_output_description(accessorial_output),
354
+ )
355
+ end
356
+
357
+ transit_days = response[:transit_days].to_i
358
+
359
+ rate = Rate.new(
360
+ carrier: self,
361
+ carrier_name: self.class.name,
362
+ currency: 'USD',
363
+ estimate_reference: nil,
364
+ scac: self.class.scac.upcase,
365
+ service_name: :standard,
366
+ shipment:,
367
+ prices:,
368
+ transit_days:,
369
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
370
+ )
371
+
372
+ rate_response.rates = [rate]
373
+ rate_response
374
+ end
375
+
376
+ def accessorial_output_description(accessorial_output)
377
+ return '' if accessorial_output[:accessorial_desc].blank?
378
+
379
+ description = accessorial_output[:accessorial_desc]
380
+ description = description.capitalize
381
+ description.gsub('Smc', 'SMC')
382
+ end
383
+
384
+ def parse_amount(amount)
385
+ ['$', ','].each do |char|
386
+ amount = amount.sub(char, '')
387
+ end
388
+
389
+ return 0 if amount.blank?
390
+
391
+ amount = (amount.to_f * 100).to_i
392
+ end
393
+
394
+ # Tracking
395
+ end
396
+ end