reactive_freight 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +5 -0
- data/MIT-LICENSE +20 -0
- data/README.md +166 -0
- data/Rakefile +8 -0
- data/accessorial_symbols.txt +95 -0
- data/lib/reactive_freight.rb +21 -0
- data/lib/reactive_freight/carrier.rb +62 -0
- data/lib/reactive_freight/carriers.rb +18 -0
- data/lib/reactive_freight/carriers/btvp.rb +384 -0
- data/lib/reactive_freight/carriers/clni.rb +59 -0
- data/lib/reactive_freight/carriers/ctbv.rb +35 -0
- data/lib/reactive_freight/carriers/dphe.rb +296 -0
- data/lib/reactive_freight/carriers/drrq.rb +303 -0
- data/lib/reactive_freight/carriers/fcsy.rb +24 -0
- data/lib/reactive_freight/carriers/fwda.rb +243 -0
- data/lib/reactive_freight/carriers/jfj_transportation.rb +11 -0
- data/lib/reactive_freight/carriers/pens.rb +135 -0
- data/lib/reactive_freight/carriers/rdfs.rb +320 -0
- data/lib/reactive_freight/carriers/saia.rb +336 -0
- data/lib/reactive_freight/carriers/sefl.rb +234 -0
- data/lib/reactive_freight/carriers/totl.rb +96 -0
- data/lib/reactive_freight/carriers/wrds.rb +218 -0
- data/lib/reactive_freight/configuration/carriers/btvp.yml +139 -0
- data/lib/reactive_freight/configuration/carriers/clni.yml +107 -0
- data/lib/reactive_freight/configuration/carriers/ctbv.yml +117 -0
- data/lib/reactive_freight/configuration/carriers/dphe.yml +124 -0
- data/lib/reactive_freight/configuration/carriers/drrq.yml +115 -0
- data/lib/reactive_freight/configuration/carriers/fcsy.yml +104 -0
- data/lib/reactive_freight/configuration/carriers/fwda.yml +117 -0
- data/lib/reactive_freight/configuration/carriers/jfj_transportation.yml +2 -0
- data/lib/reactive_freight/configuration/carriers/pens.yml +22 -0
- data/lib/reactive_freight/configuration/carriers/rdfs.yml +135 -0
- data/lib/reactive_freight/configuration/carriers/saia.yml +117 -0
- data/lib/reactive_freight/configuration/carriers/sefl.yml +115 -0
- data/lib/reactive_freight/configuration/carriers/totl.yml +107 -0
- data/lib/reactive_freight/configuration/carriers/wrds.yml +19 -0
- data/lib/reactive_freight/configuration/platforms/carrier_logistics.yml +25 -0
- data/lib/reactive_freight/configuration/platforms/liftoff.yml +12 -0
- data/lib/reactive_freight/package.rb +137 -0
- data/lib/reactive_freight/platform.rb +36 -0
- data/lib/reactive_freight/platforms.rb +4 -0
- data/lib/reactive_freight/platforms/carrier_logistics.rb +317 -0
- data/lib/reactive_freight/platforms/liftoff.rb +102 -0
- data/lib/reactive_freight/rate_estimate.rb +113 -0
- data/lib/reactive_freight/shipment_event.rb +10 -0
- data/reactive_freight.gemspec +39 -0
- data/service_type_symbols.txt +4 -0
- metadata +198 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReactiveShipping
|
4
|
+
class FCSY < CarrierLogistics
|
5
|
+
REACTIVE_FREIGHT_CARRIER = true
|
6
|
+
|
7
|
+
cattr_reader :name, :scac
|
8
|
+
@@name = 'Frontline Freight'
|
9
|
+
@@scac = 'FCSY'
|
10
|
+
|
11
|
+
# Documents
|
12
|
+
|
13
|
+
# Rates
|
14
|
+
def build_calculated_accessorials(*); end
|
15
|
+
|
16
|
+
# Tracking
|
17
|
+
|
18
|
+
# protected
|
19
|
+
|
20
|
+
# Documents
|
21
|
+
|
22
|
+
# Rates
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,243 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReactiveShipping
|
4
|
+
class FWDA < ReactiveShipping::Carrier
|
5
|
+
REACTIVE_FREIGHT_CARRIER = true
|
6
|
+
|
7
|
+
cattr_reader :name, :scac
|
8
|
+
@@name = 'Forward Air'
|
9
|
+
@@scac = 'FWDA'
|
10
|
+
|
11
|
+
JSON_HEADERS = {
|
12
|
+
'Accept': 'application/json',
|
13
|
+
'charset': 'utf-8',
|
14
|
+
'Content-Type' => 'application/json'
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# Override Carrier#serviceable_accessorials? since we have separate delivery/pickup accessorials
|
18
|
+
def serviceable_accessorials?(accessorials)
|
19
|
+
return true if accessorials.blank?
|
20
|
+
|
21
|
+
if !self.class::REACTIVE_FREIGHT_CARRIER ||
|
22
|
+
!@conf.dig(:accessorials, :mappable) ||
|
23
|
+
!@conf.dig(:accessorials, :unquotable) ||
|
24
|
+
!@conf.dig(:accessorials, :unserviceable)
|
25
|
+
raise NotImplementedError, "#{self.class.name}: #serviceable_accessorials? not supported"
|
26
|
+
end
|
27
|
+
|
28
|
+
serviceable_accessorials = @conf.dig(:accessorials, :mappable, :delivery).keys +
|
29
|
+
@conf.dig(:accessorials, :mappable, :pickup).keys +
|
30
|
+
@conf.dig(:accessorials, :unquotable)
|
31
|
+
serviceable_count = (serviceable_accessorials & accessorials).size
|
32
|
+
|
33
|
+
unserviceable_accessorials = @conf.dig(:accessorials, :unserviceable)
|
34
|
+
unserviceable_count = (unserviceable_accessorials & accessorials).size
|
35
|
+
|
36
|
+
if serviceable_count != accessorials.size || !unserviceable_count.zero?
|
37
|
+
raise ArgumentError, "#{self.class.name}: Some accessorials unserviceable"
|
38
|
+
end
|
39
|
+
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
# Documents
|
44
|
+
|
45
|
+
# Rates
|
46
|
+
def find_rates(origin, destination, packages, options = {})
|
47
|
+
options = @options.merge(options)
|
48
|
+
origin = Location.from(origin)
|
49
|
+
destination = Location.from(destination)
|
50
|
+
packages = Array(packages)
|
51
|
+
|
52
|
+
request = build_rate_request(origin, destination, packages, options)
|
53
|
+
parse_rate_response(origin, destination, commit(request))
|
54
|
+
end
|
55
|
+
|
56
|
+
# Tracking
|
57
|
+
|
58
|
+
protected
|
59
|
+
|
60
|
+
def build_url(action, options = {})
|
61
|
+
options = @options.merge(options)
|
62
|
+
"#{base_url}#{@conf.dig(:api, :endpoints, action)}"
|
63
|
+
end
|
64
|
+
|
65
|
+
def base_url
|
66
|
+
"https://#{@conf.dig(:api, :domain)}"
|
67
|
+
end
|
68
|
+
|
69
|
+
def build_headers(options = {})
|
70
|
+
options = @options.merge(options)
|
71
|
+
if !options[:username].blank? && !options[:password].blank? && !options[:account].blank?
|
72
|
+
return JSON_HEADERS.merge(
|
73
|
+
'user': options[:username],
|
74
|
+
'password': options[:password],
|
75
|
+
'customerId': options[:account]
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
JSON_HEADERS
|
80
|
+
end
|
81
|
+
|
82
|
+
def build_request(action, options = {})
|
83
|
+
options = @options.merge(options)
|
84
|
+
headers = JSON_HEADERS
|
85
|
+
headers = headers.merge(options[:headers]) unless options[:headers].blank?
|
86
|
+
body = options[:body].to_json unless options[:body].blank?
|
87
|
+
|
88
|
+
request = {
|
89
|
+
url: build_url(action, options),
|
90
|
+
headers: headers,
|
91
|
+
method: @conf.dig(:api, :methods, action),
|
92
|
+
body: body
|
93
|
+
}
|
94
|
+
|
95
|
+
save_request(request)
|
96
|
+
request
|
97
|
+
end
|
98
|
+
|
99
|
+
def commit(request)
|
100
|
+
url = request[:url]
|
101
|
+
headers = request[:headers]
|
102
|
+
method = request[:method]
|
103
|
+
body = request[:body]
|
104
|
+
|
105
|
+
response = case method
|
106
|
+
when :post
|
107
|
+
HTTParty.post(url, headers: headers, body: body)
|
108
|
+
else
|
109
|
+
HTTParty.get(url, headers: headers)
|
110
|
+
end
|
111
|
+
|
112
|
+
JSON.parse(response.body)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Documents
|
116
|
+
|
117
|
+
# Rates
|
118
|
+
def build_rate_request(origin, destination, packages, options = {})
|
119
|
+
options = @options.merge(options)
|
120
|
+
|
121
|
+
delivery_accessorials = []
|
122
|
+
pickup_accessorials = []
|
123
|
+
unless options[:accessorials].blank?
|
124
|
+
serviceable_accessorials?(options[:accessorials])
|
125
|
+
options[:accessorials].each do |a|
|
126
|
+
unless @conf.dig(:accessorials, :unserviceable).include?(a)
|
127
|
+
if @conf.dig(:accessorials, :mappable, :pickup).include?(a)
|
128
|
+
pickup_accessorials << @conf.dig(:accessorials, :mappable, :pickup)[a]
|
129
|
+
elsif delivery_accessorials << @conf.dig(:accessorials, :mappable, :delivery)[a]
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
unless delivery_accessorials.blank?
|
136
|
+
# Remove duplicate delivery appointment accessorial when residential delivery (included with RDE)
|
137
|
+
delivery_accessorials -= ['ADE'] if delivery_accessorials.include?('RDE')
|
138
|
+
end
|
139
|
+
|
140
|
+
unless pickup_accessorials.blank?
|
141
|
+
# Remove duplicate pickup appointment accessorial when residential pickup (included with RPU)
|
142
|
+
pickup_accessorials -= ['APP'] if pickup_accessorials.include?('RPU')
|
143
|
+
end
|
144
|
+
|
145
|
+
delivery_accessorials = delivery_accessorials.uniq
|
146
|
+
pickup_accessorials = pickup_accessorials.uniq
|
147
|
+
|
148
|
+
# API doesn't like empty arrays
|
149
|
+
delivery_accessorials = nil if delivery_accessorials.blank?
|
150
|
+
pickup_accessorials = nil if pickup_accessorials.blank?
|
151
|
+
|
152
|
+
freight_details = []
|
153
|
+
packages.each do |package|
|
154
|
+
freight_details << {
|
155
|
+
description: 'Freight',
|
156
|
+
freightClass: package.freight_class.to_s,
|
157
|
+
pieces: '1',
|
158
|
+
weightType: 'L',
|
159
|
+
weight: package.pounds.ceil.to_s
|
160
|
+
}
|
161
|
+
end
|
162
|
+
|
163
|
+
request = {
|
164
|
+
url: build_url(:rates, options),
|
165
|
+
headers: build_headers(options),
|
166
|
+
method: @conf.dig(:api, :methods, :rates),
|
167
|
+
body: {
|
168
|
+
billToCustomerNumber: options[:account],
|
169
|
+
origin: {
|
170
|
+
originZipCode: origin.to_hash[:postal_code].to_s.upcase,
|
171
|
+
pickup: {
|
172
|
+
airportPickup: pickup_accessorials&.include?('ALP') ? 'Y' : 'N',
|
173
|
+
pickupAccessorials: { pickupAccessorial: pickup_accessorials }
|
174
|
+
}
|
175
|
+
},
|
176
|
+
destination: {
|
177
|
+
destinationZipCode: destination.to_hash[:postal_code].to_s.upcase,
|
178
|
+
delivery: {
|
179
|
+
airportDelivery: delivery_accessorials&.include?('ALD') ? 'Y' : 'N',
|
180
|
+
deliveryAccessorials: { deliveryAccessorial: delivery_accessorials }
|
181
|
+
}
|
182
|
+
},
|
183
|
+
freightDetails: { freightDetail: freight_details },
|
184
|
+
hazmat: 'N',
|
185
|
+
inBondShipment: 'N',
|
186
|
+
declaredValue: '0.00',
|
187
|
+
shipmentDate: Date.current.strftime('%Y-%m-%d')
|
188
|
+
}.to_json
|
189
|
+
}
|
190
|
+
|
191
|
+
save_request(request)
|
192
|
+
request
|
193
|
+
end
|
194
|
+
|
195
|
+
def parse_rate_response(origin, destination, response)
|
196
|
+
success = true
|
197
|
+
message = ''
|
198
|
+
|
199
|
+
if !response
|
200
|
+
success = false
|
201
|
+
message = 'API Error: Unknown response'
|
202
|
+
elsif response.key?('errorMessage')
|
203
|
+
success = false
|
204
|
+
message = response.dig('errorMessage')
|
205
|
+
else
|
206
|
+
cost = response.dig('quoteTotal')
|
207
|
+
if cost
|
208
|
+
cost = (cost.to_f * 100).to_i
|
209
|
+
transit_days = response.dig('transitDaysTotal')
|
210
|
+
|
211
|
+
rate_estimates = [
|
212
|
+
RateEstimate.new(
|
213
|
+
origin,
|
214
|
+
destination,
|
215
|
+
self.class,
|
216
|
+
:standard,
|
217
|
+
transit_days: transit_days,
|
218
|
+
estimate_reference: nil,
|
219
|
+
total_cost: cost,
|
220
|
+
total_price: cost,
|
221
|
+
currency: 'USD',
|
222
|
+
with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
|
223
|
+
)
|
224
|
+
]
|
225
|
+
else
|
226
|
+
success = false
|
227
|
+
message = 'API Error: Cost is emtpy'
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
RateResponse.new(
|
232
|
+
success,
|
233
|
+
message,
|
234
|
+
response.to_hash,
|
235
|
+
rates: rate_estimates,
|
236
|
+
response: response,
|
237
|
+
request: last_request
|
238
|
+
)
|
239
|
+
end
|
240
|
+
|
241
|
+
# Tracking
|
242
|
+
end
|
243
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReactiveShipping
|
4
|
+
class PENS < ReactiveShipping::Carrier
|
5
|
+
REACTIVE_FREIGHT_CARRIER = true
|
6
|
+
|
7
|
+
cattr_reader :name, :scac
|
8
|
+
@@name = 'Peninsula Truck Lines'
|
9
|
+
@@scac = 'PENS'
|
10
|
+
|
11
|
+
def requirements
|
12
|
+
%i[username password account]
|
13
|
+
end
|
14
|
+
|
15
|
+
# Documents
|
16
|
+
|
17
|
+
# Rates
|
18
|
+
def find_rates(origin, destination, packages, options = {})
|
19
|
+
options = @options.merge(options)
|
20
|
+
origin = Location.from(origin)
|
21
|
+
destination = Location.from(destination)
|
22
|
+
packages = Array(packages)
|
23
|
+
|
24
|
+
request = build_rate_request(origin, destination, packages, options)
|
25
|
+
parse_rate_response(origin, destination, commit_soap(:rates, request))
|
26
|
+
end
|
27
|
+
|
28
|
+
# Tracking
|
29
|
+
|
30
|
+
protected
|
31
|
+
|
32
|
+
def commit_soap(action, request)
|
33
|
+
Savon.client(
|
34
|
+
wsdl: request_url(action),
|
35
|
+
convert_request_keys_to: :lower_camelcase,
|
36
|
+
env_namespace: :soap,
|
37
|
+
element_form_default: :qualified
|
38
|
+
).call(
|
39
|
+
@conf.dig(:api, :actions, action),
|
40
|
+
message: request
|
41
|
+
).body.to_json
|
42
|
+
end
|
43
|
+
|
44
|
+
def request_url(action)
|
45
|
+
scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
|
46
|
+
"#{scheme}#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
|
47
|
+
end
|
48
|
+
|
49
|
+
# Documents
|
50
|
+
|
51
|
+
# Rates
|
52
|
+
def build_rate_request(origin, destination, packages, options = {})
|
53
|
+
options = @options.merge(options)
|
54
|
+
|
55
|
+
request = {
|
56
|
+
user_id: @options[:username],
|
57
|
+
password: @options[:password],
|
58
|
+
account: @options[:account],
|
59
|
+
customer_type: @options[:customer_type].blank? ? 'B' : @options[:customer_type],
|
60
|
+
origin_zip: origin.to_hash[:postal_code].to_s,
|
61
|
+
destination_zip: destination.to_hash[:postal_code].to_s,
|
62
|
+
accessorial_list: '', # TODO: Fix this!
|
63
|
+
class_list: packages.map(&:freight_class).join(','),
|
64
|
+
weight_list: packages.map(&:lbs).inject([]) { |weights, lbs| weights << lbs.ceil }.join(','),
|
65
|
+
none_palletized_mode: 'N',
|
66
|
+
plt_count_list: Array.new(packages.size, 1).join(','),
|
67
|
+
plt_length_list: packages.map(&:inches).inject([]) { |lengths, inches| lengths << length(:in).ceil }.join(','),
|
68
|
+
plt_total_weight: packages.map(&:lbs).inject(0) { |sum, lbs| sum += lbs }.ceil,
|
69
|
+
plt_width_list: packages.map(&:inches).inject([]) { |lengths, inches| lengths << width(:in).ceil }.join(',')
|
70
|
+
}
|
71
|
+
|
72
|
+
save_request(request)
|
73
|
+
request
|
74
|
+
end
|
75
|
+
|
76
|
+
def parse_rate_response(origin, destination, response)
|
77
|
+
success = true
|
78
|
+
message = ''
|
79
|
+
|
80
|
+
if !response
|
81
|
+
success = false
|
82
|
+
message = 'API Error: Unknown response'
|
83
|
+
else
|
84
|
+
response = JSON.parse(response)
|
85
|
+
error = response.dig('create_pens_rate_quote_response', 'create_pens_rate_quote_result', 'errors', 'message')
|
86
|
+
if !error.blank?
|
87
|
+
success = false
|
88
|
+
message = error
|
89
|
+
else
|
90
|
+
result = response.dig('create_pens_rate_quote_response', 'create_pens_rate_quote_result')
|
91
|
+
|
92
|
+
service_type = :standard
|
93
|
+
api_service_type = result.dig('quote', 'transit_type')
|
94
|
+
@conf.dig(:services, :mappable).each do |key, val|
|
95
|
+
service_type = key if api_service_type.downcase.include?(val)
|
96
|
+
end
|
97
|
+
|
98
|
+
cost = result.dig('quote', 'gross_charge').sub(',', '').sub('.', '').to_i
|
99
|
+
transit_days = service_type == :next_day_ltl ? 1 : nil # TODO: Detect correctly
|
100
|
+
estimate_reference = result.dig('quote', 'quote_number')
|
101
|
+
if cost
|
102
|
+
rate_estimates = [
|
103
|
+
RateEstimate.new(
|
104
|
+
origin,
|
105
|
+
destination,
|
106
|
+
{ scac: self.class.scac.upcase, name: self.class.name },
|
107
|
+
service_type,
|
108
|
+
transit_days: transit_days,
|
109
|
+
estimate_reference: estimate_reference,
|
110
|
+
total_cost: cost,
|
111
|
+
total_price: cost,
|
112
|
+
currency: 'USD',
|
113
|
+
with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
|
114
|
+
)
|
115
|
+
]
|
116
|
+
else
|
117
|
+
success = false
|
118
|
+
message = 'API Error: Cost is emtpy'
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
RateResponse.new(
|
124
|
+
success,
|
125
|
+
message,
|
126
|
+
response.to_hash,
|
127
|
+
rates: rate_estimates,
|
128
|
+
response: response,
|
129
|
+
request: last_request
|
130
|
+
)
|
131
|
+
end
|
132
|
+
|
133
|
+
# Tracking
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,320 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReactiveShipping
|
4
|
+
class RDFS < ReactiveShipping::Carrier
|
5
|
+
REACTIVE_FREIGHT_CARRIER = true
|
6
|
+
|
7
|
+
cattr_reader :name, :scac
|
8
|
+
@@name = 'Roadrunner Transportation Services'
|
9
|
+
@@scac = 'RRDS'
|
10
|
+
|
11
|
+
def requirements
|
12
|
+
%i[username password account]
|
13
|
+
end
|
14
|
+
|
15
|
+
# Documents
|
16
|
+
def find_bol(tracking_number, options = {})
|
17
|
+
options = @options.merge(options)
|
18
|
+
parse_document_response(:bol, tracking_number, options)
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_pod(tracking_number, options = {})
|
22
|
+
options = @options.merge(options)
|
23
|
+
parse_document_response(:pod, tracking_number, options)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Rates
|
27
|
+
def find_rates(origin, destination, packages, options = {})
|
28
|
+
options = @options.merge(options)
|
29
|
+
origin = Location.from(origin)
|
30
|
+
destination = Location.from(destination)
|
31
|
+
packages = Array(packages)
|
32
|
+
|
33
|
+
request = build_rate_request(origin, destination, packages, options)
|
34
|
+
parse_rate_response(origin, destination, commit_soap(:rates, request))
|
35
|
+
end
|
36
|
+
|
37
|
+
# Tracking
|
38
|
+
def find_tracking_info(tracking_number)
|
39
|
+
tracking_request = build_tracking_request(tracking_number)
|
40
|
+
parse_tracking_response(tracking_request)
|
41
|
+
end
|
42
|
+
|
43
|
+
protected
|
44
|
+
|
45
|
+
def build_soap_header(action)
|
46
|
+
{
|
47
|
+
authentication_header: {
|
48
|
+
:@xmlns => @conf.dig(:api, :soap, :namespaces, action),
|
49
|
+
:user_name => @options[:username],
|
50
|
+
:password => @options[:password]
|
51
|
+
}
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
def commit_soap(action, request)
|
56
|
+
Savon.client(
|
57
|
+
wsdl: request_url(action),
|
58
|
+
convert_request_keys_to: :camelcase,
|
59
|
+
env_namespace: :soap,
|
60
|
+
element_form_default: :qualified
|
61
|
+
).call(
|
62
|
+
@conf.dig(:api, :actions, action),
|
63
|
+
soap_header: build_soap_header(action),
|
64
|
+
message: request
|
65
|
+
).body.to_json
|
66
|
+
end
|
67
|
+
|
68
|
+
def parse_date(date)
|
69
|
+
date ? DateTime.strptime(date, '%Y-%m-%dT%H:%M:%S').to_s(:db) : nil
|
70
|
+
end
|
71
|
+
|
72
|
+
def request_url(action)
|
73
|
+
scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
|
74
|
+
"#{scheme}#{@conf.dig(:api, :domains, action)}#{@conf.dig(:api, :endpoints, action)}"
|
75
|
+
end
|
76
|
+
|
77
|
+
def strip_date(str)
|
78
|
+
str ? str.split(/[A|P]M /)[1] : nil
|
79
|
+
end
|
80
|
+
|
81
|
+
# Documents
|
82
|
+
def parse_document_response(type, tracking_number, options = {})
|
83
|
+
url = request_url(type).sub('%%TRACKING_NUMBER%%', tracking_number.to_s)
|
84
|
+
|
85
|
+
begin
|
86
|
+
doc = Nokogiri::HTML(URI.parse(url).open)
|
87
|
+
rescue OpenURI::HTTPError
|
88
|
+
raise ReactiveShipping::ResponseError, "API Error: #{@@name}: Document not found"
|
89
|
+
end
|
90
|
+
|
91
|
+
data = Base64.decode64(doc.css('img').first['src'].split('data:image/jpg;base64,').last)
|
92
|
+
path = if options[:path].blank?
|
93
|
+
File.join(Dir.tmpdir, "#{@@name} #{tracking_number} #{type.to_s.upcase}.pdf")
|
94
|
+
else
|
95
|
+
options[:path]
|
96
|
+
end
|
97
|
+
|
98
|
+
file = Tempfile.new
|
99
|
+
file.write(data)
|
100
|
+
file = Magick::ImageList.new(file.path)
|
101
|
+
file.write(path)
|
102
|
+
File.exist?(path) ? path : false
|
103
|
+
end
|
104
|
+
|
105
|
+
# Rates
|
106
|
+
def build_rate_request(origin, destination, packages, options = {})
|
107
|
+
options = @options.merge(options)
|
108
|
+
|
109
|
+
service_deliveryoptions = [
|
110
|
+
serviceoptions: { service_code: 'SS' }
|
111
|
+
]
|
112
|
+
|
113
|
+
unless options[:accessorials].blank?
|
114
|
+
serviceable_accessorials?(options[:accessorials])
|
115
|
+
options[:accessorials].each do |a|
|
116
|
+
unless @conf.dig(:accessorials, :unserviceable).include?(a)
|
117
|
+
service_deliveryoptions << { serviceoptions: { service_code: @conf.dig(:accessorials, :mappable)[a] } }
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
|
123
|
+
if longest_dimension > 144
|
124
|
+
service_deliveryoptions << { serviceoptions: { service_code: 'EXL' } }
|
125
|
+
elsif longest_dimension > 96
|
126
|
+
service_deliveryoptions << { serviceoptions: { service_code: 'EXM' } }
|
127
|
+
end
|
128
|
+
|
129
|
+
service_deliveryoptions = service_deliveryoptions.uniq.to_a
|
130
|
+
|
131
|
+
request = {
|
132
|
+
'request' => {
|
133
|
+
origin_zip: origin.to_hash[:postal_code].to_s,
|
134
|
+
destination_zip: destination.to_hash[:postal_code].to_s,
|
135
|
+
shipment_details: {
|
136
|
+
shipment_detail: packages.inject([]) do |arr, package|
|
137
|
+
arr << {
|
138
|
+
'ActualClass' => package.freight_class,
|
139
|
+
'Weight' => package.pounds.ceil
|
140
|
+
}
|
141
|
+
end
|
142
|
+
},
|
143
|
+
service_deliveryoptions: service_deliveryoptions,
|
144
|
+
origin_type: options[:origin_type] || 'B', # O for shipper, I for consignee, B for third party
|
145
|
+
payment_type: options[:payment_type] || 'P', # Prepaid
|
146
|
+
pallet_count: packages.size,
|
147
|
+
# :linear_feet => linear_ft(packages),
|
148
|
+
pieces: packages.size,
|
149
|
+
account: options[:account]
|
150
|
+
}
|
151
|
+
}
|
152
|
+
|
153
|
+
save_request(request)
|
154
|
+
request
|
155
|
+
end
|
156
|
+
|
157
|
+
def parse_rate_response(origin, destination, response)
|
158
|
+
success = true
|
159
|
+
message = ''
|
160
|
+
|
161
|
+
if !response
|
162
|
+
success = false
|
163
|
+
message = 'API Error: Unknown response'
|
164
|
+
else
|
165
|
+
response = JSON.parse(response)
|
166
|
+
if response[:error]
|
167
|
+
success = false
|
168
|
+
message = response[:error]
|
169
|
+
else
|
170
|
+
cost = response.dig('rate_quote_by_account_response', 'rate_quote_by_account_result', 'net_charge')
|
171
|
+
transit_days = response.dig(
|
172
|
+
'rate_quote_by_account_response',
|
173
|
+
'rate_quote_by_account_result',
|
174
|
+
'routing_info',
|
175
|
+
'estimated_transit_days'
|
176
|
+
).to_i
|
177
|
+
estimate_reference = response.dig(
|
178
|
+
'rate_quote_by_account_response',
|
179
|
+
'rate_quote_by_account_result',
|
180
|
+
'quote_number'
|
181
|
+
)
|
182
|
+
if cost
|
183
|
+
rate_estimates = [
|
184
|
+
RateEstimate.new(
|
185
|
+
origin,
|
186
|
+
destination,
|
187
|
+
{ scac: self.class.scac.upcase, name: self.class.name },
|
188
|
+
:standard,
|
189
|
+
transit_days: transit_days,
|
190
|
+
estimate_reference: estimate_reference,
|
191
|
+
total_cost: cost,
|
192
|
+
total_price: cost,
|
193
|
+
currency: 'USD',
|
194
|
+
with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
|
195
|
+
)
|
196
|
+
]
|
197
|
+
else
|
198
|
+
success = false
|
199
|
+
message = 'API Error: Cost is emtpy'
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
RateResponse.new(
|
205
|
+
success,
|
206
|
+
message,
|
207
|
+
response.to_hash,
|
208
|
+
rates: rate_estimates,
|
209
|
+
response: response,
|
210
|
+
request: last_request
|
211
|
+
)
|
212
|
+
end
|
213
|
+
|
214
|
+
# Tracking
|
215
|
+
def build_tracking_request(tracking_number)
|
216
|
+
URI.parse("#{request_url(:track)}/#{tracking_number}").open
|
217
|
+
end
|
218
|
+
|
219
|
+
def parse_location(comment, delimiters)
|
220
|
+
city = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[0].titleize
|
221
|
+
state = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[1].upcase
|
222
|
+
|
223
|
+
Location.new(
|
224
|
+
city: city,
|
225
|
+
province: state,
|
226
|
+
state: state,
|
227
|
+
country: ActiveUtils::Country.find('USA')
|
228
|
+
)
|
229
|
+
end
|
230
|
+
|
231
|
+
def parse_tracking_response(response)
|
232
|
+
json = JSON.parse(response.read)
|
233
|
+
|
234
|
+
if (response.status[0] != '200') || !json.dig('SearchResults')
|
235
|
+
status = json.dig('error') || "API Error: HTTP #{response.status[0]}"
|
236
|
+
return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
|
237
|
+
end
|
238
|
+
|
239
|
+
search_result = json.dig('SearchResults')[0]
|
240
|
+
if search_result.dig('Shipment', 'ProNumber').downcase.include?('not available')
|
241
|
+
status = "API Error: #{@@name} tracking number not found"
|
242
|
+
return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
|
243
|
+
end
|
244
|
+
|
245
|
+
receiver_address = Location.new(
|
246
|
+
city: search_result.dig('Shipment', 'Consignee', 'City').titleize,
|
247
|
+
province: search_result.dig('Shipment', 'Consignee', 'State').upcase,
|
248
|
+
state: search_result.dig('Shipment', 'Consignee', 'State').upcase,
|
249
|
+
country: ActiveUtils::Country.find('USA')
|
250
|
+
)
|
251
|
+
|
252
|
+
shipper_address = Location.new(
|
253
|
+
city: search_result.dig('Shipment', 'Origin', 'City').titleize,
|
254
|
+
province: search_result.dig('Shipment', 'Origin', 'State').upcase,
|
255
|
+
state: search_result.dig('Shipment', 'Origin', 'State').upcase,
|
256
|
+
country: ActiveUtils::Country.find('USA')
|
257
|
+
)
|
258
|
+
|
259
|
+
actual_delivery_date = parse_date(search_result.dig('Shipment', 'DeliveredDateTime'))
|
260
|
+
scheduled_delivery_date = parse_date(search_result.dig('Shipment', 'ApptDateTime'))
|
261
|
+
tracking_number = search_result.dig('Shipment', 'SearchItem')
|
262
|
+
|
263
|
+
last_location = nil
|
264
|
+
shipment_events = []
|
265
|
+
search_result.dig('Shipment', 'Comments').each do |api_event|
|
266
|
+
type_code = api_event.dig('ActivityCode')
|
267
|
+
next if !type_code || type_code == 'ARQ'
|
268
|
+
|
269
|
+
event = @conf.dig(:events, :types).key(type_code)
|
270
|
+
next if event.blank?
|
271
|
+
|
272
|
+
datetime_without_time_zone = parse_date(api_event.dig('StatusDateTime'))
|
273
|
+
comment = strip_date(api_event.dig('StatusComment'))
|
274
|
+
|
275
|
+
case event
|
276
|
+
when :arrived_at_terminal
|
277
|
+
location = parse_location(comment, [' to ', 'in '])
|
278
|
+
when :delivered
|
279
|
+
location = receiver_address
|
280
|
+
when :departed
|
281
|
+
location = parse_location(comment, [' to ', 'from '])
|
282
|
+
when :out_for_delivery
|
283
|
+
location = parse_location(comment, [' to ', 'from '])
|
284
|
+
when :picked_up
|
285
|
+
location = shipper_address
|
286
|
+
when :trailer_closed
|
287
|
+
location = last_location
|
288
|
+
when :trailer_unloaded
|
289
|
+
location = parse_location(comment, [' to ', 'in '])
|
290
|
+
end
|
291
|
+
last_location = location
|
292
|
+
|
293
|
+
# status and type_code set automatically by ActiveFreight based on event
|
294
|
+
shipment_events << ShipmentEvent.new(event, datetime_without_time_zone, location)
|
295
|
+
end
|
296
|
+
|
297
|
+
shipment_events = shipment_events.sort_by(&:time)
|
298
|
+
|
299
|
+
TrackingResponse.new(
|
300
|
+
true,
|
301
|
+
shipment_events.last.status,
|
302
|
+
json,
|
303
|
+
carrier: "#{@@scac}, #{@@name}",
|
304
|
+
json: json,
|
305
|
+
response: response,
|
306
|
+
status: status,
|
307
|
+
type_code: shipment_events.last.status,
|
308
|
+
ship_time: parse_date(search_result.dig('Shipment', 'ProDateTime')),
|
309
|
+
scheduled_delivery_date: scheduled_delivery_date,
|
310
|
+
actual_delivery_date: actual_delivery_date,
|
311
|
+
delivery_signature: nil,
|
312
|
+
shipment_events: shipment_events,
|
313
|
+
shipper_address: shipper_address,
|
314
|
+
origin: shipper_address,
|
315
|
+
destination: receiver_address,
|
316
|
+
tracking_number: tracking_number
|
317
|
+
)
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|