freight_kit 0.1.15 → 0.1.16

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +96 -43
  3. data/README.md +28 -6
  4. data/VERSION +1 -1
  5. data/configuration/carriers/abfs.yml +124 -0
  6. data/configuration/carriers/btvp.yml +84 -0
  7. data/configuration/carriers/ccyq.yml +121 -0
  8. data/configuration/carriers/clni.yml +113 -0
  9. data/configuration/carriers/cnwy.yml +113 -0
  10. data/configuration/carriers/ctbv.yml +117 -0
  11. data/configuration/carriers/dcha.yml +105 -0
  12. data/configuration/carriers/dlds.yml +111 -0
  13. data/configuration/carriers/dphe.yml +130 -0
  14. data/configuration/carriers/drrq.yml +131 -0
  15. data/configuration/carriers/fcsy.yml +102 -0
  16. data/configuration/carriers/fwda.yml +137 -0
  17. data/configuration/carriers/jfj_transportation.yml +2 -0
  18. data/configuration/carriers/mtvl.yml +12 -0
  19. data/configuration/carriers/numk.yml +14 -0
  20. data/configuration/carriers/otcl.yml +124 -0
  21. data/configuration/carriers/pens.yml +22 -0
  22. data/configuration/carriers/rdfs.yml +142 -0
  23. data/configuration/carriers/saia.yml +129 -0
  24. data/configuration/carriers/sefl.yml +115 -0
  25. data/configuration/carriers/totl.yml +111 -0
  26. data/configuration/carriers/tqyl.yml +28 -0
  27. data/configuration/carriers/wrds.yml +20 -0
  28. data/configuration/platforms/carrier_logistics.yml +25 -0
  29. data/configuration/platforms/next.yml +12 -0
  30. data/configuration/platforms/the_great_information_factory.yml +122 -0
  31. data/freight_kit.gemspec +9 -7
  32. data/lib/freight_kit/api_clients/soap_client.rb +70 -0
  33. data/lib/freight_kit/api_clients.rb +3 -0
  34. data/lib/freight_kit/carriers/abfs.rb +421 -0
  35. data/lib/freight_kit/carriers/btvp.rb +29 -0
  36. data/lib/freight_kit/carriers/ccyq.rb +317 -0
  37. data/lib/freight_kit/carriers/clni.rb +396 -0
  38. data/lib/freight_kit/carriers/cnwy.rb +327 -0
  39. data/lib/freight_kit/carriers/ctbv.rb +53 -0
  40. data/lib/freight_kit/carriers/dcha.rb +76 -0
  41. data/lib/freight_kit/carriers/dlds.rb +49 -0
  42. data/lib/freight_kit/carriers/dphe.rb +474 -0
  43. data/lib/freight_kit/carriers/drrq.rb +580 -0
  44. data/lib/freight_kit/carriers/fcsy.rb +57 -0
  45. data/lib/freight_kit/carriers/fwda.rb +744 -0
  46. data/lib/freight_kit/carriers/jfj_transportation.rb +13 -0
  47. data/lib/freight_kit/carriers/mtvl.rb +34 -0
  48. data/lib/freight_kit/carriers/numk.rb +58 -0
  49. data/lib/freight_kit/carriers/otcl.rb +528 -0
  50. data/lib/freight_kit/carriers/pens.rb +204 -0
  51. data/lib/freight_kit/carriers/rdfs.rb +521 -0
  52. data/lib/freight_kit/carriers/saia.rb +438 -0
  53. data/lib/freight_kit/carriers/sefl.rb +342 -0
  54. data/lib/freight_kit/carriers/totl.rb +172 -0
  55. data/lib/freight_kit/carriers/tqyl.rb +339 -0
  56. data/lib/freight_kit/carriers/wrds.rb +246 -0
  57. data/lib/freight_kit/carriers.rb +26 -0
  58. data/lib/freight_kit/helpers/documentable.rb +13 -0
  59. data/lib/freight_kit/helpers/pickupable.rb +39 -0
  60. data/lib/freight_kit/helpers/rateable.rb +28 -0
  61. data/lib/freight_kit/helpers/trackable.rb +25 -0
  62. data/lib/freight_kit/helpers.rb +6 -0
  63. data/lib/freight_kit/platforms/carrier_logistics.rb +450 -0
  64. data/lib/freight_kit/platforms/next.rb +101 -0
  65. data/lib/freight_kit/platforms/the_great_information_factory.rb +528 -0
  66. data/lib/freight_kit/platforms.rb +5 -0
  67. data/lib/freight_kit.rb +20 -1
  68. metadata +94 -14
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class TQYL < FreightKit::Carrier
5
+ class << self
6
+ def minimum_length_for_overlength_fees
7
+ Measured::Length.new(6, :feet)
8
+ end
9
+
10
+ def overlength_fees_require_tariff?
11
+ false
12
+ end
13
+
14
+ def required_credential_types
15
+ %i[api]
16
+ end
17
+
18
+ def requirements
19
+ %i[credentials]
20
+ end
21
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ class << self
26
+ attr_reader :name, :scac
27
+ end
28
+ @name = 'Total Quality Logistics'
29
+ @scac = 'TQYL'
30
+
31
+ API_SCOPE = 'https://tqlidentity.onmicrosoft.com/services_combined/LTLQuotes.Tender'
32
+
33
+ include FreightKit::Rateable
34
+ include FreightKit::Trackable
35
+ include FreightKit::Pickupable
36
+
37
+ protected
38
+
39
+ def build_url(action)
40
+ "https://#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
41
+ end
42
+
43
+ def build_request(action, body: {}, query: {})
44
+ fetch_credential(:api).api_key
45
+
46
+ request = {
47
+ url: build_url(action),
48
+ method: @conf.dig(:api, :methods, action),
49
+ headers: {},
50
+ body:,
51
+ query:
52
+ }.compact
53
+
54
+ request[:headers] = { 'Authorization' => "Bearer #{build_access_token}" } unless action == :auth
55
+
56
+ save_request(request)
57
+ request
58
+ end
59
+
60
+ def commit(request)
61
+ response = HTTParty.send(
62
+ request[:method],
63
+ request[:url],
64
+ headers: request[:headers].merge(subscription_key_headers),
65
+ query: request[:query],
66
+ body: request[:body],
67
+ debug_output: $stdout,
68
+ )
69
+
70
+ parsed_response = JSON.parse(response.body)
71
+ return parsed_response if [200, 201].include?(response.code)
72
+
73
+ message = if parsed_response.is_a?(String)
74
+ parsed_response
75
+ else
76
+ parsed_response.dig('content', 'message').presence || "HTTP #{response.code}"
77
+ end
78
+
79
+ raise FreightKit::ResponseError, message
80
+ rescue JSON::ParserError => e
81
+ if response.body.include?('Sorry, but we&#39;re having trouble signing you in')
82
+ raise FreightKit::InvalidCredentialsError
83
+ end
84
+
85
+ raise FreightKit::ResponseError, e.message
86
+ end
87
+
88
+ def build_access_token
89
+ build_url(:auth)
90
+ credentials = fetch_credential(:api)
91
+
92
+ request_body = {
93
+ username: credentials.username,
94
+ password: credentials.password,
95
+ grant_type: 'password',
96
+ scope: API_SCOPE,
97
+ client_id: credentials.api_key
98
+ }
99
+
100
+ request = build_request(:auth, query: request_body)
101
+ response = commit(request)
102
+
103
+ response['access_token']
104
+ end
105
+
106
+ def subscription_key_headers
107
+ {
108
+ 'Ocp-Apim-Subscription-Key' => fetch_credential(:api).account,
109
+ 'Content-Type' => 'application/json'
110
+ }
111
+ end
112
+
113
+ # Tracking
114
+
115
+ def build_tracking_request(tracking_number)
116
+ request = {
117
+ url: build_url(:track).gsub('%TRACKING_NUMBER%', tracking_number.to_s),
118
+ method: @conf.dig(:api, :methods, :track),
119
+ headers: { 'Authorization' => "Bearer #{build_access_token}" }
120
+ }.compact
121
+
122
+ save_request(request)
123
+ request
124
+ end
125
+
126
+ def parse_tracking_response(response)
127
+ tracking_response = TrackingResponse.new(carrier: self, request: last_request, response:)
128
+
129
+ actual_delivery_date = nil
130
+ estimated_delivery_date = nil
131
+ scheduled_delivery_date = nil
132
+ ship_time = nil
133
+
134
+ pickup_city, pickup_state = response['firstPick'].split(', ')
135
+ drop_city, drop_state = response['lastDrop'].split(', ')
136
+ country = ActiveUtils::Country.find('US') # Fallback To US. Country not provided in response
137
+ receiver_location = Location.new(city: pickup_city, province: pickup_state, country:)
138
+ shipper_location = Location.new(city: drop_city, province: drop_state, country:)
139
+
140
+ tracking_number = response['poNumber']
141
+ status = response['status']
142
+
143
+ shipment_events = []
144
+
145
+ response['trackingDetails'].each do |api_event|
146
+ event = @conf.dig(:events, :types).key(api_event['status'])
147
+
148
+ case event
149
+ when :picked_up
150
+ ship_time = api_event['time']
151
+ when :delivered
152
+ actual_delivery_date = api_event['time'].to_date
153
+ end
154
+
155
+ shipment_events << ShipmentEvent.new(
156
+ date_time: api_event['time'],
157
+ location: Location.new(city: api_event['city'], province: api_event['state'], country:),
158
+ type_code: event,
159
+ )
160
+ end
161
+
162
+ tracking_response.assign_attributes(
163
+ actual_delivery_date:,
164
+ destination: receiver_location,
165
+ estimated_delivery_date:,
166
+ origin: shipper_location,
167
+ scheduled_delivery_date:,
168
+ ship_time:,
169
+ shipment_events:,
170
+ status:,
171
+ tracking_number:,
172
+ )
173
+ end
174
+
175
+ # Pickup
176
+
177
+ def build_pickup_request(
178
+ delivery_from:,
179
+ delivery_to:,
180
+ dispatcher:,
181
+ pickup_from:,
182
+ pickup_to:,
183
+ scac:,
184
+ service:,
185
+ shipment:
186
+ )
187
+
188
+ origin = shipment.origin
189
+ destination = shipment.destination
190
+ shipper_phone = shipment.origin.contact.phone.delete('^0-9')
191
+ receiver_phone = shipment.destination.contact.phone.delete('^0-9')
192
+
193
+ build_request(
194
+ :pickup,
195
+ body: {
196
+ scac:,
197
+ serviceLevel: service.to_s.capitalize,
198
+ shipmentDate: pickup_from.iso8601,
199
+ commodities: build_commodities(shipment),
200
+ accessorials: build_accessorials(shipment:),
201
+ pickupDetails: {
202
+ address1: origin.address1,
203
+ postalCode: origin.postal_code.to_i,
204
+ city: origin.city,
205
+ state: origin.province.upcase,
206
+ country: origin.country.code(:alpha3).value,
207
+ contactName: origin.contact.name || 'Shipping',
208
+ contactPhone: shipper_phone || '',
209
+ stopName: origin.contact.name || 'Shipping',
210
+ hoursOpen: pickup_from.strftime('%I:%M %p'),
211
+ hoursClosed: pickup_to.strftime('%I:%M %p')
212
+ },
213
+ deliveryDetails: {
214
+ address1: destination.address1,
215
+ postalCode: destination.postal_code.to_i,
216
+ city: destination.city,
217
+ state: destination.province.upcase,
218
+ country: destination.country.code(:alpha3).value,
219
+ contactName: destination.contact.name || 'Receiving',
220
+ contactPhone: receiver_phone || '',
221
+ stopName: destination.contact.name || 'Receiving',
222
+ hoursOpen: delivery_from.strftime('%I:%M %p'),
223
+ hoursClosed: delivery_to.strftime('%I:%M %p')
224
+ }
225
+ },
226
+ )
227
+ end
228
+
229
+ def parse_pickup_response(response)
230
+ pickup_response = PickupResponse.new(request: last_request, response:)
231
+ pickup_number = response.dig('content', 'poNumber')
232
+
233
+ if pickup_number.blank?
234
+ pickup_response.error = FreightKit::ResponseError.new('Unknown response')
235
+ return pickup_response
236
+ end
237
+
238
+ pickup_response.pickup_number = pickup_number
239
+ pickup_response
240
+ end
241
+
242
+ def build_commodities(shipment)
243
+ shipment.packages.map do |package|
244
+ unit_type = package.packaging.type.to_s
245
+ {
246
+ freightClassCode: package.freight_class,
247
+ unitTypeCode: package.packaging.pallet? ? 'PLT' : unit_type.upcase,
248
+ description: package.description,
249
+ quantity: package.quantity.to_i,
250
+ weight: package.pounds(:total).ceil.to_i,
251
+ dimensionHeight: package.inches(:height).ceil.to_i,
252
+ dimensionLength: package.inches(:length).ceil.to_i,
253
+ dimensionWidth: package.inches(:width).ceil.to_i,
254
+ isHazmat: package.hazmat?
255
+ }
256
+ end
257
+ end
258
+
259
+ # Rates
260
+
261
+ def build_accessorials(shipment:)
262
+ accessorials = []
263
+ serviceable_accessorials?(shipment.accessorials)
264
+
265
+ shipment.accessorials.map do |accessorial|
266
+ next unless @conf.dig(:accessorials, :mappable)&.include?(accessorial)
267
+
268
+ accessorials << @conf.dig(:accessorials, :mappable, accessorial.to_sym)
269
+ end
270
+
271
+ accessorials
272
+ end
273
+
274
+ def build_rate_request(shipment:)
275
+ origin = shipment.origin
276
+ destination = shipment.destination
277
+
278
+ build_request(
279
+ :rates,
280
+ body: {
281
+ accessorials: build_accessorials(shipment:),
282
+ pickLocationType: origin.type || 'Commercial',
283
+ origin: {
284
+ postalCode: origin.postal_code.to_i,
285
+ city: origin.city,
286
+ state: origin.province.upcase,
287
+ country: origin.country.code(:alpha3).value
288
+ },
289
+ dropLocationType: destination.type || 'Commercial',
290
+ destination: {
291
+ postalCode: destination.postal_code.to_i,
292
+ city: destination.city,
293
+ state: destination.province.upcase,
294
+ country: destination.country.code(:alpha3).value
295
+ },
296
+ shipmentDate: shipment.pickup_at.date_time_with_zone.iso8601,
297
+ quoteCommodities: build_commodities(shipment)
298
+ }.to_json,
299
+ )
300
+ end
301
+
302
+ def parse_rate_response(shipment:, response:)
303
+ rate_response = RateResponse.new(request: last_request, response:)
304
+
305
+ if response.blank?
306
+ rate_response.error = ResponseError.new('Unknown response')
307
+ return rate_response
308
+ end
309
+
310
+ if response['statusCode'] != 201
311
+ rate_response.error = ResponseError.new(response.dig('content', 'message'))
312
+ return rate_response
313
+ end
314
+
315
+ rates = []
316
+
317
+ response.dig('content', 'carrierPrices').each do |response_line|
318
+ rate_in_cents = (response_line['customerRate'].to_f * 100).round
319
+ rates << Rate.new(
320
+ carrier_name: response_line['carrier'],
321
+ carrier: self,
322
+ currency: 'USD',
323
+ estimate_reference: response_line['id'],
324
+ prices: [
325
+ Price.new(blame: :api, cents: rate_in_cents, description: response_line['CarrierName']),
326
+ ],
327
+ scac: response_line['scac'],
328
+ service_name: :standard,
329
+ shipment:,
330
+ transit_days: response_line['transitDays'],
331
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
332
+ )
333
+ end
334
+
335
+ rate_response.rates = rates
336
+ rate_response
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class WRDS < FreightKit::Carrier
5
+ class << self
6
+ def required_credential_types
7
+ %i[selenoid website]
8
+ end
9
+
10
+ def requirements
11
+ %i[credentials]
12
+ end
13
+ end
14
+
15
+ REACTIVE_FREIGHT_CARRIER = true
16
+
17
+ class << self
18
+ attr_reader :name, :scac
19
+ end
20
+ @name = 'Western Regional Delivery Service'
21
+ @scac = 'WRDS'
22
+
23
+ # Documents
24
+ def pod(tracking_number)
25
+ parse_pod_response(tracking_number)
26
+ end
27
+
28
+ # Rates
29
+
30
+ # Tracking
31
+ def find_tracking_info(tracking_number)
32
+ parse_tracking_response(tracking_number)
33
+ end
34
+
35
+ protected
36
+
37
+ def build_url(action, *)
38
+ "#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
39
+ end
40
+
41
+ def commit(action, options = {})
42
+ url = request_url(action)
43
+
44
+ response = if @conf.dig(:api, :methods, action) == :post
45
+ options[:params].blank? ? HTTParty.post(url) : HTTParty.post(url, query: options[:params])
46
+ else
47
+ HTTParty.get(url)
48
+ end
49
+
50
+ response.parsed_response if response&.parsed_response
51
+ end
52
+
53
+ def request_url(action)
54
+ scheme = @conf.dig(:api, :use_ssl, action) ? 'https://' : 'http://'
55
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
56
+ end
57
+
58
+ # Documents
59
+ def parse_document_response(url)
60
+ document_response = DocumentResponse.new(request: URI.parse(url))
61
+
62
+ begin
63
+ response = HTTParty.get(url)
64
+ rescue StandardError => e
65
+ document_response.error = e
66
+ return document_response
67
+ end
68
+
69
+ document_response.assign_attributes(content_type: response.headers['content-type'], data: response.body)
70
+ document_response
71
+ end
72
+
73
+ def parse_pod_response(tracking_number)
74
+ selenoid_credentials = fetch_credential(:selenoid)
75
+ website_credentials = fetch_credential(:website)
76
+
77
+ browser = Watir::Browser.new(*selenoid_credentials.watir_args)
78
+ browser.goto(build_url(:pod))
79
+
80
+ browser.text_field(name: 'ctl00$cphMain$txtUserName').set(website_credentials.username)
81
+ browser.text_field(name: 'ctl00$cphMain$txtPassword').set(website_credentials.password)
82
+ browser.button(name: 'ctl00$cphMain$btnLogIn').click
83
+
84
+ if browser.html.include?('Username or password is invalid.')
85
+ browser.close
86
+ raise InvalidCredentialsError
87
+ end
88
+
89
+ browser.text_field(name: 'ctl00$cphMain$txtProNumber').set(tracking_number)
90
+ browser.button(name: 'ctl00$cphMain$btnSearchProNumber').wait_until(&:present?).click
91
+ browser.element(xpath: '/html/body/form/div[3]/div/div/table/tbody/tr[2]/td[1]/a').wait_until(&:present?).click
92
+ browser.element(xpath: '/html/body/form/div[3]/table[2]/tbody/tr[16]/td[2]/a').wait_until(&:present?).click
93
+
94
+ image_url = nil
95
+ browser.switch_window.use do
96
+ page_count = browser.element(xpath: '/html/body/form/div[3]/b/span').text.strip.to_i
97
+ (page_count - 1).times do
98
+ browser.element(xpath: '/html/body/form/div[3]/input[2]').wait_until(&:present?).click
99
+ end
100
+ image_url = browser.element(css: '#cphMain_imgImage').attribute_value('src')
101
+ end
102
+ browser.close
103
+
104
+ parse_document_response(image_url)
105
+ end
106
+
107
+ # Rates
108
+
109
+ # Tracking
110
+
111
+ def parse_api_city_state_zip(str)
112
+ return if str.blank?
113
+
114
+ Location.new(
115
+ city: str.split(', ')[0].titleize,
116
+ province: str.split(', ')[1].split[0].upcase,
117
+ postal_code: str.split(', ')[1].split[1],
118
+ country: ActiveUtils::Country.find('USA'),
119
+ )
120
+ end
121
+
122
+ def parse_api_city_state(str)
123
+ return if str.blank?
124
+
125
+ Location.new(
126
+ city: str[..-3].strip.titleize,
127
+ province: str[-2..].upcase,
128
+ country: ActiveUtils::Country.find('USA'),
129
+ )
130
+ end
131
+
132
+ def parse_api_date(date, location)
133
+ return if date.blank?
134
+
135
+ local_date = ::Date.strptime(date, '%m/%d/%Y')
136
+ ::FreightKit::DateTime.new(local_date:, location:)
137
+ end
138
+
139
+ def parse_api_date_time(date_time, location)
140
+ return if date_time.blank?
141
+
142
+ local_date_time = ::Time.strptime(date_time, '%m/%d/%Y %l:%M:%S %p').to_fs(:db)
143
+ ::FreightKit::DateTime.new(local_date_time:, location:)
144
+ end
145
+
146
+ def parse_tracking_response(tracking_number)
147
+ tracking_response = TrackingResponse.new(carrier: self)
148
+
149
+ selenoid_credentials = fetch_credential(:selenoid)
150
+
151
+ browser = Watir::Browser.new(*selenoid_credentials.watir_args)
152
+ browser.goto(build_url(:track))
153
+
154
+ browser.text_field(name: 'ctl00$cphMain$txtProNumber').set(tracking_number)
155
+ browser.button(name: 'ctl00$cphMain$btnSearchProNumber').wait_until(&:present?).click
156
+ browser.element(xpath: '/html/body/form/div[3]/div/div/table/tbody/tr[2]/td[1]/a').wait_until(&:present?).click
157
+
158
+ tracking_response.response = browser.html
159
+
160
+ html = browser.table(id: 'cphMain_grvLogNotes').inner_html
161
+ html = Nokogiri::HTML(html)
162
+
163
+ api_city_state_zip = browser.element(xpath: '/html/body/form/div[3]/table[2]/tbody/tr[14]/td[1]/span').text
164
+ shipper_location = parse_api_city_state_zip(api_city_state_zip)
165
+
166
+ api_city_state_zip = browser.element(xpath: '/html/body/form/div[3]/table[2]/tbody/tr[14]/td[2]/span').text
167
+ receiver_location = parse_api_city_state_zip(api_city_state_zip)
168
+
169
+ actual_delivery_date = nil
170
+ delivery_appointment_scheduled = false
171
+ scheduled_delivery_date = nil
172
+ ship_time = nil
173
+
174
+ shipment_events = []
175
+
176
+ html.css('tr').each do |tr|
177
+ next if tr.text.include?('DateNotes')
178
+
179
+ event = tr.css('td')[1].text
180
+ event_key = nil
181
+
182
+ @conf.dig(:events, :types).each do |key, val|
183
+ if event.downcase.include?(val) && !event.downcase.include?('estimated')
184
+ event_key = key
185
+ break
186
+ end
187
+ end
188
+
189
+ next if event_key.blank?
190
+
191
+ location = nil
192
+
193
+ unless event_key == :delivery_appointment_scheduled
194
+ api_city_state = event.downcase.split(@conf.dig(:events, :types, event_key)).last
195
+ api_city_state = api_city_state.downcase.sub(event_key.to_s, '')
196
+ api_city_state = api_city_state.gsub(',', '')
197
+
198
+ location = api_city_state.downcase.include?('carrier') ? nil : parse_api_city_state(api_city_state)
199
+ end
200
+
201
+ api_date_time = tr.css('td')[0].text
202
+ date_time = parse_api_date_time(api_date_time, location)
203
+
204
+ actual_delivery_date = date_time if event_key == :delivered
205
+ delivery_appointment_scheduled = true if event_key == :delivery_appointment_scheduled
206
+
207
+ # API doesn't provide pickup information
208
+ ship_time = date_time if event_key == :arrived_at_terminal && ship_time.blank?
209
+
210
+ shipment_event = ShipmentEvent.new(date_time:, location:, type_code: event_key)
211
+ shipment_events << shipment_event
212
+ end
213
+
214
+ # API doesn't provide appointment information on :delivery_appointment_scheduled
215
+ if delivery_appointment_scheduled
216
+ html.css('tr').each do |tr|
217
+ next if tr.text.include?('DateNotes')
218
+ next if tr.css('td')[1].text.exclude?('Estimated Delivery Date')
219
+
220
+ api_date = tr.css('td')[0].text.split&.first
221
+ scheduled_delivery_date = parse_api_date(api_date, shipment_events.last.location)
222
+
223
+ break
224
+ end
225
+ end
226
+
227
+ browser.close
228
+
229
+ # API events sometimes appear after delivered
230
+ status = actual_delivery_date.blank? ? shipment_events.last&.type_code : :delivered
231
+
232
+ tracking_response.assign_attributes(
233
+ actual_delivery_date:,
234
+ destination: receiver_location,
235
+ origin: shipper_location,
236
+ scheduled_delivery_date:,
237
+ ship_time:,
238
+ shipment_events:,
239
+ status:,
240
+ tracking_number:,
241
+ )
242
+
243
+ tracking_response
244
+ end
245
+ end
246
+ end
@@ -22,3 +22,29 @@ module FreightKit
22
22
  end
23
23
  end
24
24
  end
25
+
26
+ FreightKit::Carriers.register(:ABFS, 'freight_kit/carriers/abfs')
27
+ FreightKit::Carriers.register(:BTVP, 'freight_kit/carriers/btvp')
28
+ FreightKit::Carriers.register(:CCYQ, 'freight_kit/carriers/ccyq')
29
+ FreightKit::Carriers.register(:CLNI, 'freight_kit/carriers/clni')
30
+ FreightKit::Carriers.register(:CNWY, 'freight_kit/carriers/cnwy')
31
+ FreightKit::Carriers.register(:DLDS, 'freight_kit/carriers/dlds')
32
+ FreightKit::Carriers.register(:DPHE, 'freight_kit/carriers/dphe')
33
+ FreightKit::Carriers.register(:DRRQ, 'freight_kit/carriers/drrq')
34
+ FreightKit::Carriers.register(:FWDA, 'freight_kit/carriers/fwda')
35
+ FreightKit::Carriers.register(:MTVL, 'freight_kit/carriers/mtvl')
36
+ FreightKit::Carriers.register(:NUMK, 'freight_kit/carriers/numk')
37
+ FreightKit::Carriers.register(:OTCL, 'freight_kit/carriers/otcl')
38
+ FreightKit::Carriers.register(:PENS, 'freight_kit/carriers/pens')
39
+ FreightKit::Carriers.register(:RDFS, 'freight_kit/carriers/rdfs')
40
+ FreightKit::Carriers.register(:SAIA, 'freight_kit/carriers/saia')
41
+ FreightKit::Carriers.register(:SEFL, 'freight_kit/carriers/sefl')
42
+ FreightKit::Carriers.register(:TQYL, 'freight_kit/carriers/tqyl')
43
+ FreightKit::Carriers.register(:WRDS, 'freight_kit/carriers/wrds')
44
+
45
+ # Based on Platform
46
+ FreightKit::Carriers.register(:CTBV, 'freight_kit/carriers/ctbv')
47
+ FreightKit::Carriers.register(:DCHA, 'freight_kit/carriers/dcha')
48
+ FreightKit::Carriers.register(:JFJTransportation, 'freight_kit/carriers/jfj_transportation')
49
+ FreightKit::Carriers.register(:FCSY, 'freight_kit/carriers/fcsy')
50
+ FreightKit::Carriers.register(:TOTL, 'freight_kit/carriers/totl')
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ module Documentable
5
+ def pod(tracking_number)
6
+ parse_document_response(:pod, tracking_number)
7
+ end
8
+
9
+ def scanned_bol(tracking_number)
10
+ parse_document_response(:bol, tracking_number)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ module Pickupable
5
+ def create_pickup(
6
+ delivery_from:,
7
+ delivery_to:,
8
+ dispatcher:,
9
+ pickup_from:,
10
+ pickup_to:,
11
+ scac:,
12
+ service:,
13
+ shipment:
14
+ )
15
+ request = build_pickup_request(
16
+ delivery_from:,
17
+ delivery_to:,
18
+ dispatcher:,
19
+ pickup_from:,
20
+ pickup_to:,
21
+ scac:,
22
+ service:,
23
+ shipment:,
24
+ )
25
+
26
+ begin
27
+ # For SOAP APIs, the :action parameter is required
28
+ response = commit(:pickup, request) if method(:commit).parameters.count == 2
29
+ response ||= commit(request)
30
+ rescue FreightKit::Error => error
31
+ response = PickupResponse.new(request:, response: nil, error:)
32
+ end
33
+
34
+ return response if response.is_a?(PickupResponse)
35
+
36
+ parse_pickup_response(response)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ module Rateable
5
+ def find_rates(shipment:)
6
+ begin
7
+ validate_packages(shipment.packages, @tariff)
8
+ rescue UnserviceableError => e
9
+ return RateResponse.new(error: e)
10
+ end
11
+
12
+ request = build_rate_request(shipment:)
13
+
14
+ # For SOAP APIs, the :action parameter is required
15
+ response = commit(:rates, request) if method(:commit).parameters.count == 2
16
+ response ||= commit(request)
17
+
18
+ return response if response.is_a?(RateResponse)
19
+
20
+ parse_rate_response(shipment:, response:)
21
+ rescue FreightKit::InvalidCredentialsError => e
22
+ rate_response = RateResponse.new(request:, response:)
23
+ rate_response.error = e
24
+
25
+ rate_response
26
+ end
27
+ end
28
+ end