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.
- checksums.yaml +4 -4
- data/Gemfile.lock +96 -43
- data/README.md +28 -6
- data/VERSION +1 -1
- data/configuration/carriers/abfs.yml +124 -0
- data/configuration/carriers/btvp.yml +84 -0
- data/configuration/carriers/ccyq.yml +121 -0
- data/configuration/carriers/clni.yml +113 -0
- data/configuration/carriers/cnwy.yml +113 -0
- data/configuration/carriers/ctbv.yml +117 -0
- data/configuration/carriers/dcha.yml +105 -0
- data/configuration/carriers/dlds.yml +111 -0
- data/configuration/carriers/dphe.yml +130 -0
- data/configuration/carriers/drrq.yml +131 -0
- data/configuration/carriers/fcsy.yml +102 -0
- data/configuration/carriers/fwda.yml +137 -0
- data/configuration/carriers/jfj_transportation.yml +2 -0
- data/configuration/carriers/mtvl.yml +12 -0
- data/configuration/carriers/numk.yml +14 -0
- data/configuration/carriers/otcl.yml +124 -0
- data/configuration/carriers/pens.yml +22 -0
- data/configuration/carriers/rdfs.yml +142 -0
- data/configuration/carriers/saia.yml +129 -0
- data/configuration/carriers/sefl.yml +115 -0
- data/configuration/carriers/totl.yml +111 -0
- data/configuration/carriers/tqyl.yml +28 -0
- data/configuration/carriers/wrds.yml +20 -0
- data/configuration/platforms/carrier_logistics.yml +25 -0
- data/configuration/platforms/next.yml +12 -0
- data/configuration/platforms/the_great_information_factory.yml +122 -0
- data/freight_kit.gemspec +9 -7
- data/lib/freight_kit/api_clients/soap_client.rb +70 -0
- data/lib/freight_kit/api_clients.rb +3 -0
- data/lib/freight_kit/carriers/abfs.rb +421 -0
- data/lib/freight_kit/carriers/btvp.rb +29 -0
- data/lib/freight_kit/carriers/ccyq.rb +317 -0
- data/lib/freight_kit/carriers/clni.rb +396 -0
- data/lib/freight_kit/carriers/cnwy.rb +327 -0
- data/lib/freight_kit/carriers/ctbv.rb +53 -0
- data/lib/freight_kit/carriers/dcha.rb +76 -0
- data/lib/freight_kit/carriers/dlds.rb +49 -0
- data/lib/freight_kit/carriers/dphe.rb +474 -0
- data/lib/freight_kit/carriers/drrq.rb +580 -0
- data/lib/freight_kit/carriers/fcsy.rb +57 -0
- data/lib/freight_kit/carriers/fwda.rb +744 -0
- data/lib/freight_kit/carriers/jfj_transportation.rb +13 -0
- data/lib/freight_kit/carriers/mtvl.rb +34 -0
- data/lib/freight_kit/carriers/numk.rb +58 -0
- data/lib/freight_kit/carriers/otcl.rb +528 -0
- data/lib/freight_kit/carriers/pens.rb +204 -0
- data/lib/freight_kit/carriers/rdfs.rb +521 -0
- data/lib/freight_kit/carriers/saia.rb +438 -0
- data/lib/freight_kit/carriers/sefl.rb +342 -0
- data/lib/freight_kit/carriers/totl.rb +172 -0
- data/lib/freight_kit/carriers/tqyl.rb +339 -0
- data/lib/freight_kit/carriers/wrds.rb +246 -0
- data/lib/freight_kit/carriers.rb +26 -0
- data/lib/freight_kit/helpers/documentable.rb +13 -0
- data/lib/freight_kit/helpers/pickupable.rb +39 -0
- data/lib/freight_kit/helpers/rateable.rb +28 -0
- data/lib/freight_kit/helpers/trackable.rb +25 -0
- data/lib/freight_kit/helpers.rb +6 -0
- data/lib/freight_kit/platforms/carrier_logistics.rb +450 -0
- data/lib/freight_kit/platforms/next.rb +101 -0
- data/lib/freight_kit/platforms/the_great_information_factory.rb +528 -0
- data/lib/freight_kit/platforms.rb +5 -0
- data/lib/freight_kit.rb +20 -1
- 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
|