webgui/lib/WebGUI/Shop/ShipDriver/UPS.pm
Colin Kuskie f3e699e9a7 Retrofit UPS to use Locales instead of Locales::Country. Fixes bug #11221
Updated tests, module, gotcha.txt, and testEnvironment.pl
2009-11-17 09:37:05 -08:00

533 lines
18 KiB
Perl

package WebGUI::Shop::ShipDriver::UPS;
use strict;
use base qw/WebGUI::Shop::ShipDriver/;
use WebGUI::Exception;
use WebGUI::Exception::Shop;
use XML::Simple;
use LWP;
use Tie::IxHash;
use Locales;
use Class::InsideOut qw/ :std /;
use Data::Dumper;
public testMode => my %testMode;
=head1 NAME
Package WebGUI::Shop::ShipDriver::UPS
=head1 DESCRIPTION
Shipping driver for the United Parcel Service, for US Domestic shipping only.
The UPS XML interface will only do a lookup for one destination at a time. However,
each destination may have multiple packages. This means that if a cart holds packages
with multiple destinations, that multiple requests must be sent to the UPS server.
=head1 SYNOPSIS
=head1 METHODS
See the master class, WebGUI::Shop::ShipDriver for information about
base methods. These methods are customized in this class:
=cut
#-------------------------------------------------------------------
=head2 buildXML ( $cart, @packages )
Returns XML for submitting to the UPS servers
=head3 $cart
A WebGUI::Shop::Cart object. This allows us access to the user's
address book
=head3 $packages
An array reference. Each array element is 1 set of items. The
quantity of items will vary in each set. All packages in the set must
go to the same zipcode.
=cut
sub buildXML {
my ($self, $cart, $packages) = @_;
#tie my %xmlHash, 'Tie::IxHash';
my %xmlHash = (
AccessRequest => {},
);
my $xmlAcc = $xmlHash{AccessRequest};
$xmlAcc->{'xml:lang'} = 'en-US';
$xmlAcc->{AccessLicenseNumber} = [ $self->get('licenseNo') ];
$xmlAcc->{UserId} = [ $self->get('userId') ];
$xmlAcc->{Password} = [ $self->get('password') ];
my $localizedCountry = Locales->new('en');
my $xml = XMLout(\%xmlHash,
KeepRoot => 1,
NoSort => 1,
SuppressEmpty => 0,
XMLDecl => 1,
);
my $destination = $packages->[0]->[0]->getShippingAddress;
%xmlHash = (
RatingServiceSelectionRequest => {},
);
my $xmlRate = $xmlHash{RatingServiceSelectionRequest };
$xmlRate->{'xml:lang'} = 'en-US';
$xmlRate->{Request} = {
# Shown in example request, but optional
# TransactionReference => {
# CustomerContext => [ 'Rating and Service' ],
# XpciVersion => [ 1.0001 ],
# },
RequestAction => [ 'Rate' ],
# RequestOption => [ 'shop' ],
};
$xmlRate->{PickupType} = {
Code => [ $self->get('pickupType') ],
};
$xmlRate->{CustomerClassification} = {
Code => [ $self->get('customerClassification') ],
};
$xmlRate->{Shipment} = {
Shipper => {
Address => [ {
PostalCode => [ $self->get('sourceZip') ],
CountryCode => [ $localizedCountry->get_code_from_territory($self->get('sourceCountry')) ],
}, ],
},
ShipTo => {
Address => [ {
PostalCode => [ $destination->get('code') ],
CountryCode => [ $localizedCountry->get_code_from_territory($destination->get('country')) ],
} ],
},
Service => {
Code => [ $self->get('shipService') ],
},
Package => [],
};
if ($self->get('residentialIndicator') eq 'residential') {
$xmlRate->{Shipment}->{ShipTo}->{Address}->[0]->{ResidentialAddressIndicator} = [''];
}
my $packHash = $xmlRate->{Shipment}->{Package};
PACKAGE: foreach my $package (@{ $packages }) {
my $weight = 0;
ITEM: foreach my $item (@{ $package }) {
my $sku = $item->getSku();
next ITEM unless $sku->isShippingRequired;
##If shipsSeparately is set, the item was placed N times in the shippingBundles,
##where N is the quantity. This means that the quantity is wrong for
##any item where that option is set.
my $skuWeight = $sku->getWeight;
if (! $sku->shipsSeparately() ) {
$skuWeight *= $item->get('quantity');
}
$weight += $skuWeight;
}
next PACKAGE unless $weight;
$weight = sprintf "%.1f", $weight;
$weight = '0.1' if $weight == 0;
my $options = {
PackagingType => [ {
Code => [ '02' ],
} ],
PackageWeight => [ {
Weight => [ $weight ], ##Required formatting from spec
} ],
};
push @{ $packHash }, $options;
}
return '' unless scalar @{ $packHash }; ##Nothing to calculate shipping for.
$xml .= XMLout(\%xmlHash,
KeepRoot => 1,
NoSort => 1,
SuppressEmpty => '',
XMLDecl => 1,
);
return $xml;
}
#-------------------------------------------------------------------
=head2 calculate ( $cart )
Returns a shipping price. Since the UPS will only allow a lookup from one source
to one destination at a time, this method may make several XML requests from the
UPS server.
=head3 $cart
A WebGUI::Shop::Cart object. The contents of the cart are analyzed to calculate
the shipping costs. If no items in the cart require shipping, then no shipping
costs are assessed.
=cut
sub calculate {
my ($self, $cart) = @_;
if (! $self->get('sourceZip')) {
WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a source zipcode.});
}
if (! $self->get('sourceCountry')) {
WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a source country.});
}
if (! $self->get('userId')) {
WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a UPS userId.});
}
if (! $self->get('password')) {
WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a UPS password.});
}
if (! $self->get('licenseNo')) {
WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a UPS license number.});
}
my $cost = 0;
##Sort the items into shippable bundles.
my @shippableUnits = $self->_getShippableUnits($cart);
my $packageCount = scalar @shippableUnits;
my $anyShippable = $packageCount > 0 ? 1 : 0;
return $cost unless $anyShippable;
#$cost = scalar @shippableUnits * $self->get('flatFee');
##Build XML ($cart, @shippableUnits)
foreach my $unit (@shippableUnits) {
if ($packageCount > 200) {
WebGUI::Error::InvalidParam->throw(error => q{Cannot do UPS lookups for more than 200 items.});
}
my $xml = $self->buildXML($cart, $unit);
##Do request ($xml)
my $response = $self->_doXmlRequest($xml);
##Error handling
if (! $response->is_success) {
WebGUI::Error::Shop::RemoteShippingRate->throw(error => 'Problem connecting to UPS Web Tools: '. $response->status_line);
}
my $returnedXML = $response->content;
my $xmlData = XMLin($returnedXML, ForceArray => [qw/RatedPackage/]);
if (! $xmlData->{Response}->{ResponseStatusCode}) {
WebGUI::Error::Shop::RemoteShippingRate->throw(error => 'Problem with UPS Online Tools XML: '. $xmlData->{Response}->{Error}->{ErrorDescription});
}
##Summarize costs from returned data
$cost += $self->_calculateFromXML($xmlData);
}
return $cost;
}
#-------------------------------------------------------------------
=head2 _calculateFromXML ( $xmlData )
Takes data from the UPS and returns the calculated shipping price.
=head3 $xmlData
Processed data from an XML rate request, as a perl data structure. The data is expected to
have this structure:
{
RatedShipment => {
TotalCharges => {
MonetaryValue => xx.yy
}
}
}
=cut
sub _calculateFromXML {
my ($self, $xmlData) = @_;
##Additional error checking on the XML data can be done in here. Or, in the future,
##individual elements of the cost can be parsed and returned.
return $xmlData->{RatedShipment}->{TotalCharges}->{MonetaryValue};
}
#-------------------------------------------------------------------
=head2 definition ( $session )
This subroutine returns an arrayref of hashrefs, used to validate data put into
the object by the user, and to automatically generate the edit form to show
the user.
=cut
sub definition {
my $class = shift;
my $session = shift;
WebGUI::Error::InvalidParam->throw(error => q{Must provide a session variable})
unless ref $session eq 'WebGUI::Session';
my $definition = shift || [];
my $i18n = WebGUI::International->new($session, 'ShipDriver_UPS');
tie my %shippingTypes, 'Tie::IxHash';
##Other shipping types can be added below, but also need to be handled by the
##javascript.
$shippingTypes{'us domestic'} = $i18n->get('us domestic');
$shippingTypes{'us international'} = $i18n->get('us international');
tie my %shippingServices, 'Tie::IxHash';
##Note, these keys are required XML keywords in the UPS XML API.
##It needs a one of every key, regardless of the correct label.
##The right set of options is set via JavaScript in the form.
$shippingServices{'01'} = $i18n->get('us domestic 01');
$shippingServices{'02'} = $i18n->get('us domestic 02');
$shippingServices{'03'} = $i18n->get('us domestic 03');
$shippingServices{'07'} = $i18n->get('us international 07');
$shippingServices{'08'} = $i18n->get('us international 08');
$shippingServices{'11'} = $i18n->get('us international 11');
$shippingServices{'12'} = $i18n->get('us domestic 12');
$shippingServices{'13'} = $i18n->get('us domestic 13');
$shippingServices{'14'} = $i18n->get('us domestic 14');
$shippingServices{'54'} = $i18n->get('us international 54');
$shippingServices{'59'} = $i18n->get('us domestic 59');
$shippingServices{'65'} = $i18n->get('us international 65');
tie my %pickupTypes, 'Tie::IxHash';
##Note, these keys are required XML keywords in the UPS XML API.
$pickupTypes{'01'} = $i18n->get('pickup code 01');
$pickupTypes{'03'} = $i18n->get('pickup code 03');
$pickupTypes{'06'} = $i18n->get('pickup code 06');
$pickupTypes{'07'} = $i18n->get('pickup code 07');
$pickupTypes{'11'} = $i18n->get('pickup code 11');
$pickupTypes{'19'} = $i18n->get('pickup code 19');
$pickupTypes{'20'} = $i18n->get('pickup code 20');
tie my %customerClassification, 'Tie::IxHash';
##Note, these keys are required XML keywords in the UPS XML API.
$customerClassification{'01'} = $i18n->get('customer classification 01');
$customerClassification{'03'} = $i18n->get('customer classification 03');
$customerClassification{'04'} = $i18n->get('customer classification 04');
my $localizedCountries = Locales->new('en'); ##Note, for future i18n change the locale
tie my %localizedCountries, 'Tie::IxHash';
%localizedCountries = map { $_ => $_ } grep { !ref $_ } $localizedCountries->get_territory_names();
tie my %fields, 'Tie::IxHash';
%fields = (
instructions => {
fieldType => 'readOnly',
label => $i18n->get('instructions'),
defaultValue => $i18n->get('ups instructions'),
noFormProcess => 1,
},
userId => {
fieldType => 'text',
label => $i18n->get('userid'),
hoverHelp => $i18n->get('userid help'),
defaultValue => '',
},
password => {
fieldType => 'password',
label => $i18n->get('password'),
hoverHelp => $i18n->get('password help'),
defaultValue => '',
},
licenseNo => {
fieldType => 'text',
label => $i18n->get('license'),
hoverHelp => $i18n->get('license help'),
defaultValue => '',
},
sourceZip => {
fieldType => 'zipcode',
label => $i18n->get('source zipcode'),
hoverHelp => $i18n->get('source zipcode help'),
defaultValue => '',
},
sourceCountry => {
fieldType => 'selectBox',
label => $i18n->get('source country'),
hoverHelp => $i18n->get('source country help'),
options => \%localizedCountries,
defaultValue => 'US',
},
shipType => {
fieldType => 'selectBox',
label => $i18n->get('ship type'),
hoverHelp => $i18n->get('ship type help'),
options => \%shippingTypes,
defaultValue => 'us domestic',
extras => q{onchange="WebGUI.ShipDriver.UPS.changeServices(this.options[this.selectedIndex].value,'shipService_formId')"},
},
shipService => {
fieldType => 'selectBox',
label => $i18n->get('ship service'),
hoverHelp => $i18n->get('ship service help'),
options => \%shippingServices,
defaultValue => '03',
},
pickupType => {
fieldType => 'selectBox',
label => $i18n->get('pickup type'),
hoverHelp => $i18n->get('pickup type help'),
options => \%pickupTypes,
defaultValue => '01',
},
customerClassification => {
fieldType => 'selectBox',
label => $i18n->get('customer classification'),
hoverHelp => $i18n->get('customer classification help'),
options => \%customerClassification,
defaultValue => '01',
},
residentialIndicator => {
fieldType => 'radioList',
label => $i18n->get('residential'),
hoverHelp => $i18n->get('residential help'),
options => {
residential => $i18n->get('residential'),
commercial => $i18n->get('commercial'),
},
defaultValue => 'commercial',
},
##Note, if a flat fee is added to this driver, then according to the license
##terms the website must display a note to the user (shop customer) that additional
##fees have been added.
# flatFee => {
# fieldType => 'float',
# label => $i18n->get('flatFee'),
# hoverHelp => $i18n->get('flatFee help'),
# defaultValue => 0,
# },
);
my %properties = (
name => 'UPS',
properties => \%fields,
);
push @{ $definition }, \%properties;
return $class->SUPER::definition($session, $definition);
}
#-------------------------------------------------------------------
=head2 _doXmlRequest ( $xml )
Contact the UPS website and submit the XML for a shipping rate lookup.
Returns a LWP::UserAgent response object.
=head3 $xml
XML to send. It has some very high standards, including XML components in
the right order and sets of allowed tags.
=cut
sub _doXmlRequest {
my ($self, $xml) = @_;
my $userAgent = LWP::UserAgent->new;
$userAgent->env_proxy;
$userAgent->agent('WebGUI');
#
my $url;
if ($self->testMode) {
$url = 'https://wwwcie.ups.com/ups.app/xml/Rate';
}
else {
$url = 'https://wwwcie.ups.com/ups.app/xml/Rate';
}
my $request = HTTP::Request->new(POST => $url);
$request->content_type( 'text/xml' );
$request->content( $xml );
my $response = $userAgent->request($request);
return $response;
}
#-------------------------------------------------------------------
=head2 getEditForm ( )
Override the master method to stuff in some javascript.
=cut
sub getEditForm {
my $self = shift;
$self->session->style->setScript(
$self->session->url->extras('yui/build/utilities/utilities.js'),
{ type => 'text/javascript', },
);
$self->session->style->setScript(
$self->session->url->extras('yui/build/yahoo-dom-event/yahoo-dom-event.js'),
{ type => 'text/javascript', },
);
$self->session->style->setScript(
$self->session->url->extras('yui/build/json/json-min.js'),
{ type => 'text/javascript', },
);
$self->session->style->setScript(
$self->session->url->extras('yui-webgui/build/i18n/i18n.js'),
{ type => 'text/javascript', },
);
$self->session->style->setScript(
$self->session->url->extras('yui-webgui/build/ShipDriver/UPS.js'),
{ type => 'text/javascript', },
);
$self->session->style->setRawHeadTags(<<EOL);
<script type="text/javascript">
YAHOO.util.Event.onDOMReady( WebGUI.ShipDriver.UPS.initI18n );
</script>
EOL
return $self->SUPER::getEditForm();
}
#-------------------------------------------------------------------
=head2 _getShippableUnits ( $cart )
This is a private method.
Sorts items into the cart by how they must be shipped; together, separate,
etc, following these rules:
=over 4
=item *
Each item which ships separately is 1 shippable unit.
=item *
All loose items are bundled together by zip code.
=back
This method returns a
For an empty cart (which shouldn't ever happen), it would return an empty array.
=head3 $cart
A WebGUI::Shop::Cart object. It provides access to the items in the cart
that must be sorted.
=cut
sub _getShippableUnits {
my ($self, $cart) = @_;
##All units sorted by zip code. Loose units kept separately so they
##can be easily bundled together by zip code.
my %shippableUnits = ();
my %looseUnits = ();
ITEM: foreach my $item (@{$cart->getItems}) {
my $sku = $item->getSku;
next ITEM unless $sku->isShippingRequired;
my $zip = $item->getShippingAddress->get('code');
if ($sku->shipsSeparately) {
push @{ $shippableUnits{$zip} }, ( [ $item ] ) x $item->get('quantity');
}
else {
push @{ $looseUnits{$zip} }, $item;
}
}
##Merge the two together now
while (my ($zip, $units) = each %looseUnits) {
push @{ $shippableUnits{$zip} }, $units;
}
return values %shippableUnits;
}
1;