Skip to content

Commit

Permalink
Merge pull request #8 from mediocre/ups-support
Browse files Browse the repository at this point in the history
Ups support
  • Loading branch information
freshlogic committed Jul 19, 2019
2 parents 9d14535 + 966d6ff commit d87d52d
Show file tree
Hide file tree
Showing 11 changed files with 2,928 additions and 2,039 deletions.
2 changes: 0 additions & 2 deletions .travis.yml
Expand Up @@ -5,8 +5,6 @@ before_script:
language: node_js

node_js:
- 8
- 10
- 12

notifications:
Expand Down
10 changes: 10 additions & 0 deletions README.md
Expand Up @@ -35,6 +35,7 @@ Bloodhound also examines each of the activity/movement/scan events for "shipped"
## Supported Carriers
- DHL
- FedEx
- UPS
- USPS

## Getting Started
Expand Down Expand Up @@ -85,6 +86,11 @@ const bloodhound = new Bloodhound({
},
port: 6379
},
ups: {
accessKey: 'ABCDEFGHIJKLMNOPQ',
password: 'password',
username: 'username',
},
usps: {
userId: 'USPS_USER_ID'
}
Expand All @@ -107,6 +113,10 @@ By default Bloodhound uses the OpenStreetMap geocode provider. You can optionall

By default Bloodhound caches geocode results in-memory locally. You can optionally enable caching of geocoder results to a remote Redis server. These options are passed to the [petty-cache](https://www.npmjs.com/package/petty-cache) module.

**ups**

The UPS API requires a username, password, and an access key.

**usps**

The USPS API simply requires a user ID: https://www.usps.com/business/web-tools-apis/track-and-confirm-api.htm
Expand Down
190 changes: 190 additions & 0 deletions carriers/ups.js
@@ -0,0 +1,190 @@
const async = require('async');
const moment = require('moment-timezone');
const request = require('request');

const geography = require('../util/geography');

// These are all of the status descriptions related to delivery provided by UPS.
const DELIVERED_DESCRIPTIONS = ['DELIVERED', 'DELIVERED BY LOCAL POST OFFICE', 'DELIVERED TO UPS ACCESS POINT AWAITING CUSTOMER PICKUP'];

// These are all of the status descriptions related to shipping provided by UPS.
const SHIPPED_DESCRIPTIONS = ['ARRIVAL SCAN', 'DELIVERED', 'DEPARTURE SCAN', 'DESTINATION SCAN', 'ORIGIN SCAN', 'OUT FOR DELIVERY', 'OUT FOR DELIVERY TODAY', 'PACKAGE DEPARTED UPS MAIL INNOVATIONS FACILITY ENROUTE TO USPS FOR INDUCTION', 'PACKAGE PROCESSED BY UPS MAIL INNOVATIONS ORIGIN FACILITY', 'PACKAGE RECEIVED FOR PROCESSING BY UPS MAIL INNOVATIONS', 'PACKAGE RECEIVED FOR SORT BY DESTINATION UPS MAIL INNOVATIONS FACILITY', 'PACKAGE TRANSFERRED TO DESTINATION UPS MAIL INNOVATIONS FACILITY', 'PACKAGE OUT FOR POST OFFICE DELIVERY', 'PACKAGE SORTED BY POST OFFICE', 'RECEIVED BY THE POST OFFICE', 'SHIPMENT ACCEPTANCE AT POST OFFICE', 'YOUR PACKAGE IS IN TRANSIT TO THE UPS FACILITY.', 'LOADED ON DELIVERY VEHICLE'];

function getActivities(package) {
var activitiesList = package.Activity;

if (!Array.isArray(package.Activity)) {
activitiesList = [package.Activity];
} else {
activitiesList = package.Activity;
}

if (activitiesList.length) {
activitiesList.forEach(activity => {
if (activity.ActivityLocation) {
activity.address = {
city: activity.ActivityLocation.City || (activity.ActivityLocation.Address && activity.ActivityLocation.Address.City),
country: activity.ActivityLocation.CountryCode || (activity.ActivityLocation.Address && activity.ActivityLocation.Address.CountryCode),
state: activity.ActivityLocation.StateProvinceCode || (activity.ActivityLocation.Address && activity.ActivityLocation.Address.StateProvinceCode),
zip: activity.ActivityLocation.PostalCode || (activity.ActivityLocation.Address && activity.ActivityLocation.Address.PostalCode)
}

activity.location = geography.addressToString(activity.address);
} else {
activity.address = {};
activity.location = undefined;
}
});

return activitiesList;
}
}

function UPS(options) {
this.isTrackingNumberValid = function(trackingNumber) {
// Remove whitespace
trackingNumber = trackingNumber.replace(/\s/g, '');

// https://www.ups.com/us/en/tracking/help/tracking/tnh.page
if (/^1Z[0-9A-Z]{16}$/.test(trackingNumber)) {
return true;
}

if (/^(H|T|J|K|F|W|M|Q|A)\d{10}$/.test(trackingNumber)) {
return true;
}

return false;
};

this.track = function(trackingNumber, callback) {
const req = {
baseUrl: options.baseUrl || 'https://onlinetools.ups.com',
forever: true,
gzip: true,
json: {
Security: {
UPSServiceAccessToken: {
AccessLicenseNumber: options.accessKey
},
UsernameToken: {
Username: options.username,
Password: options.password
}
},
TrackRequest: {
InquiryNumber: trackingNumber,
Request: {
RequestAction: 'Track',
RequestOption: 'activity'
}
}
},
method: 'POST',
timeout: 5000,
url: '/rest/Track'
};

async.retry(function(callback) {
request(req, function(err, res, body) {
if (err) {
return callback(err);
}

if (body && !body.TrackResponse) {
if (body.Fault && body.Fault.detail && body.Fault.detail.Errors && body.Fault.detail.Errors.ErrorDetail && body.Fault.detail.Errors.ErrorDetail.PrimaryErrorCode && body.Fault.detail.Errors.ErrorDetail.PrimaryErrorCode.Description) {
return callback(new Error(body.Fault.detail.Errors.ErrorDetail.PrimaryErrorCode.Description));
}
}

callback(null, body);
});
}, function(err, body) {
const results = {
events: []
};

if (err) {
if (err.message === 'No tracking information available') {
return callback(null, results);
}

return callback(err);
}

const packageInfo = body.TrackResponse.Shipment.Package || body.TrackResponse.Shipment;
var activitiesList = [];

if (Array.isArray(packageInfo)) {
activitiesList = packageInfo.map(package => getActivities(package)).flat();
} else {
activitiesList = getActivities(packageInfo);
}

async.mapLimit(Array.from(new Set(activitiesList.map(activity => activity.location))), 10, function(location, callback) {
if (!location) {
callback();
} else {
geography.parseLocation(location, function(err, address) {
if (err || !address) {
return callback(err);
}

address.location = location;

callback(null, address);
});
}
}, function(err, addresses) {
if (err) {
return callback(err);
}

let address = null;

activitiesList.forEach(activity => {
if (addresses) {
address = addresses.find(a => a && a.location === activity.location);
}

let timezone = 'America/New_York';

if (address && address.timezone) {
timezone = address.timezone;
}

const event = {
address: activity.address,
date: moment.tz(`${activity.Date} ${activity.Time}`, 'YYYYMMDD HHmmss', timezone).toDate(),
description: activity.Description || (activity.Status && activity.Status.Description)
};

if (DELIVERED_DESCRIPTIONS.includes(event.description.toUpperCase())) {
results.deliveredAt = event.date;
}

if (SHIPPED_DESCRIPTIONS.includes(event.description.toUpperCase())) {
results.shippedAt = event.date;
}

// Use the city and state from the parsed address (for scenarios where the city includes the state like "New York, NY")
if (address) {
if (address.city) {
event.address.city = address.city;
}

if (address.state) {
event.address.state = address.state;
}
}

results.events.push(event);
});

callback(null, results);
});
})
}
}

module.exports = UPS;
2 changes: 2 additions & 0 deletions carriers/usps.js
Expand Up @@ -94,6 +94,8 @@ function USPS(options) {

const trackDetailList = data.TrackResponse.TrackInfo[0].TrackDetail;

//console.log(data.TrackResponse.TrackInfo[0].TrackDetail);

// If we have tracking details, push them into statuses
// Tracking details only exist if the item has more than one status update
if (trackDetailList) {
Expand Down
18 changes: 12 additions & 6 deletions index.js
@@ -1,5 +1,6 @@
const NodeGeocoder = require('node-geocoder');
const PitneyBowes = require('./carriers/pitneyBowes');
const UPS = require('./carriers/ups');
const FedEx = require('./carriers/fedEx');
const USPS = require('./carriers/usps');
const DHL = require('./carriers/dhl');
Expand All @@ -24,16 +25,19 @@ function Bloodhound(options) {

const fedEx = new FedEx(options && options.fedEx);
const pitneyBowes = new PitneyBowes(options && options.pitneyBowes);
const ups = new UPS(options && options.ups);
const usps = new USPS(options && options.usps);
const dhl = new DHL(options && options.dhl);

this.guessCarrier = function(trackingNumber) {
if (fedEx.isTrackingNumberValid(trackingNumber)) {
if (dhl.isTrackingNumberValid(trackingNumber)) {
return 'DHL';
} else if (fedEx.isTrackingNumberValid(trackingNumber)) {
return 'FedEx';
} else if (ups.isTrackingNumberValid(trackingNumber)) {
return 'UPS';
} else if (usps.isTrackingNumberValid(trackingNumber)) {
return 'USPS';
} else if (dhl.isTrackingNumberValid(trackingNumber)) {
return 'DHL';
} else {
return undefined;
}
Expand Down Expand Up @@ -62,14 +66,16 @@ function Bloodhound(options) {

carrier = carrier.toLowerCase();

if (carrier === 'fedex') {
if (carrier === 'dhl') {
dhl.track(trackingNumber, callback);
} else if (carrier === 'fedex') {
fedEx.track(trackingNumber, callback);
} else if (carrier === 'newgistics') {
pitneyBowes.track(trackingNumber, callback);
} else if (carrier === 'ups'){
ups.track(trackingNumber, callback);
} else if (carrier === 'usps') {
usps.track(trackingNumber, callback);
} else if (carrier === 'dhl') {
dhl.track(trackingNumber, callback);
} else {
return callback(new Error(`Carrier ${carrier} is not supported.`));
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -4,7 +4,7 @@
"async": "~3.1.0",
"moment-timezone": "~0.5.25",
"node-geocoder": "~3.23.0",
"petty-cache": "~2.4.1",
"petty-cache": "^2.4.1",
"pitney-bowes": "~0.1.0",
"shipping-fedex": "0.2.0",
"tz-lookup": "~6.1.18",
Expand Down

0 comments on commit d87d52d

Please sign in to comment.