active_shipping 0.12.6 → 1.0.0.pre1
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
- checksums.yaml.gz.sig +2 -0
- data.tar.gz.sig +0 -0
- data/{CHANGELOG → CHANGELOG.md} +6 -2
- data/CONTRIBUTING.md +32 -0
- data/{README.markdown → README.md} +45 -61
- data/lib/active_shipping.rb +20 -28
- data/lib/active_shipping/carrier.rb +82 -0
- data/lib/active_shipping/carriers.rb +33 -0
- data/lib/active_shipping/carriers/benchmark_carrier.rb +31 -0
- data/lib/active_shipping/carriers/bogus_carrier.rb +12 -0
- data/lib/active_shipping/carriers/canada_post.rb +253 -0
- data/lib/active_shipping/carriers/canada_post_pws.rb +870 -0
- data/lib/active_shipping/carriers/fedex.rb +579 -0
- data/lib/active_shipping/carriers/kunaki.rb +164 -0
- data/lib/active_shipping/carriers/new_zealand_post.rb +262 -0
- data/lib/active_shipping/carriers/shipwire.rb +181 -0
- data/lib/active_shipping/carriers/stamps.rb +861 -0
- data/lib/active_shipping/carriers/ups.rb +648 -0
- data/lib/active_shipping/carriers/usps.rb +642 -0
- data/lib/active_shipping/errors.rb +7 -0
- data/lib/active_shipping/label_response.rb +23 -0
- data/lib/active_shipping/location.rb +149 -0
- data/lib/active_shipping/package.rb +241 -0
- data/lib/active_shipping/rate_estimate.rb +64 -0
- data/lib/active_shipping/rate_response.rb +13 -0
- data/lib/active_shipping/response.rb +41 -0
- data/lib/active_shipping/shipment_event.rb +17 -0
- data/lib/active_shipping/shipment_packer.rb +73 -0
- data/lib/active_shipping/shipping_response.rb +12 -0
- data/lib/active_shipping/tracking_response.rb +52 -0
- data/lib/active_shipping/version.rb +1 -1
- data/lib/vendor/quantified/test/length_test.rb +2 -2
- data/lib/vendor/xml_node/test/test_parsing.rb +1 -1
- metadata +58 -36
- metadata.gz.sig +0 -0
- data/lib/active_shipping/shipping/base.rb +0 -13
- data/lib/active_shipping/shipping/carrier.rb +0 -84
- data/lib/active_shipping/shipping/carriers.rb +0 -23
- data/lib/active_shipping/shipping/carriers/benchmark_carrier.rb +0 -33
- data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +0 -14
- data/lib/active_shipping/shipping/carriers/canada_post.rb +0 -257
- data/lib/active_shipping/shipping/carriers/canada_post_pws.rb +0 -874
- data/lib/active_shipping/shipping/carriers/fedex.rb +0 -581
- data/lib/active_shipping/shipping/carriers/kunaki.rb +0 -166
- data/lib/active_shipping/shipping/carriers/new_zealand_post.rb +0 -262
- data/lib/active_shipping/shipping/carriers/shipwire.rb +0 -184
- data/lib/active_shipping/shipping/carriers/stamps.rb +0 -864
- data/lib/active_shipping/shipping/carriers/ups.rb +0 -650
- data/lib/active_shipping/shipping/carriers/usps.rb +0 -649
- data/lib/active_shipping/shipping/errors.rb +0 -9
- data/lib/active_shipping/shipping/label_response.rb +0 -25
- data/lib/active_shipping/shipping/location.rb +0 -152
- data/lib/active_shipping/shipping/package.rb +0 -243
- data/lib/active_shipping/shipping/rate_estimate.rb +0 -66
- data/lib/active_shipping/shipping/rate_response.rb +0 -15
- data/lib/active_shipping/shipping/response.rb +0 -43
- data/lib/active_shipping/shipping/shipment_event.rb +0 -19
- data/lib/active_shipping/shipping/shipment_packer.rb +0 -75
- data/lib/active_shipping/shipping/shipping_response.rb +0 -14
- data/lib/active_shipping/shipping/tracking_response.rb +0 -54
@@ -0,0 +1,648 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
module ActiveShipping
|
4
|
+
class UPS < Carrier
|
5
|
+
self.retry_safe = true
|
6
|
+
|
7
|
+
cattr_accessor :default_options
|
8
|
+
cattr_reader :name
|
9
|
+
@@name = "UPS"
|
10
|
+
|
11
|
+
TEST_URL = 'https://wwwcie.ups.com'
|
12
|
+
LIVE_URL = 'https://onlinetools.ups.com'
|
13
|
+
|
14
|
+
RESOURCES = {
|
15
|
+
:rates => 'ups.app/xml/Rate',
|
16
|
+
:track => 'ups.app/xml/Track',
|
17
|
+
:ship_confirm => 'ups.app/xml/ShipConfirm',
|
18
|
+
:ship_accept => 'ups.app/xml/ShipAccept'
|
19
|
+
}
|
20
|
+
|
21
|
+
PICKUP_CODES = HashWithIndifferentAccess.new(
|
22
|
+
:daily_pickup => "01",
|
23
|
+
:customer_counter => "03",
|
24
|
+
:one_time_pickup => "06",
|
25
|
+
:on_call_air => "07",
|
26
|
+
:suggested_retail_rates => "11",
|
27
|
+
:letter_center => "19",
|
28
|
+
:air_service_center => "20"
|
29
|
+
)
|
30
|
+
|
31
|
+
CUSTOMER_CLASSIFICATIONS = HashWithIndifferentAccess.new(
|
32
|
+
:wholesale => "01",
|
33
|
+
:occasional => "03",
|
34
|
+
:retail => "04"
|
35
|
+
)
|
36
|
+
|
37
|
+
# these are the defaults described in the UPS API docs,
|
38
|
+
# but they don't seem to apply them under all circumstances,
|
39
|
+
# so we need to take matters into our own hands
|
40
|
+
DEFAULT_CUSTOMER_CLASSIFICATIONS = Hash.new do |hash, key|
|
41
|
+
hash[key] = case key.to_sym
|
42
|
+
when :daily_pickup then :wholesale
|
43
|
+
when :customer_counter then :retail
|
44
|
+
else
|
45
|
+
:occasional
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
DEFAULT_SERVICES = {
|
50
|
+
"01" => "UPS Next Day Air",
|
51
|
+
"02" => "UPS Second Day Air",
|
52
|
+
"03" => "UPS Ground",
|
53
|
+
"07" => "UPS Worldwide Express",
|
54
|
+
"08" => "UPS Worldwide Expedited",
|
55
|
+
"11" => "UPS Standard",
|
56
|
+
"12" => "UPS Three-Day Select",
|
57
|
+
"13" => "UPS Next Day Air Saver",
|
58
|
+
"14" => "UPS Next Day Air Early A.M.",
|
59
|
+
"54" => "UPS Worldwide Express Plus",
|
60
|
+
"59" => "UPS Second Day Air A.M.",
|
61
|
+
"65" => "UPS Saver",
|
62
|
+
"82" => "UPS Today Standard",
|
63
|
+
"83" => "UPS Today Dedicated Courier",
|
64
|
+
"84" => "UPS Today Intercity",
|
65
|
+
"85" => "UPS Today Express",
|
66
|
+
"86" => "UPS Today Express Saver"
|
67
|
+
}
|
68
|
+
|
69
|
+
CANADA_ORIGIN_SERVICES = {
|
70
|
+
"01" => "UPS Express",
|
71
|
+
"02" => "UPS Expedited",
|
72
|
+
"14" => "UPS Express Early A.M."
|
73
|
+
}
|
74
|
+
|
75
|
+
MEXICO_ORIGIN_SERVICES = {
|
76
|
+
"07" => "UPS Express",
|
77
|
+
"08" => "UPS Expedited",
|
78
|
+
"54" => "UPS Express Plus"
|
79
|
+
}
|
80
|
+
|
81
|
+
EU_ORIGIN_SERVICES = {
|
82
|
+
"07" => "UPS Express",
|
83
|
+
"08" => "UPS Expedited"
|
84
|
+
}
|
85
|
+
|
86
|
+
OTHER_NON_US_ORIGIN_SERVICES = {
|
87
|
+
"07" => "UPS Express"
|
88
|
+
}
|
89
|
+
|
90
|
+
TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
|
91
|
+
'I' => :in_transit,
|
92
|
+
'D' => :delivered,
|
93
|
+
'X' => :exception,
|
94
|
+
'P' => :pickup,
|
95
|
+
'M' => :manifest_pickup
|
96
|
+
)
|
97
|
+
|
98
|
+
# From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
|
99
|
+
EU_COUNTRY_CODES = %w(GB AT BE BG CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE)
|
100
|
+
|
101
|
+
US_TERRITORIES_TREATED_AS_COUNTRIES = %w(AS FM GU MH MP PW PR VI)
|
102
|
+
|
103
|
+
IMPERIAL_COUNTRIES = %w(US LR MM)
|
104
|
+
|
105
|
+
def requirements
|
106
|
+
[:key, :login, :password]
|
107
|
+
end
|
108
|
+
|
109
|
+
def find_rates(origin, destination, packages, options = {})
|
110
|
+
origin, destination = upsified_location(origin), upsified_location(destination)
|
111
|
+
options = @options.merge(options)
|
112
|
+
packages = Array(packages)
|
113
|
+
access_request = build_access_request
|
114
|
+
rate_request = build_rate_request(origin, destination, packages, options)
|
115
|
+
response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
|
116
|
+
parse_rate_response(origin, destination, packages, response, options)
|
117
|
+
end
|
118
|
+
|
119
|
+
def find_tracking_info(tracking_number, options = {})
|
120
|
+
options = @options.update(options)
|
121
|
+
access_request = build_access_request
|
122
|
+
tracking_request = build_tracking_request(tracking_number, options)
|
123
|
+
response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
|
124
|
+
parse_tracking_response(response, options)
|
125
|
+
end
|
126
|
+
|
127
|
+
def create_shipment(origin, destination, packages, options = {})
|
128
|
+
options = @options.merge(options)
|
129
|
+
packages = Array(packages)
|
130
|
+
access_request = build_access_request
|
131
|
+
|
132
|
+
begin
|
133
|
+
|
134
|
+
# STEP 1: Confirm. Validation step, important for verifying price.
|
135
|
+
confirm_request = build_shipment_request(origin, destination, packages, options)
|
136
|
+
logger.debug(confirm_request) if logger
|
137
|
+
|
138
|
+
confirm_response = commit(:ship_confirm, save_request(access_request + confirm_request), (options[:test] || false))
|
139
|
+
logger.debug(confirm_response) if logger
|
140
|
+
|
141
|
+
# ... now, get the digest, it's needed to get the label. In theory,
|
142
|
+
# one could make decisions based on the price or some such to avoid
|
143
|
+
# surprises. This also has *no* error handling yet.
|
144
|
+
xml = parse_ship_confirm(confirm_response)
|
145
|
+
success = response_success?(xml)
|
146
|
+
message = response_message(xml)
|
147
|
+
digest = response_digest(xml)
|
148
|
+
raise message unless success
|
149
|
+
|
150
|
+
# STEP 2: Accept. Use shipment digest in first response to get the actual label.
|
151
|
+
accept_request = build_accept_request(digest, options)
|
152
|
+
logger.debug(accept_request) if logger
|
153
|
+
|
154
|
+
accept_response = commit(:ship_accept, save_request(access_request + accept_request), (options[:test] || false))
|
155
|
+
logger.debug(accept_response) if logger
|
156
|
+
|
157
|
+
# ...finally, build a map from the response that contains
|
158
|
+
# the label data and tracking information.
|
159
|
+
parse_ship_accept(accept_response)
|
160
|
+
|
161
|
+
rescue RuntimeError => e
|
162
|
+
raise "Could not obtain shipping label. #{e.message}."
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
protected
|
168
|
+
|
169
|
+
def upsified_location(location)
|
170
|
+
if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
|
171
|
+
atts = {:country => location.state}
|
172
|
+
[:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
|
173
|
+
atts[att] = location.send(att)
|
174
|
+
end
|
175
|
+
Location.new(atts)
|
176
|
+
else
|
177
|
+
location
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def build_access_request
|
182
|
+
xml_request = XmlNode.new('AccessRequest') do |access_request|
|
183
|
+
access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
|
184
|
+
access_request << XmlNode.new('UserId', @options[:login])
|
185
|
+
access_request << XmlNode.new('Password', @options[:password])
|
186
|
+
end
|
187
|
+
xml_request.to_s
|
188
|
+
end
|
189
|
+
|
190
|
+
def build_rate_request(origin, destination, packages, options = {})
|
191
|
+
packages = Array(packages)
|
192
|
+
xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
|
193
|
+
root_node << XmlNode.new('Request') do |request|
|
194
|
+
request << XmlNode.new('RequestAction', 'Rate')
|
195
|
+
request << XmlNode.new('RequestOption', 'Shop')
|
196
|
+
# not implemented: 'Rate' RequestOption to specify a single service query
|
197
|
+
# request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
|
198
|
+
end
|
199
|
+
|
200
|
+
pickup_type = options[:pickup_type] || :daily_pickup
|
201
|
+
|
202
|
+
root_node << XmlNode.new('PickupType') do |pickup_type_node|
|
203
|
+
pickup_type_node << XmlNode.new('Code', PICKUP_CODES[pickup_type])
|
204
|
+
# not implemented: PickupType/PickupDetails element
|
205
|
+
end
|
206
|
+
cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
|
207
|
+
root_node << XmlNode.new('CustomerClassification') do |cc_node|
|
208
|
+
cc_node << XmlNode.new('Code', CUSTOMER_CLASSIFICATIONS[cc])
|
209
|
+
end
|
210
|
+
|
211
|
+
root_node << XmlNode.new('Shipment') do |shipment|
|
212
|
+
# not implemented: Shipment/Description element
|
213
|
+
shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
|
214
|
+
shipment << build_location_node('ShipTo', destination, options)
|
215
|
+
if options[:shipper] and options[:shipper] != origin
|
216
|
+
shipment << build_location_node('ShipFrom', origin, options)
|
217
|
+
end
|
218
|
+
|
219
|
+
# not implemented: * Shipment/ShipmentWeight element
|
220
|
+
# * Shipment/ReferenceNumber element
|
221
|
+
# * Shipment/Service element
|
222
|
+
# * Shipment/PickupDate element
|
223
|
+
# * Shipment/ScheduledDeliveryDate element
|
224
|
+
# * Shipment/ScheduledDeliveryTime element
|
225
|
+
# * Shipment/AlternateDeliveryTime element
|
226
|
+
# * Shipment/DocumentsOnly element
|
227
|
+
|
228
|
+
packages.each do |package|
|
229
|
+
options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
|
230
|
+
shipment << build_package_node(package, options)
|
231
|
+
end
|
232
|
+
|
233
|
+
# not implemented: * Shipment/ShipmentServiceOptions element
|
234
|
+
if options[:origin_account]
|
235
|
+
shipment << XmlNode.new("RateInformation") do |rate_info_node|
|
236
|
+
rate_info_node << XmlNode.new("NegotiatedRatesIndicator")
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
xml_request.to_s
|
242
|
+
end
|
243
|
+
|
244
|
+
# Build XML node to request a shipping label for the given packages.
|
245
|
+
#
|
246
|
+
# options:
|
247
|
+
# * origin_account: who will pay for the shipping label
|
248
|
+
# * customer_context: a "guid like substance" -- according to UPS
|
249
|
+
# * shipper: who is sending the package and where it should be returned
|
250
|
+
# if it is undeliverable.
|
251
|
+
# * ship_from: where the package is picked up.
|
252
|
+
# * service_code: default to '14'
|
253
|
+
# * service_descriptor: default to 'Next Day Air Early AM'
|
254
|
+
# * saturday_delivery: any truthy value causes this element to exist
|
255
|
+
# * optional_processing: 'validate' (blank) or 'nonvalidate' or blank
|
256
|
+
#
|
257
|
+
def build_shipment_request(origin, destination, packages, options = {})
|
258
|
+
# There are a lot of unimplemented elements, documenting all of them
|
259
|
+
# wouldprobably be unhelpful.
|
260
|
+
|
261
|
+
xml_request = XmlNode.new('ShipmentConfirmRequest') do |root_node|
|
262
|
+
root_node << XmlNode.new('Request') do |request|
|
263
|
+
# Required element and the text must be "ShipConfirm"
|
264
|
+
request << XmlNode.new('RequestAction', 'ShipConfirm')
|
265
|
+
# Required element cotnrols level of address validation.
|
266
|
+
request << XmlNode.new('RequestOption', options[:optional_processing] || 'validate')
|
267
|
+
# Optional element to identify transactions between client and server.
|
268
|
+
if options[:customer_context]
|
269
|
+
request << XmlNode.new('TransactionReference') do |refer|
|
270
|
+
refer << XmlNode.new('CustomerContext', options[:customer_context])
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
root_node << XmlNode.new('Shipment') do |shipment|
|
275
|
+
# Required element.
|
276
|
+
shipment << XmlNode.new('Service') do |service|
|
277
|
+
service << XmlNode.new('Code', options[:service_code] || '14')
|
278
|
+
service << XmlNode.new('Description', options[:service_description] || 'Next Day Air Early AM')
|
279
|
+
end
|
280
|
+
# Required element. The delivery destination.
|
281
|
+
shipment << build_location_node('ShipTo', destination, {})
|
282
|
+
# Required element. The company whose account is responsible for the label(s).
|
283
|
+
shipment << build_location_node('Shipper', options[:shipper] || origin, {})
|
284
|
+
# Required if pickup is different different from shipper's address.
|
285
|
+
if options[:ship_from]
|
286
|
+
shipment << build_location_node('ShipFrom', options[:ship_from], {})
|
287
|
+
end
|
288
|
+
# Optional.
|
289
|
+
if options[:saturday_delivery]
|
290
|
+
shipment << XmlNode.new('ShipmentServiceOptions') do |opts|
|
291
|
+
opts << XmlNode.new('SaturdayDelivery')
|
292
|
+
end
|
293
|
+
end
|
294
|
+
# Optional.
|
295
|
+
if options[:origin_account]
|
296
|
+
shipment << XmlNode.new('RateInformation') do |rate|
|
297
|
+
rate << XmlNode.new('NegotiatedRatesIndicator')
|
298
|
+
end
|
299
|
+
end
|
300
|
+
# Optional.
|
301
|
+
if options[:shipment] && options[:shipment][:reference_number]
|
302
|
+
shipment << XmlNode.new("ReferenceNumber") do |ref_node|
|
303
|
+
ref_node << XmlNode.new("Code", options[:shipment][:reference_number][:code] || "")
|
304
|
+
ref_node << XmlNode.new("Value", options[:shipment][:reference_number][:value])
|
305
|
+
end
|
306
|
+
end
|
307
|
+
# Conditionally required. Either this element or an ItemizedPaymentInformation
|
308
|
+
# is needed. However, only PaymentInformation is not implemented.
|
309
|
+
shipment << XmlNode.new('PaymentInformation') do |payment|
|
310
|
+
payment << XmlNode.new('Prepaid') do |prepay|
|
311
|
+
prepay << XmlNode.new('BillShipper') do |bill|
|
312
|
+
bill << XmlNode.new('AccountNumber', options[:origin_account])
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
# A request may specify multiple packages.
|
317
|
+
options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
|
318
|
+
packages.each do |package|
|
319
|
+
shipment << build_package_node(package, options)
|
320
|
+
end
|
321
|
+
end
|
322
|
+
# I don't know all of the options that UPS supports for labels
|
323
|
+
# so I'm going with something very simple for now.
|
324
|
+
root_node << XmlNode.new('LabelSpecification') do |specification|
|
325
|
+
specification << XmlNode.new('LabelPrintMethod') do |print_method|
|
326
|
+
print_method << XmlNode.new('Code', 'GIF')
|
327
|
+
end
|
328
|
+
specification << XmlNode.new('HTTPUserAgent', 'Mozilla/4.5') # hmmm
|
329
|
+
specification << XmlNode.new('LabelImageFormat', 'GIF') do |image_format|
|
330
|
+
image_format << XmlNode.new('Code', 'GIF')
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
334
|
+
xml_request.to_s
|
335
|
+
end
|
336
|
+
|
337
|
+
def build_accept_request(digest, options = {})
|
338
|
+
xml_request = XmlNode.new('ShipmentAcceptRequest') do |root_node|
|
339
|
+
root_node << XmlNode.new('Request') do |request|
|
340
|
+
request << XmlNode.new('RequestAction', 'ShipAccept')
|
341
|
+
end
|
342
|
+
root_node << XmlNode.new('ShipmentDigest', digest)
|
343
|
+
end
|
344
|
+
xml_request.to_s
|
345
|
+
end
|
346
|
+
|
347
|
+
def build_tracking_request(tracking_number, options = {})
|
348
|
+
xml_request = XmlNode.new('TrackRequest') do |root_node|
|
349
|
+
root_node << XmlNode.new('Request') do |request|
|
350
|
+
request << XmlNode.new('RequestAction', 'Track')
|
351
|
+
request << XmlNode.new('RequestOption', '1')
|
352
|
+
end
|
353
|
+
root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
|
354
|
+
end
|
355
|
+
xml_request.to_s
|
356
|
+
end
|
357
|
+
|
358
|
+
def build_location_node(name, location, options = {})
|
359
|
+
# not implemented: * Shipment/Shipper/Name element
|
360
|
+
# * Shipment/(ShipTo|ShipFrom)/CompanyName element
|
361
|
+
# * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
|
362
|
+
# * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
|
363
|
+
XmlNode.new(name) do |location_node|
|
364
|
+
# You must specify the shipper name when creating labels.
|
365
|
+
if shipper_name = (options[:origin_name] || @options[:origin_name])
|
366
|
+
location_node << XmlNode.new('Name', shipper_name)
|
367
|
+
end
|
368
|
+
location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/, '')) unless location.phone.blank?
|
369
|
+
location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/, '')) unless location.fax.blank?
|
370
|
+
|
371
|
+
if name == 'Shipper' and (origin_account = options[:origin_account] || @options[:origin_account])
|
372
|
+
location_node << XmlNode.new('ShipperNumber', origin_account)
|
373
|
+
elsif name == 'ShipTo' and (destination_account = options[:destination_account] || @options[:destination_account])
|
374
|
+
location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
|
375
|
+
end
|
376
|
+
|
377
|
+
if name = location.company_name || location.name
|
378
|
+
location_node << XmlNode.new('CompanyName', name)
|
379
|
+
end
|
380
|
+
|
381
|
+
if phone = location.phone
|
382
|
+
location_node << XmlNode.new('PhoneNumber', phone)
|
383
|
+
end
|
384
|
+
|
385
|
+
if attn = location.name
|
386
|
+
location_node << XmlNode.new('AttentionName', attn)
|
387
|
+
end
|
388
|
+
|
389
|
+
location_node << XmlNode.new('Address') do |address|
|
390
|
+
address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
|
391
|
+
address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
|
392
|
+
address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
|
393
|
+
address << XmlNode.new("City", location.city) unless location.city.blank?
|
394
|
+
address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
|
395
|
+
# StateProvinceCode required for negotiated rates but not otherwise, for some reason
|
396
|
+
address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
|
397
|
+
address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
|
398
|
+
address << XmlNode.new("ResidentialAddressIndicator", true) unless location.commercial? # the default should be that UPS returns residential rates for destinations that it doesn't know about
|
399
|
+
# not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
|
400
|
+
end
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
def build_package_node(package, options = {})
|
405
|
+
XmlNode.new("Package") do |package_node|
|
406
|
+
|
407
|
+
# not implemented: * Shipment/Package/PackagingType element
|
408
|
+
# * Shipment/Package/Description element
|
409
|
+
|
410
|
+
package_node << XmlNode.new("PackagingType") do |packaging_type|
|
411
|
+
packaging_type << XmlNode.new("Code", '02')
|
412
|
+
end
|
413
|
+
|
414
|
+
package_node << XmlNode.new("Dimensions") do |dimensions|
|
415
|
+
dimensions << XmlNode.new("UnitOfMeasurement") do |units|
|
416
|
+
units << XmlNode.new("Code", options[:imperial] ? 'IN' : 'CM')
|
417
|
+
end
|
418
|
+
[:length, :width, :height].each do |axis|
|
419
|
+
value = ((options[:imperial] ? package.inches(axis) : package.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals
|
420
|
+
dimensions << XmlNode.new(axis.to_s.capitalize, [value, 0.1].max)
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
package_node << XmlNode.new("PackageWeight") do |package_weight|
|
425
|
+
package_weight << XmlNode.new("UnitOfMeasurement") do |units|
|
426
|
+
units << XmlNode.new("Code", options[:imperial] ? 'LBS' : 'KGS')
|
427
|
+
end
|
428
|
+
|
429
|
+
value = ((options[:imperial] ? package.lbs : package.kgs).to_f * 1000).round / 1000.0 # 3 decimals
|
430
|
+
package_weight << XmlNode.new("Weight", [value, 0.1].max)
|
431
|
+
end
|
432
|
+
|
433
|
+
if options[:package] && options[:package][:reference_number]
|
434
|
+
package_node << XmlNode.new("ReferenceNumber") do |ref_node|
|
435
|
+
ref_node << XmlNode.new("Code", options[:package][:reference_number][:code] || "")
|
436
|
+
ref_node << XmlNode.new("Value", options[:package][:reference_number][:value])
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
package_node
|
441
|
+
|
442
|
+
# not implemented: * Shipment/Package/LargePackageIndicator element
|
443
|
+
# * Shipment/Package/PackageServiceOptions element
|
444
|
+
# * Shipment/Package/AdditionalHandling element
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
448
|
+
def parse_rate_response(origin, destination, packages, response, options = {})
|
449
|
+
xml = REXML::Document.new(response)
|
450
|
+
success = response_success?(xml)
|
451
|
+
message = response_message(xml)
|
452
|
+
|
453
|
+
if success
|
454
|
+
rate_estimates = []
|
455
|
+
|
456
|
+
xml.elements.each('/*/RatedShipment') do |rated_shipment|
|
457
|
+
service_code = rated_shipment.get_text('Service/Code').to_s
|
458
|
+
days_to_delivery = rated_shipment.get_text('GuaranteedDaysToDelivery').to_s.to_i
|
459
|
+
days_to_delivery = nil if days_to_delivery == 0
|
460
|
+
rate_estimates << RateEstimate.new(origin, destination, @@name,
|
461
|
+
service_name_for(origin, service_code),
|
462
|
+
:total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
|
463
|
+
:insurance_price => rated_shipment.get_text('ServiceOptionsCharges/MonetaryValue').to_s.to_f,
|
464
|
+
:currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
|
465
|
+
:service_code => service_code,
|
466
|
+
:packages => packages,
|
467
|
+
:delivery_range => [timestamp_from_business_day(days_to_delivery)],
|
468
|
+
:negotiated_rate => rated_shipment.get_text('NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue').to_s.to_f)
|
469
|
+
end
|
470
|
+
end
|
471
|
+
RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
|
472
|
+
end
|
473
|
+
|
474
|
+
def parse_tracking_response(response, options = {})
|
475
|
+
xml = REXML::Document.new(response)
|
476
|
+
success = response_success?(xml)
|
477
|
+
message = response_message(xml)
|
478
|
+
|
479
|
+
if success
|
480
|
+
delivery_signature = nil
|
481
|
+
exception_event, scheduled_delivery_date, actual_delivery_date = nil
|
482
|
+
delivered, exception = false
|
483
|
+
shipment_events = []
|
484
|
+
|
485
|
+
first_shipment = xml.elements['/*/Shipment']
|
486
|
+
first_package = first_shipment.elements['Package']
|
487
|
+
tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
|
488
|
+
|
489
|
+
# Build status hash
|
490
|
+
status_nodes = first_package.elements.to_a('Activity/Status/StatusType')
|
491
|
+
|
492
|
+
# Prefer a delivery node
|
493
|
+
status_node = status_nodes.detect { |x| x.get_text('Code').to_s == 'D' }
|
494
|
+
status_node ||= status_nodes.first
|
495
|
+
|
496
|
+
status_code = status_node.get_text('Code').to_s
|
497
|
+
status_description = status_node.get_text('Description').to_s
|
498
|
+
status = TRACKING_STATUS_CODES[status_code]
|
499
|
+
|
500
|
+
if status_description =~ /out.*delivery/i
|
501
|
+
status = :out_for_delivery
|
502
|
+
end
|
503
|
+
|
504
|
+
origin, destination = %w(Shipper ShipTo).map do |location|
|
505
|
+
location_from_address_node(first_shipment.elements["#{location}/Address"])
|
506
|
+
end
|
507
|
+
|
508
|
+
# Get scheduled delivery date
|
509
|
+
unless status == :delivered
|
510
|
+
scheduled_delivery_date = parse_ups_datetime(
|
511
|
+
:date => first_shipment.get_text('ScheduledDeliveryDate'),
|
512
|
+
:time => nil
|
513
|
+
)
|
514
|
+
end
|
515
|
+
|
516
|
+
activities = first_package.get_elements('Activity')
|
517
|
+
unless activities.empty?
|
518
|
+
shipment_events = activities.map do |activity|
|
519
|
+
description = activity.get_text('Status/StatusType/Description').to_s
|
520
|
+
zoneless_time = parse_ups_datetime(:time => activity.get_text('Time'), :date => activity.get_text('Date'))
|
521
|
+
location = location_from_address_node(activity.elements['ActivityLocation/Address'])
|
522
|
+
ShipmentEvent.new(description, zoneless_time, location)
|
523
|
+
end
|
524
|
+
|
525
|
+
shipment_events = shipment_events.sort_by(&:time)
|
526
|
+
|
527
|
+
# UPS will sometimes archive a shipment, stripping all shipment activity except for the delivery
|
528
|
+
# event (see test/fixtures/xml/delivered_shipment_without_events_tracking_response.xml for an example).
|
529
|
+
# This adds an origin event to the shipment activity in such cases.
|
530
|
+
if origin && !(shipment_events.count == 1 && status == :delivered)
|
531
|
+
first_event = shipment_events[0]
|
532
|
+
origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
|
533
|
+
|
534
|
+
if within_same_area?(origin, first_event.location)
|
535
|
+
shipment_events[0] = origin_event
|
536
|
+
else
|
537
|
+
shipment_events.unshift(origin_event)
|
538
|
+
end
|
539
|
+
end
|
540
|
+
|
541
|
+
# Has the shipment been delivered?
|
542
|
+
if status == :delivered
|
543
|
+
delivered_activity = activities.first
|
544
|
+
delivery_signature = delivered_activity.get_text('ActivityLocation/SignedForByName').to_s
|
545
|
+
if delivered_activity.get_text('Status/StatusType/Code') == 'D'
|
546
|
+
actual_delivery_date = parse_ups_datetime(:date => delivered_activity.get_text('Date'), :time => delivered_activity.get_text('Time'))
|
547
|
+
end
|
548
|
+
unless destination
|
549
|
+
destination = shipment_events[-1].location
|
550
|
+
end
|
551
|
+
shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
|
552
|
+
end
|
553
|
+
end
|
554
|
+
|
555
|
+
end
|
556
|
+
TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
|
557
|
+
:carrier => @@name,
|
558
|
+
:xml => response,
|
559
|
+
:request => last_request,
|
560
|
+
:status => status,
|
561
|
+
:status_code => status_code,
|
562
|
+
:status_description => status_description,
|
563
|
+
:delivery_signature => delivery_signature,
|
564
|
+
:scheduled_delivery_date => scheduled_delivery_date,
|
565
|
+
:actual_delivery_date => actual_delivery_date,
|
566
|
+
:shipment_events => shipment_events,
|
567
|
+
:delivered => delivered,
|
568
|
+
:exception => exception,
|
569
|
+
:exception_event => exception_event,
|
570
|
+
:origin => origin,
|
571
|
+
:destination => destination,
|
572
|
+
:tracking_number => tracking_number)
|
573
|
+
end
|
574
|
+
|
575
|
+
def location_from_address_node(address)
|
576
|
+
return nil unless address
|
577
|
+
Location.new(
|
578
|
+
:country => node_text_or_nil(address.elements['CountryCode']),
|
579
|
+
:postal_code => node_text_or_nil(address.elements['PostalCode']),
|
580
|
+
:province => node_text_or_nil(address.elements['StateProvinceCode']),
|
581
|
+
:city => node_text_or_nil(address.elements['City']),
|
582
|
+
:address1 => node_text_or_nil(address.elements['AddressLine1']),
|
583
|
+
:address2 => node_text_or_nil(address.elements['AddressLine2']),
|
584
|
+
:address3 => node_text_or_nil(address.elements['AddressLine3'])
|
585
|
+
)
|
586
|
+
end
|
587
|
+
|
588
|
+
def parse_ups_datetime(options = {})
|
589
|
+
time, date = options[:time].to_s, options[:date].to_s
|
590
|
+
if time.nil?
|
591
|
+
hour, minute, second = 0
|
592
|
+
else
|
593
|
+
hour, minute, second = time.scan(/\d{2}/)
|
594
|
+
end
|
595
|
+
year, month, day = date[0..3], date[4..5], date[6..7]
|
596
|
+
|
597
|
+
Time.utc(year, month, day, hour, minute, second)
|
598
|
+
end
|
599
|
+
|
600
|
+
def response_success?(xml)
|
601
|
+
xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
|
602
|
+
end
|
603
|
+
|
604
|
+
def response_message(xml)
|
605
|
+
xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
|
606
|
+
end
|
607
|
+
|
608
|
+
def response_digest(xml)
|
609
|
+
xml.get_text('/*/ShipmentDigest').to_s
|
610
|
+
end
|
611
|
+
|
612
|
+
def parse_ship_confirm(response)
|
613
|
+
REXML::Document.new(response)
|
614
|
+
end
|
615
|
+
|
616
|
+
def parse_ship_accept(response)
|
617
|
+
xml = REXML::Document.new(response)
|
618
|
+
success = response_success?(xml)
|
619
|
+
message = response_message(xml)
|
620
|
+
|
621
|
+
LabelResponse.new(success, message, Hash.from_xml(response).values.first)
|
622
|
+
end
|
623
|
+
|
624
|
+
def commit(action, request, test = false)
|
625
|
+
ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
|
626
|
+
end
|
627
|
+
|
628
|
+
def within_same_area?(origin, location)
|
629
|
+
return false unless location
|
630
|
+
matching_country_codes = origin.country_code(:alpha2) == location.country_code(:alpha2)
|
631
|
+
matching_or_blank_city = location.city.blank? || location.city == origin.city
|
632
|
+
matching_country_codes && matching_or_blank_city
|
633
|
+
end
|
634
|
+
|
635
|
+
def service_name_for(origin, code)
|
636
|
+
origin = origin.country_code(:alpha2)
|
637
|
+
|
638
|
+
name = case origin
|
639
|
+
when "CA" then CANADA_ORIGIN_SERVICES[code]
|
640
|
+
when "MX" then MEXICO_ORIGIN_SERVICES[code]
|
641
|
+
when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
|
642
|
+
end
|
643
|
+
|
644
|
+
name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
|
645
|
+
name || DEFAULT_SERVICES[code]
|
646
|
+
end
|
647
|
+
end
|
648
|
+
end
|