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,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