lib/active_shipping/carriers/ups.rb in active_shipping-1.0.0.pre4 vs lib/active_shipping/carriers/ups.rb in active_shipping-1.0.0
- old
+ new
@@ -13,11 +13,12 @@
RESOURCES = {
:rates => 'ups.app/xml/Rate',
:track => 'ups.app/xml/Track',
:ship_confirm => 'ups.app/xml/ShipConfirm',
- :ship_accept => 'ups.app/xml/ShipAccept'
+ :ship_accept => 'ups.app/xml/ShipAccept',
+ :delivery_dates => 'ups.app/xml/TimeInTransit'
}
PICKUP_CODES = HashWithIndifferentAccess.new(
:daily_pickup => "01",
:customer_counter => "03",
@@ -100,10 +101,26 @@
US_TERRITORIES_TREATED_AS_COUNTRIES = %w(AS FM GU MH MP PW PR VI)
IMPERIAL_COUNTRIES = %w(US LR MM)
+ DEFAULT_SERVICE_NAME_TO_CODE = Hash[UPS::DEFAULT_SERVICES.to_a.map(&:reverse)]
+ DEFAULT_SERVICE_NAME_TO_CODE['UPS 2nd Day Air'] = "02"
+ DEFAULT_SERVICE_NAME_TO_CODE['UPS 3 Day Select'] = "12"
+
+ SHIPMENT_DELIVERY_CONFIRMATION_CODES = {
+ delivery_confirmation_signature_required: 1,
+ delivery_confirmation_adult_signature_required: 2
+ }
+
+ PACKAGE_DELIVERY_CONFIRMATION_CODES = {
+ delivery_confirmation: 1,
+ delivery_confirmation_signature_required: 2,
+ delivery_confirmation_adult_signature_required: 3,
+ usps_delivery_confirmation: 4
+ }
+
def requirements
[:key, :login, :password]
end
def find_rates(origin, destination, packages, options = {})
@@ -142,12 +159,12 @@
# one could make decisions based on the price or some such to avoid
# surprises. This also has *no* error handling yet.
xml = parse_ship_confirm(confirm_response)
success = response_success?(xml)
message = response_message(xml)
- digest = response_digest(xml)
raise message unless success
+ digest = response_digest(xml)
# STEP 2: Accept. Use shipment digest in first response to get the actual label.
accept_request = build_accept_request(digest, options)
logger.debug(accept_request) if logger
@@ -162,10 +179,20 @@
raise "Could not obtain shipping label. #{e.message}."
end
end
+ def get_delivery_date_estimates(origin, destination, packages, pickup_date=Date.current, options = {})
+ origin, destination = upsified_location(origin), upsified_location(destination)
+ options = @options.merge(options)
+ packages = Array(packages)
+ access_request = build_access_request
+ dates_request = build_delivery_dates_request(origin, destination, packages, pickup_date, options)
+ response = commit(:delivery_dates, save_request(access_request + dates_request), (options[:test] || false))
+ parse_delivery_dates_response(origin, destination, packages, response, options)
+ end
+
protected
def upsified_location(location)
if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
atts = {:country => location.state}
@@ -180,11 +207,11 @@
def build_access_request
xml_builder = Nokogiri::XML::Builder.new do |xml|
xml.AccessRequest do
xml.AccessLicenseNumber(@options[:key])
- xml.UserId(@options[:password])
+ xml.UserId(@options[:login])
xml.Password(@options[:password])
end
end
xml_builder.to_xml
end
@@ -192,13 +219,11 @@
def build_rate_request(origin, destination, packages, options = {})
xml_builder = Nokogiri::XML::Builder.new do |xml|
xml.RatingServiceSelectionRequest do
xml.Request do
xml.RequestAction('Rate')
- xml.RequestOption('Shop')
- # not implemented: 'Rate' RequestOption to specify a single service query
- # xml.RequestOption((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate')
+ xml.RequestOption((options[:service].nil?) ? 'Shop' : 'Rate')
end
pickup_type = options[:pickup_type] || :daily_pickup
xml.PickupType do
@@ -224,17 +249,23 @@
# * Shipment/ScheduledDeliveryDate element
# * Shipment/ScheduledDeliveryTime element
# * Shipment/AlternateDeliveryTime element
# * Shipment/DocumentsOnly element
+ unless options[:service].nil?
+ xml.Service do
+ xml.Code(options[:service])
+ end
+ end
+
Array(packages).each do |package|
options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
build_package_node(xml, package, options)
end
# not implemented: * Shipment/ShipmentServiceOptions element
- if options[:origin_account]
+ if options[:negotiated_rates]
xml.RateInformation do
xml.NegotiatedRatesIndicator
end
end
end
@@ -249,22 +280,38 @@
# * origin_account: who will pay for the shipping label
# * customer_context: a "guid like substance" -- according to UPS
# * shipper: who is sending the package and where it should be returned
# if it is undeliverable.
# * ship_from: where the package is picked up.
- # * service_code: default to '14'
- # * service_descriptor: default to 'Next Day Air Early AM'
+ # * service_code: default to '03'
# * saturday_delivery: any truthy value causes this element to exist
# * optional_processing: 'validate' (blank) or 'nonvalidate' or blank
- #
- def build_shipment_request(origin, destination, packages, options = {})
- # There are a lot of unimplemented elements, documenting all of them
- # wouldprobably be unhelpful.
+ # * paperless_invoice: set to truthy if using paperless invoice to ship internationally
+ # * terms_of_shipment: used with paperless invoice to specify who pays duties and taxes
+ # * reference_numbers: Array of hashes with :value => a reference number value and optionally :code => reference number type
+ # * prepay: if truthy the shipper will be bill immediatly. Otherwise the shipper is billed when the label is used.
+ # * negotiated_rates: if truthy negotiated rates will be requested from ups. Only valid if shipper account has negotiated rates.
+ # * delivery_confirmation: Can be set to any key from SHIPMENT_DELIVERY_CONFIRMATION_CODES. Can also be set on package level via package.options
+ def build_shipment_request(origin, destination, packages, options={})
+ packages = Array(packages)
+ options[:international] = origin.country.name != destination.country.name
+ options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
+
+ if allow_package_level_reference_numbers(origin, destination)
+ if options[:reference_numbers]
+ packages.each do |package|
+ package.options[:reference_numbers] = options[:reference_numbers]
+ end
+ end
+ options[:reference_numbers] = []
+ end
+
+ handle_delivery_confirmation_options(origin, destination, packages, options)
+
xml_builder = Nokogiri::XML::Builder.new do |xml|
xml.ShipmentConfirmRequest do
xml.Request do
- # Required element and the text must be "ShipConfirm"
xml.RequestAction('ShipConfirm')
# Required element cotnrols level of address validation.
xml.RequestOption(options[:optional_processing] || 'validate')
# Optional element to identify transactions between client and server.
if options[:customer_context]
@@ -273,58 +320,100 @@
end
end
end
xml.Shipment do
- # Required element.
xml.Service do
- xml.Code(options[:service_code] || '14')
- xml.Description(options[:service_description] || 'Next Day Air Early AM')
+ xml.Code(options[:service_code] || '03')
end
- # Required element. The delivery destination.
- build_location_node(xml, 'ShipTo', destination, {})
+ build_location_node(xml, 'ShipTo', destination, options)
+ build_location_node(xml, 'ShipFrom', origin, options)
# Required element. The company whose account is responsible for the label(s).
- build_location_node(xml, 'Shipper', options[:shipper] || origin, {})
- # Required if pickup is different different from shipper's address.
- build_location_node(xml, 'ShipFrom', options[:ship_from], {}) if options[:ship_from]
+ build_location_node(xml, 'Shipper', options[:shipper] || origin, options)
- # Optional.
if options[:saturday_delivery]
xml.ShipmentServiceOptions do
xml.SaturdayDelivery
end
end
- # Optional.
if options[:origin_account]
xml.RateInformation do
xml.NegotiatedRatesIndicator
end
end
- # Optional.
- if options[:shipment] && options[:shipment][:reference_number]
+ Array(options[:reference_numbers]).each do |reference_num_info|
xml.ReferenceNumber do
- xml.Code(options[:shipment][:reference_number][:code] || "")
- xml.Value(options[:shipment][:reference_number][:value])
+ xml.Code(reference_num_info[:code] || "")
+ xml.Value(reference_num_info[:value])
end
end
- # Conditionally required. Either this element or an ItemizedPaymentInformation
- # is needed. However, only PaymentInformation is not implemented.
- xml.PaymentInformation do
- xml.Prepaid do
- xml.BillShipper do
- xml.AccountNumber(options[:origin_account])
+ if options[:prepay]
+ xml.PaymentInformation do
+ xml.Prepaid do
+ xml.BillShipper do
+ xml.AccountNumber(options[:origin_account])
+ end
end
end
+ else
+ xml.ItemizedPaymentInformation do
+ xml.ShipmentCharge do
+ # Type '01' means 'Transportation'
+ # This node specifies who will be billed for transportation.
+ xml.Type('01')
+ xml.BillShipper do
+ xml.AccountNumber(options[:origin_account])
+ end
+ end
+ if options[:terms_of_shipment] == 'DDP'
+ # DDP stands for delivery duty paid and means the shipper will cover duties and taxes
+ # Otherwise UPS will charge the receiver
+ xml.ShipmentCharge do
+ xml.Type('02') # Type '02' means 'Duties and Taxes'
+ xml.BillShipper do
+ xml.AccountNumber(options[:origin_account])
+ end
+ end
+ end
+ end
end
+ if options[:international]
+ build_location_node(xml, 'SoldTo', options[:sold_to] || destination, options)
+
+ if origin.country_code(:alpha2) == 'US' && ['CA', 'PR'].include?(destination.country_code(:alpha2))
+ # Required for shipments from the US to Puerto Rico or Canada
+ xml.InvoiceLineTotal do
+ total_value = packages.inject(0) {|sum, package| sum + (package.value || 0)}
+ xml.MonetaryValue(total_value)
+ end
+ end
+
+ contents_description = packages.map {|p| p.options[:description]}.compact.join(',')
+ unless contents_description.empty?
+ xml.Description(contents_description)
+ end
+ end
+
+ xml.ShipmentServiceOptions do
+ if delivery_confirmation = options[:delivery_confirmation]
+ xml.DeliveryConfirmation do
+ xml.DCISType(SHIPMENT_DELIVERY_CONFIRMATION_CODES[delivery_confirmation])
+ end
+ end
+
+ if options[:international]
+ build_international_forms(xml, origin, destination, packages, options)
+ end
+ end
+
# A request may specify multiple packages.
- options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
- Array(packages).each do |package|
+ packages.each do |package|
build_package_node(xml, package, options)
end
end
# I don't know all of the options that UPS supports for labels
@@ -341,10 +430,65 @@
end
end
xml_builder.to_xml
end
+ def build_delivery_dates_request(origin, destination, packages, pickup_date, options={})
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
+
+ xml.TimeInTransitRequest do
+ xml.Request do
+ xml.RequestAction('TimeInTransit')
+ end
+
+ build_address_artifact_format_location(xml, 'TransitFrom', origin)
+ build_address_artifact_format_location(xml, 'TransitTo', destination)
+
+ xml.InvoiceLineTotal do
+ xml.CurrencyCode('USD')
+ total_value = packages.inject(0) {|sum, package| sum + package.value}
+ xml.MonetaryValue(total_value)
+ end
+
+ xml.PickupDate(pickup_date.strftime('%Y%m%d'))
+ end
+ end
+
+ xml_builder.to_xml
+ end
+
+ def build_international_forms(xml, origin, destination, packages, options)
+ if options[:paperless_invoice]
+ xml.InternationalForms do
+ xml.FormType('01') # 01 is "Invoice"
+ xml.InvoiceDate(options[:invoice_date] || Date.today.strftime('%Y%m%d'))
+ xml.ReasonForExport(options[:reason_for_export] || 'SALE')
+ xml.CurrencyCode(options[:currency_code] || 'USD')
+
+ if options[:terms_of_shipment]
+ xml.TermsOfShipment(options[:terms_of_shipment])
+ end
+
+ packages.each do |package|
+ xml.Product do |xml|
+ xml.Description(package.options[:description])
+ xml.CommodityCode(package.options[:commodity_code])
+ xml.OriginCountryCode(origin.country_code(:alpha2))
+ xml.Unit do |xml|
+ xml.Value(package.value / (package.options[:item_count] || 1))
+ xml.Number((package.options[:item_count] || 1))
+ xml.UnitOfMeasurement do |xml|
+ # NMB = number. You can specify units in barrels, boxes, etc. Codes are in the api docs.
+ xml.Code(package.options[:unit_of_item_count] || 'NMB')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
def build_accept_request(digest, options = {})
xml_builder = Nokogiri::XML::Builder.new do |xml|
xml.ShipmentAcceptRequest do
xml.Request do
xml.RequestAction('ShipAccept')
@@ -372,12 +516,11 @@
# not implemented: * Shipment/Shipper/Name element
# * Shipment/(ShipTo|ShipFrom)/CompanyName element
# * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
# * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
xml.public_send(name) do
- # You must specify the shipper name when creating labels.
- if shipper_name = (options[:origin_name] || @options[:origin_name])
+ if shipper_name = (location.name || location.company_name || options[:origin_name])
xml.Name(shipper_name)
end
xml.PhoneNumber(location.phone.gsub(/[^\d]/, '')) unless location.phone.blank?
xml.FaxNumber(location.fax.gsub(/[^\d]/, '')) unless location.fax.blank?
@@ -385,11 +528,11 @@
xml.ShipperNumber(origin_account)
elsif name == 'ShipTo' and (destination_account = options[:destination_account] || @options[:destination_account])
xml.ShipperAssignedIdentificationNumber(destination_account)
end
- if name = location.company_name || location.name
+ if name = (location.company_name || location.name || options[:origin_name])
xml.CompanyName(name)
end
if phone = location.phone
xml.PhoneNumber(phone)
@@ -412,10 +555,22 @@
# not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
end
end
end
+ def build_address_artifact_format_location(xml, name, location)
+ xml.public_send(name) do
+ xml.AddressArtifactFormat do
+ xml.PoliticalDivision2(location.city)
+ xml.PoliticalDivision1(location.province)
+ xml.CountryCode(location.country_code(:alpha2))
+ xml.PostcodePrimaryLow(location.postal_code)
+ xml.ResidentialAddressIndicator(true) unless location.commercial?
+ end
+ end
+ end
+
def build_package_node(xml, package, options = {})
xml.Package do
# not implemented: * Shipment/Package/PackagingType element
# * Shipment/Package/Description element
@@ -441,19 +596,27 @@
value = ((options[:imperial] ? package.lbs : package.kgs).to_f * 1000).round / 1000.0 # 3 decimals
xml.Weight([value, 0.1].max)
end
- if options[:package] && options[:package][:reference_number]
+
+ Array(package.options[:reference_numbers]).each do |reference_number_info|
xml.ReferenceNumber do
- xml.Code(options[:package][:reference_number][:code] || "")
- xml.Value(options[:package][:reference_number][:value])
+ xml.Code(reference_number_info[:code] || "")
+ xml.Value(reference_number_info[:value])
end
end
+ xml.PackageServiceOptions do
+ if delivery_confirmation = package.options[:delivery_confirmation]
+ xml.DeliveryConfirmation do
+ xml.DCISType(PACKAGE_DELIVERY_CONFIRMATION_CODES[delivery_confirmation])
+ end
+ end
+ end
+
# not implemented: * Shipment/Package/LargePackageIndicator element
- # * Shipment/Package/PackageServiceOptions element
# * Shipment/Package/AdditionalHandling element
end
end
def build_document(xml, expected_root_tag)
@@ -524,14 +687,19 @@
location_from_address_node(first_shipment.at("#{location}/Address"))
end
# Get scheduled delivery date
unless status == :delivered
- scheduled_delivery_date = parse_ups_datetime(
- :date => first_shipment.at('ScheduledDeliveryDate'),
- :time => nil
- )
+ scheduled_delivery_date_node = first_shipment.at('ScheduledDeliveryDate')
+ scheduled_delivery_date_node ||= first_shipment.at('RescheduledDeliveryDate')
+
+ if scheduled_delivery_date_node
+ scheduled_delivery_date = parse_ups_datetime(
+ :date => scheduled_delivery_date_node,
+ :time => nil
+ )
+ end
end
activities = first_package.css('> Activity')
unless activities.empty?
shipment_events = activities.map do |activity|
@@ -589,10 +757,34 @@
:origin => origin,
:destination => destination,
:tracking_number => tracking_number)
end
+ def parse_delivery_dates_response(origin, destination, packages, response, options={})
+ xml = build_document(response, 'TimeInTransitResponse')
+ success = response_success?(xml)
+ message = response_message(xml)
+ delivery_estimates = []
+
+ if success
+ xml.css('ServiceSummary').each do |service_summary|
+ # Translate the Time in Transit Codes to the service codes used elsewhere
+ service_name = service_summary.at('Service/Description').text
+ service_code = UPS::DEFAULT_SERVICE_NAME_TO_CODE[service_name]
+ date = Date.strptime(service_summary.at('EstimatedArrival/Date').text, '%Y-%m-%d')
+ business_transit_days = service_summary.at('EstimatedArrival/BusinessTransitDays').text.to_i
+ delivery_estimates << DeliveryDateEstimate.new(origin, destination, self.class.class_variable_get(:@@name),
+ service_name,
+ :service_code => service_code,
+ :guaranteed => service_summary.at('Guaranteed/Code').text == 'Y',
+ :date => date,
+ :business_transit_days => business_transit_days)
+ end
+ end
+ response = DeliveryDateEstimatesResponse.new(success, message, Hash.from_xml(response).values.first, :delivery_estimates => delivery_estimates, :xml => response, :request => last_request)
+ end
+
def location_from_address_node(address)
return nil unless address
Location.new(
:country => address.at('CountryCode').try(:text),
:postal_code => address.at('PostalCode').try(:text),
@@ -619,11 +811,13 @@
def response_success?(document)
document.root.at('Response/ResponseStatusCode').text == '1'
end
def response_message(document)
- document.root.at_xpath('Response/Error/ErrorDescription | Response/ResponseStatusDescription').text
+ status = document.root.at_xpath('Response/ResponseStatusDescription').try(:text)
+ desc = document.root.at_xpath('Response/Error/ErrorDescription').try(:text)
+ [status, desc].select(&:present?).join(": ").presence || "UPS could not process the request."
end
def response_digest(xml)
xml.root.at('ShipmentDigest').text
end
@@ -660,8 +854,53 @@
when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
end
name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
name || DEFAULT_SERVICES[code]
+ end
+
+ def allow_package_level_reference_numbers(origin, destination)
+ # if the package is US -> US or PR -> PR the only type of reference numbers that are allowed are package-level
+ # Otherwise the only type of reference numbers that are allowed are shipment-level
+ [['US','US'],['PR', 'PR']].include?([origin,destination].map(&:country_code))
+ end
+
+ def handle_delivery_confirmation_options(origin, destination, packages, options)
+ if package_level_delivery_confirmation?(origin, destination)
+ handle_package_level_delivery_confirmation(origin, destination, packages, options)
+ else
+ handle_shipment_level_delivery_confirmation(origin, destination, packages, options)
+ end
+ end
+
+ def handle_package_level_delivery_confirmation(origin, destination, packages, options)
+ packages.each do |package|
+ # Transfer shipment-level option to package with no specified delivery_confirmation
+ package.options[:delivery_confirmation] = options[:delivery_confirmation] unless package.options[:delivery_confirmation]
+
+ # Assert that option is valid
+ if package.options[:delivery_confirmation] && !PACKAGE_DELIVERY_CONFIRMATION_CODES[package.options[:delivery_confirmation]]
+ raise "Invalid delivery_confirmation option on package: '#{package.options[:delivery_confirmation]}'. Use a key from PACKAGE_DELIVERY_CONFIRMATION_CODES"
+ end
+ end
+ options.delete(:delivery_confirmation)
+ end
+
+ def handle_shipment_level_delivery_confirmation(origin, destination, packages, options)
+ if packages.any? { |p| p.options[:delivery_confirmation] }
+ raise "origin/destination pair does not support package level delivery_confirmation options"
+ end
+
+ if options[:delivery_confirmation] && !SHIPMENT_DELIVERY_CONFIRMATION_CODES[options[:delivery_confirmation]]
+ raise "Invalid delivery_confirmation option: '#{options[:delivery_confirmation]}'. Use a key from SHIPMENT_DELIVERY_CONFIRMATION_CODES"
+ end
+ end
+
+ # For certain origin/destination pairs, UPS allows each package in a shipment to have a specified delivery_confirmation option
+ # otherwise the delivery_confirmation option must be specified on the entire shipment.
+ # See Appendix P of UPS Shipping Package XML Developers Guide for the rules on which the logic below is based.
+ def package_level_delivery_confirmation?(origin, destination)
+ origin.country_code == destination.country_code ||
+ [['US','PR'], ['PR','US']].include?([origin,destination].map(&:country_code))
end
end
end