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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_freight/platforms/carrier_logistics'
4
+ require 'reactive_freight/platforms/liftoff'
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class CarrierLogistics < ReactiveShipping::Platform
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ # Documents
8
+ def find_bol(tracking_number, options = {})
9
+ options = @options.merge(options)
10
+ parse_document_response(:bol, tracking_number, options)
11
+ end
12
+
13
+ def find_pod(tracking_number, options = {})
14
+ options = @options.merge(options)
15
+ parse_document_response(:pod, tracking_number, options)
16
+ end
17
+
18
+ # Rates
19
+ def find_rates(origin, destination, packages, options = {})
20
+ options = @options.merge(options)
21
+ origin = Location.from(origin)
22
+ destination = Location.from(destination)
23
+ packages = Array(packages)
24
+
25
+ params = build_rate_params(origin, destination, packages, options)
26
+ parse_rate_response(origin, destination, packages, commit(:rates, params: params))
27
+ end
28
+
29
+ # Tracking
30
+ def find_tracking_info(tracking_number)
31
+ parse_tracking_response(tracking_number)
32
+ end
33
+
34
+ # protected
35
+
36
+ def debug?
37
+ return false if @options[:debug].blank?
38
+
39
+ @options[:debug]
40
+ end
41
+
42
+ def build_url(action, options = {})
43
+ options = @options.merge(options)
44
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
45
+ url = ''.dup
46
+ url << "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
47
+ url = url.sub('@CARRIER_CODE@', @conf.dig(:api, :carrier_code)) if url.include?('@CARRIER_CODE@')
48
+ url << options[:params] unless options[:params].blank?
49
+ url
50
+ end
51
+
52
+ def commit(action, options = {})
53
+ options = @options.merge(options)
54
+ url = build_url(action, params: options[:params])
55
+
56
+ response = HTTParty.get(url)
57
+ response.parsed_response if response&.parsed_response
58
+ end
59
+
60
+ # Documents
61
+ def parse_document_response(action, tracking_number, options = {})
62
+ options = @options.merge(options)
63
+ browser = Watir::Browser.new(:chrome, headless: !debug?)
64
+ browser.goto(build_url(action))
65
+
66
+ browser.text_field(name: 'wlogin').set(@options[:username])
67
+ browser.text_field(name: 'wpword').set(@options[:password])
68
+ browser.button(name: 'BtnAction1').click
69
+
70
+ browser.frameset.frames[1].text_field(id: 'menuquicktrack').set(tracking_number)
71
+ browser.browser.frameset.frames[1].button(id: 'menusubmit').click
72
+
73
+ if action == :bol
74
+ element = browser.frameset.frames[1].button(value: 'View Bill Of Lading Image')
75
+ if element.exists?
76
+ element.click
77
+ else
78
+ browser.close
79
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Document not found"
80
+ end
81
+ else
82
+ element = browser.frameset.frames[1].button(value: 'View Delivery Receipt Image')
83
+ if element.exists?
84
+ element.click
85
+ else
86
+ browser.close
87
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Document not found"
88
+ end
89
+ end
90
+
91
+ url = nil
92
+ browser.windows.last.use do
93
+ url = browser.url
94
+ if url.include?('viewdoc.php')
95
+ browser.close
96
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Documnent cannot be downloaded"
97
+ end
98
+ end
99
+
100
+ browser.close
101
+
102
+ path = if options[:path].blank?
103
+ File.join(Dir.tmpdir, "#{self.class.name} #{tracking_number} #{action.to_s.upcase}.pdf")
104
+ else
105
+ options[:path]
106
+ end
107
+ file = File.new(path, 'w')
108
+
109
+ File.open(file.path, 'wb') do |file|
110
+ URI.parse(url).open do |input|
111
+ file.write(input.read)
112
+ end
113
+ rescue OpenURI::HTTPError
114
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Document not found"
115
+ end
116
+
117
+ File.exist?(path) ? path : false
118
+ end
119
+
120
+ # Tracking
121
+ def parse_city_state(str)
122
+ return nil if str.blank?
123
+
124
+ Location.new(
125
+ city: str.split(', ')[0].titleize,
126
+ state: str.split(', ')[1].upcase,
127
+ country: ActiveUtils::Country.find('USA')
128
+ )
129
+ end
130
+
131
+ def parse_city_state_zip(str)
132
+ return nil if str.blank?
133
+
134
+ Location.new(
135
+ city: str.split(', ')[0].titleize,
136
+ state: str.split(', ')[1].split(' ')[0].upcase,
137
+ zip_code: str.split(', ')[1].split(' ')[1],
138
+ country: ActiveUtils::Country.find('USA')
139
+ )
140
+ end
141
+
142
+ def parse_date(date)
143
+ date ? DateTime.strptime(date, '%m/%d/%Y %I:%M %p').to_s(:db) : nil
144
+ end
145
+
146
+ def parse_tracking_response(tracking_number)
147
+ url = "#{build_url(:track)}wbtn=PRO&wpro1=#{tracking_number}"
148
+ save_request({ url: url })
149
+
150
+ begin
151
+ response = HTTParty.get(url)
152
+ if !response.code == 200
153
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: HTTP #{response.code}"
154
+ end
155
+ rescue StandardError
156
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Unknown response:\n#{response.inspect}"
157
+ end
158
+
159
+ if response.body.downcase.include?('please enter a valid pro')
160
+ raise ReactiveShipping::ResponseError, "API Error: #{self.class.name}: Invalid tracking number"
161
+ end
162
+
163
+ html = Nokogiri::HTML(response.body)
164
+ tracking_table = html.css('.newtables2')[0]
165
+
166
+ actual_delivery_date = nil
167
+ receiver_address = nil
168
+ ship_time = nil
169
+ shipper_address = nil
170
+
171
+ shipment_events = []
172
+ tracking_table.css('tr').reverse.each do |tr|
173
+ next if tr.text.include?('shipment status')
174
+ next if tr.css('td').blank?
175
+
176
+ datetime_without_time_zone = "#{tr.css('td')[2].text} #{tr.css('td')[3].text}".squeeze
177
+ event = tr.css('td')[0].text
178
+ location = tr.css('td')[1].text
179
+
180
+ event_key = nil
181
+ @conf.dig(:events, :types).each do |key, val|
182
+ if event.downcase.include?(val) && !event.downcase.include?('estimated')
183
+ event_key = key
184
+ break
185
+ end
186
+ end
187
+ next if event_key.blank?
188
+
189
+ location = (parse_city_state(location.squeeze) if !location.blank? && location.downcase.include?(','))
190
+
191
+ event = event_key
192
+ datetime_without_time_zone = parse_date(datetime_without_time_zone)
193
+
194
+ case event_key
195
+ when :delivered
196
+ actual_delivery_date = datetime_without_time_zone
197
+ receiver_address = location
198
+ when :picked_up
199
+ shipper_address = location
200
+ ship_time = datetime_without_time_zone
201
+ end
202
+
203
+ # status and type_code set automatically by ActiveFreight based on event
204
+ shipment_events << ShipmentEvent.new(event, datetime_without_time_zone, location)
205
+ end
206
+
207
+ scheduled_delivery_date = nil
208
+ status = shipment_events.last.status
209
+
210
+ shipment_events = shipment_events.sort_by(&:time)
211
+
212
+ TrackingResponse.new(
213
+ true,
214
+ status,
215
+ { html: html.to_s },
216
+ carrier: "#{self.class.scac}, #{self.class.name}",
217
+ html: html,
218
+ response: html.to_s,
219
+ status: status,
220
+ type_code: status,
221
+ ship_time: ship_time,
222
+ scheduled_delivery_date: scheduled_delivery_date,
223
+ actual_delivery_date: actual_delivery_date,
224
+ delivery_signature: nil,
225
+ shipment_events: shipment_events,
226
+ shipper_address: shipper_address,
227
+ origin: shipper_address,
228
+ destination: receiver_address,
229
+ tracking_number: tracking_number,
230
+ request: last_request
231
+ )
232
+ end
233
+
234
+ # Rates
235
+ def build_rate_params(origin, destination, packages, options = {})
236
+ options = @options.merge(options)
237
+ params = ''.dup
238
+ params << "xmlv=yes&xmluser=#{@options[:username]}"
239
+ params << "&xmlpass=#{@options[:password]}"
240
+ params << "&vozip=#{origin.to_hash[:postal_code]}"
241
+ params << "&vdzip=#{destination.to_hash[:postal_code]}"
242
+
243
+ i = 0
244
+ packages.each do |package|
245
+ i += 1 # API starts at 1 (not 0)
246
+ params << "&wpieces[#{i}]=1"
247
+ params << "&wpallets[#{i}]=1"
248
+ params << "&vclass[#{i}]=#{package.freight_class}"
249
+ params << "&wweight[#{i}]=#{package.pounds.ceil}"
250
+ end
251
+
252
+ accessorials = []
253
+ unless options[:accessorials].blank?
254
+ serviceable_accessorials?(options[:accessorials])
255
+ options[:accessorials].each do |a|
256
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
257
+ accessorials << @conf.dig(:accessorials, :mappable)[a]
258
+ end
259
+ end
260
+ end
261
+
262
+ calculated_accessorials = build_calculated_accessorials(packages, origin, destination)
263
+ accessorials = accessorials + calculated_accessorials unless calculated_accessorials.blank?
264
+
265
+ accessorials.uniq!
266
+ params << accessorials.join unless accessorials.blank?
267
+
268
+ save_request({ params: params })
269
+ params
270
+ end
271
+
272
+ def parse_rate_response(origin, destination, _packages, response)
273
+ success = true
274
+ message = ''
275
+
276
+ if !response
277
+ success = false
278
+ message = 'API Error: Unknown response'
279
+ else
280
+ if response['error']
281
+ success = false
282
+ message = response['error']
283
+ else
284
+ cost = response.dig('ratequote', 'quotetotal').delete(',').delete('.').to_i
285
+ transit_days = response.dig('ratequote', 'busdays').to_i
286
+ if cost
287
+ rate_estimates = [
288
+ RateEstimate.new(
289
+ origin,
290
+ destination,
291
+ { scac: self.class.scac.upcase, name: self.class.name },
292
+ :standard,
293
+ transit_days: transit_days,
294
+ estimate_reference: nil,
295
+ total_price: cost,
296
+ currency: 'USD',
297
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
298
+ )
299
+ ]
300
+ else
301
+ success = false
302
+ message = 'API Error: Cost is emtpy'
303
+ end
304
+ end
305
+ end
306
+
307
+ RateResponse.new(
308
+ success,
309
+ message,
310
+ response.to_hash,
311
+ rates: rate_estimates,
312
+ response: response,
313
+ request: last_request
314
+ )
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class Liftoff < ReactiveShipping::Platform
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ JSON_HEADERS = {
8
+ 'Accept': 'application/json',
9
+ 'Content-Type': 'application/json',
10
+ 'charset': 'utf-8'
11
+ }.freeze
12
+
13
+ def requirements
14
+ %i[email password scope]
15
+ end
16
+
17
+ # Documents
18
+
19
+ # Rates
20
+
21
+ def show(id)
22
+ request = build_request(:show, params: "/#{id}")
23
+ commit(request)
24
+ end
25
+
26
+ # Tracking
27
+
28
+ # protected
29
+
30
+ def build_url(action, options = {})
31
+ options = @options.merge(options)
32
+ url = ''.dup
33
+ url += "#{base_url}#{@conf.dig(:api, :scopes, options[:scope])}#{@conf.dig(:api, :endpoints, action)}"
34
+ url = url.sub(@conf.dig(:api, :scopes, options[:scope]), '') if action == :authenticate
35
+ url += options[:params] unless options[:params].blank?
36
+ url
37
+ end
38
+
39
+ def build_request(action, options = {})
40
+ options = @options.merge(options)
41
+ headers = JSON_HEADERS
42
+ headers = headers.merge(options[:headers]) unless options[:headers].blank?
43
+ body = options[:body].to_json unless options[:body].blank?
44
+
45
+ unless action == :authenticate
46
+ set_auth_token
47
+ headers = headers.merge(token)
48
+ end
49
+
50
+ request = {
51
+ url: build_url(action, options),
52
+ headers: headers,
53
+ method: @conf.dig(:api, :methods, action),
54
+ body: body
55
+ }
56
+
57
+ save_request(request)
58
+ request
59
+ end
60
+
61
+ def commit(request)
62
+ url = request[:url]
63
+ headers = request[:headers]
64
+ method = request[:method]
65
+ body = request[:body]
66
+
67
+ response = case method
68
+ when :post
69
+ HTTParty.post(url, headers: headers, body: body)
70
+ else
71
+ HTTParty.get(url, headers: headers)
72
+ end
73
+
74
+ JSON.parse(response.body) if response&.body
75
+ end
76
+
77
+ def base_url
78
+ "https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :prefix)}#{@conf.dig(:api, :scope, @options[:scope])}"
79
+ end
80
+
81
+ def set_auth_token
82
+ return @auth_token unless @auth_token.blank?
83
+
84
+ request = build_request(
85
+ :authenticate,
86
+ body: {
87
+ email: @options[:email],
88
+ password: @options[:password]
89
+ }
90
+ )
91
+
92
+ response = commit(request)
93
+ @auth_token = response.dig('auth_token')
94
+ end
95
+
96
+ def token
97
+ { 'Authorization': "Bearer #{@auth_token}" }
98
+ end
99
+
100
+ # Show
101
+ end
102
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class RateEstimate
5
+ attr_accessor :carrier, :charge_items, :compare_price, :currency,
6
+ :delivery_category, :delivery_date, :delivery_range,
7
+ :description, :destination, :estimate_reference, :expires_at,
8
+ :insurance_price, :messages, :negotiated_rate, :origin,
9
+ :package_rates, :phone_required, :pickup_time, :service_code,
10
+ :service_name, :shipment_options, :shipping_date, :transit_days,
11
+ :with_excessive_length_fees
12
+
13
+ def initialize(origin, destination, carrier, service_name, options = {})
14
+ self.charge_items = options[:charge_items] || []
15
+ self.compare_price = options[:compare_price]
16
+ self.currency = options[:currency]
17
+ self.delivery_category = options[:delivery_category]
18
+ self.delivery_range = options[:delivery_range]
19
+ self.description = options[:description]
20
+ self.estimate_reference = options[:estimate_reference]
21
+ self.expires_at = options[:expires_at]
22
+ self.insurance_price = options[:insurance_price]
23
+ self.messages = options[:messages] || []
24
+ self.negotiated_rate = options[:negotiated_rate]
25
+ self.origin = origin
26
+ self.destination = destination
27
+ self.carrier = carrier
28
+ self.service_name = service_name
29
+ self.package_rates = if options[:package_rates]
30
+ options[:package_rates].map { |p| p.update(rate: Package.cents_from(p[:rate])) }
31
+ else
32
+ Array(options[:packages]).map { |p| { package: p } }
33
+ end
34
+ self.phone_required = options[:phone_required]
35
+ self.pickup_time = options[:pickup_time]
36
+ self.service_code = options[:service_code]
37
+ self.shipment_options = options[:shipment_options] || []
38
+ self.shipping_date = options[:shipping_date]
39
+ self.transit_days = options[:transit_days]
40
+ self.total_price = options[:total_price]
41
+ self.with_excessive_length_fees = options.dig(:with_excessive_length_fees)
42
+
43
+ self.delivery_date = @delivery_range.last
44
+ end
45
+
46
+ def total_price
47
+ @total_price || @package_rates.sum { |pr| pr[:rate] }
48
+ rescue NoMethodError
49
+ raise ArgumentError, 'RateEstimate must have a total_price set, or have a full set of valid package rates.'
50
+ end
51
+ alias price total_price
52
+
53
+ def add(package, rate = nil)
54
+ cents = Package.cents_from(rate)
55
+ if cents.nil? && total_price.nil?
56
+ raise ArgumentError, 'New packages must have valid rate information since this RateEstimate has no total_price set.'
57
+ end
58
+
59
+ @package_rates << { package: package, rate: cents }
60
+ self
61
+ end
62
+
63
+ def packages
64
+ package_rates.map { |p| p[:package] }
65
+ end
66
+
67
+ def package_count
68
+ package_rates.length
69
+ end
70
+
71
+ protected
72
+
73
+ def delivery_range=(delivery_range)
74
+ @delivery_range = delivery_range ? delivery_range.map { |date| date_for(date) }.compact : []
75
+ end
76
+
77
+ def total_price=(total_price)
78
+ @total_price = Package.cents_from(total_price)
79
+ end
80
+
81
+ def negotiated_rate=(negotiated_rate)
82
+ @negotiated_rate = negotiated_rate ? Package.cents_from(negotiated_rate) : nil
83
+ end
84
+
85
+ def compare_price=(compare_price)
86
+ @compare_price = compare_price ? Package.cents_from(compare_price) : nil
87
+ end
88
+
89
+ def currency=(currency)
90
+ @currency = ActiveUtils::CurrencyCode.standardize(currency)
91
+ end
92
+
93
+ def phone_required=(phone_required)
94
+ @phone_required = !!phone_required
95
+ end
96
+
97
+ def shipping_date=(shipping_date)
98
+ @shipping_date = date_for(shipping_date)
99
+ end
100
+
101
+ def insurance_price=(insurance_price)
102
+ @insurance_price = Package.cents_from(insurance_price)
103
+ end
104
+
105
+ private
106
+
107
+ def date_for(date)
108
+ date && Date.strptime(date.to_s, '%Y-%m-%d')
109
+ rescue ArgumentError
110
+ nil
111
+ end
112
+ end
113
+ end