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,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreightKit
|
|
4
|
+
class MTVL < TheGreatInformationFactory
|
|
5
|
+
class << self
|
|
6
|
+
def maximum_height
|
|
7
|
+
Measured::Length.new(105, :inches)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def maximum_weight
|
|
11
|
+
Measured::Weight.new(10_000, :pounds)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def minimum_length_for_overlength_fees
|
|
15
|
+
Measured::Length.new(8, :feet)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
REACTIVE_FREIGHT_CARRIER = true
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
attr_reader :name, :scac
|
|
23
|
+
end
|
|
24
|
+
@name = 'GLS Freight'
|
|
25
|
+
@scac = 'MTVL'
|
|
26
|
+
|
|
27
|
+
def build_soap_header
|
|
28
|
+
soap_header = super
|
|
29
|
+
soap_header[:password] = soap_header[:password]&.downcase
|
|
30
|
+
|
|
31
|
+
soap_header
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreightKit
|
|
4
|
+
class NUMK < TheGreatInformationFactory
|
|
5
|
+
class << self
|
|
6
|
+
def maximum_height
|
|
7
|
+
Measured::Length.new(105, :inches)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def maximum_weight
|
|
11
|
+
Measured::Weight.new(10_000, :pounds)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def minimum_length_for_overlength_fees
|
|
15
|
+
Measured::Length.new(4, :ft)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def overlength_fees_require_tariff?
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def requirements
|
|
23
|
+
%i[credentials]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
include FreightKit::Documentable
|
|
28
|
+
|
|
29
|
+
REACTIVE_FREIGHT_CARRIER = true
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
attr_reader :name, :scac
|
|
33
|
+
end
|
|
34
|
+
@name = 'Numark Transportation'
|
|
35
|
+
@scac = 'NUMK'
|
|
36
|
+
|
|
37
|
+
def build_calculated_accessorials(packages)
|
|
38
|
+
longest_dimension = packages.map { |package| [package.length(:in), package.width(:in)].max }.max.ceil
|
|
39
|
+
|
|
40
|
+
return ['OVER'] if longest_dimension > 48
|
|
41
|
+
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_soap_header
|
|
46
|
+
soap_header = super
|
|
47
|
+
soap_header[:password] = soap_header[:password]&.downcase
|
|
48
|
+
|
|
49
|
+
soap_header
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
protected
|
|
53
|
+
|
|
54
|
+
def wrap_request(request)
|
|
55
|
+
{ 'args0' => request }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FreightKit
|
|
4
|
+
class OTCL < 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(150, :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_key]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def requirements
|
|
35
|
+
%i[credentials]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
REACTIVE_FREIGHT_CARRIER = true
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
attr_reader :name, :scac
|
|
43
|
+
end
|
|
44
|
+
@name = 'OnTrac'
|
|
45
|
+
@scac = 'OTCL'
|
|
46
|
+
|
|
47
|
+
XML_HEADERS = {
|
|
48
|
+
Accept: 'application/xml',
|
|
49
|
+
charset: 'utf-8',
|
|
50
|
+
'Content-Type': 'application/xml'
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Override Carrier#serviceable_accessorials? since we have separate delivery/pickup accessorials
|
|
54
|
+
def serviceable_accessorials?(accessorials)
|
|
55
|
+
return true if accessorials.blank?
|
|
56
|
+
|
|
57
|
+
if !self.class::REACTIVE_FREIGHT_CARRIER ||
|
|
58
|
+
!@conf.dig(:accessorials, :mappable) ||
|
|
59
|
+
!@conf.dig(:accessorials, :unquotable) ||
|
|
60
|
+
!@conf.dig(:accessorials, :unserviceable)
|
|
61
|
+
raise NotImplementedError, "#{self.class.name}: #serviceable_accessorials? not supported"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
serviceable_accessorials = @conf.dig(:accessorials, :mappable).keys +
|
|
65
|
+
@conf.dig(:accessorials, :unquotable)
|
|
66
|
+
serviceable_count = (serviceable_accessorials & accessorials).size
|
|
67
|
+
|
|
68
|
+
unserviceable_accessorials = @conf.dig(:accessorials, :unserviceable)
|
|
69
|
+
unserviceable_count = (unserviceable_accessorials & accessorials).size
|
|
70
|
+
|
|
71
|
+
if serviceable_count != accessorials.size || !unserviceable_count.zero?
|
|
72
|
+
raise FreightKit::UnserviceableError, "#{self.class.name}: Some accessorials unserviceable"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Documents
|
|
79
|
+
|
|
80
|
+
# Pickups
|
|
81
|
+
|
|
82
|
+
def create_pickup(
|
|
83
|
+
delivery_from:,
|
|
84
|
+
delivery_to:,
|
|
85
|
+
dispatcher:,
|
|
86
|
+
pickup_from:,
|
|
87
|
+
pickup_to:,
|
|
88
|
+
scac:,
|
|
89
|
+
service:,
|
|
90
|
+
shipment:
|
|
91
|
+
)
|
|
92
|
+
request = build_shipment_request(
|
|
93
|
+
delivery_from:,
|
|
94
|
+
delivery_to:,
|
|
95
|
+
dispatcher:,
|
|
96
|
+
pickup_from:,
|
|
97
|
+
pickup_to:,
|
|
98
|
+
scac:,
|
|
99
|
+
service:,
|
|
100
|
+
shipment:,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
labels = parse_shipment_response(commit(request))
|
|
104
|
+
|
|
105
|
+
request = build_pickup_request(
|
|
106
|
+
delivery_from:,
|
|
107
|
+
delivery_to:,
|
|
108
|
+
dispatcher:,
|
|
109
|
+
pickup_from:,
|
|
110
|
+
pickup_to:,
|
|
111
|
+
scac:,
|
|
112
|
+
service:,
|
|
113
|
+
shipment:,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
parse_pickup_response(response: commit(request), labels:)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Rates
|
|
120
|
+
|
|
121
|
+
def find_rates(shipment:)
|
|
122
|
+
begin
|
|
123
|
+
validate_packages(shipment.packages)
|
|
124
|
+
rescue UnserviceableError => e
|
|
125
|
+
return RateResponse.new(error: e)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
request = build_rate_request(shipment:)
|
|
129
|
+
parse_rate_response(shipment:, response: commit(request))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Tracking
|
|
133
|
+
|
|
134
|
+
protected
|
|
135
|
+
|
|
136
|
+
def build_url(action, options = {})
|
|
137
|
+
api_credentials = fetch_credential(:api_key)
|
|
138
|
+
|
|
139
|
+
env = @test_mode ? :test : :production
|
|
140
|
+
|
|
141
|
+
url = "https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, env, action)}"
|
|
142
|
+
url = url.gsub('%ACCOUNT_NUMBER%', api_credentials.account)
|
|
143
|
+
|
|
144
|
+
url += "?pw=#{api_credentials.api_key}"
|
|
145
|
+
url << "&#{options[:params]}" if options[:params].present?
|
|
146
|
+
|
|
147
|
+
url
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_request(action, options = {})
|
|
151
|
+
request = {
|
|
152
|
+
url: build_url(action, options),
|
|
153
|
+
headers: XML_HEADERS,
|
|
154
|
+
method: @conf.dig(:api, :methods, action)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
save_request(request)
|
|
158
|
+
request
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def commit(request)
|
|
162
|
+
body = request[:body]
|
|
163
|
+
headers = request[:headers]
|
|
164
|
+
method = request[:method]
|
|
165
|
+
url = request[:url]
|
|
166
|
+
|
|
167
|
+
response = case method
|
|
168
|
+
when :post
|
|
169
|
+
HTTParty.post(url, headers:, body:, debug_output: $stdout)
|
|
170
|
+
else
|
|
171
|
+
HTTParty.get(url, headers:, debug_output: $stdout)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
response.parsed_response if response&.parsed_response
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def serviceable_states?(states)
|
|
178
|
+
valid_states = ['AZ', 'CA', 'CO', 'ID', 'NV', 'OR', 'UT', 'WA']
|
|
179
|
+
|
|
180
|
+
invalid_states = []
|
|
181
|
+
states.each do |state|
|
|
182
|
+
invalid_states << state if valid_states.exclude?(state)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return true if invalid_states.blank?
|
|
186
|
+
|
|
187
|
+
raise FreightKit::UnserviceableError, "No service to #{invalid_states.join(", ")}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Documents
|
|
191
|
+
|
|
192
|
+
# Pickups
|
|
193
|
+
|
|
194
|
+
def build_pickup_request(
|
|
195
|
+
delivery_from:,
|
|
196
|
+
delivery_to:,
|
|
197
|
+
dispatcher:,
|
|
198
|
+
pickup_from:,
|
|
199
|
+
pickup_to:,
|
|
200
|
+
scac:,
|
|
201
|
+
service:,
|
|
202
|
+
shipment:
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
dispatcher_phone = dispatcher.phone.sub('+1', '').delete('^0-9')
|
|
206
|
+
|
|
207
|
+
raise UnserviceableError, 'Palletized freight unsupported' unless shipment.loose?
|
|
208
|
+
|
|
209
|
+
request_body = {
|
|
210
|
+
Address: shipment.origin.address1,
|
|
211
|
+
City: shipment.origin.city,
|
|
212
|
+
CloseAt: pickup_to.strftime('%H:%M:00'),
|
|
213
|
+
Contact: shipment.origin.contact.name || 'Shipping',
|
|
214
|
+
Date: pickup_from.to_date,
|
|
215
|
+
DelZip: shipment.destination.postal_code,
|
|
216
|
+
Instructions: '',
|
|
217
|
+
Name: shipment.origin.contact.company_name,
|
|
218
|
+
Phone: dispatcher_phone,
|
|
219
|
+
ReadyAt: pickup_from.strftime('%H:%M:00'),
|
|
220
|
+
State: shipment.origin.province,
|
|
221
|
+
Zip: shipment.origin.postal_code
|
|
222
|
+
}.freeze
|
|
223
|
+
|
|
224
|
+
request = {
|
|
225
|
+
headers: XML_HEADERS,
|
|
226
|
+
method: @conf.dig(:api, :methods, :pickups),
|
|
227
|
+
url: build_url(:pickups),
|
|
228
|
+
body: request_body.to_xml(root: 'OnTracPickupRequest', skip_types: true)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
save_request(request)
|
|
232
|
+
request
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_shipment_request(
|
|
236
|
+
delivery_from:,
|
|
237
|
+
delivery_to:,
|
|
238
|
+
dispatcher:,
|
|
239
|
+
pickup_from:,
|
|
240
|
+
pickup_to:,
|
|
241
|
+
scac:,
|
|
242
|
+
service:,
|
|
243
|
+
shipment:
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
dispatcher_phone = dispatcher.phone.sub('+1', '').delete('^0-9')
|
|
247
|
+
receiver_phone = shipment.destination.contact.phone.sub('+1', '').delete('^0-9')
|
|
248
|
+
|
|
249
|
+
declared_value = if shipment.declared_value_cents.blank?
|
|
250
|
+
'0'
|
|
251
|
+
else
|
|
252
|
+
format('%.2f', (shipment.declared_value_cents.to_f / 100).ceil)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
raise UnserviceableError, 'Palletized freight unsupported' unless shipment.loose?
|
|
256
|
+
|
|
257
|
+
base_api_shipment = {
|
|
258
|
+
consignee: {
|
|
259
|
+
Name: shipment.destination.contact.company_name,
|
|
260
|
+
Addr1: shipment.destination.address1,
|
|
261
|
+
Addr2: '',
|
|
262
|
+
Addr3: '',
|
|
263
|
+
City: shipment.destination.city,
|
|
264
|
+
Contact: shipment.destination.contact.name || 'Shipping',
|
|
265
|
+
Phone: receiver_phone || '',
|
|
266
|
+
State: shipment.destination.province,
|
|
267
|
+
Zip: shipment.destination.postal_code.to_s
|
|
268
|
+
},
|
|
269
|
+
shipper: {
|
|
270
|
+
Name: shipment.origin.contact.company_name,
|
|
271
|
+
Addr1: shipment.origin.address1,
|
|
272
|
+
City: shipment.origin.city,
|
|
273
|
+
State: shipment.origin.province,
|
|
274
|
+
Zip: shipment.origin.postal_code,
|
|
275
|
+
Contact: shipment.origin.contact.name || 'Shipping',
|
|
276
|
+
Phone: dispatcher_phone
|
|
277
|
+
},
|
|
278
|
+
BillTo: '0',
|
|
279
|
+
CargoType: '0',
|
|
280
|
+
COD: '0',
|
|
281
|
+
CODType: 'NONE',
|
|
282
|
+
Declared: declared_value,
|
|
283
|
+
DelEmail: dispatcher.email,
|
|
284
|
+
Instructions: '',
|
|
285
|
+
LabelType: '1', # PDF label
|
|
286
|
+
Reference: shipment.order_number,
|
|
287
|
+
Reference2: shipment.po_number,
|
|
288
|
+
Reference3: '',
|
|
289
|
+
Residential: shipment.accessorials.include?(:residential_delivery) ? 'true' : 'false',
|
|
290
|
+
SaturdayDel: 'false',
|
|
291
|
+
Service: 'C',
|
|
292
|
+
ShipDate: pickup_from.to_date.to_s,
|
|
293
|
+
ShipEmail: dispatcher.email,
|
|
294
|
+
SignatureRequired: 'true',
|
|
295
|
+
Tracking: ''
|
|
296
|
+
}.freeze
|
|
297
|
+
|
|
298
|
+
api_shipments = []
|
|
299
|
+
|
|
300
|
+
shipment.packages.each do |package|
|
|
301
|
+
package.quantity.times do
|
|
302
|
+
api_shipments << base_api_shipment.merge(
|
|
303
|
+
{
|
|
304
|
+
DIM: {
|
|
305
|
+
Length: package.length(:inches).ceil,
|
|
306
|
+
Width: package.width(:inches).ceil,
|
|
307
|
+
Height: package.height(:inches).ceil
|
|
308
|
+
},
|
|
309
|
+
UID: SecureRandom.uuid,
|
|
310
|
+
Weight: package.pounds(:each).ceil
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
request = {
|
|
317
|
+
headers: XML_HEADERS,
|
|
318
|
+
method: @conf.dig(:api, :methods, :shipments),
|
|
319
|
+
url: build_url(:shipments),
|
|
320
|
+
body: {
|
|
321
|
+
Shipments: api_shipments
|
|
322
|
+
}.to_xml(root: 'OnTracShipmentRequest', skip_types: true)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
save_request(request)
|
|
326
|
+
request
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def parse_pickup_response(response:, labels:)
|
|
330
|
+
pickup_response = PickupResponse.new(request: last_request, response:)
|
|
331
|
+
|
|
332
|
+
if response.blank?
|
|
333
|
+
pickup_response.error = FreightKit::ResponseError.new('API Error: Blank response')
|
|
334
|
+
return pickup_response
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
error = response.dig('OnTracPickupResponse', 'Error')
|
|
338
|
+
|
|
339
|
+
if error.present?
|
|
340
|
+
pickup_response.error = FreightKit::ResponseError.new(error.capitalize)
|
|
341
|
+
return pickup_response
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
pickup_number = response.dig('OnTracPickupResponse', 'Tracking')
|
|
345
|
+
|
|
346
|
+
if pickup_number.blank?
|
|
347
|
+
pickup_response.error = FreightKit::ResponseError.new('Blank pickup number')
|
|
348
|
+
return pickup_response
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
pickup_response.pickup_number = pickup_number
|
|
352
|
+
pickup_response
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def parse_shipment_response(response)
|
|
356
|
+
raise FreightKit::ResponseError, 'API Error: Blank response' if response.blank?
|
|
357
|
+
|
|
358
|
+
error = response.dig('OnTracShipmentResponse', 'Shipments', 'Error')
|
|
359
|
+
|
|
360
|
+
if error.blank? && response.dig('OnTracShipmentResponse', 'Shipments', 'Shipment').is_a?(Hash)
|
|
361
|
+
error = response.dig('OnTracShipmentResponse', 'Shipments', 'Shipment', 'Error')
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
if error.present?
|
|
365
|
+
error = error.capitalize
|
|
366
|
+
|
|
367
|
+
raise FreightKit::InvalidCredentialsError, error if error.downcase.include?('invalid username')
|
|
368
|
+
|
|
369
|
+
raise FreightKit::UnserviceableError, error if error.downcase.include?('no valid service')
|
|
370
|
+
|
|
371
|
+
raise FreightKit::UnserviceableError, error if error.downcase.include?('not serviced')
|
|
372
|
+
|
|
373
|
+
raise FreightKit::ResponseError, "API Error: #{error}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
api_shipments = response.dig('OnTracShipmentResponse', 'Shipments', 'Shipment')
|
|
377
|
+
api_shipments = [api_shipments] unless api_shipments.is_a?(Array)
|
|
378
|
+
|
|
379
|
+
base64_labels = api_shipments&.map { |s| s['Label'] }
|
|
380
|
+
raise FreightKit::ResponseError, 'API Error: Blank label' if base64_labels.blank?
|
|
381
|
+
|
|
382
|
+
labels = []
|
|
383
|
+
|
|
384
|
+
base64_labels.each do |base64_label|
|
|
385
|
+
labels << Label.new(data: base64_label)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
labels
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Rates
|
|
392
|
+
|
|
393
|
+
def build_rate_request(shipment:)
|
|
394
|
+
serviceable_accessorials?(shipment.accessorials)
|
|
395
|
+
serviceable_states?([shipment.origin.province, shipment.destination.province])
|
|
396
|
+
|
|
397
|
+
# API supports non-loose items (see below) but per OnTrac it shouldn't be quoted. We'll raise an error here but
|
|
398
|
+
# leave the support baked-in below anyway.
|
|
399
|
+
raise FreightKit::UnserviceableError, 'Palletized freight unsupported' unless shipment.loose?
|
|
400
|
+
|
|
401
|
+
dim_weights_too_heavy = shipment.packages.map(&:dim_weight).select { |w| w > self.class.maximum_weight.value }
|
|
402
|
+
|
|
403
|
+
if dim_weights_too_heavy.any?
|
|
404
|
+
message = <<~MESSAGE.squish
|
|
405
|
+
Dimensional weight(s) of #{dim_weights_too_heavy.map(&:round).join("lbs, ")} lbs more than maximum of
|
|
406
|
+
#{self.class.maximum_weight.value.round} lbs
|
|
407
|
+
MESSAGE
|
|
408
|
+
raise FreightKit::UnserviceableError, message
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
params = ''.dup
|
|
412
|
+
params << 'packages='
|
|
413
|
+
|
|
414
|
+
total_weight = shipment.packages.map { |p| p.pounds(:total) }.sum
|
|
415
|
+
|
|
416
|
+
i = 1
|
|
417
|
+
package_param_parts = []
|
|
418
|
+
|
|
419
|
+
shipment.packages.each do |package|
|
|
420
|
+
package.quantity.times do
|
|
421
|
+
declared_value = if shipment.declared_value_cents.blank?
|
|
422
|
+
0
|
|
423
|
+
else
|
|
424
|
+
shipment.declared_value_cents.to_f * (package.pounds(:each) / total_weight)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
declared_value = declared_value.to_s
|
|
428
|
+
|
|
429
|
+
parts = []
|
|
430
|
+
|
|
431
|
+
parts << "ID#{i}"
|
|
432
|
+
parts << shipment.origin.postal_code
|
|
433
|
+
parts << shipment.destination.postal_code
|
|
434
|
+
parts << shipment.accessorials.include?(:residential_delivery) ? 'true' : 'false'
|
|
435
|
+
parts << '0'
|
|
436
|
+
parts << 'false' # Staurday delivery
|
|
437
|
+
parts << declared_value
|
|
438
|
+
parts << package.pounds(:each).ceil
|
|
439
|
+
parts << "#{package.inches(:length).ceil}X#{package.inches(:width).ceil}X#{package.inches(:height).ceil}"
|
|
440
|
+
parts << 'C'
|
|
441
|
+
parts << '0' # not a letter
|
|
442
|
+
parts << '0' # always 0 per documentation
|
|
443
|
+
|
|
444
|
+
package_param_parts << parts.join(';')
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
params << package_param_parts.join(',')
|
|
449
|
+
|
|
450
|
+
build_request(:rates, { params: })
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def parse_rate_response(shipment:, response:)
|
|
454
|
+
rate_response = RateResponse.new(request: last_request, response:)
|
|
455
|
+
|
|
456
|
+
if response.blank?
|
|
457
|
+
rate_response.error = ResponseError.new('Blank response')
|
|
458
|
+
return rate_response
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
error = response.dig('OnTracRateResponse', 'Shipments', 'Shipment', 'Error') ||
|
|
462
|
+
response.dig('OnTracRateResponse', 'Shipments', 'Error') ||
|
|
463
|
+
response.dig('OnTracRateResponse', 'Error')
|
|
464
|
+
|
|
465
|
+
if error.blank? && response.dig('OnTracRateResponse', 'Shipments', 'Shipment').is_a?(Hash)
|
|
466
|
+
error = response.dig('OnTracRateResponse', 'Shipments', 'Shipment', 'Rates', 'Rate', 'Error')
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
if error.present?
|
|
470
|
+
case error.downcase
|
|
471
|
+
when ->(value) { value.include?('not serviced') }
|
|
472
|
+
rate_response.error = UnserviceableError.new(error)
|
|
473
|
+
return rate_response
|
|
474
|
+
when ->(value) { value.include?('invalid username') }
|
|
475
|
+
rate_response.error = InvalidCredentialsError.new(error)
|
|
476
|
+
return rate_response
|
|
477
|
+
when ->(value) { value.include?('no valid service') }
|
|
478
|
+
rate_response.error = UnserviceableError.new(error)
|
|
479
|
+
return rate_response
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
rate_response.error = ResponseError.new(error)
|
|
483
|
+
return rate_response
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
prices = []
|
|
487
|
+
transit_days = nil
|
|
488
|
+
|
|
489
|
+
api_shipments = response.dig('OnTracRateResponse', 'Shipments', 'Shipment')
|
|
490
|
+
api_shipments = [api_shipments] unless api_shipments.is_a?(Array)
|
|
491
|
+
|
|
492
|
+
if api_shipments.any? { |api_shipment| api_shipment['Error']&.downcase&.include?('not serviced') }
|
|
493
|
+
rate_response.error = UnserviceableError.new
|
|
494
|
+
return rate_response
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
api_shipments.each do |api_shipment|
|
|
498
|
+
api_rate = api_shipment.dig('Rates', 'Rate')
|
|
499
|
+
api_transit_days = api_rate['TransitDays'].to_i
|
|
500
|
+
|
|
501
|
+
transit_days = api_transit_days if transit_days.blank? || transit_days < api_transit_days
|
|
502
|
+
|
|
503
|
+
cents = (api_rate['ServiceCharge'].to_f * 100).to_i
|
|
504
|
+
prices << Price.new(blame: :api, cents:, description: 'Service charge')
|
|
505
|
+
|
|
506
|
+
cents = (api_rate['FuelCharge'].to_f * 100).to_i
|
|
507
|
+
prices << Price.new(blame: :api, cents:, description: 'Fuel charge')
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
rate = Rate.new(
|
|
511
|
+
carrier_name: self.class.name,
|
|
512
|
+
carrier: self,
|
|
513
|
+
currency: 'USD',
|
|
514
|
+
prices:,
|
|
515
|
+
scac: self.class.scac.upcase,
|
|
516
|
+
service_name: :standard,
|
|
517
|
+
shipment:,
|
|
518
|
+
transit_days:,
|
|
519
|
+
with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
rate_response.rates = [rate]
|
|
523
|
+
rate_response
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Tracking
|
|
527
|
+
end
|
|
528
|
+
end
|