active_shipping 0.12.5 → 0.12.6
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
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 715dd60ad9d4a6fbe5c9ba36834326fa383c4ee4
|
4
|
+
data.tar.gz: 1f1991bf39492b70a068e92e52bb687f4b3526b4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 725e84ee191ce38e56e89a3af8ff0592c21a5e6bc6f584edf9cfdcc4fbb01a5532bc999ae28dfc07045fa403cb8f86c363b8d62e12441fc488efe0e863c33645
|
7
|
+
data.tar.gz: db8156ebcbd65a863f222b7bdfb26b37ac3fbd5a6b4e9036fc499488133a7075ec9906f996cafdce5b4ccc002b2e08fabdeffaabf59c82c7da26430ce4519a6a
|
@@ -264,7 +264,7 @@ module ActiveMerchant
|
|
264
264
|
end
|
265
265
|
|
266
266
|
def world_rates(origin, destination, packages, options = {})
|
267
|
-
request = build_world_rate_request(packages, destination, options)
|
267
|
+
request = build_world_rate_request(origin, packages, destination, options)
|
268
268
|
# never use test mode; rate requests just won't work on test servers
|
269
269
|
parse_rate_response origin, destination, packages, commit(:world_rates, request, false), options
|
270
270
|
end
|
@@ -343,9 +343,10 @@ module ActiveMerchant
|
|
343
343
|
#
|
344
344
|
# package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
|
345
345
|
# Defaults to :package.
|
346
|
-
def build_world_rate_request(packages, destination, options)
|
346
|
+
def build_world_rate_request(origin, packages, destination, options)
|
347
347
|
country = COUNTRY_NAME_CONVERSIONS[destination.country.code(:alpha2).value] || destination.country.name
|
348
348
|
request = XmlNode.new('IntlRateV2Request', :USERID => @options[:login]) do |rate_request|
|
349
|
+
rate_request << XmlNode.new('Revision', 2)
|
349
350
|
packages.each_index do |id|
|
350
351
|
p = packages[id]
|
351
352
|
rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
|
@@ -374,6 +375,9 @@ module ActiveMerchant
|
|
374
375
|
if commercial_type = commercial_type(options)
|
375
376
|
package << XmlNode.new(COMMERCIAL_FLAG_NAME.fetch(commercial_type), 'Y')
|
376
377
|
end
|
378
|
+
package << XmlNode.new('OriginZip', origin.zip)
|
379
|
+
package << XmlNode.new('AcceptanceDateTime', (options[:acceptance_time] || Time.now.utc).iso8601)
|
380
|
+
package << XmlNode.new('DestinationPostalCode', destination.zip)
|
377
381
|
end
|
378
382
|
end
|
379
383
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_shipping
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.12.
|
4
|
+
version: 0.12.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- James MacAulay
|
@@ -11,7 +11,7 @@ authors:
|
|
11
11
|
autorequire:
|
12
12
|
bindir: bin
|
13
13
|
cert_chain: []
|
14
|
-
date:
|
14
|
+
date: 2015-06-03 00:00:00.000000000 Z
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
17
17
|
name: activesupport
|
@@ -177,9 +177,7 @@ files:
|
|
177
177
|
- lib/active_shipping/shipping/carriers/shipwire.rb
|
178
178
|
- lib/active_shipping/shipping/carriers/stamps.rb
|
179
179
|
- lib/active_shipping/shipping/carriers/ups.rb
|
180
|
-
- lib/active_shipping/shipping/carriers/ups.rb.orig
|
181
180
|
- lib/active_shipping/shipping/carriers/usps.rb
|
182
|
-
- lib/active_shipping/shipping/carriers/usps.rb.orig
|
183
181
|
- lib/active_shipping/shipping/errors.rb
|
184
182
|
- lib/active_shipping/shipping/label_response.rb
|
185
183
|
- lib/active_shipping/shipping/location.rb
|
@@ -1,456 +0,0 @@
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
2
|
-
|
3
|
-
module ActiveMerchant
|
4
|
-
module Shipping
|
5
|
-
class UPS < Carrier
|
6
|
-
self.retry_safe = true
|
7
|
-
|
8
|
-
cattr_accessor :default_options
|
9
|
-
cattr_reader :name
|
10
|
-
@@name = "UPS"
|
11
|
-
|
12
|
-
TEST_URL = 'https://wwwcie.ups.com'
|
13
|
-
LIVE_URL = 'https://onlinetools.ups.com'
|
14
|
-
|
15
|
-
RESOURCES = {
|
16
|
-
:rates => 'ups.app/xml/Rate',
|
17
|
-
:track => 'ups.app/xml/Track'
|
18
|
-
}
|
19
|
-
|
20
|
-
PICKUP_CODES = HashWithIndifferentAccess.new({
|
21
|
-
:daily_pickup => "01",
|
22
|
-
:customer_counter => "03",
|
23
|
-
:one_time_pickup => "06",
|
24
|
-
:on_call_air => "07",
|
25
|
-
:suggested_retail_rates => "11",
|
26
|
-
:letter_center => "19",
|
27
|
-
:air_service_center => "20"
|
28
|
-
})
|
29
|
-
|
30
|
-
CUSTOMER_CLASSIFICATIONS = HashWithIndifferentAccess.new({
|
31
|
-
:wholesale => "01",
|
32
|
-
:occasional => "03",
|
33
|
-
:retail => "04"
|
34
|
-
})
|
35
|
-
|
36
|
-
# these are the defaults described in the UPS API docs,
|
37
|
-
# but they don't seem to apply them under all circumstances,
|
38
|
-
# so we need to take matters into our own hands
|
39
|
-
DEFAULT_CUSTOMER_CLASSIFICATIONS = Hash.new do |hash,key|
|
40
|
-
hash[key] = case key.to_sym
|
41
|
-
when :daily_pickup then :wholesale
|
42
|
-
when :customer_counter then :retail
|
43
|
-
else
|
44
|
-
:occasional
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
DEFAULT_SERVICES = {
|
49
|
-
"01" => "UPS Next Day Air",
|
50
|
-
"02" => "UPS Second Day Air",
|
51
|
-
"03" => "UPS Ground",
|
52
|
-
"07" => "UPS Worldwide Express",
|
53
|
-
"08" => "UPS Worldwide Expedited",
|
54
|
-
"11" => "UPS Standard",
|
55
|
-
"12" => "UPS Three-Day Select",
|
56
|
-
"13" => "UPS Next Day Air Saver",
|
57
|
-
"14" => "UPS Next Day Air Early A.M.",
|
58
|
-
"54" => "UPS Worldwide Express Plus",
|
59
|
-
"59" => "UPS Second Day Air A.M.",
|
60
|
-
"65" => "UPS Saver",
|
61
|
-
"82" => "UPS Today Standard",
|
62
|
-
"83" => "UPS Today Dedicated Courier",
|
63
|
-
"84" => "UPS Today Intercity",
|
64
|
-
"85" => "UPS Today Express",
|
65
|
-
"86" => "UPS Today Express Saver"
|
66
|
-
}
|
67
|
-
|
68
|
-
CANADA_ORIGIN_SERVICES = {
|
69
|
-
"01" => "UPS Express",
|
70
|
-
"02" => "UPS Expedited",
|
71
|
-
"14" => "UPS Express Early A.M."
|
72
|
-
}
|
73
|
-
|
74
|
-
MEXICO_ORIGIN_SERVICES = {
|
75
|
-
"07" => "UPS Express",
|
76
|
-
"08" => "UPS Expedited",
|
77
|
-
"54" => "UPS Express Plus"
|
78
|
-
}
|
79
|
-
|
80
|
-
EU_ORIGIN_SERVICES = {
|
81
|
-
"07" => "UPS Express",
|
82
|
-
"08" => "UPS Expedited"
|
83
|
-
}
|
84
|
-
|
85
|
-
OTHER_NON_US_ORIGIN_SERVICES = {
|
86
|
-
"07" => "UPS Express"
|
87
|
-
}
|
88
|
-
|
89
|
-
TRACKING_STATUS_CODES = HashWithIndifferentAccess.new({
|
90
|
-
'I' => :in_transit,
|
91
|
-
'D' => :delivered,
|
92
|
-
'X' => :exception,
|
93
|
-
'P' => :pickup,
|
94
|
-
'M' => :manifest_pickup
|
95
|
-
})
|
96
|
-
|
97
|
-
# From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
|
98
|
-
EU_COUNTRY_CODES = ["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"]
|
99
|
-
|
100
|
-
US_TERRITORIES_TREATED_AS_COUNTRIES = ["AS", "FM", "GU", "MH", "MP", "PW", "PR", "VI"]
|
101
|
-
|
102
|
-
def requirements
|
103
|
-
[:key, :login, :password]
|
104
|
-
end
|
105
|
-
|
106
|
-
def find_rates(origin, destination, packages, options={})
|
107
|
-
origin, destination = upsified_location(origin), upsified_location(destination)
|
108
|
-
options = @options.merge(options)
|
109
|
-
packages = Array(packages)
|
110
|
-
access_request = build_access_request
|
111
|
-
rate_request = build_rate_request(origin, destination, packages, options)
|
112
|
-
response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
|
113
|
-
parse_rate_response(origin, destination, packages, response, options)
|
114
|
-
end
|
115
|
-
|
116
|
-
def find_tracking_info(tracking_number, options={})
|
117
|
-
options = @options.update(options)
|
118
|
-
access_request = build_access_request
|
119
|
-
tracking_request = build_tracking_request(tracking_number, options)
|
120
|
-
response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
|
121
|
-
parse_tracking_response(response, options)
|
122
|
-
end
|
123
|
-
|
124
|
-
protected
|
125
|
-
|
126
|
-
def upsified_location(location)
|
127
|
-
if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
|
128
|
-
atts = {:country => location.state}
|
129
|
-
[:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
|
130
|
-
atts[att] = location.send(att)
|
131
|
-
end
|
132
|
-
Location.new(atts)
|
133
|
-
else
|
134
|
-
location
|
135
|
-
end
|
136
|
-
end
|
137
|
-
|
138
|
-
def build_access_request
|
139
|
-
xml_request = XmlNode.new('AccessRequest') do |access_request|
|
140
|
-
access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
|
141
|
-
access_request << XmlNode.new('UserId', @options[:login])
|
142
|
-
access_request << XmlNode.new('Password', @options[:password])
|
143
|
-
end
|
144
|
-
xml_request.to_s
|
145
|
-
end
|
146
|
-
|
147
|
-
def build_rate_request(origin, destination, packages, options={})
|
148
|
-
packages = Array(packages)
|
149
|
-
xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
|
150
|
-
root_node << XmlNode.new('Request') do |request|
|
151
|
-
request << XmlNode.new('RequestAction', 'Rate')
|
152
|
-
request << XmlNode.new('RequestOption', 'Shop')
|
153
|
-
# not implemented: 'Rate' RequestOption to specify a single service query
|
154
|
-
# request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
|
155
|
-
end
|
156
|
-
|
157
|
-
pickup_type = options[:pickup_type] || :daily_pickup
|
158
|
-
|
159
|
-
root_node << XmlNode.new('PickupType') do |pickup_type_node|
|
160
|
-
pickup_type_node << XmlNode.new('Code', PICKUP_CODES[pickup_type])
|
161
|
-
# not implemented: PickupType/PickupDetails element
|
162
|
-
end
|
163
|
-
cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
|
164
|
-
root_node << XmlNode.new('CustomerClassification') do |cc_node|
|
165
|
-
cc_node << XmlNode.new('Code', CUSTOMER_CLASSIFICATIONS[cc])
|
166
|
-
end
|
167
|
-
|
168
|
-
root_node << XmlNode.new('Shipment') do |shipment|
|
169
|
-
# not implemented: Shipment/Description element
|
170
|
-
shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
|
171
|
-
shipment << build_location_node('ShipTo', destination, options)
|
172
|
-
if options[:shipper] and options[:shipper] != origin
|
173
|
-
shipment << build_location_node('ShipFrom', origin, options)
|
174
|
-
end
|
175
|
-
|
176
|
-
# not implemented: * Shipment/ShipmentWeight element
|
177
|
-
# * Shipment/ReferenceNumber element
|
178
|
-
# * Shipment/Service element
|
179
|
-
# * Shipment/PickupDate element
|
180
|
-
# * Shipment/ScheduledDeliveryDate element
|
181
|
-
# * Shipment/ScheduledDeliveryTime element
|
182
|
-
# * Shipment/AlternateDeliveryTime element
|
183
|
-
# * Shipment/DocumentsOnly element
|
184
|
-
|
185
|
-
packages.each do |package|
|
186
|
-
imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
|
187
|
-
|
188
|
-
shipment << XmlNode.new("Package") do |package_node|
|
189
|
-
|
190
|
-
# not implemented: * Shipment/Package/PackagingType element
|
191
|
-
# * Shipment/Package/Description element
|
192
|
-
|
193
|
-
package_node << XmlNode.new("PackagingType") do |packaging_type|
|
194
|
-
packaging_type << XmlNode.new("Code", '02')
|
195
|
-
end
|
196
|
-
|
197
|
-
package_node << XmlNode.new("Dimensions") do |dimensions|
|
198
|
-
dimensions << XmlNode.new("UnitOfMeasurement") do |units|
|
199
|
-
units << XmlNode.new("Code", imperial ? 'IN' : 'CM')
|
200
|
-
end
|
201
|
-
[:length,:width,:height].each do |axis|
|
202
|
-
value = ((imperial ? package.inches(axis) : package.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
|
203
|
-
dimensions << XmlNode.new(axis.to_s.capitalize, [value,0.1].max)
|
204
|
-
end
|
205
|
-
end
|
206
|
-
|
207
|
-
package_node << XmlNode.new("PackageWeight") do |package_weight|
|
208
|
-
package_weight << XmlNode.new("UnitOfMeasurement") do |units|
|
209
|
-
units << XmlNode.new("Code", imperial ? 'LBS' : 'KGS')
|
210
|
-
end
|
211
|
-
|
212
|
-
value = ((imperial ? package.lbs : package.kgs).to_f*1000).round/1000.0 # 3 decimals
|
213
|
-
package_weight << XmlNode.new("Weight", [value,0.1].max)
|
214
|
-
end
|
215
|
-
|
216
|
-
# not implemented: * Shipment/Package/LargePackageIndicator element
|
217
|
-
# * Shipment/Package/ReferenceNumber element
|
218
|
-
# * Shipment/Package/PackageServiceOptions element
|
219
|
-
# * Shipment/Package/AdditionalHandling element
|
220
|
-
end
|
221
|
-
|
222
|
-
end
|
223
|
-
|
224
|
-
# not implemented: * Shipment/ShipmentServiceOptions element
|
225
|
-
if options[:origin_account]
|
226
|
-
shipment << XmlNode.new("RateInformation") do |rate_info_node|
|
227
|
-
rate_info_node << XmlNode.new("NegotiatedRatesIndicator")
|
228
|
-
end
|
229
|
-
end
|
230
|
-
|
231
|
-
end
|
232
|
-
|
233
|
-
end
|
234
|
-
xml_request.to_s
|
235
|
-
end
|
236
|
-
|
237
|
-
def build_tracking_request(tracking_number, options={})
|
238
|
-
xml_request = XmlNode.new('TrackRequest') do |root_node|
|
239
|
-
root_node << XmlNode.new('Request') do |request|
|
240
|
-
request << XmlNode.new('RequestAction', 'Track')
|
241
|
-
request << XmlNode.new('RequestOption', '1')
|
242
|
-
end
|
243
|
-
root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
|
244
|
-
end
|
245
|
-
xml_request.to_s
|
246
|
-
end
|
247
|
-
|
248
|
-
def build_location_node(name,location,options={})
|
249
|
-
# not implemented: * Shipment/Shipper/Name element
|
250
|
-
# * Shipment/(ShipTo|ShipFrom)/CompanyName element
|
251
|
-
# * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
|
252
|
-
# * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
|
253
|
-
location_node = XmlNode.new(name) do |location_node|
|
254
|
-
location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/,'')) unless location.phone.blank?
|
255
|
-
location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/,'')) unless location.fax.blank?
|
256
|
-
|
257
|
-
if name == 'Shipper' and (origin_account = @options[:origin_account] || options[:origin_account])
|
258
|
-
location_node << XmlNode.new('ShipperNumber', origin_account)
|
259
|
-
elsif name == 'ShipTo' and (destination_account = @options[:destination_account] || options[:destination_account])
|
260
|
-
location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
|
261
|
-
end
|
262
|
-
|
263
|
-
location_node << XmlNode.new('Address') do |address|
|
264
|
-
address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
|
265
|
-
address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
|
266
|
-
address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
|
267
|
-
address << XmlNode.new("City", location.city) unless location.city.blank?
|
268
|
-
address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
|
269
|
-
# StateProvinceCode required for negotiated rates but not otherwise, for some reason
|
270
|
-
address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
|
271
|
-
address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
|
272
|
-
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
|
273
|
-
# not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
|
274
|
-
end
|
275
|
-
end
|
276
|
-
end
|
277
|
-
|
278
|
-
def parse_rate_response(origin, destination, packages, response, options={})
|
279
|
-
rates = []
|
280
|
-
File.open('/Users/timmy/code/active_shipping/foo.txt', 'w') {|f| f.write(response) }
|
281
|
-
xml = REXML::Document.new(response)
|
282
|
-
success = response_success?(xml)
|
283
|
-
message = response_message(xml)
|
284
|
-
|
285
|
-
if success
|
286
|
-
rate_estimates = []
|
287
|
-
|
288
|
-
xml.elements.each('/*/RatedShipment') do |rated_shipment|
|
289
|
-
service_code = rated_shipment.get_text('Service/Code').to_s
|
290
|
-
days_to_delivery = rated_shipment.get_text('GuaranteedDaysToDelivery').to_s.to_i
|
291
|
-
delivery_date = days_to_delivery >= 1 ? days_to_delivery.days.from_now.strftime("%Y-%m-%d") : nil
|
292
|
-
rate_estimates << RateEstimate.new(origin, destination, @@name,
|
293
|
-
service_name_for(origin, service_code),
|
294
|
-
:total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
|
295
|
-
:currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
|
296
|
-
:service_code => service_code,
|
297
|
-
:packages => packages,
|
298
|
-
:delivery_range => [delivery_date],
|
299
|
-
:negotiated_rate => rated_shipment.get_text('NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue').to_s.to_f)
|
300
|
-
end
|
301
|
-
end
|
302
|
-
RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
|
303
|
-
end
|
304
|
-
|
305
|
-
def parse_tracking_response(response, options={})
|
306
|
-
xml = REXML::Document.new(response)
|
307
|
-
success = response_success?(xml)
|
308
|
-
message = response_message(xml)
|
309
|
-
|
310
|
-
if success
|
311
|
-
tracking_number, origin, destination, status_code, status_description = nil
|
312
|
-
delivered, exception = false
|
313
|
-
exception_event = nil
|
314
|
-
shipment_events = []
|
315
|
-
status = {}
|
316
|
-
scheduled_delivery_date = nil
|
317
|
-
|
318
|
-
first_shipment = xml.elements['/*/Shipment']
|
319
|
-
first_package = first_shipment.elements['Package']
|
320
|
-
tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
|
321
|
-
|
322
|
-
# Build status hash
|
323
|
-
status_node = first_package.elements['Activity/Status/StatusType']
|
324
|
-
status_code = status_node.get_text('Code').to_s
|
325
|
-
status_description = status_node.get_text('Description').to_s
|
326
|
-
status = TRACKING_STATUS_CODES[status_code]
|
327
|
-
|
328
|
-
if status_description =~ /out.*delivery/i
|
329
|
-
status = :out_for_delivery
|
330
|
-
end
|
331
|
-
|
332
|
-
origin, destination = %w{Shipper ShipTo}.map do |location|
|
333
|
-
location_from_address_node(first_shipment.elements["#{location}/Address"])
|
334
|
-
end
|
335
|
-
|
336
|
-
# Get scheduled delivery date
|
337
|
-
unless status == :delivered
|
338
|
-
scheduled_delivery_date = parse_ups_datetime({
|
339
|
-
:date => first_shipment.get_text('ScheduledDeliveryDate'),
|
340
|
-
:time => nil
|
341
|
-
})
|
342
|
-
end
|
343
|
-
|
344
|
-
activities = first_package.get_elements('Activity')
|
345
|
-
unless activities.empty?
|
346
|
-
shipment_events = activities.map do |activity|
|
347
|
-
description = activity.get_text('Status/StatusType/Description').to_s
|
348
|
-
zoneless_time = if (time = activity.get_text('Time')) &&
|
349
|
-
(date = activity.get_text('Date'))
|
350
|
-
time, date = time.to_s, date.to_s
|
351
|
-
hour, minute, second = time.scan(/\d{2}/)
|
352
|
-
year, month, day = date[0..3], date[4..5], date[6..7]
|
353
|
-
Time.utc(year, month, day, hour, minute, second)
|
354
|
-
end
|
355
|
-
location = location_from_address_node(activity.elements['ActivityLocation/Address'])
|
356
|
-
ShipmentEvent.new(description, zoneless_time, location)
|
357
|
-
end
|
358
|
-
|
359
|
-
shipment_events = shipment_events.sort_by(&:time)
|
360
|
-
|
361
|
-
# UPS will sometimes archive a shipment, stripping all shipment activity except for the delivery
|
362
|
-
# event (see test/fixtures/xml/delivered_shipment_without_events_tracking_response.xml for an example).
|
363
|
-
# This adds an origin event to the shipment activity in such cases.
|
364
|
-
if origin && !(shipment_events.count == 1 && status == :delivered)
|
365
|
-
first_event = shipment_events[0]
|
366
|
-
same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
|
367
|
-
same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
|
368
|
-
origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
|
369
|
-
if same_country and same_or_blank_city
|
370
|
-
shipment_events[0] = origin_event
|
371
|
-
else
|
372
|
-
shipment_events.unshift(origin_event)
|
373
|
-
end
|
374
|
-
end
|
375
|
-
|
376
|
-
# Has the shipment been delivered?
|
377
|
-
if status == :delivered
|
378
|
-
if !destination
|
379
|
-
destination = shipment_events[-1].location
|
380
|
-
end
|
381
|
-
shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
|
382
|
-
end
|
383
|
-
end
|
384
|
-
|
385
|
-
end
|
386
|
-
TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
|
387
|
-
:carrier => @@name,
|
388
|
-
:xml => response,
|
389
|
-
:request => last_request,
|
390
|
-
:status => status,
|
391
|
-
:status_code => status_code,
|
392
|
-
:status_description => status_description,
|
393
|
-
:scheduled_delivery_date => scheduled_delivery_date,
|
394
|
-
:shipment_events => shipment_events,
|
395
|
-
:delivered => delivered,
|
396
|
-
:exception => exception,
|
397
|
-
:exception_event => exception_event,
|
398
|
-
:origin => origin,
|
399
|
-
:destination => destination,
|
400
|
-
:tracking_number => tracking_number)
|
401
|
-
end
|
402
|
-
|
403
|
-
def location_from_address_node(address)
|
404
|
-
return nil unless address
|
405
|
-
Location.new(
|
406
|
-
:country => node_text_or_nil(address.elements['CountryCode']),
|
407
|
-
:postal_code => node_text_or_nil(address.elements['PostalCode']),
|
408
|
-
:province => node_text_or_nil(address.elements['StateProvinceCode']),
|
409
|
-
:city => node_text_or_nil(address.elements['City']),
|
410
|
-
:address1 => node_text_or_nil(address.elements['AddressLine1']),
|
411
|
-
:address2 => node_text_or_nil(address.elements['AddressLine2']),
|
412
|
-
:address3 => node_text_or_nil(address.elements['AddressLine3'])
|
413
|
-
)
|
414
|
-
end
|
415
|
-
|
416
|
-
def parse_ups_datetime(options = {})
|
417
|
-
time, date = options[:time].to_s, options[:date].to_s
|
418
|
-
if time.nil?
|
419
|
-
hour, minute, second = 0
|
420
|
-
else
|
421
|
-
hour, minute, second = time.scan(/\d{2}/)
|
422
|
-
end
|
423
|
-
year, month, day = date[0..3], date[4..5], date[6..7]
|
424
|
-
|
425
|
-
Time.utc(year, month, day, hour, minute, second)
|
426
|
-
end
|
427
|
-
|
428
|
-
def response_success?(xml)
|
429
|
-
xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
|
430
|
-
end
|
431
|
-
|
432
|
-
def response_message(xml)
|
433
|
-
xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
|
434
|
-
end
|
435
|
-
|
436
|
-
def commit(action, request, test = false)
|
437
|
-
ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
|
438
|
-
end
|
439
|
-
|
440
|
-
|
441
|
-
def service_name_for(origin, code)
|
442
|
-
origin = origin.country_code(:alpha2)
|
443
|
-
|
444
|
-
name = case origin
|
445
|
-
when "CA" then CANADA_ORIGIN_SERVICES[code]
|
446
|
-
when "MX" then MEXICO_ORIGIN_SERVICES[code]
|
447
|
-
when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
|
448
|
-
end
|
449
|
-
|
450
|
-
name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
|
451
|
-
name ||= DEFAULT_SERVICES[code]
|
452
|
-
end
|
453
|
-
|
454
|
-
end
|
455
|
-
end
|
456
|
-
end
|
@@ -1,616 +0,0 @@
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
2
|
-
require 'cgi'
|
3
|
-
|
4
|
-
module ActiveMerchant
|
5
|
-
module Shipping
|
6
|
-
|
7
|
-
# After getting an API login from USPS (looks like '123YOURNAME456'),
|
8
|
-
# run the following test:
|
9
|
-
#
|
10
|
-
# usps = USPS.new(:login => '123YOURNAME456', :test => true)
|
11
|
-
# usps.valid_credentials?
|
12
|
-
#
|
13
|
-
# This will send a test request to the USPS test servers, which they ask you
|
14
|
-
# to do before they put your API key in production mode.
|
15
|
-
class USPS < Carrier
|
16
|
-
self.retry_safe = true
|
17
|
-
|
18
|
-
cattr_reader :name
|
19
|
-
@@name = "USPS"
|
20
|
-
|
21
|
-
LIVE_DOMAIN = 'production.shippingapis.com'
|
22
|
-
LIVE_RESOURCE = 'ShippingAPI.dll'
|
23
|
-
|
24
|
-
TEST_DOMAINS = { #indexed by security; e.g. TEST_DOMAINS[USE_SSL[:rates]]
|
25
|
-
true => 'secure.shippingapis.com',
|
26
|
-
false => 'testing.shippingapis.com'
|
27
|
-
}
|
28
|
-
|
29
|
-
TEST_RESOURCE = 'ShippingAPITest.dll'
|
30
|
-
|
31
|
-
API_CODES = {
|
32
|
-
:us_rates => 'RateV4',
|
33
|
-
:world_rates => 'IntlRateV2',
|
34
|
-
:test => 'CarrierPickupAvailability',
|
35
|
-
:track => 'TrackV2'
|
36
|
-
}
|
37
|
-
USE_SSL = {
|
38
|
-
:us_rates => false,
|
39
|
-
:world_rates => false,
|
40
|
-
:test => true,
|
41
|
-
:track => false
|
42
|
-
}
|
43
|
-
CONTAINERS = {
|
44
|
-
:envelope => 'Flat Rate Envelope',
|
45
|
-
:box => 'Flat Rate Box'
|
46
|
-
}
|
47
|
-
MAIL_TYPES = {
|
48
|
-
:package => 'Package',
|
49
|
-
:postcard => 'Postcards or aerogrammes',
|
50
|
-
:matter_for_the_blind => 'Matter for the blind',
|
51
|
-
:envelope => 'Envelope'
|
52
|
-
}
|
53
|
-
|
54
|
-
PACKAGE_PROPERTIES = {
|
55
|
-
'ZipOrigination' => :origin_zip,
|
56
|
-
'ZipDestination' => :destination_zip,
|
57
|
-
'Pounds' => :pounds,
|
58
|
-
'Ounces' => :ounces,
|
59
|
-
'Container' => :container,
|
60
|
-
'Size' => :size,
|
61
|
-
'Machinable' => :machinable,
|
62
|
-
'Zone' => :zone,
|
63
|
-
'Postage' => :postage,
|
64
|
-
'Restrictions' => :restrictions
|
65
|
-
}
|
66
|
-
POSTAGE_PROPERTIES = {
|
67
|
-
'MailService' => :service,
|
68
|
-
'Rate' => :rate
|
69
|
-
}
|
70
|
-
US_SERVICES = {
|
71
|
-
:first_class => 'FIRST CLASS',
|
72
|
-
:priority => 'PRIORITY',
|
73
|
-
:express => 'EXPRESS',
|
74
|
-
:bpm => 'BPM',
|
75
|
-
:parcel => 'PARCEL',
|
76
|
-
:media => 'MEDIA',
|
77
|
-
:library => 'LIBRARY',
|
78
|
-
:all => 'ALL'
|
79
|
-
}
|
80
|
-
FIRST_CLASS_MAIL_TYPES = {
|
81
|
-
:letter => 'LETTER',
|
82
|
-
:flat => 'FLAT',
|
83
|
-
:parcel => 'PARCEL',
|
84
|
-
:post_card => 'POSTCARD',
|
85
|
-
:package_service => 'PACKAGESERVICE'
|
86
|
-
}
|
87
|
-
|
88
|
-
# TODO: get rates for "U.S. possessions and Trust Territories" like Guam, etc. via domestic rates API: http://www.usps.com/ncsc/lookups/abbr_state.txt
|
89
|
-
# TODO: figure out how USPS likes to say "Ivory Coast"
|
90
|
-
#
|
91
|
-
# Country names:
|
92
|
-
# http://pe.usps.gov/text/Imm/immctry.htm
|
93
|
-
COUNTRY_NAME_CONVERSIONS = {
|
94
|
-
"BA" => "Bosnia-Herzegovina",
|
95
|
-
"CD" => "Congo, Democratic Republic of the",
|
96
|
-
"CG" => "Congo (Brazzaville),Republic of the",
|
97
|
-
"CI" => "Côte d'Ivoire (Ivory Coast)",
|
98
|
-
"CK" => "Cook Islands (New Zealand)",
|
99
|
-
"FK" => "Falkland Islands",
|
100
|
-
"GB" => "Great Britain and Northern Ireland",
|
101
|
-
"GE" => "Georgia, Republic of",
|
102
|
-
"IR" => "Iran",
|
103
|
-
"KN" => "Saint Kitts (St. Christopher and Nevis)",
|
104
|
-
"KP" => "North Korea (Korea, Democratic People's Republic of)",
|
105
|
-
"KR" => "South Korea (Korea, Republic of)",
|
106
|
-
"LA" => "Laos",
|
107
|
-
"LY" => "Libya",
|
108
|
-
"MC" => "Monaco (France)",
|
109
|
-
"MD" => "Moldova",
|
110
|
-
"MK" => "Macedonia, Republic of",
|
111
|
-
"MM" => "Burma",
|
112
|
-
"PN" => "Pitcairn Island",
|
113
|
-
"RU" => "Russia",
|
114
|
-
"SK" => "Slovak Republic",
|
115
|
-
"TK" => "Tokelau (Union) Group (Western Samoa)",
|
116
|
-
"TW" => "Taiwan",
|
117
|
-
"TZ" => "Tanzania",
|
118
|
-
"VA" => "Vatican City",
|
119
|
-
"VG" => "British Virgin Islands",
|
120
|
-
"VN" => "Vietnam",
|
121
|
-
"WF" => "Wallis and Futuna Islands",
|
122
|
-
"WS" => "Western Samoa"
|
123
|
-
}
|
124
|
-
|
125
|
-
def find_tracking_info(tracking_number, options={})
|
126
|
-
options = @options.update(options)
|
127
|
-
tracking_request = build_tracking_request(tracking_number, options)
|
128
|
-
response = commit(:track, tracking_request, (options[:test] || false))
|
129
|
-
parse_tracking_response(response, options)
|
130
|
-
end
|
131
|
-
|
132
|
-
def self.size_code_for(package)
|
133
|
-
if package.inches(:max) <= 12
|
134
|
-
'REGULAR'
|
135
|
-
else
|
136
|
-
'LARGE'
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
|
-
# from info at http://www.usps.com/businessmail101/mailcharacteristics/parcels.htm
|
141
|
-
#
|
142
|
-
# package.options[:books] -- 25 lb. limit instead of 35 for books or other printed matter.
|
143
|
-
# Defaults to false.
|
144
|
-
def self.package_machinable?(package, options={})
|
145
|
-
at_least_minimum = package.inches(:length) >= 6.0 &&
|
146
|
-
package.inches(:width) >= 3.0 &&
|
147
|
-
package.inches(:height) >= 0.25 &&
|
148
|
-
package.ounces >= 6.0
|
149
|
-
at_most_maximum = package.inches(:length) <= 34.0 &&
|
150
|
-
package.inches(:width) <= 17.0 &&
|
151
|
-
package.inches(:height) <= 17.0 &&
|
152
|
-
package.pounds <= (package.options[:books] ? 25.0 : 35.0)
|
153
|
-
at_least_minimum && at_most_maximum
|
154
|
-
end
|
155
|
-
|
156
|
-
def requirements
|
157
|
-
[:login]
|
158
|
-
end
|
159
|
-
|
160
|
-
def find_rates(origin, destination, packages, options = {})
|
161
|
-
options = @options.merge(options)
|
162
|
-
|
163
|
-
origin = Location.from(origin)
|
164
|
-
destination = Location.from(destination)
|
165
|
-
packages = Array(packages)
|
166
|
-
|
167
|
-
#raise ArgumentError.new("USPS packages must originate in the U.S.") unless ['US',nil].include?(origin.country_code(:alpha2))
|
168
|
-
|
169
|
-
|
170
|
-
# domestic or international?
|
171
|
-
|
172
|
-
response = if ['US',nil].include?(destination.country_code(:alpha2))
|
173
|
-
us_rates(origin, destination, packages, options)
|
174
|
-
else
|
175
|
-
world_rates(origin, destination, packages, options)
|
176
|
-
end
|
177
|
-
end
|
178
|
-
|
179
|
-
def valid_credentials?
|
180
|
-
# Cannot test with find_rates because USPS doesn't allow that in test mode
|
181
|
-
test_mode? ? canned_address_verification_works? : super
|
182
|
-
end
|
183
|
-
|
184
|
-
def maximum_weight
|
185
|
-
Mass.new(70, :pounds)
|
186
|
-
end
|
187
|
-
<<<<<<< HEAD
|
188
|
-
|
189
|
-
|
190
|
-
def find_tracking_info(tracking_number, options={})
|
191
|
-
options = @options.update(options)
|
192
|
-
tracking_request = build_tracking_request(tracking_number, options)
|
193
|
-
response = commit(save_request(tracking_request), (options[:test] || false)).gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
|
194
|
-
parse_tracking_response(response, options)
|
195
|
-
end
|
196
|
-
|
197
|
-
|
198
|
-
=======
|
199
|
-
|
200
|
-
>>>>>>> d1ef4f7... further cleanup prior to pull request
|
201
|
-
protected
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
def response_success?(xml)
|
206
|
-
xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
|
207
|
-
end
|
208
|
-
|
209
|
-
def response_message(xml)
|
210
|
-
xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
|
211
|
-
end
|
212
|
-
|
213
|
-
def parse_tracking_response(response, options={})
|
214
|
-
xml = REXML::Document.new(response)
|
215
|
-
success = response_success?(xml)
|
216
|
-
message = response_message(xml)
|
217
|
-
|
218
|
-
if success
|
219
|
-
tracking_number, origin, destination = nil
|
220
|
-
shipment_events = []
|
221
|
-
|
222
|
-
first_shipment = xml.elements['/*/Shipment']
|
223
|
-
first_package = first_shipment.elements['Package']
|
224
|
-
tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
|
225
|
-
|
226
|
-
origin, destination = %w{Shipper ShipTo}.map do |location|
|
227
|
-
location_from_address_node(first_shipment.elements["#{location}/Address"])
|
228
|
-
end
|
229
|
-
|
230
|
-
activities = first_package.get_elements('Activity')
|
231
|
-
unless activities.empty?
|
232
|
-
shipment_events = activities.map do |activity|
|
233
|
-
description = activity.get_text('Status/StatusType/Description').to_s
|
234
|
-
zoneless_time = if (time = activity.get_text('Time')) &&
|
235
|
-
(date = activity.get_text('Date'))
|
236
|
-
time, date = time.to_s, date.to_s
|
237
|
-
hour, minute, second = time.scan(/\d{2}/)
|
238
|
-
year, month, day = date[0..3], date[4..5], date[6..7]
|
239
|
-
Time.utc(year, month, day, hour, minute, second)
|
240
|
-
end
|
241
|
-
location = location_from_address_node(activity.elements['ActivityLocation/Address'])
|
242
|
-
ShipmentEvent.new(description, zoneless_time, location)
|
243
|
-
end
|
244
|
-
|
245
|
-
shipment_events = shipment_events.sort_by(&:time)
|
246
|
-
|
247
|
-
if origin
|
248
|
-
first_event = shipment_events[0]
|
249
|
-
same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
|
250
|
-
same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
|
251
|
-
origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
|
252
|
-
if same_country and same_or_blank_city
|
253
|
-
shipment_events[0] = origin_event
|
254
|
-
else
|
255
|
-
shipment_events.unshift(origin_event)
|
256
|
-
end
|
257
|
-
end
|
258
|
-
if shipment_events.last.name.downcase == 'delivered'
|
259
|
-
shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
|
260
|
-
end
|
261
|
-
end
|
262
|
-
|
263
|
-
end
|
264
|
-
TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
|
265
|
-
:xml => response,
|
266
|
-
:request => last_request,
|
267
|
-
:shipment_events => shipment_events,
|
268
|
-
:origin => origin,
|
269
|
-
:destination => destination,
|
270
|
-
:tracking_number => tracking_number)
|
271
|
-
end
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
def build_tracking_request(tracking_number, options={})
|
279
|
-
xml_request = XmlNode.new('TrackRequest', 'USERID' => @options[:login]) do |root_node|
|
280
|
-
root_node << XmlNode.new('TrackID', :ID => tracking_number)
|
281
|
-
end
|
282
|
-
URI.encode(xml_request.to_s)
|
283
|
-
end
|
284
|
-
|
285
|
-
def us_rates(origin, destination, packages, options={})
|
286
|
-
request = build_us_rate_request(packages, origin.zip, destination.zip, options)
|
287
|
-
# never use test mode; rate requests just won't work on test servers
|
288
|
-
parse_rate_response origin, destination, packages, commit(:us_rates,request,false), options
|
289
|
-
end
|
290
|
-
|
291
|
-
def world_rates(origin, destination, packages, options={})
|
292
|
-
request = build_world_rate_request(packages, destination)
|
293
|
-
# never use test mode; rate requests just won't work on test servers
|
294
|
-
parse_rate_response origin, destination, packages, commit(:world_rates,request,false), options
|
295
|
-
end
|
296
|
-
|
297
|
-
# Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead.
|
298
|
-
def canned_address_verification_works?
|
299
|
-
request = "%3CCarrierPickupAvailabilityRequest%20USERID=%22#{URI.encode(@options[:login])}%22%3E%20%0A%3CFirmName%3EABC%20Corp.%3C/FirmName%3E%20%0A%3CSuiteOrApt%3ESuite%20777%3C/SuiteOrApt%3E%20%0A%3CAddress2%3E1390%20Market%20Street%3C/Address2%3E%20%0A%3CUrbanization%3E%3C/Urbanization%3E%20%0A%3CCity%3EHouston%3C/City%3E%20%0A%3CState%3ETX%3C/State%3E%20%0A%3CZIP5%3E77058%3C/ZIP5%3E%20%0A%3CZIP4%3E1234%3C/ZIP4%3E%20%0A%3C/CarrierPickupAvailabilityRequest%3E%0A"
|
300
|
-
# expected_hash = {"CarrierPickupAvailabilityResponse"=>{"City"=>"HOUSTON", "Address2"=>"1390 Market Street", "FirmName"=>"ABC Corp.", "State"=>"TX", "Date"=>"3/1/2004", "DayOfWeek"=>"Monday", "Urbanization"=>nil, "ZIP4"=>"1234", "ZIP5"=>"77058", "CarrierRoute"=>"C", "SuiteOrApt"=>"Suite 777"}}
|
301
|
-
xml = REXML::Document.new(commit(:test, request, true))
|
302
|
-
xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'HOUSTON' &&
|
303
|
-
xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '1390 Market Street'
|
304
|
-
end
|
305
|
-
|
306
|
-
# options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel,
|
307
|
-
# :media, :library, :all]. defaults to :all.
|
308
|
-
# options[:container] -- One of [:envelope, :box]. defaults to neither (this field has
|
309
|
-
# special meaning in the USPS API).
|
310
|
-
# options[:books] -- Either true or false. Packages of books or other printed matter
|
311
|
-
# have a lower weight limit to be considered machinable.
|
312
|
-
# package.options[:machinable] -- Either true or false. Overrides the detection of
|
313
|
-
# "machinability" entirely.
|
314
|
-
def build_us_rate_request(packages, origin_zip, destination_zip, options={})
|
315
|
-
packages = Array(packages)
|
316
|
-
request = XmlNode.new('RateV4Request', :USERID => @options[:login]) do |rate_request|
|
317
|
-
packages.each_with_index do |p,id|
|
318
|
-
rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
|
319
|
-
package << XmlNode.new('Service', US_SERVICES[options[:service] || :all])
|
320
|
-
package << XmlNode.new('FirstClassMailType', FIRST_CLASS_MAIL_TYPES[options[:first_class_mail_type]])
|
321
|
-
package << XmlNode.new('ZipOrigination', strip_zip(origin_zip))
|
322
|
-
package << XmlNode.new('ZipDestination', strip_zip(destination_zip))
|
323
|
-
package << XmlNode.new('Pounds', 0)
|
324
|
-
package << XmlNode.new('Ounces', "%0.1f" % [p.ounces,1].max)
|
325
|
-
package << XmlNode.new('Container', CONTAINERS[p.options[:container]])
|
326
|
-
package << XmlNode.new('Size', USPS.size_code_for(p))
|
327
|
-
package << XmlNode.new('Width', "%0.2f" % p.inches(:width))
|
328
|
-
package << XmlNode.new('Length', "%0.2f" % p.inches(:length))
|
329
|
-
package << XmlNode.new('Height', "%0.2f" % p.inches(:height))
|
330
|
-
package << XmlNode.new('Girth', "%0.2f" % p.inches(:girth))
|
331
|
-
is_machinable = if p.options.has_key?(:machinable)
|
332
|
-
p.options[:machinable] ? true : false
|
333
|
-
else
|
334
|
-
USPS.package_machinable?(p)
|
335
|
-
end
|
336
|
-
package << XmlNode.new('Machinable', is_machinable.to_s.upcase)
|
337
|
-
end
|
338
|
-
end
|
339
|
-
end
|
340
|
-
URI.encode(save_request(request.to_s))
|
341
|
-
end
|
342
|
-
|
343
|
-
# important difference with international rate requests:
|
344
|
-
# * services are not given in the request
|
345
|
-
# * package sizes are not given in the request
|
346
|
-
# * services are returned in the response along with restrictions of size
|
347
|
-
# * the size restrictions are returned AS AN ENGLISH SENTENCE (!?)
|
348
|
-
#
|
349
|
-
#
|
350
|
-
# package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
|
351
|
-
# Defaults to :package.
|
352
|
-
def build_world_rate_request(packages, destination)
|
353
|
-
country = COUNTRY_NAME_CONVERSIONS[destination.country.code(:alpha2).value] || destination.country.name
|
354
|
-
request = XmlNode.new('IntlRateV2Request', :USERID => @options[:login]) do |rate_request|
|
355
|
-
packages.each_index do |id|
|
356
|
-
p = packages[id]
|
357
|
-
rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
|
358
|
-
package << XmlNode.new('Pounds', 0)
|
359
|
-
package << XmlNode.new('Ounces', [p.ounces,1].max.ceil) #takes an integer for some reason, must be rounded UP
|
360
|
-
package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package')
|
361
|
-
package << XmlNode.new('GXG') do |gxg|
|
362
|
-
gxg << XmlNode.new('POBoxFlag', destination.po_box? ? 'Y' : 'N')
|
363
|
-
gxg << XmlNode.new('GiftFlag', p.gift? ? 'Y' : 'N')
|
364
|
-
end
|
365
|
-
value = if p.value && p.value > 0 && p.currency && p.currency != 'USD'
|
366
|
-
0.0
|
367
|
-
else
|
368
|
-
(p.value || 0) / 100.0
|
369
|
-
end
|
370
|
-
package << XmlNode.new('ValueOfContents', value)
|
371
|
-
package << XmlNode.new('Country') do |node|
|
372
|
-
node.cdata = country
|
373
|
-
end
|
374
|
-
package << XmlNode.new('Container', p.cylinder? ? 'NONRECTANGULAR' : 'RECTANGULAR')
|
375
|
-
package << XmlNode.new('Size', USPS.size_code_for(p))
|
376
|
-
package << XmlNode.new('Width', "%0.2f" % [p.inches(:width), 0.01].max)
|
377
|
-
package << XmlNode.new('Length', "%0.2f" % [p.inches(:length), 0.01].max)
|
378
|
-
package << XmlNode.new('Height', "%0.2f" % [p.inches(:height), 0.01].max)
|
379
|
-
package << XmlNode.new('Girth', "%0.2f" % [p.inches(:girth), 0.01].max)
|
380
|
-
end
|
381
|
-
end
|
382
|
-
end
|
383
|
-
URI.encode(save_request(request.to_s))
|
384
|
-
end
|
385
|
-
|
386
|
-
def parse_rate_response(origin, destination, packages, response, options={})
|
387
|
-
success = true
|
388
|
-
message = ''
|
389
|
-
rate_hash = {}
|
390
|
-
|
391
|
-
xml = REXML::Document.new(response)
|
392
|
-
|
393
|
-
if error = xml.elements['/Error']
|
394
|
-
success = false
|
395
|
-
message = error.elements['Description'].text
|
396
|
-
else
|
397
|
-
xml.elements.each('/*/Package') do |package|
|
398
|
-
if package.elements['Error']
|
399
|
-
success = false
|
400
|
-
message = package.get_text('Error/Description').to_s
|
401
|
-
break
|
402
|
-
end
|
403
|
-
end
|
404
|
-
|
405
|
-
if success
|
406
|
-
rate_hash = rates_from_response_node(xml, packages)
|
407
|
-
unless rate_hash
|
408
|
-
success = false
|
409
|
-
message = "Unknown root node in XML response: '#{xml.root.name}'"
|
410
|
-
end
|
411
|
-
end
|
412
|
-
|
413
|
-
end
|
414
|
-
|
415
|
-
if success
|
416
|
-
rate_estimates = rate_hash.keys.map do |service_name|
|
417
|
-
RateEstimate.new(origin,destination,@@name,"USPS #{service_name}",
|
418
|
-
:package_rates => rate_hash[service_name][:package_rates],
|
419
|
-
:service_code => rate_hash[service_name][:service_code],
|
420
|
-
:currency => 'USD')
|
421
|
-
end
|
422
|
-
rate_estimates.reject! {|e| e.package_count != packages.length}
|
423
|
-
rate_estimates = rate_estimates.sort_by(&:total_price)
|
424
|
-
end
|
425
|
-
|
426
|
-
RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request)
|
427
|
-
end
|
428
|
-
|
429
|
-
def rates_from_response_node(response_node, packages)
|
430
|
-
rate_hash = {}
|
431
|
-
return false unless (root_node = response_node.elements['/IntlRateV2Response | /RateV4Response'])
|
432
|
-
domestic = (root_node.name == 'RateV4Response')
|
433
|
-
|
434
|
-
domestic_elements = ['Postage', 'CLASSID', 'MailService', 'Rate']
|
435
|
-
international_elements = ['Service', 'ID', 'SvcDescription', 'Postage']
|
436
|
-
service_node, service_code_node, service_name_node, rate_node = domestic ? domestic_elements : international_elements
|
437
|
-
|
438
|
-
root_node.each_element('Package') do |package_node|
|
439
|
-
this_package = packages[package_node.attributes['ID'].to_i]
|
440
|
-
|
441
|
-
package_node.each_element(service_node) do |service_response_node|
|
442
|
-
service_name = service_response_node.get_text(service_name_node).to_s
|
443
|
-
|
444
|
-
# strips the double-escaped HTML for trademark symbols from service names
|
445
|
-
service_name.gsub!(/&lt;\S*&gt;/,'')
|
446
|
-
# ...leading "USPS"
|
447
|
-
service_name.gsub!(/^USPS/,'')
|
448
|
-
# ...trailing asterisks
|
449
|
-
service_name.gsub!(/\*+$/,'')
|
450
|
-
# ...surrounding spaces
|
451
|
-
service_name.strip!
|
452
|
-
|
453
|
-
# aggregate specific package rates into a service-centric RateEstimate
|
454
|
-
# first package with a given service name will initialize these;
|
455
|
-
# later packages with same service will add to them
|
456
|
-
this_service = rate_hash[service_name] ||= {}
|
457
|
-
this_service[:service_code] ||= service_response_node.attributes[service_code_node]
|
458
|
-
package_rates = this_service[:package_rates] ||= []
|
459
|
-
this_package_rate = {:package => this_package,
|
460
|
-
:rate => Package.cents_from(service_response_node.get_text(rate_node).to_s.to_f)}
|
461
|
-
|
462
|
-
package_rates << this_package_rate if package_valid_for_service(this_package,service_response_node)
|
463
|
-
end
|
464
|
-
end
|
465
|
-
rate_hash
|
466
|
-
end
|
467
|
-
|
468
|
-
def package_valid_for_service(package, service_node)
|
469
|
-
return true if service_node.elements['MaxWeight'].nil?
|
470
|
-
max_weight = service_node.get_text('MaxWeight').to_s.to_f
|
471
|
-
name = service_node.get_text('SvcDescription | MailService').to_s.downcase
|
472
|
-
|
473
|
-
if name =~ /flat.rate.box/ #domestic or international flat rate box
|
474
|
-
# flat rate dimensions from http://www.usps.com/shipping/flatrate.htm
|
475
|
-
return (package_valid_for_max_dimensions(package,
|
476
|
-
:weight => max_weight, #domestic apparently has no weight restriction
|
477
|
-
:length => 11.0,
|
478
|
-
:width => 8.5,
|
479
|
-
:height => 5.5) or
|
480
|
-
package_valid_for_max_dimensions(package,
|
481
|
-
:weight => max_weight,
|
482
|
-
:length => 13.625,
|
483
|
-
:width => 11.875,
|
484
|
-
:height => 3.375))
|
485
|
-
elsif name =~ /flat.rate.envelope/
|
486
|
-
return package_valid_for_max_dimensions(package,
|
487
|
-
:weight => max_weight,
|
488
|
-
:length => 12.5,
|
489
|
-
:width => 9.5,
|
490
|
-
:height => 0.75)
|
491
|
-
elsif service_node.elements['MailService'] # domestic non-flat rates
|
492
|
-
return true
|
493
|
-
else #international non-flat rates
|
494
|
-
# Some sample english that this is required to parse:
|
495
|
-
#
|
496
|
-
# 'Max. length 46", width 35", height 46" and max. length plus girth 108"'
|
497
|
-
# 'Max. length 24", Max. length, height, depth combined 36"'
|
498
|
-
#
|
499
|
-
sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s)
|
500
|
-
tokens = sentence.downcase.split(/[^\d]*"/).reject {|t| t.empty?}
|
501
|
-
max_dimensions = {:weight => max_weight}
|
502
|
-
single_axis_values = []
|
503
|
-
tokens.each do |token|
|
504
|
-
axis_sum = [/length/,/width/,/height/,/depth/].sum {|regex| (token =~ regex) ? 1 : 0}
|
505
|
-
unless axis_sum == 0
|
506
|
-
value = token[/\d+$/].to_f
|
507
|
-
if axis_sum == 3
|
508
|
-
max_dimensions[:length_plus_width_plus_height] = value
|
509
|
-
elsif token =~ /girth/ and axis_sum == 1
|
510
|
-
max_dimensions[:length_plus_girth] = value
|
511
|
-
else
|
512
|
-
single_axis_values << value
|
513
|
-
end
|
514
|
-
end
|
515
|
-
end
|
516
|
-
single_axis_values.sort!.reverse!
|
517
|
-
[:length, :width, :height].each_with_index do |axis,i|
|
518
|
-
max_dimensions[axis] = single_axis_values[i] if single_axis_values[i]
|
519
|
-
end
|
520
|
-
return package_valid_for_max_dimensions(package, max_dimensions)
|
521
|
-
end
|
522
|
-
end
|
523
|
-
|
524
|
-
def package_valid_for_max_dimensions(package,dimensions)
|
525
|
-
valid = ((not ([:length,:width,:height].map {|dim| dimensions[dim].nil? || dimensions[dim].to_f >= package.inches(dim).to_f}.include?(false))) and
|
526
|
-
(dimensions[:weight].nil? || dimensions[:weight] >= package.pounds) and
|
527
|
-
(dimensions[:length_plus_girth].nil? or
|
528
|
-
dimensions[:length_plus_girth].to_f >=
|
529
|
-
package.inches(:length) + package.inches(:girth)) and
|
530
|
-
(dimensions[:length_plus_width_plus_height].nil? or
|
531
|
-
dimensions[:length_plus_width_plus_height].to_f >=
|
532
|
-
package.inches(:length) + package.inches(:width) + package.inches(:height)))
|
533
|
-
|
534
|
-
return valid
|
535
|
-
end
|
536
|
-
|
537
|
-
def parse_tracking_response(response, options)
|
538
|
-
xml = REXML::Document.new(response)
|
539
|
-
root_node = xml.elements['TrackResponse']
|
540
|
-
|
541
|
-
success = response_success?(xml)
|
542
|
-
message = response_message(xml)
|
543
|
-
|
544
|
-
if success
|
545
|
-
tracking_number, origin, destination = nil
|
546
|
-
shipment_events = []
|
547
|
-
tracking_details = xml.elements.collect('*/*/TrackDetail'){ |e| e }
|
548
|
-
|
549
|
-
tracking_summary = xml.elements.collect('*/*/TrackSummary'){ |e| e }.first
|
550
|
-
tracking_details << tracking_summary
|
551
|
-
|
552
|
-
tracking_number = root_node.elements['TrackInfo'].attributes['ID'].to_s
|
553
|
-
|
554
|
-
tracking_details.each do |event|
|
555
|
-
location = nil
|
556
|
-
timestamp = nil
|
557
|
-
description = nil
|
558
|
-
if event.get_text.to_s =~ /^(.*), (\w+ \d\d, \d{4}, \d{1,2}:\d\d [ap]m), (.*), (\w\w) (\d{5})$/i ||
|
559
|
-
event.get_text.to_s =~ /^Your item \w{2,3} (out for delivery|delivered) at (\d{1,2}:\d\d [ap]m on \w+ \d\d, \d{4}) in (.*), (\w\w) (\d{5})\.$/i
|
560
|
-
description = $1.upcase
|
561
|
-
timestamp = $2
|
562
|
-
city = $3
|
563
|
-
state = $4
|
564
|
-
zip_code = $5
|
565
|
-
location = Location.new(:city => city, :state => state, :postal_code => zip_code, :country => 'USA')
|
566
|
-
end
|
567
|
-
if location
|
568
|
-
time = Time.parse(timestamp)
|
569
|
-
zoneless_time = Time.utc(time.year, time.month, time.mday, time.hour, time.min, time.sec)
|
570
|
-
shipment_events << ShipmentEvent.new(description, zoneless_time, location)
|
571
|
-
end
|
572
|
-
end
|
573
|
-
shipment_events = shipment_events.sort_by(&:time)
|
574
|
-
end
|
575
|
-
|
576
|
-
TrackingResponse.new(success, message, Hash.from_xml(response),
|
577
|
-
:xml => response,
|
578
|
-
:request => last_request,
|
579
|
-
:shipment_events => shipment_events,
|
580
|
-
:destination => destination,
|
581
|
-
:tracking_number => tracking_number
|
582
|
-
)
|
583
|
-
end
|
584
|
-
|
585
|
-
def response_status_node(document)
|
586
|
-
document.elements['*/*/TrackSummary'] || document.elements['Error/Description']
|
587
|
-
end
|
588
|
-
|
589
|
-
def response_success?(document)
|
590
|
-
summary = response_status_node(document).get_text.to_s
|
591
|
-
!(summary =~ /There is no record of that mail item/ || summary =~ /This Information has not been included in this Test Server\./)
|
592
|
-
end
|
593
|
-
|
594
|
-
def response_message(document)
|
595
|
-
response_node = response_status_node(document)
|
596
|
-
response_status_node(document).get_text.to_s
|
597
|
-
end
|
598
|
-
|
599
|
-
def commit(action, request, test = false)
|
600
|
-
ssl_get(request_url(action, request, test))
|
601
|
-
end
|
602
|
-
|
603
|
-
def request_url(action, request, test)
|
604
|
-
scheme = USE_SSL[action] ? 'https://' : 'http://'
|
605
|
-
host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN
|
606
|
-
resource = test ? TEST_RESOURCE : LIVE_RESOURCE
|
607
|
-
"#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}"
|
608
|
-
end
|
609
|
-
|
610
|
-
def strip_zip(zip)
|
611
|
-
zip.to_s.scan(/\d{5}/).first || zip
|
612
|
-
end
|
613
|
-
|
614
|
-
end
|
615
|
-
end
|
616
|
-
end
|