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