reactive_freight 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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