reactive_freight 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +166 -0
  5. data/Rakefile +8 -0
  6. data/accessorial_symbols.txt +95 -0
  7. data/lib/reactive_freight.rb +21 -0
  8. data/lib/reactive_freight/carrier.rb +62 -0
  9. data/lib/reactive_freight/carriers.rb +18 -0
  10. data/lib/reactive_freight/carriers/btvp.rb +384 -0
  11. data/lib/reactive_freight/carriers/clni.rb +59 -0
  12. data/lib/reactive_freight/carriers/ctbv.rb +35 -0
  13. data/lib/reactive_freight/carriers/dphe.rb +296 -0
  14. data/lib/reactive_freight/carriers/drrq.rb +303 -0
  15. data/lib/reactive_freight/carriers/fcsy.rb +24 -0
  16. data/lib/reactive_freight/carriers/fwda.rb +243 -0
  17. data/lib/reactive_freight/carriers/jfj_transportation.rb +11 -0
  18. data/lib/reactive_freight/carriers/pens.rb +135 -0
  19. data/lib/reactive_freight/carriers/rdfs.rb +320 -0
  20. data/lib/reactive_freight/carriers/saia.rb +336 -0
  21. data/lib/reactive_freight/carriers/sefl.rb +234 -0
  22. data/lib/reactive_freight/carriers/totl.rb +96 -0
  23. data/lib/reactive_freight/carriers/wrds.rb +218 -0
  24. data/lib/reactive_freight/configuration/carriers/btvp.yml +139 -0
  25. data/lib/reactive_freight/configuration/carriers/clni.yml +107 -0
  26. data/lib/reactive_freight/configuration/carriers/ctbv.yml +117 -0
  27. data/lib/reactive_freight/configuration/carriers/dphe.yml +124 -0
  28. data/lib/reactive_freight/configuration/carriers/drrq.yml +115 -0
  29. data/lib/reactive_freight/configuration/carriers/fcsy.yml +104 -0
  30. data/lib/reactive_freight/configuration/carriers/fwda.yml +117 -0
  31. data/lib/reactive_freight/configuration/carriers/jfj_transportation.yml +2 -0
  32. data/lib/reactive_freight/configuration/carriers/pens.yml +22 -0
  33. data/lib/reactive_freight/configuration/carriers/rdfs.yml +135 -0
  34. data/lib/reactive_freight/configuration/carriers/saia.yml +117 -0
  35. data/lib/reactive_freight/configuration/carriers/sefl.yml +115 -0
  36. data/lib/reactive_freight/configuration/carriers/totl.yml +107 -0
  37. data/lib/reactive_freight/configuration/carriers/wrds.yml +19 -0
  38. data/lib/reactive_freight/configuration/platforms/carrier_logistics.yml +25 -0
  39. data/lib/reactive_freight/configuration/platforms/liftoff.yml +12 -0
  40. data/lib/reactive_freight/package.rb +137 -0
  41. data/lib/reactive_freight/platform.rb +36 -0
  42. data/lib/reactive_freight/platforms.rb +4 -0
  43. data/lib/reactive_freight/platforms/carrier_logistics.rb +317 -0
  44. data/lib/reactive_freight/platforms/liftoff.rb +102 -0
  45. data/lib/reactive_freight/rate_estimate.rb +113 -0
  46. data/lib/reactive_freight/shipment_event.rb +10 -0
  47. data/reactive_freight.gemspec +39 -0
  48. data/service_type_symbols.txt +4 -0
  49. metadata +198 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 17738be42395599ca8147e6dbcd058e3829f7d72978471ff9c8bda4bbac09716
4
+ data.tar.gz: b2d39f6d615b2cd4cc298a0ab0c9897d1f7ebae626fdf2c7760e730aa92faae8
5
+ SHA512:
6
+ metadata.gz: 52059a010d29aecae940475ad22a1eccb7d4d1abaf14e74b62d879c61a97ef1aa233dc2bd2afd3605b8ecad8540222abe7453e418f340b3d3106d8902e9dcaa9
7
+ data.tar.gz: 3d0acc30c3db90aff7d273ba3e4931324acff2c8c44cf59e25029a0e3367e449430ddb5a54795539e8e272b53f6c5832bb595a7434e715a6b78e7ef556982230
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2020 Brody Hoskins
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,166 @@
1
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop-hq/rubocop)
2
+ ![stability-wip](https://img.shields.io/badge/stability-work_in_progress-lightgrey.svg)
3
+
4
+ # ReactiveFreight
5
+
6
+ ReactiveFreight extends [ReactiveShipping](https://github.com/realsubpop/reactive_shipping) to support LTL carriers.
7
+
8
+ Features specific to ReactiveFreight:
9
+
10
+ **Important:** The following features require carriers to be defined as a ReactiveFreight carriers specifically; this means that carriers included with ReactiveShipping function the same as before (and do not inherit the features).
11
+
12
+ - Abstracted accessorials
13
+ - Abstracted tracking events
14
+ - Cubic feet and density calculations
15
+ - Freight class calculations (and manual overriding)
16
+ - Download scanned documents including bill of lading and/or proof of delivery where supported
17
+
18
+ ## Supported Freight Carriers & Platforms
19
+
20
+ *This list varies day to day as this the project is a work in progress*
21
+
22
+ **In addition** to the carriers supported by [ReactiveShipping](https://github.com/realsubpop/reactive_shipping), ReactiveFreight supports the following carriers and platforms.
23
+
24
+ Carriers differ from platforms in that they have unique web services whereas platforms host several carriers' web services on a single service (platform). Carriers however may extend platforms and override them for carrier-specific functionality.
25
+
26
+ ### Carriers
27
+
28
+ |Carrier |BOL|POD|Rates|Tracking|
29
+ |-----------------------------------|---|---|-----|--------|
30
+ |Best Overnite Express |✓ |✓ |✓ |✓ |
31
+ |Clear Lane Freight Systems |✓ |✓ |✓ |✓ |
32
+ |The Custom Companies | | |✓ |✓ |
33
+ |Dependable Highway Express | | |✓ |✓ |
34
+ |Forward Air | |✓ |✓ |✓ |
35
+ |Frontline Freight |✓ |✓ |✓ |✓ |
36
+ |Peninsula Truck Lines | | |✓ | |
37
+ |Roadrunner Transportation Services |✓ |✓ |✓ |✓ |
38
+ |Saia | | |✓ |✓ |
39
+ |Southeastern Freight Lines | | |✓ | |
40
+ |Tforce Worldwide | |✓ |✓ | |
41
+ |Total Transportation & Distribution|✓ |✓ |✓ |✓ |
42
+ |Western Regional Delivery Service | |✓ | |✓ |
43
+
44
+ ### Platforms
45
+
46
+ * [Carrier Logistics](https://carrierlogistics.com)
47
+
48
+ ## Versions
49
+
50
+ [See releases](https://github.com/brodyhoskins/reactive_freight/releases)
51
+
52
+ ## Installation
53
+
54
+ Using bundler, add to the `Gemfile`:
55
+
56
+ ```ruby
57
+ gem 'reactive_freight'
58
+ ```
59
+
60
+ Or stand alone:
61
+
62
+ ```
63
+ $ gem install reactive_freight
64
+ ```
65
+
66
+ ## Sample Usage
67
+
68
+ Start off by initializing the carrier:
69
+
70
+ ```ruby
71
+ require 'reactive_freight'
72
+ carrier = ReactiveShipping::BTVP.new(account: 'account_number',
73
+ username: 'username',
74
+ password: 'password')
75
+ ```
76
+
77
+ ### Documents
78
+
79
+ ```ruby
80
+ carrier.find_bol(tracking_number)
81
+ carrier.find_pod(tracking_number, path: 'POD.pdf') # path is optional
82
+ ```
83
+
84
+ ### Tracking
85
+
86
+ **Important:** When a ReactiveFreight carrier is loaded `ReactiveShipping::ShipmentEvent` objects' `name` and `status` will return symbols rather than text — it is up to higher-level libraries to provide translations.
87
+
88
+ Carriers included with ReactiveShipping (typically non-freight) will retain the original behavior for compatibility.
89
+
90
+ ```ruby
91
+ tracking = carrier.find_tracking_info(tracking_number)
92
+
93
+ tracking.delivered?
94
+ tracking.status
95
+
96
+ tracking_info.shipment_events.each do |event|
97
+ puts "#{event.name} at #{event.location.city}, #{event.location.state} on #{event.time}. #{event.message}"
98
+ end
99
+ ```
100
+
101
+ ### Quoting
102
+
103
+ **Note:** Dimensions from ReactiveShipping were passed as an array in `height x width x length` order. While this is still supported, explicitly setting dimensions in a hash (as demonstrated below) is highly recommended to reduce confusion.
104
+
105
+ ```ruby
106
+ packages = [
107
+ ReactiveShipping::Package.new(371 * 16, # 371 lbs
108
+ {
109
+ length: 40, # inches
110
+ width: 48,
111
+ height: 47
112
+ },
113
+ units: :imperial),
114
+ ReactiveShipping::Package.new(371 * 16, # 371 lbs
115
+ {
116
+ length: 40, # inches
117
+ width: 48,
118
+ height: 47
119
+ },
120
+ freight_class: 125, # override calculated freight class
121
+ units: :imperial)
122
+ ]
123
+
124
+ origin = ReactiveShipping::Location.new(country: 'US',
125
+ state: 'CA',
126
+ city: 'Los Angeles',
127
+ zip: '90001')
128
+
129
+ destination = ReactiveShipping::Location.new(country: 'US',
130
+ state: 'IL',
131
+ city: 'Chicago',
132
+ zip: '60007')
133
+
134
+ accessorials = %i[
135
+ appointment_delivery
136
+ liftgate_delivery
137
+ residential_delivery
138
+ ]
139
+
140
+ response = carrier.find_rates(origin, destination, packages, accessorials: accessorials)
141
+ rates = response.rates
142
+ rates = response.rates.sort_by(&:price).collect { |rate| [rate.service_name, rate.price] }
143
+ ```
144
+
145
+ **Important:** A ReactiveFreight `RateEstimate` returns a `Hash` rather than a `String` with the carrier's name; stock ReactiveShipping `RateEstimates` return the latter:
146
+
147
+ ```ruby
148
+ rate = rates.first
149
+ rate.carrier
150
+
151
+ => "Best Overnite Express" # ReactiveShipping
152
+ => {:scac=>"BTVP", :name=>"Best Overnite Express"} # ReactiveFreight
153
+
154
+ # To find the relevant information, check the hash
155
+ rate.carrier.dig(:name)
156
+ => "Best Overnite Express"
157
+ rate.carrier.dig(:scac)
158
+ => "BTVP"
159
+
160
+ # Maintain compatibility with ReactiveShipping
161
+ rate.carrier.is_a?(Hash) ? rate.carrier.dig(:name) : rate.carrier
162
+ => "Best Overnite Express"
163
+
164
+ rate.carrier.is_a?(Hash) ? rate.carrier.dig(:scac) : nil
165
+ => "BTVP"
166
+ ```
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,95 @@
1
+ :afterhours_delivery
2
+ :afterhours_pickup
3
+ :airport_delivery
4
+ :airport_pickup
5
+ :amusement_park_delivery
6
+ :amusement_park_pickup
7
+ :appointment_delivery
8
+ :appointment_pickup
9
+ :athletic_facility_delivery
10
+ :athletic_facility_pickup
11
+ :brewery_delivery
12
+ :brewery_pickup
13
+ :cemetery_delivery
14
+ :cemetery_pickup
15
+ :church_delivery
16
+ :church_pickup
17
+ :construction_delivery
18
+ :construction_pickup
19
+ :convention_delivery
20
+ :convention_pickup
21
+ :customs_brokerage
22
+ :dock_dropoff
23
+ :dock_pickup
24
+ :driver_assist
25
+ :early_morning_delivery
26
+ :early_morning_pickup
27
+ :fair_delivery
28
+ :fair_pickup
29
+ :farm_delivery
30
+ :farm_pickup
31
+ :fitness_center_delivery
32
+ :fitness_center_pickup
33
+ :flatbed_delivery
34
+ :flatbed_pickup
35
+ :golf_course_delivery
36
+ :golf_course_pickup
37
+ :government_delivery
38
+ :government_pickup
39
+ :grocery_store_delivery
40
+ :grocery_store_pickup
41
+ :grocery_warehouse_delivery
42
+ :grocery_warehouse_pickup
43
+ :guaranteed_delivery
44
+ :guaranteed_delivery_am
45
+ :guaranteed_delivery_pm
46
+ :hospital_delivery
47
+ :hospital_pickup
48
+ :hotel_delivery
49
+ :hotel_pickup
50
+ :inside_delivery
51
+ :inside_pickup
52
+ :inspection_site_delivery
53
+ :inspection_site_pickup
54
+ :jobsite_delivery
55
+ :jobsite_pickup
56
+ :liftgate_delivery
57
+ :liftgate_pickup
58
+ :mall_delivery
59
+ :mall_pickup
60
+ :marina_delivery
61
+ :marina_pickup
62
+ :military_site_delivery
63
+ :military_site_pickup
64
+ :mine_site_delivery
65
+ :mine_site_pickup
66
+ :motel_delivery
67
+ :motel_pickup
68
+ :nursing_home_delivery
69
+ :nursing_home_pickup
70
+ :park_delivery
71
+ :park_pickup
72
+ :prison_delivery
73
+ :prison_pickup
74
+ :ranch_delivery
75
+ :ranch_pickup
76
+ :reservation_delivery
77
+ :reservation_pickup
78
+ :residential_delivery
79
+ :residential_pickup
80
+ :resort_delivery
81
+ :resort_pickup
82
+ :restaurant_delivery
83
+ :restaurant_pickup
84
+ :school_delivery
85
+ :school_pickup
86
+ :steel_mill_delivery
87
+ :steel_mill_pickup
88
+ :storage_facility_delivery
89
+ :storage_facility_pickup
90
+ :university_delivery
91
+ :university_pickup
92
+ :utility_site_delivery
93
+ :utility_site_pickup
94
+ :winery_delivery
95
+ :winery_pickup
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'nokogiri'
5
+ require 'open-uri'
6
+ require 'rmagick'
7
+ require 'savon'
8
+ require 'watir'
9
+ require 'webdrivers/chromedriver'
10
+ require 'yaml'
11
+
12
+ require 'reactive_shipping'
13
+ require 'reactive_freight/package'
14
+ require 'reactive_freight/rate_estimate'
15
+ require 'reactive_freight/shipment_event'
16
+
17
+ require 'reactive_freight/carrier'
18
+ require 'reactive_freight/platform'
19
+
20
+ require 'reactive_freight/carriers'
21
+ require 'reactive_freight/platforms'
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class Carrier
5
+ attr_accessor :conf, :rates_with_excessive_length_fees
6
+
7
+ def initialize(options = {})
8
+ requirements.each { |key| requires!(options, key) }
9
+ @conf = nil
10
+ @debug = options[:debug].blank? ? false : true
11
+ @options = options
12
+ @last_request = nil
13
+ @test_mode = @options[:test]
14
+
15
+ return unless self.class::REACTIVE_FREIGHT_CARRIER
16
+
17
+ conf_path = File.join(__dir__, 'configuration', 'carriers', "#{self.class.to_s.split('::')[1].underscore}.yml")
18
+ @conf = YAML.safe_load(File.read(conf_path), permitted_classes: [Symbol])
19
+
20
+ @rates_with_excessive_length_fees = @conf.dig(:attributes, :rates, :with_excessive_length_fees)
21
+ end
22
+
23
+ def maximum_weight
24
+ Measured::Weight.new(10_000, :pounds)
25
+ end
26
+
27
+ def serviceable_accessorials?(accessorials)
28
+ return true if accessorials.blank?
29
+
30
+ if !self.class::REACTIVE_FREIGHT_CARRIER ||
31
+ !@conf.dig(:accessorials, :mappable) ||
32
+ !@conf.dig(:accessorials, :unquotable) ||
33
+ !@conf.dig(:accessorials, :unserviceable)
34
+ raise NotImplementedError, "#{self.class.name}: #serviceable_accessorials? not supported"
35
+ end
36
+
37
+ serviceable_accessorials = @conf.dig(:accessorials, :mappable).keys + @conf.dig(:accessorials, :unquotable)
38
+ serviceable_count = (serviceable_accessorials & accessorials).size
39
+
40
+ unserviceable_accessorials = @conf.dig(:accessorials, :unserviceable)
41
+ unserviceable_count = (unserviceable_accessorials & accessorials).size
42
+
43
+ if serviceable_count != accessorials.size || !unserviceable_count.zero?
44
+ raise ArgumentError, "#{self.class.name}: Some accessorials unserviceable"
45
+ end
46
+
47
+ true
48
+ end
49
+
50
+ def find_bol(*)
51
+ raise NotImplementedError, "#{self.class.name}: #find_bol not supported"
52
+ end
53
+
54
+ def find_estimate(*)
55
+ raise NotImplementedError, "#{self.class.name}: #find_estimate not supported"
56
+ end
57
+
58
+ def find_pod(*)
59
+ raise NotImplementedError, "#{self.class.name}: #find_pod not supported"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ ReactiveShipping::Carriers.register :BTVP, 'reactive_freight/carriers/btvp'
4
+ ReactiveShipping::Carriers.register :DPHE, 'reactive_freight/carriers/dphe'
5
+ ReactiveShipping::Carriers.register :DRRQ, 'reactive_freight/carriers/drrq'
6
+ ReactiveShipping::Carriers.register :FWDA, 'reactive_freight/carriers/fwda'
7
+ ReactiveShipping::Carriers.register :PENS, 'reactive_freight/carriers/pens'
8
+ ReactiveShipping::Carriers.register :RDFS, 'reactive_freight/carriers/rdfs'
9
+ ReactiveShipping::Carriers.register :SAIA, 'reactive_freight/carriers/saia'
10
+ ReactiveShipping::Carriers.register :SEFL, 'reactive_freight/carriers/sefl'
11
+ ReactiveShipping::Carriers.register :WRDS, 'reactive_freight/carriers/wrds'
12
+
13
+ # Carriers based on platforms
14
+ ReactiveShipping::Carriers.register :CLNI, 'reactive_freight/carriers/clni'
15
+ ReactiveShipping::Carriers.register :CTBV, 'reactive_freight/carriers/ctbv'
16
+ ReactiveShipping::Carriers.register :JFJTransportation, 'reactive_freight/carriers/jfj_transportation'
17
+ ReactiveShipping::Carriers.register :FCSY, 'reactive_freight/carriers/fcsy'
18
+ ReactiveShipping::Carriers.register :TOTL, 'reactive_freight/carriers/totl'
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class BTVP < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Best Overnite Express'
9
+ @@scac = 'BTVP'
10
+
11
+ def requirements
12
+ %i[username password]
13
+ end
14
+
15
+ # Documents
16
+ def find_bol(tracking_number, options = {})
17
+ options = @options.merge(options)
18
+ parse_document_response(:bol, tracking_number, options)
19
+ end
20
+
21
+ def find_pod(tracking_number, options = {})
22
+ options = @options.merge(options)
23
+ parse_document_response(:pod, tracking_number, options)
24
+ end
25
+
26
+ # Rates
27
+ def find_rates(origin, destination, packages, options = {})
28
+ options = @options.merge(options)
29
+ origin = Location.from(origin)
30
+ destination = Location.from(destination)
31
+ packages = Array(packages)
32
+
33
+ request = build_rate_request(origin, destination, packages, options)
34
+ parse_rate_response(origin, destination, packages, commit(:rates, request))
35
+ end
36
+
37
+ # Tracking
38
+ def find_tracking_info(tracking_number, options = {})
39
+ options = @options.merge(options)
40
+ request = build_tracking_request(tracking_number)
41
+ parse_tracking_response(commit(:track, request))
42
+ end
43
+
44
+ protected
45
+
46
+ def build_soap_header
47
+ {
48
+ username: @options[:username],
49
+ password: @options[:password]
50
+ }
51
+ end
52
+
53
+ def commit(action, request)
54
+ Savon.client(
55
+ wsdl: build_url(action),
56
+ convert_request_keys_to: :upcase,
57
+ env_namespace: :soapenv
58
+ ).call(
59
+ @conf.dig(:api, :actions, action),
60
+ headers: { 'SOAPAction' => '""' },
61
+ soap_action: false,
62
+ message: request
63
+ ).body
64
+ end
65
+
66
+ def parse_date(date)
67
+ date ? Date.strptime(date, '%m/%d/%Y').to_s(:db) : nil
68
+ end
69
+
70
+ def parse_datetime(datetime)
71
+ return nil unless datetime
72
+
73
+ if datetime.include?('-')
74
+ DateTime.strptime(datetime, '%Y-%m-%d %H:%M').to_s(:db)
75
+ else
76
+ DateTime.strptime(datetime, '%m/%d/%Y %H:%M').to_s(:db)
77
+ end
78
+ end
79
+
80
+ def build_url(action)
81
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
82
+ "#{scheme}#{@conf.dig(:api, :domain)}:#{@conf.dig(:api, :ports, action)}#{@conf.dig(:api, :endpoints, action)}"
83
+ end
84
+
85
+ def strip_date(str)
86
+ str ? str.split(/[A|P]M /)[1] : nil
87
+ end
88
+
89
+ # Documents
90
+ def download_document(type, tracking_number, url, options = {})
91
+ options = @options.merge(options)
92
+ path = options[:path].blank? ? File.join(Dir.tmpdir, "#{@@name} #{tracking_number} #{type.to_s.upcase}.pdf") : options[:path]
93
+ file = File.new(path, 'w')
94
+
95
+ File.open(file.path, 'wb') do |file|
96
+ URI.parse(url).open do |input|
97
+ file.write(input.read)
98
+ end
99
+ rescue OpenURI::HTTPError
100
+ raise ReactiveShipping::ResponseError, "API Error: #{@@name}: Document not found"
101
+ end
102
+
103
+ File.exist?(path) ? path : false
104
+ end
105
+
106
+ def parse_document_response(type, tracking_number, options = {})
107
+ options = @options.merge(options)
108
+ browser = Watir::Browser.new(:chrome, headless: !@debug)
109
+ browser.goto(build_url(:pod))
110
+
111
+ browser.text_field(name: 'userid').set(@options[:username])
112
+ browser.text_field(name: 'password').set(@options[:password])
113
+ browser.button(name: 'btnLogin').click
114
+
115
+ browser.element(xpath: '/html/body/div[1]/div[2]/div[2]/div[1]/div[2]/img').click
116
+ browser.element(xpath: '/html/body/div[1]/div[2]/div[2]/div[1]/div[3]/ul[2]/li').click
117
+ browser.element(xpath: '/html/body/div[1]/div[2]/div[2]/div[1]/div[3]/ul[2]/li[6]/span[2]').click
118
+ browser.select_list(name: 'TATIWT').select('S')
119
+
120
+ browser.textarea(name: 'TATFB').set(tracking_number)
121
+ browser.button(xpath: '/html/body/div[1]/div[3]/div[3]/div/div[1]/div/button[2]').click
122
+
123
+ browser.element(xpath: '/html/body/div[1]/div[3]/div[2]/div/div[1]/div[4]/div[3]/div/table/tbody/tr[2]/td[2]').double_click
124
+
125
+ html = browser.element(xpath: '/html/body/div[1]/div[3]/div[2]/div/div/div/form/div[4]/div[2]/div/div/table').inner_html
126
+ html = Nokogiri::HTML.parse(html)
127
+
128
+ url = nil
129
+ html.css('tr').each do |tr|
130
+ next unless tr.text.downcase.include?(@conf.dig(:documents, :types, type).downcase)
131
+
132
+ link_id = tr.css('td')[1].css('a').to_html.split('id=')[1].split('onfocus')[0].gsub('"', '').strip
133
+ browser.element(css: "##{link_id}").click
134
+ url = browser.element(xpath: '/html/body/div[1]/div[3]/div[2]/div/embed').attribute_value('src')
135
+ end
136
+
137
+ browser.close
138
+
139
+ raise ReactiveShipping::ResponseError, "API Error: #{@@name}: Document not found" if url.blank?
140
+
141
+ download_document(type, tracking_number, url, options)
142
+ end
143
+
144
+ # Rates
145
+ def build_rate_request(origin, destination, packages, options = {})
146
+ options = @options.merge(options)
147
+
148
+ accessorials = []
149
+
150
+ unless options[:accessorials].blank?
151
+ serviceable_accessorials?(options[:accessorials])
152
+ options[:accessorials].each do |a|
153
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
154
+ accessorials << { code: @conf.dig(:accessorials, :mappable)[a] }
155
+ end
156
+ end
157
+ end
158
+
159
+ accessorials = accessorials.uniq.to_a
160
+
161
+ request = {
162
+ 'arg0' => {
163
+ securityinfo: build_soap_header,
164
+ quote: {
165
+ iam: options[:iam].blank? ? 'D' : options[:iam], # S for shipper, C for consignee, D for third party
166
+ shipper: {
167
+ city: origin.to_hash[:city].to_s.upcase,
168
+ state: origin.to_hash[:province].to_s.upcase,
169
+ zip: origin.to_hash[:postal_code].to_s.upcase
170
+ },
171
+ consignee: {
172
+ city: destination.to_hash[:city].to_s.upcase,
173
+ state: destination.to_hash[:province].to_s.upcase,
174
+ zip: destination.to_hash[:postal_code].to_s.upcase
175
+ },
176
+ accessorialcount: accessorials.size,
177
+ accessorial: accessorials.blank? ? [] : accessorials,
178
+ ppdcol: options[:payment_type].blank? ? 'P' : options[:payment_type].blank?, # Prepaid
179
+ itemcount: packages.size,
180
+ item: packages.inject([]) do |arr, package|
181
+ arr << {
182
+ _class: package.freight_class,
183
+ description: 'Freight'.upcase, # Required
184
+ haz: '', # Y if yes
185
+ pallets: 1,
186
+ pieces: 1,
187
+ weight: package.pounds.ceil
188
+ }
189
+ end
190
+ }
191
+ }
192
+ }
193
+
194
+ save_request(request)
195
+ request
196
+ end
197
+
198
+ def parse_rate_response(origin, destination, packages, response)
199
+ success = true
200
+ message = ''
201
+
202
+ if !response
203
+ success = false
204
+ message = 'API Error: Unknown response'
205
+ else
206
+ if !response.dig(:getquote_response, :return, :rating, :errorcode).blank?
207
+ success = false
208
+ message = response.dig(:getquote_response, :return, :rating, :errorcode)
209
+ else
210
+ cost = (response.dig(:getquote_response, :return, :rating, :amount).to_f * 100).to_i
211
+
212
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
213
+ if !@options[:tariff].blank?
214
+ if longest_dimension >= 168
215
+ cost += @options[:tariff].dig('overlength_fees').dig('over_14_ft')
216
+ elsif longest_dimension >= 144 && longest_dimension < 168
217
+ cost += @options[:tariff].dig('overlength_fees').dig('12_through_14_ft')
218
+ elsif longest_dimension >= 120 && longest_dimension < 144
219
+ cost += @options[:tariff].dig('overlength_fees').dig('10_through_12_ft')
220
+ elsif longest_dimension >= 96 && longest_dimension < 120
221
+ cost += @options[:tariff].dig('overlength_fees').dig('8_through_10_ft')
222
+ end
223
+ elsif longest_dimension >= 96
224
+ warn 'API Warning: Overlength fees not applied because `tariff` is empty!'
225
+ end
226
+
227
+ transit_days = response.dig(
228
+ :getquote_response,
229
+ :return,
230
+ :service,
231
+ :days
232
+ ).to_i
233
+
234
+ # Calculate real transit time based on information we have about the destination service days
235
+ %i[mon tue wed thu fri].each do |weekday|
236
+ days += 1 if response.dig(:getquote_response, :return, :service, :destination, weekday) == 'N'
237
+ end
238
+
239
+ estimate_reference = response.dig(
240
+ :getquote_response,
241
+ :return,
242
+ :rating,
243
+ :quotenumber
244
+ )
245
+
246
+ if cost
247
+ rate_estimates = [
248
+ RateEstimate.new(
249
+ origin,
250
+ destination,
251
+ { scac: self.class.scac.upcase, name: self.class.name },
252
+ :standard,
253
+ transit_days: transit_days,
254
+ estimate_reference: estimate_reference,
255
+ total_cost: cost,
256
+ total_price: cost,
257
+ currency: 'USD',
258
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
259
+ )
260
+ ]
261
+ else
262
+ success = false
263
+ message = 'API Error: Cost is emtpy'
264
+ end
265
+ end
266
+ end
267
+
268
+ RateResponse.new(
269
+ success,
270
+ message,
271
+ response,
272
+ rates: rate_estimates,
273
+ response: response,
274
+ request: last_request
275
+ )
276
+ end
277
+
278
+ # Tracking
279
+ def build_tracking_request(tracking_number)
280
+ request = {
281
+ 'arg0' => {
282
+ securityinfo: build_soap_header,
283
+ pronumber: tracking_number
284
+ }
285
+ }
286
+
287
+ save_request(request)
288
+ request
289
+ end
290
+
291
+ def parse_location(code)
292
+ case code
293
+ when 'LA'
294
+ Location.new(
295
+ city: 'Los Angeles',
296
+ province: 'CA',
297
+ state: 'CA',
298
+ country: ActiveUtils::Country.find('USA')
299
+ )
300
+ else
301
+ Location.new(
302
+ city: code,
303
+ province: nil,
304
+ state: nil,
305
+ country: ActiveUtils::Country.find('USA')
306
+ )
307
+ end
308
+ end
309
+
310
+ def parse_tracking_response(response)
311
+ unless response.dig(:tracktrace_response, :return, :currentstatus, :errorcode).blank?
312
+ status = response.dig(:tracktrace_response, :return, :currentstatus, :errorcode)
313
+ return TrackingResponse.new(false, status, response, carrier: "#{@@scac}, #{@@name}", xml: response, response: response, request: last_request)
314
+ end
315
+
316
+ receiver_address = Location.new(
317
+ city: response.dig(:tracktrace_response, :return, :currentstatus, :consignee, :city).titleize,
318
+ province: response.dig(:tracktrace_response, :return, :currentstatus, :consignee, :state).upcase,
319
+ state: response.dig(:tracktrace_response, :return, :currentstatus, :consignee, :state).upcase,
320
+ country: ActiveUtils::Country.find('USA')
321
+ )
322
+
323
+ shipper_address = Location.new(
324
+ city: response.dig(:tracktrace_response, :return, :currentstatus, :shipper, :city).titleize,
325
+ province: response.dig(:tracktrace_response, :return, :currentstatus, :shipper, :state).upcase,
326
+ state: response.dig(:tracktrace_response, :return, :currentstatus, :shipper, :state).upcase,
327
+ country: ActiveUtils::Country.find('USA')
328
+ )
329
+
330
+ actual_delivery_date = response.dig(:tracktrace_response, :return, :currentstatus, :deliverydate)
331
+ unless actual_delivery_date.blank?
332
+ comment = response.dig(:tracktrace_response, :return, :currentstatus, :status).downcase
333
+ if comment.starts_with?('delivered')
334
+ actual_delivery_date = parse_date(comment.downcase.split('signed')[0].split('on')[1].strip.sub('at ', ''))
335
+ end
336
+ end
337
+
338
+ ship_time = parse_date(response.dig(:tracktrace_response, :return, :currentstatus, :shipdate))
339
+ scheduled_delivery_date = parse_date(response.dig(:tracktrace_response, :return, :currentstatus, :estdeliverydate))
340
+ tracking_number = response.dig(:tracktrace_response, :return, :pronumber)
341
+
342
+ shipment_events = []
343
+ response.dig(:tracktrace_response, :return, :history).each do |api_event|
344
+ event = nil
345
+ @conf.dig(:events, :types).each do |key, val|
346
+ if api_event.dig(:description).downcase.include? val
347
+ event = key
348
+ break
349
+ end
350
+ end
351
+ next if event.blank?
352
+
353
+ datetime_without_time_zone = parse_datetime("#{api_event.dig(:date)} #{api_event.dig(:time)}")
354
+ location = parse_location(api_event.dig(:location))
355
+
356
+ # status and type_code set automatically by ActiveFreight based on event
357
+ shipment_events << ShipmentEvent.new(event, datetime_without_time_zone, location)
358
+ end
359
+
360
+ shipment_events = shipment_events.sort_by(&:time)
361
+
362
+ TrackingResponse.new(
363
+ true,
364
+ shipment_events.last.status,
365
+ response,
366
+ carrier: "#{@@scac}, #{@@name}",
367
+ xml: response,
368
+ response: response,
369
+ status: status,
370
+ type_code: shipment_events.last.status,
371
+ ship_time: ship_time,
372
+ scheduled_delivery_date: scheduled_delivery_date,
373
+ actual_delivery_date: actual_delivery_date,
374
+ delivery_signature: nil,
375
+ shipment_events: shipment_events,
376
+ shipper_address: shipper_address,
377
+ origin: shipper_address,
378
+ destination: receiver_address,
379
+ tracking_number: tracking_number,
380
+ request: last_request
381
+ )
382
+ end
383
+ end
384
+ end