lib/active_shipping/carriers/fedex.rb in active_shipping-1.0.0.pre4 vs lib/active_shipping/carriers/fedex.rb in active_shipping-1.0.0

- old
+ new

@@ -1,14 +1,11 @@ -# FedEx module by Jimmy Baker -# http://github.com/jimmyebaker - -require 'date' module ActiveShipping - # :key is your developer API key - # :password is your API password - # :account is your FedEx account number - # :login is your meter number + + # FedEx carrier implementation. + # + # FedEx module by Jimmy Baker (http://github.com/jimmyebaker) + # Documentation can be found here: http://images.fedex.com/us/developer/product/WebServices/MyWebHelp/PropDevGuide.pdf class FedEx < Carrier self.retry_safe = true cattr_reader :name @@name = "FedEx" @@ -85,11 +82,12 @@ 'ground_shipment_id' => 'GROUND_SHIPMENT_ID', 'ground_invoice_number' => 'GROUND_INVOICE_NUMBER', 'ground_customer_reference' => 'GROUND_CUSTOMER_REFERENCE', 'ground_po' => 'GROUND_PO', 'express_reference' => 'EXPRESS_REFERENCE', - 'express_mps_master' => 'EXPRESS_MPS_MASTER' + 'express_mps_master' => 'EXPRESS_MPS_MASTER', + 'shipper_reference' => 'SHIPPER_REFERENCE', } TRANSIT_TIMES = %w(UNKNOWN ONE_DAY TWO_DAYS THREE_DAYS FOUR_DAYS FIVE_DAYS SIX_DAYS SEVEN_DAYS EIGHT_DAYS NINE_DAYS TEN_DAYS ELEVEN_DAYS TWELVE_DAYS THIRTEEN_DAYS FOURTEEN_DAYS FIFTEEN_DAYS SIXTEEN_DAYS SEVENTEEN_DAYS EIGHTEEN_DAYS) # FedEx tracking codes as described in the FedEx Tracking Service WSDL Guide @@ -141,241 +139,230 @@ packages = Array(packages) rate_request = build_rate_request(origin, destination, packages, options) xml = commit(save_request(rate_request), (options[:test] || false)) - response = remove_version_prefix(xml) - parse_rate_response(origin, destination, packages, response, options) + parse_rate_response(origin, destination, packages, xml, options) end def find_tracking_info(tracking_number, options = {}) options = @options.update(options) tracking_request = build_tracking_request(tracking_number, options) xml = commit(save_request(tracking_request), (options[:test] || false)) - response = remove_version_prefix(xml) - parse_tracking_response(response, options) + parse_tracking_response(xml, options) end protected def build_rate_request(origin, destination, packages, options = {}) imperial = %w(US LR MM).include?(origin.country_code(:alpha2)) - xml_request = XmlNode.new('RateRequest', 'xmlns' => 'http://fedex.com/ws/rate/v13') do |root_node| - root_node << build_request_header - root_node << build_version_node + xml_builder = Nokogiri::XML::Builder.new do |xml| + xml.RateRequest(xmlns: 'http://fedex.com/ws/rate/v13') do + build_request_header(xml) + build_version_node(xml, 'crs', 13, 0 ,0) - # Returns delivery dates - root_node << XmlNode.new('ReturnTransitAndCommit', true) - # Returns saturday delivery shipping options when available - root_node << XmlNode.new('VariableOptions', 'SATURDAY_DELIVERY') + # Returns delivery dates + xml.ReturnTransitAndCommit(true) - root_node << XmlNode.new('RequestedShipment') do |rs| - rs << XmlNode.new('ShipTimestamp', ship_timestamp(options[:turn_around_time])) + # Returns saturday delivery shipping options when available + xml.VariableOptions('SATURDAY_DELIVERY') - freight = has_freight?(options) + xml.RequestedShipment do + xml.ShipTimestamp(ship_timestamp(options[:turn_around_time]).iso8601(0)) - unless freight - # fedex api wants this up here otherwise request returns an error - rs << XmlNode.new('DropoffType', options[:dropoff_type] || 'REGULAR_PICKUP') - rs << XmlNode.new('PackagingType', options[:packaging_type] || 'YOUR_PACKAGING') - end + freight = has_freight?(options) - rs << build_location_node('Shipper', (options[:shipper] || origin)) - rs << build_location_node('Recipient', destination) - if options[:shipper] and options[:shipper] != origin - rs << build_location_node('Origin', origin) - end + unless freight + # fedex api wants this up here otherwise request returns an error + xml.DropoffType(options[:dropoff_type] || 'REGULAR_PICKUP') + xml.PackagingType(options[:packaging_type] || 'YOUR_PACKAGING') + end - if freight - # build xml for freight rate requests - freight_options = options[:freight] - rs << build_shipping_charges_payment_node(freight_options) - rs << build_freight_shipment_detail_node(freight_options, packages, imperial) - rs << build_rate_request_types_node - else - # build xml for non-freight rate requests - rs << XmlNode.new('SmartPostDetail') do |spd| - spd << XmlNode.new('Indicia', options[:smart_post_indicia] || 'PARCEL_SELECT') - spd << XmlNode.new('HubId', options[:smart_post_hub_id] || 5902) # default to LA + build_location_node(xml, 'Shipper', options[:shipper] || origin) + build_location_node(xml, 'Recipient', destination) + if options[:shipper] && options[:shipper] != origin + build_location_node(xml, 'Origin', origin) end - rs << build_rate_request_types_node - rs << XmlNode.new('PackageCount', packages.size) - rs << build_packages_nodes(packages, imperial) + if freight + freight_options = options[:freight] + build_shipping_charges_payment_node(xml, freight_options) + build_freight_shipment_detail_node(xml, freight_options, packages, imperial) + build_rate_request_types_node(xml) + else + xml.SmartPostDetail do + xml.Indicia(options[:smart_post_indicia] || 'PARCEL_SELECT') + xml.HubId(options[:smart_post_hub_id] || 5902) # default to LA + end + build_rate_request_types_node(xml) + xml.PackageCount(packages.size) + build_packages_nodes(xml, packages, imperial) + end end end end - xml_request.to_s + xml_builder.to_xml end - def build_packages_nodes(packages, imperial) + def build_packages_nodes(xml, packages, imperial) packages.map do |pkg| - XmlNode.new('RequestedPackageLineItems') do |rps| - rps << XmlNode.new('GroupPackageCount', 1) - rps << build_package_weight_node(pkg, imperial) - rps << build_package_dimensions_node(pkg, imperial) + xml.RequestedPackageLineItems do + xml.GroupPackageCount(1) + build_package_weight_node(xml, pkg, imperial) + build_package_dimensions_node(xml, pkg, imperial) end end end - def build_shipping_charges_payment_node(freight_options) - XmlNode.new('ShippingChargesPayment') do |shipping_charges_payment| - shipping_charges_payment << XmlNode.new('PaymentType', freight_options[:payment_type]) - shipping_charges_payment << XmlNode.new('Payor') do |payor| - payor << XmlNode.new('ResponsibleParty') do |responsible_party| + def build_shipping_charges_payment_node(xml, freight_options) + xml.ShippingChargesPayment do + xml.PaymentType(freight_options[:payment_type]) + xml.Payor do + xml.ResponsibleParty do # TODO: case of different freight account numbers? - responsible_party << XmlNode.new('AccountNumber', freight_options[:account]) + xml.AccountNumber(freight_options[:account]) end end end end - def build_freight_shipment_detail_node(freight_options, packages, imperial) - XmlNode.new('FreightShipmentDetail') do |freight_shipment_detail| + def build_freight_shipment_detail_node(xml, freight_options, packages, imperial) + xml.FreightShipmentDetail do # TODO: case of different freight account numbers? - freight_shipment_detail << XmlNode.new('FedExFreightAccountNumber', freight_options[:account]) - freight_shipment_detail << build_location_node('FedExFreightBillingContactAndAddress', freight_options[:billing_location]) - freight_shipment_detail << XmlNode.new('Role', freight_options[:role]) + xml.FedExFreightAccountNumber(freight_options[:account]) + build_location_node(xml, 'FedExFreightBillingContactAndAddress', freight_options[:billing_location]) + xml.Role(freight_options[:role]) packages.each do |pkg| - freight_shipment_detail << XmlNode.new('LineItems') do |line_items| - line_items << XmlNode.new('FreightClass', freight_options[:freight_class]) - line_items << XmlNode.new('Packaging', freight_options[:packaging]) - line_items << build_package_weight_node(pkg, imperial) - line_items << build_package_dimensions_node(pkg, imperial) + xml.LineItems do + xml.FreightClass(freight_options[:freight_class]) + xml.Packaging(freight_options[:packaging]) + build_package_weight_node(xml, pkg, imperial) + build_package_dimensions_node(xml, pkg, imperial) end end end end def has_freight?(options) options[:freight] && options[:freight].present? end - def build_package_weight_node(pkg, imperial) - XmlNode.new('Weight') do |tw| - tw << XmlNode.new('Units', imperial ? 'LB' : 'KG') - tw << XmlNode.new('Value', [((imperial ? pkg.lbs : pkg.kgs).to_f * 1000).round / 1000.0, 0.1].max) + def build_package_weight_node(xml, pkg, imperial) + xml.Weight do + xml.Units(imperial ? 'LB' : 'KG') + xml.Value([((imperial ? pkg.lbs : pkg.kgs).to_f * 1000).round / 1000.0, 0.1].max) end end - def build_version_node - XmlNode.new('Version') do |version_node| - version_node << XmlNode.new('ServiceId', 'crs') - version_node << XmlNode.new('Major', '13') - version_node << XmlNode.new('Intermediate', '0') - version_node << XmlNode.new('Minor', '0') - end - end - - def build_package_dimensions_node(pkg, imperial) - XmlNode.new('Dimensions') do |dimensions| + def build_package_dimensions_node(xml, pkg, imperial) + xml.Dimensions do [:length, :width, :height].each do |axis| value = ((imperial ? pkg.inches(axis) : pkg.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals - dimensions << XmlNode.new(axis.to_s.capitalize, value.ceil) + xml.public_send(axis.to_s.capitalize, value.ceil) end - dimensions << XmlNode.new('Units', imperial ? 'IN' : 'CM') + xml.Units(imperial ? 'IN' : 'CM') end end - def build_rate_request_types_node(type = 'ACCOUNT') - XmlNode.new('RateRequestTypes', type) + def build_rate_request_types_node(xml, type = 'ACCOUNT') + xml.RateRequestTypes(type) end def build_tracking_request(tracking_number, options = {}) - xml_request = XmlNode.new('TrackRequest', 'xmlns' => 'http://fedex.com/ws/track/v3') do |root_node| - root_node << build_request_header + xml_builder = Nokogiri::XML::Builder.new do |xml| + xml.TrackRequest(xmlns: 'http://fedex.com/ws/track/v7') do + build_request_header(xml) + build_version_node(xml, 'trck', 7, 0, 0) - # Version - root_node << XmlNode.new('Version') do |version_node| - version_node << XmlNode.new('ServiceId', 'trck') - version_node << XmlNode.new('Major', '3') - version_node << XmlNode.new('Intermediate', '0') - version_node << XmlNode.new('Minor', '0') - end + xml.SelectionDetails do + xml.PackageIdentifier do + xml.Type(PACKAGE_IDENTIFIER_TYPES[options[:package_identifier_type] || 'tracking_number']) + xml.Value(tracking_number) + end - root_node << XmlNode.new('PackageIdentifier') do |package_node| - package_node << XmlNode.new('Value', tracking_number) - package_node << XmlNode.new('Type', PACKAGE_IDENTIFIER_TYPES[options['package_identifier_type'] || 'tracking_number']) - end + xml.ShipDateRangeBegin(options[:ship_date_range_begin]) if options[:ship_date_range_begin] + xml.ShipDateRangeEnd(options[:ship_date_range_end]) if options[:ship_date_range_end] + xml.TrackingNumberUniqueIdentifier(options[:unique_identifier]) if options[:unique_identifier] + end - root_node << XmlNode.new('ShipDateRangeBegin', options['ship_date_range_begin']) if options['ship_date_range_begin'] - root_node << XmlNode.new('ShipDateRangeEnd', options['ship_date_range_end']) if options['ship_date_range_end'] - root_node << XmlNode.new('IncludeDetailedScans', 1) + xml.ProcessingOptions('INCLUDE_DETAILED_SCANS') + end end - xml_request.to_s + xml_builder.to_xml end - def build_request_header - web_authentication_detail = XmlNode.new('WebAuthenticationDetail') do |wad| - wad << XmlNode.new('UserCredential') do |uc| - uc << XmlNode.new('Key', @options[:key]) - uc << XmlNode.new('Password', @options[:password]) + def build_request_header(xml) + xml.WebAuthenticationDetail do + xml.UserCredential do + xml.Key(@options[:key]) + xml.Password(@options[:password]) end end - client_detail = XmlNode.new('ClientDetail') do |cd| - cd << XmlNode.new('AccountNumber', @options[:account]) - cd << XmlNode.new('MeterNumber', @options[:login]) + xml.ClientDetail do + xml.AccountNumber(@options[:account]) + xml.MeterNumber(@options[:login]) end - trasaction_detail = XmlNode.new('TransactionDetail') do |td| - td << XmlNode.new('CustomerTransactionId', @options[:transaction_id] || 'ActiveShipping') # TODO: Need to do something better with this.. + xml.TransactionDetail do + xml.CustomerTransactionId(@options[:transaction_id] || 'ActiveShipping') # TODO: Need to do something better with this... end + end - [web_authentication_detail, client_detail, trasaction_detail] + def build_version_node(xml, service_id, major, intermediate, minor) + xml.Version do + xml.ServiceId(service_id) + xml.Major(major) + xml.Intermediate(intermediate) + xml.Minor(minor) + end end - def build_location_node(name, location) - XmlNode.new(name) do |xml_node| - xml_node << XmlNode.new('Address') do |address_node| - address_node << XmlNode.new('StreetLines', location.address1) if location.address1 - address_node << XmlNode.new('StreetLines', location.address2) if location.address2 - address_node << XmlNode.new('City', location.city) if location.city - address_node << XmlNode.new('PostalCode', location.postal_code) - address_node << XmlNode.new("CountryCode", location.country_code(:alpha2)) - - address_node << XmlNode.new("Residential", true) unless location.commercial? + def build_location_node(xml, name, location) + xml.public_send(name) do + xml.Address do + xml.StreetLines(location.address1) if location.address1 + xml.StreetLines(location.address2) if location.address2 + xml.City(location.city) if location.city + xml.PostalCode(location.postal_code) + xml.CountryCode(location.country_code(:alpha2)) + xml.Residential(true) unless location.commercial? end end end def parse_rate_response(origin, destination, packages, response, options) - rate_estimates = [] + xml = build_document(response, 'RateReply') - xml = build_document(response) - root_node = xml.elements['RateReply'] - success = response_success?(xml) message = response_message(xml) - raise ActiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml) unless root_node - - root_node.elements.each('RateReplyDetails') do |rated_shipment| - service_code = rated_shipment.get_text('ServiceType').to_s - is_saturday_delivery = rated_shipment.get_text('AppliedOptions').to_s == 'SATURDAY_DELIVERY' + rate_estimates = xml.root.css('> RateReplyDetails').map do |rated_shipment| + service_code = rated_shipment.at('ServiceType').text + is_saturday_delivery = rated_shipment.at('AppliedOptions').try(:text) == 'SATURDAY_DELIVERY' service_type = is_saturday_delivery ? "#{service_code}_SATURDAY_DELIVERY" : service_code - transit_time = rated_shipment.get_text('TransitTime').to_s if service_code == "FEDEX_GROUND" - max_transit_time = rated_shipment.get_text('MaximumTransitTime').to_s if service_code == "FEDEX_GROUND" + transit_time = rated_shipment.at('TransitTime').text if service_code == "FEDEX_GROUND" + max_transit_time = rated_shipment.at('MaximumTransitTime').try(:text) if service_code == "FEDEX_GROUND" - delivery_timestamp = rated_shipment.get_text('DeliveryTimestamp').to_s + delivery_timestamp = rated_shipment.at('DeliveryTimestamp').try(:text) delivery_range = delivery_range_from(transit_time, max_transit_time, delivery_timestamp, options) - currency = rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').to_s - rate_estimates << RateEstimate.new(origin, destination, @@name, - self.class.service_name_for_code(service_type), - :service_code => service_code, - :total_price => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').to_s.to_f, - :currency => currency, - :packages => packages, - :delivery_range => delivery_range) + currency = rated_shipment.at('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').text + RateEstimate.new(origin, destination, @@name, + self.class.service_name_for_code(service_type), + :service_code => service_code, + :total_price => rated_shipment.at('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').text.to_f, + :currency => currency, + :packages => packages, + :delivery_range => delivery_range) end if rate_estimates.empty? success = false message = "No shipping rates could be found for the destination address" if message.blank? @@ -411,61 +398,84 @@ def business_day?(date) (1..5).include?(date.wday) end def parse_tracking_response(response, options) - xml = build_document(response) - root_node = xml.elements['TrackReply'] + xml = build_document(response, 'TrackReply') success = response_success?(xml) message = response_message(xml) if success origin = nil delivery_signature = nil shipment_events = [] - tracking_details = root_node.elements['TrackDetails'] - tracking_number = tracking_details.get_text('TrackingNumber').to_s - status_code = tracking_details.get_text('StatusCode').to_s - status_description = tracking_details.get_text('StatusDescription').to_s - status = TRACKING_STATUS_CODES[status_code] + all_tracking_details = xml.root.xpath('CompletedTrackDetails/TrackDetails') + tracking_details = case all_tracking_details.length + when 1 + all_tracking_details.first + when 0 + raise ActiveShipping::Error, "The response did not contain tracking details" + else + all_unique_identifiers = xml.root.xpath('CompletedTrackDetails/TrackDetails/TrackingNumberUniqueIdentifier').map(&:text) + raise ActiveShipping::Error, "Multiple matches were found. Specify a unqiue identifier: #{all_unique_identifiers.join(', ')}" + end - if status_code == 'DL' && tracking_details.get_text('SignatureProofOfDeliveryAvailable').to_s == 'true' - delivery_signature = tracking_details.get_text('DeliverySignatureName').to_s + + first_notification = tracking_details.at('Notification') + if first_notification.at('Severity').text == 'ERROR' + case first_notification.at('Code').text + when '9040' + raise ActiveShipping::ShipmentNotFound, first_notification.at('Message').text + else + raise ActiveShipping::ResponseContentError, first_notification.at('Message').text + end end - origin_node = tracking_details.elements['OriginLocationAddress'] + tracking_number = tracking_details.at('TrackingNumber').text + status_detail = tracking_details.at('StatusDetail') + if status_detail.nil? + raise ActiveShipping::Error, "Tracking response does not contain status information" + end - if origin_node + status_code = status_detail.at('Code').text + status_description = (status_detail.at('AncillaryDetails/ReasonDescription') || status_detail.at('Description')).text + status = TRACKING_STATUS_CODES[status_code] + + if status_code == 'DL' && tracking_details.at('AvailableImages').try(:text) == 'SIGNATURE_PROOF_OF_DELIVERY' + delivery_signature = tracking_details.at('DeliverySignatureName').text + end + + if origin_node = tracking_details.at('OriginLocationAddress') origin = Location.new( - :country => origin_node.get_text('CountryCode').to_s, - :province => origin_node.get_text('StateOrProvinceCode').to_s, - :city => origin_node.get_text('City').to_s + :country => origin_node.at('CountryCode').text, + :province => origin_node.at('StateOrProvinceCode').text, + :city => origin_node.at('City').text ) end destination = extract_address(tracking_details, DELIVERY_ADDRESS_NODE_NAMES) shipper_address = extract_address(tracking_details, SHIPPER_ADDRESS_NODE_NAMES) ship_time = extract_timestamp(tracking_details, 'ShipTimestamp') actual_delivery_time = extract_timestamp(tracking_details, 'ActualDeliveryTimestamp') scheduled_delivery_time = extract_timestamp(tracking_details, 'EstimatedDeliveryTimestamp') - tracking_details.elements.each('Events') do |event| - address = event.elements['Address'] + tracking_details.xpath('Events').each do |event| + address = event.at('Address') + next if address.nil? || address.at('CountryCode').nil? - city = address.get_text('City').to_s - state = address.get_text('StateOrProvinceCode').to_s - zip_code = address.get_text('PostalCode').to_s - country = address.get_text('CountryCode').to_s - next if country.blank? + city = address.at('City').try(:text) + state = address.at('StateOrProvinceCode').try(:text) + zip_code = address.at('PostalCode').try(:text) + country = address.at('CountryCode').try(:text) location = Location.new(:city => city, :state => state, :postal_code => zip_code, :country => country) - description = event.get_text('EventDescription').to_s + description = event.at('EventDescription').text - time = Time.parse("#{event.get_text('Timestamp').to_s}") + time = Time.parse(event.at('Timestamp').text) zoneless_time = time.utc shipment_events << ShipmentEvent.new(description, zoneless_time, location) end shipment_events = shipment_events.sort_by(&:time) @@ -499,26 +509,21 @@ def ship_date(delay_in_hours) delay_in_hours ||= 0 (Time.now + delay_in_hours.hours).to_date end - def response_status_node(document) - document.elements['/*/Notifications/'] - end - def response_success?(document) - response_node = response_status_node(document) - return false if response_node.nil? - - %w(SUCCESS WARNING NOTE).include? response_node.get_text('Severity').to_s + highest_severity = document.root.at('HighestSeverity') + return false if highest_severity.nil? + %w(SUCCESS WARNING NOTE).include?(highest_severity.text) end def response_message(document) - response_node = response_status_node(document) - return "" if response_node.nil? + notifications = document.root.at('Notifications') + return "" if notifications.nil? - "#{response_node.get_text('Severity')} - #{response_node.get_text('Code')}: #{response_node.get_text('Message')}" + "#{notifications.at('Severity').text} - #{notifications.at('Code').text}: #{notifications.at('Message').text}" end def commit(request, test = false) ssl_post(test ? TEST_URL : LIVE_URL, request.gsub("\n", '')) end @@ -533,19 +538,19 @@ end def extract_address(document, possible_node_names) node = nil possible_node_names.each do |name| - node ||= document.elements[name] + node = document.at(name) break if node end - args = if node && node.elements['CountryCode'] + args = if node && node.at('CountryCode') { - :country => node.get_text('CountryCode').to_s, - :province => node.get_text('StateOrProvinceCode').to_s, - :city => node.get_text('City').to_s + :country => node.at('CountryCode').text, + :province => node.at('StateOrProvinceCode').text, + :city => node.at('City').text } else { :country => ActiveUtils::Country.new(:alpha2 => 'ZZ', :name => 'Unknown or Invalid Territory', :alpha3 => 'ZZZ', :numeric => '999'), :province => 'unknown', @@ -555,25 +560,26 @@ Location.new(args) end def extract_timestamp(document, node_name) - if timestamp_node = document.elements[node_name] - Time.parse(timestamp_node.to_s).utc + if timestamp_node = document.at(node_name) + if timestamp_node.text =~ /\A(\d{4}-\d{2}-\d{2})T00:00:00\Z/ + Date.parse($1) + else + Time.parse(timestamp_node.text) + end end end - def remove_version_prefix(xml) - if xml =~ /xmlns:v[0-9]/ - xml.gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>') - else - xml + def build_document(xml, expected_root_tag) + document = Nokogiri.XML(xml) { |config| config.strict } + document.remove_namespaces! + if document.root.nil? || document.root.name != expected_root_tag + raise ActiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml) end - end - - def build_document(xml) - REXML::Document.new(xml) - rescue REXML::ParseException => e + document + rescue Nokogiri::XML::SyntaxError => e raise ActiveShipping::ResponseContentError.new(e, xml) end end end