omniship 0.1.0

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.
Files changed (38) hide show
  1. data/CHANGELOG +0 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.markdown +91 -0
  4. data/lib/omniship/address.rb +135 -0
  5. data/lib/omniship/base.rb +11 -0
  6. data/lib/omniship/carrier.rb +69 -0
  7. data/lib/omniship/carriers/fedex.rb +330 -0
  8. data/lib/omniship/carriers/ups.rb +564 -0
  9. data/lib/omniship/carriers/usps.rb +438 -0
  10. data/lib/omniship/carriers.rb +13 -0
  11. data/lib/omniship/contact.rb +19 -0
  12. data/lib/omniship/package.rb +147 -0
  13. data/lib/omniship/rate_estimate.rb +59 -0
  14. data/lib/omniship/rate_response.rb +16 -0
  15. data/lib/omniship/response.rb +42 -0
  16. data/lib/omniship/shipment_event.rb +12 -0
  17. data/lib/omniship/tracking_response.rb +18 -0
  18. data/lib/omniship/version.rb +3 -0
  19. data/lib/omniship.rb +73 -0
  20. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  21. data/lib/vendor/quantified/README.markdown +49 -0
  22. data/lib/vendor/quantified/Rakefile +21 -0
  23. data/lib/vendor/quantified/init.rb +0 -0
  24. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  25. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  26. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  27. data/lib/vendor/quantified/lib/quantified.rb +6 -0
  28. data/lib/vendor/quantified/test/length_test.rb +92 -0
  29. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  30. data/lib/vendor/quantified/test/test_helper.rb +10 -0
  31. data/lib/vendor/xml_node/README +36 -0
  32. data/lib/vendor/xml_node/Rakefile +21 -0
  33. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  34. data/lib/vendor/xml_node/init.rb +1 -0
  35. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  36. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  37. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  38. metadata +205 -0
@@ -0,0 +1,438 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require 'cgi'
3
+
4
+ module Omniship
5
+ # After getting an API login from USPS (looks like '123YOURNAME456'),
6
+ # run the following test:
7
+ #
8
+ # usps = USPS.new(:login => '123YOURNAME456', :test => true)
9
+ # usps.valid_credentials?
10
+ #
11
+ # This will send a test request to the USPS test servers, which they ask you
12
+ # to do before they put your API key in production mode.
13
+ class USPS < Carrier
14
+ self.retry_safe = true
15
+
16
+ cattr_reader :name
17
+ @@name = "USPS"
18
+
19
+ LIVE_DOMAIN = 'production.shippingapis.com'
20
+ LIVE_RESOURCE = 'ShippingAPI.dll'
21
+
22
+ TEST_DOMAINS = { #indexed by security; e.g. TEST_DOMAINS[USE_SSL[:rates]]
23
+ true => 'secure.shippingapis.com',
24
+ false => 'testing.shippingapis.com'
25
+ }
26
+
27
+ TEST_RESOURCE = 'ShippingAPITest.dll'
28
+
29
+ API_CODES = {
30
+ :us_rates => 'RateV4',
31
+ :world_rates => 'IntlRateV2',
32
+ :test => 'CarrierPickupAvailability'
33
+ }
34
+ USE_SSL = {
35
+ :us_rates => false,
36
+ :world_rates => false,
37
+ :test => true
38
+ }
39
+ CONTAINERS = {
40
+ :envelope => 'Flat Rate Envelope',
41
+ :box => 'Flat Rate Box'
42
+ }
43
+ MAIL_TYPES = {
44
+ :package => 'Package',
45
+ :postcard => 'Postcards or aerogrammes',
46
+ :matter_for_the_blind => 'Matter for the blind',
47
+ :envelope => 'Envelope'
48
+ }
49
+ PACKAGE_PROPERTIES = {
50
+ 'ZipOrigination' => :origin_zip,
51
+ 'ZipDestination' => :destination_zip,
52
+ 'Pounds' => :pounds,
53
+ 'Ounces' => :ounces,
54
+ 'Container' => :container,
55
+ 'Size' => :size,
56
+ 'Machinable' => :machinable,
57
+ 'Zone' => :zone,
58
+ 'Postage' => :postage,
59
+ 'Restrictions' => :restrictions
60
+ }
61
+ POSTAGE_PROPERTIES = {
62
+ 'MailService' => :service,
63
+ 'Rate' => :rate
64
+ }
65
+ US_SERVICES = {
66
+ :first_class => 'FIRST CLASS',
67
+ :priority => 'PRIORITY',
68
+ :express => 'EXPRESS',
69
+ :bpm => 'BPM',
70
+ :parcel => 'PARCEL',
71
+ :media => 'MEDIA',
72
+ :library => 'LIBRARY',
73
+ :all => 'ALL'
74
+ }
75
+
76
+ # 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
77
+ # TODO: figure out how USPS likes to say "Ivory Coast"
78
+ #
79
+ # Country names:
80
+ # http://pe.usps.gov/text/Imm/immctry.htm
81
+ COUNTRY_NAME_CONVERSIONS = {
82
+ "BA" => "Bosnia-Herzegovina",
83
+ "CD" => "Congo, Democratic Republic of the",
84
+ "CG" => "Congo (Brazzaville),Republic of the",
85
+ "CI" => "Côte d'Ivoire (Ivory Coast)",
86
+ "CK" => "Cook Islands (New Zealand)",
87
+ "FK" => "Falkland Islands",
88
+ "GB" => "Great Britain and Northern Ireland",
89
+ "GE" => "Georgia, Republic of",
90
+ "IR" => "Iran",
91
+ "KN" => "Saint Kitts (St. Christopher and Nevis)",
92
+ "KP" => "North Korea (Korea, Democratic People's Republic of)",
93
+ "KR" => "South Korea (Korea, Republic of)",
94
+ "LA" => "Laos",
95
+ "LY" => "Libya",
96
+ "MC" => "Monaco (France)",
97
+ "MD" => "Moldova",
98
+ "MK" => "Macedonia, Republic of",
99
+ "MM" => "Burma",
100
+ "PN" => "Pitcairn Island",
101
+ "RU" => "Russia",
102
+ "SK" => "Slovak Republic",
103
+ "TK" => "Tokelau (Union) Group (Western Samoa)",
104
+ "TW" => "Taiwan",
105
+ "TZ" => "Tanzania",
106
+ "VA" => "Vatican City",
107
+ "VG" => "British Virgin Islands",
108
+ "VN" => "Vietnam",
109
+ "WF" => "Wallis and Futuna Islands",
110
+ "WS" => "Western Samoa"
111
+ }
112
+
113
+ def self.size_code_for(package)
114
+ if package.inches(:max) <= 12
115
+ 'REGULAR'
116
+ else
117
+ 'LARGE'
118
+ end
119
+ end
120
+
121
+ # from info at http://www.usps.com/businessmail101/mailcharacteristics/parcels.htm
122
+ #
123
+ # package.options[:books] -- 25 lb. limit instead of 35 for books or other printed matter.
124
+ # Defaults to false.
125
+ def self.package_machinable?(package, options={})
126
+ at_least_minimum = package.inches(:length) >= 6.0 &&
127
+ package.inches(:width) >= 3.0 &&
128
+ package.inches(:height) >= 0.25 &&
129
+ package.ounces >= 6.0
130
+ at_most_maximum = package.inches(:length) <= 34.0 &&
131
+ package.inches(:width) <= 17.0 &&
132
+ package.inches(:height) <= 17.0 &&
133
+ package.pounds <= (package.options[:books] ? 25.0 : 35.0)
134
+ at_least_minimum && at_most_maximum
135
+ end
136
+
137
+ def requirements
138
+ [:login]
139
+ end
140
+
141
+ def find_rates(origin, destination, packages, options = {})
142
+ options = @options.merge(options)
143
+
144
+ origin = Address.from(origin)
145
+ destination = Address.from(destination)
146
+ packages = Array(packages)
147
+
148
+ #raise ArgumentError.new("USPS packages must originate in the U.S.") unless ['US',nil].include?(origin.country_code(:alpha2))
149
+
150
+
151
+ # domestic or international?
152
+
153
+ response = if ['US',nil].include?(destination.country_code(:alpha2))
154
+ us_rates(origin, destination, packages, options)
155
+ else
156
+ world_rates(origin, destination, packages, options)
157
+ end
158
+ end
159
+
160
+ def valid_credentials?
161
+ # Cannot test with find_rates because USPS doesn't allow that in test mode
162
+ test_mode? ? canned_address_verification_works? : super
163
+ end
164
+
165
+ def maximum_weight
166
+ Mass.new(70, :pounds)
167
+ end
168
+
169
+ protected
170
+
171
+ def us_rates(origin, destination, packages, options={})
172
+ request = build_us_rate_request(packages, origin.zip, destination.zip, options)
173
+ # never use test mode; rate requests just won't work on test servers
174
+ parse_rate_response origin, destination, packages, commit(:us_rates,request,false), options
175
+ end
176
+
177
+ def world_rates(origin, destination, packages, options={})
178
+ request = build_world_rate_request(packages, destination)
179
+ # never use test mode; rate requests just won't work on test servers
180
+ parse_rate_response origin, destination, packages, commit(:world_rates,request,false), options
181
+ end
182
+
183
+ # Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead.
184
+ def canned_address_verification_works?
185
+ 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"
186
+ # 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"}}
187
+ xml = REXML::Document.new(commit(:test, request, true))
188
+ xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'HOUSTON' &&
189
+ xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '1390 Market Street'
190
+ end
191
+
192
+ # options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel,
193
+ # :media, :library, :all]. defaults to :all.
194
+ # options[:container] -- One of [:envelope, :box]. defaults to neither (this field has
195
+ # special meaning in the USPS API).
196
+ # options[:books] -- Either true or false. Packages of books or other printed matter
197
+ # have a lower weight limit to be considered machinable.
198
+ # package.options[:machinable] -- Either true or false. Overrides the detection of
199
+ # "machinability" entirely.
200
+ def build_us_rate_request(packages, origin_zip, destination_zip, options={})
201
+ packages = Array(packages)
202
+ request = XmlNode.new('RateV4Request', :USERID => @options[:login]) do |rate_request|
203
+ packages.each_with_index do |p,id|
204
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
205
+ package << XmlNode.new('Service', US_SERVICES[options[:service] || :all])
206
+ package << XmlNode.new('ZipOrigination', strip_zip(origin_zip))
207
+ package << XmlNode.new('ZipDestination', strip_zip(destination_zip))
208
+ package << XmlNode.new('Pounds', 0)
209
+ package << XmlNode.new('Ounces', "%0.1f" % [p.ounces,1].max)
210
+ package << XmlNode.new('Container', CONTAINERS[p.options[:container]])
211
+ package << XmlNode.new('Size', USPS.size_code_for(p))
212
+ package << XmlNode.new('Width', "%0.2f" % p.inches(:width))
213
+ package << XmlNode.new('Length', "%0.2f" % p.inches(:length))
214
+ package << XmlNode.new('Height', "%0.2f" % p.inches(:height))
215
+ package << XmlNode.new('Girth', "%0.2f" % p.inches(:girth))
216
+ is_machinable = if p.options.has_key?(:machinable)
217
+ p.options[:machinable] ? true : false
218
+ else
219
+ USPS.package_machinable?(p)
220
+ end
221
+ package << XmlNode.new('Machinable', is_machinable.to_s.upcase)
222
+ end
223
+ end
224
+ end
225
+ URI.encode(save_request(request.to_s))
226
+ end
227
+
228
+ # important difference with international rate requests:
229
+ # * services are not given in the request
230
+ # * package sizes are not given in the request
231
+ # * services are returned in the response along with restrictions of size
232
+ # * the size restrictions are returned AS AN ENGLISH SENTENCE (!?)
233
+ #
234
+ #
235
+ # package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
236
+ # Defaults to :package.
237
+ def build_world_rate_request(packages, destination)
238
+ country = COUNTRY_NAME_CONVERSIONS[destination.country.code(:alpha2).value] || destination.country.name
239
+ request = XmlNode.new('IntlRateV2Request', :USERID => @options[:login]) do |rate_request|
240
+ packages.each_index do |id|
241
+ p = packages[id]
242
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
243
+ package << XmlNode.new('Pounds', 0)
244
+ package << XmlNode.new('Ounces', [p.ounces,1].max.ceil) #takes an integer for some reason, must be rounded UP
245
+ package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package')
246
+ package << XmlNode.new('GXG') do |gxg|
247
+ gxg << XmlNode.new('POBoxFlag', destination.po_box? ? 'Y' : 'N')
248
+ gxg << XmlNode.new('GiftFlag', p.gift? ? 'Y' : 'N')
249
+ end
250
+ value = if p.value && p.value > 0 && p.currency && p.currency != 'USD'
251
+ 0.0
252
+ else
253
+ (p.value || 0) / 100.0
254
+ end
255
+ package << XmlNode.new('ValueOfContents', value)
256
+ package << XmlNode.new('Country') do |node|
257
+ node.cdata = country
258
+ end
259
+ package << XmlNode.new('Container', p.cylinder? ? 'NONRECTANGULAR' : 'RECTANGULAR')
260
+ package << XmlNode.new('Size', USPS.size_code_for(p))
261
+ package << XmlNode.new('Width', "%0.2f" % [p.inches(:width), 0.01].max)
262
+ package << XmlNode.new('Length', "%0.2f" % [p.inches(:length), 0.01].max)
263
+ package << XmlNode.new('Height', "%0.2f" % [p.inches(:height), 0.01].max)
264
+ package << XmlNode.new('Girth', "%0.2f" % [p.inches(:girth), 0.01].max)
265
+ end
266
+ end
267
+ end
268
+ URI.encode(save_request(request.to_s))
269
+ end
270
+
271
+ def parse_rate_response(origin, destination, packages, response, options={})
272
+ success = true
273
+ message = ''
274
+ rate_hash = {}
275
+
276
+ xml = REXML::Document.new(response)
277
+
278
+ if error = xml.elements['/Error']
279
+ success = false
280
+ message = error.elements['Description'].text
281
+ else
282
+ xml.elements.each('/*/Package') do |package|
283
+ if package.elements['Error']
284
+ success = false
285
+ message = package.get_text('Error/Description').to_s
286
+ break
287
+ end
288
+ end
289
+
290
+ if success
291
+ rate_hash = rates_from_response_node(xml, packages)
292
+ unless rate_hash
293
+ success = false
294
+ message = "Unknown root node in XML response: '#{xml.root.name}'"
295
+ end
296
+ end
297
+
298
+ end
299
+
300
+ if success
301
+ rate_estimates = rate_hash.keys.map do |service_name|
302
+ RateEstimate.new(origin,destination,@@name,"USPS #{service_name}",
303
+ :package_rates => rate_hash[service_name][:package_rates],
304
+ :service_code => rate_hash[service_name][:service_code],
305
+ :currency => 'USD')
306
+ end
307
+ rate_estimates.reject! {|e| e.package_count != packages.length}
308
+ rate_estimates = rate_estimates.sort_by(&:total_price)
309
+ end
310
+
311
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request)
312
+ end
313
+
314
+ def rates_from_response_node(response_node, packages)
315
+ rate_hash = {}
316
+ return false unless (root_node = response_node.elements['/IntlRateV2Response | /RateV4Response'])
317
+ domestic = (root_node.name == 'RateV4Response')
318
+
319
+ domestic_elements = ['Postage', 'CLASSID', 'MailService', 'Rate']
320
+ international_elements = ['Service', 'ID', 'SvcDescription', 'Postage']
321
+ service_node, service_code_node, service_name_node, rate_node = domestic ? domestic_elements : international_elements
322
+
323
+ root_node.each_element('Package') do |package_node|
324
+ this_package = packages[package_node.attributes['ID'].to_i]
325
+
326
+ package_node.each_element(service_node) do |service_response_node|
327
+ service_name = service_response_node.get_text(service_name_node).to_s
328
+
329
+ # strips the double-escaped HTML for trademark symbols from service names
330
+ service_name.gsub!(/&amp;lt;\S*&amp;gt;/,'')
331
+ # ...leading "USPS"
332
+ service_name.gsub!(/^USPS/,'')
333
+ # ...trailing asterisks
334
+ service_name.gsub!(/\*+$/,'')
335
+ # ...surrounding spaces
336
+ service_name.strip!
337
+
338
+ # aggregate specific package rates into a service-centric RateEstimate
339
+ # first package with a given service name will initialize these;
340
+ # later packages with same service will add to them
341
+ this_service = rate_hash[service_name] ||= {}
342
+ this_service[:service_code] ||= service_response_node.attributes[service_code_node]
343
+ package_rates = this_service[:package_rates] ||= []
344
+ this_package_rate = {:package => this_package,
345
+ :rate => Package.cents_from(service_response_node.get_text(rate_node).to_s.to_f)}
346
+
347
+ package_rates << this_package_rate if package_valid_for_service(this_package,service_response_node)
348
+ end
349
+ end
350
+ rate_hash
351
+ end
352
+
353
+ def package_valid_for_service(package, service_node)
354
+ return true if service_node.elements['MaxWeight'].nil?
355
+ max_weight = service_node.get_text('MaxWeight').to_s.to_f
356
+ name = service_node.get_text('SvcDescription | MailService').to_s.downcase
357
+
358
+ if name =~ /flat.rate.box/ #domestic or international flat rate box
359
+ # flat rate dimensions from http://www.usps.com/shipping/flatrate.htm
360
+ return (package_valid_for_max_dimensions(package,
361
+ :weight => max_weight, #domestic apparently has no weight restriction
362
+ :length => 11.0,
363
+ :width => 8.5,
364
+ :height => 5.5) or
365
+ package_valid_for_max_dimensions(package,
366
+ :weight => max_weight,
367
+ :length => 13.625,
368
+ :width => 11.875,
369
+ :height => 3.375))
370
+ elsif name =~ /flat.rate.envelope/
371
+ return package_valid_for_max_dimensions(package,
372
+ :weight => max_weight,
373
+ :length => 12.5,
374
+ :width => 9.5,
375
+ :height => 0.75)
376
+ elsif service_node.elements['MailService'] # domestic non-flat rates
377
+ return true
378
+ else #international non-flat rates
379
+ # Some sample english that this is required to parse:
380
+ #
381
+ # 'Max. length 46", width 35", height 46" and max. length plus girth 108"'
382
+ # 'Max. length 24", Max. length, height, depth combined 36"'
383
+ #
384
+ sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s)
385
+ tokens = sentence.downcase.split(/[^\d]*"/).reject {|t| t.empty?}
386
+ max_dimensions = {:weight => max_weight}
387
+ single_axis_values = []
388
+ tokens.each do |token|
389
+ axis_sum = [/length/,/width/,/height/,/depth/].sum {|regex| (token =~ regex) ? 1 : 0}
390
+ unless axis_sum == 0
391
+ value = token[/\d+$/].to_f
392
+ if axis_sum == 3
393
+ max_dimensions[:length_plus_width_plus_height] = value
394
+ elsif token =~ /girth/ and axis_sum == 1
395
+ max_dimensions[:length_plus_girth] = value
396
+ else
397
+ single_axis_values << value
398
+ end
399
+ end
400
+ end
401
+ single_axis_values.sort!.reverse!
402
+ [:length, :width, :height].each_with_index do |axis,i|
403
+ max_dimensions[axis] = single_axis_values[i] if single_axis_values[i]
404
+ end
405
+ return package_valid_for_max_dimensions(package, max_dimensions)
406
+ end
407
+ end
408
+
409
+ def package_valid_for_max_dimensions(package,dimensions)
410
+ valid = ((not ([:length,:width,:height].map {|dim| dimensions[dim].nil? || dimensions[dim].to_f >= package.inches(dim).to_f}.include?(false))) and
411
+ (dimensions[:weight].nil? || dimensions[:weight] >= package.pounds) and
412
+ (dimensions[:length_plus_girth].nil? or
413
+ dimensions[:length_plus_girth].to_f >=
414
+ package.inches(:length) + package.inches(:girth)) and
415
+ (dimensions[:length_plus_width_plus_height].nil? or
416
+ dimensions[:length_plus_width_plus_height].to_f >=
417
+ package.inches(:length) + package.inches(:width) + package.inches(:height)))
418
+
419
+ return valid
420
+ end
421
+
422
+ def commit(action, request, test = false)
423
+ ssl_get(request_url(action, request, test))
424
+ end
425
+
426
+ def request_url(action, request, test)
427
+ scheme = USE_SSL[action] ? 'https://' : 'http://'
428
+ host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN
429
+ resource = test ? TEST_RESOURCE : LIVE_RESOURCE
430
+ "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}"
431
+ end
432
+
433
+ def strip_zip(zip)
434
+ zip.to_s.scan(/\d{5}/).first || zip
435
+ end
436
+
437
+ end
438
+ end
@@ -0,0 +1,13 @@
1
+ require 'omniship/carriers/ups'
2
+ require 'omniship/carriers/usps'
3
+ require 'omniship/carriers/fedex'
4
+
5
+ module Omniship
6
+ module Carriers
7
+ class <<self
8
+ def all
9
+ [UPS, USPS, FedEx]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ module Omniship
2
+ class Contact
3
+
4
+ attr_reader :name,
5
+ :attention,
6
+ :account,
7
+ :phone,
8
+ :fax,
9
+ :email
10
+
11
+ def initialize(options={})
12
+ @name = options[:name]
13
+ @attention = options[:attention]
14
+ @phone = options[:phone]
15
+ @fax = options[:fax]
16
+ @email = options[:email]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,147 @@
1
+ module Omniship #:nodoc:
2
+ class Package
3
+ include Quantified
4
+
5
+ cattr_accessor :default_options
6
+ attr_reader :options,
7
+ :value,
8
+ :currency
9
+
10
+ # Package.new(100, [10, 20, 30], :units => :metric)
11
+ # Package.new(Mass.new(100, :grams), [10, 20, 30].map {|m| Length.new(m, :centimetres)})
12
+ # Package.new(100.grams, [10, 20, 30].map(&:centimetres))
13
+ def initialize(grams_or_ounces, dimensions, options = {})
14
+ options = @@default_options.update(options) if @@default_options
15
+ options.symbolize_keys!
16
+ @options = options
17
+
18
+ @dimensions = [dimensions].flatten.reject {|d| d.nil?}
19
+
20
+ imperial = (options[:units] == :imperial) ||
21
+ ([grams_or_ounces, *dimensions].all? {|m| m.respond_to?(:unit) && m.unit.to_sym == :imperial})
22
+
23
+ @unit_system = imperial ? :imperial : :metric
24
+
25
+ @weight = attribute_from_metric_or_imperial(grams_or_ounces, Mass, :grams, :ounces)
26
+
27
+ if @dimensions.blank?
28
+ @dimensions = [Length.new(0, (imperial ? :inches : :centimetres))] * 3
29
+ else
30
+ process_dimensions
31
+ end
32
+
33
+ @value = Package.cents_from(options[:value])
34
+ @currency = options[:currency] || (options[:value].currency if options[:value].respond_to?(:currency))
35
+ @cylinder = (options[:cylinder] || options[:tube]) ? true : false
36
+ @gift = options[:gift] ? true : false
37
+ end
38
+
39
+ def cylinder?
40
+ @cylinder
41
+ end
42
+ alias_method :tube?, :cylinder?
43
+
44
+ def gift?; @gift end
45
+
46
+ def ounces(options={})
47
+ weight(options).in_ounces.amount
48
+ end
49
+ alias_method :oz, :ounces
50
+
51
+ def grams(options={})
52
+ weight(options).in_grams.amount
53
+ end
54
+ alias_method :g, :grams
55
+
56
+ def pounds(options={})
57
+ weight(options).in_pounds.amount
58
+ end
59
+ alias_method :lb, :pounds
60
+ alias_method :lbs, :pounds
61
+
62
+ def kilograms(options={})
63
+ weight(options).in_kilograms.amount
64
+ end
65
+ alias_method :kg, :kilograms
66
+ alias_method :kgs, :kilograms
67
+
68
+ def inches(measurement=nil)
69
+ @inches ||= @dimensions.map {|m| m.in_inches.amount }
70
+ measurement.nil? ? @inches : measure(measurement, @inches)
71
+ end
72
+ alias_method :in, :inches
73
+
74
+ def centimetres(measurement=nil)
75
+ @centimetres ||= @dimensions.map {|m| m.in_centimetres.amount }
76
+ measurement.nil? ? @centimetres : measure(measurement, @centimetres)
77
+ end
78
+ alias_method :cm, :centimetres
79
+
80
+ def weight(options = {})
81
+ case options[:type]
82
+ when nil, :actual
83
+ @weight
84
+ when :volumetric, :dimensional
85
+ @volumetric_weight ||= begin
86
+ m = Mass.new((centimetres(:box_volume) / 6.0), :grams)
87
+ @unit_system == :imperial ? m.in_pounds : m
88
+ end
89
+ when :billable
90
+ [ weight, weight(:type => :volumetric) ].max
91
+ end
92
+ end
93
+ alias_method :mass, :weight
94
+
95
+ def self.cents_from(money)
96
+ return nil if money.nil?
97
+ if money.respond_to?(:cents)
98
+ return money.cents
99
+ else
100
+ case money
101
+ when Float
102
+ (money * 100).round
103
+ when String
104
+ money =~ /\./ ? (money.to_f * 100).round : money.to_i
105
+ else
106
+ money.to_i
107
+ end
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def attribute_from_metric_or_imperial(obj, klass, metric_unit, imperial_unit)
114
+ if obj.is_a?(klass)
115
+ return value
116
+ else
117
+ return klass.new(obj, (@unit_system == :imperial ? imperial_unit : metric_unit))
118
+ end
119
+ end
120
+
121
+ def measure(measurement, ary)
122
+ case measurement
123
+ when Fixnum then ary[measurement]
124
+ when :x, :max, :length, :long then ary[2]
125
+ when :y, :mid, :width, :wide then ary[1]
126
+ when :z, :min, :height,:depth,:high,:deep then ary[0]
127
+ when :girth, :around,:circumference
128
+ self.cylinder? ? (Math::PI * (ary[0] + ary[1]) / 2) : (2 * ary[0]) + (2 * ary[1])
129
+ when :volume then self.cylinder? ? (Math::PI * (ary[0] + ary[1]) / 4)**2 * ary[2] : measure(:box_volume,ary)
130
+ when :box_volume then ary[0] * ary[1] * ary[2]
131
+ end
132
+ end
133
+
134
+ def process_dimensions
135
+ @dimensions = @dimensions.map do |l|
136
+ attribute_from_metric_or_imperial(l, Length, :centimetres, :inches)
137
+ end.sort
138
+ # [1,2] => [1,1,2]
139
+ # [5] => [5,5,5]
140
+ # etc..
141
+ 2.downto(@dimensions.length) do |n|
142
+ @dimensions.unshift(@dimensions[0])
143
+ end
144
+ end
145
+
146
+ end
147
+ end
@@ -0,0 +1,59 @@
1
+ module Omniship #:nodoc:
2
+ class RateEstimate
3
+ attr_reader :origin # Location objects
4
+ attr_reader :destination
5
+ attr_reader :package_rates # array of hashes in the form of {:package => <Package>, :rate => 500}
6
+ attr_reader :carrier # Carrier.name ('USPS', 'FedEx', etc.)
7
+ attr_reader :service_name # name of service ("First Class Ground", etc.)
8
+ attr_reader :service_code
9
+ attr_reader :currency # 'USD', 'CAD', etc.
10
+ # http://en.wikipedia.org/wiki/ISO_4217
11
+ attr_reader :delivery_date # Usually only available for express shipments
12
+ attr_reader :delivery_range # Min and max delivery estimate in days
13
+
14
+ def initialize(origin, destination, carrier, service_name, options={})
15
+ @origin, @destination, @carrier, @service_name = origin, destination, carrier, service_name
16
+ @service_code = options[:service_code]
17
+ if options[:package_rates]
18
+ @package_rates = options[:package_rates].map {|p| p.update({:rate => Package.cents_from(p[:rate])}) }
19
+ else
20
+ @package_rates = Array(options[:packages]).map {|p| {:package => p}}
21
+ end
22
+ @total_price = Package.cents_from(options[:total_price])
23
+ @currency = options[:currency]
24
+ @delivery_range = options[:delivery_range] ? options[:delivery_range].map { |date| date_for(date) }.compact : []
25
+ @delivery_date = @delivery_range.last
26
+ end
27
+
28
+ def total_price
29
+ begin
30
+ @total_price || @package_rates.sum {|p| p[:rate]}
31
+ rescue NoMethodError
32
+ raise ArgumentError.new("RateEstimate must have a total_price set, or have a full set of valid package rates.")
33
+ end
34
+ end
35
+ alias_method :price, :total_price
36
+
37
+ def add(package,rate=nil)
38
+ cents = Package.cents_from(rate)
39
+ raise ArgumentError.new("New packages must have valid rate information since this RateEstimate has no total_price set.") if cents.nil? and total_price.nil?
40
+ @package_rates << {:package => package, :rate => cents}
41
+ self
42
+ end
43
+
44
+ def packages
45
+ package_rates.map {|p| p[:package]}
46
+ end
47
+
48
+ def package_count
49
+ package_rates.length
50
+ end
51
+
52
+ private
53
+ def date_for(date)
54
+ date && DateTime.strptime(date.to_s, "%Y-%m-%d")
55
+ rescue ArgumentError
56
+ nil
57
+ end
58
+ end
59
+ end