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,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
|