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,580 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class DRRQ < FreightKit::Carrier
5
+ class << self
6
+ def overlength_fees_require_tariff?
7
+ false
8
+ end
9
+
10
+ def pickup_number_is_tracking_number?
11
+ true
12
+ end
13
+
14
+ def required_credential_types
15
+ %i[api selenoid website]
16
+ end
17
+
18
+ def requirements
19
+ %i[credentials]
20
+ end
21
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ JSON_HEADERS = {
26
+ Accept: 'application/json',
27
+ 'Content-Type': 'application/json',
28
+ charset: 'utf-8'
29
+ }.freeze
30
+
31
+ class << self
32
+ attr_reader :name, :scac
33
+ end
34
+ @name = 'TForce Worldwide'
35
+ @scac = 'DRRQ'
36
+
37
+ # Documents
38
+
39
+ def bol_requires_tracking_number?
40
+ true
41
+ end
42
+
43
+ def bol(tracking_number)
44
+ request = build_document_request(:bol, tracking_number)
45
+ parse_bol_response(commit(request), :bol, tracking_number)
46
+ end
47
+
48
+ def pod(tracking_number)
49
+ parse_pod_response(tracking_number)
50
+ end
51
+
52
+ # Pickups
53
+
54
+ def create_pickup(
55
+ delivery_from:,
56
+ delivery_to:,
57
+ dispatcher:,
58
+ pickup_from:,
59
+ pickup_to:,
60
+ scac:,
61
+ service:,
62
+ shipment:
63
+ )
64
+ request = build_pickup_request(
65
+ delivery_from:,
66
+ delivery_to:,
67
+ dispatcher:,
68
+ pickup_from:,
69
+ pickup_to:,
70
+ scac:,
71
+ service:,
72
+ shipment:,
73
+ )
74
+
75
+ parse_pickup_response(commit(request))
76
+ end
77
+
78
+ # Rates
79
+
80
+ def find_rates(shipment:)
81
+ # Not necessary
82
+ # validate_packages(packages)
83
+
84
+ request = build_rate_request(shipment:)
85
+ parse_rate_response(shipment:, response: commit(request))
86
+ end
87
+
88
+ # Tracking
89
+
90
+ def valid_tracking_number?(tracking_number)
91
+ tracking_number[..2] == 'UAP' && tracking_number.length == 13
92
+ end
93
+
94
+ protected
95
+
96
+ def build_accessorials(shipment:)
97
+ serviceable_accessorials?(shipment.accessorials)
98
+
99
+ parsed_accessorials = []
100
+
101
+ shipment.accessorials.each do |a|
102
+ if @conf.dig(:accessorials, :unserviceable).exclude?(a)
103
+ parsed_accessorials << { ServiceCode: @conf.dig(:accessorials, :mappable)[a] }
104
+ end
105
+ end
106
+
107
+ longest_dimension_ft = shipment.packages.map { |p| [p.width(:feet), p.length(:feet)].max }.max.ceil
108
+ if longest_dimension_ft >= 8 && longest_dimension_ft < 30
109
+ parsed_accessorials << { ServiceCode: "OVL#{longest_dimension_ft}" }
110
+ end
111
+
112
+ parsed_accessorials.uniq.to_a
113
+ end
114
+
115
+ def build_headers
116
+ api_credentials = fetch_credential(:api)
117
+
118
+ JSON_HEADERS.merge({ ApiKey: api_credentials.password, UserName: api_credentials.username })
119
+ end
120
+
121
+ def commit(request)
122
+ url = request[:url]
123
+ headers = request[:headers]
124
+ method = request[:method]
125
+ body = request[:body]
126
+
127
+ case method
128
+ when :post
129
+ HTTParty.post(url, headers:, body:)
130
+ else
131
+ HTTParty.get(url, headers:)
132
+ end
133
+ end
134
+
135
+ def parse_response(response)
136
+ case response.code
137
+ when 204
138
+ return {}
139
+ when 401
140
+ raise FreightKit::InvalidCredentialsError, "HTTP #{response.code}: #{response}"
141
+ end
142
+
143
+ raise FreightKit::ResponseError, "HTTP #{response.code}: #{response}" if response.code != 200
144
+
145
+ begin
146
+ JSON.parse(response.body)
147
+ rescue JSON::ParserError
148
+ raise FreightKit::ResponseError
149
+ end
150
+ end
151
+
152
+ def request_url(action)
153
+ "https://#{@conf.dig(:api, :domains, :production, action)}#{@conf.dig(:api, :endpoints, action)}"
154
+ end
155
+
156
+ # Documents
157
+
158
+ def build_document_request(type, tracking_number, options = {})
159
+ request = {
160
+ url: request_url(type).sub('%TRACKING_NUMBER%', tracking_number),
161
+ method: @conf.dig(:api, :methods, type)
162
+ }
163
+
164
+ request[:headers] = build_headers if type == :bol
165
+
166
+ save_request(request)
167
+ request
168
+ end
169
+
170
+ def parse_bol_response(response, _type, _tracking_number)
171
+ response = parse_response(response)
172
+ document_response = DocumentResponse.new(request: last_request, response:)
173
+
174
+ file_bytes = response['FileBytes']
175
+
176
+ if file_bytes.blank?
177
+ document_response.error = DocumentNotFoundError.new
178
+ return document_response
179
+ end
180
+
181
+ data = Base64.decode64(file_bytes)
182
+
183
+ document_response.assign_attributes(content_type: 'application/pdf', data:)
184
+ document_response
185
+ end
186
+
187
+ def parse_pod_response(tracking_number)
188
+ document_response = DocumentResponse.new
189
+
190
+ request = build_document_request(:pod, tracking_number)
191
+
192
+ selenoid_credentials = fetch_credential(:selenoid)
193
+ website_credentials = fetch_credential(:website)
194
+
195
+ browser = Watir::Browser.new(*selenoid_credentials.watir_args)
196
+ browser.goto(request[:url])
197
+
198
+ if browser.html.downcase.include?('unable to process request')
199
+ browser.close
200
+
201
+ document_response.error = ResponseError.new
202
+ return document_response
203
+ end
204
+
205
+ begin
206
+ browser.text_field(name: 'UserId').set(website_credentials.username)
207
+ browser.text_field(name: 'Password').set(website_credentials.password)
208
+ browser.button(name: 'submitbutton').click
209
+ rescue Selenium::WebDriver::Error::UnexpectedAlertOpenError
210
+ browser.close
211
+
212
+ document_response.error = InvalidCredentialsError.new
213
+ return document_response
214
+ end
215
+
216
+ logout_url = 'https://rrd.mercurygate.net/MercuryGate/login/urlRedirect.jsp?Logout=true'
217
+
218
+ begin
219
+ browser
220
+ .element(xpath: '//*[@id="__AppFrameBaseTable"]/tbody/tr[2]/td/div[4]')
221
+ .click
222
+ rescue Selenium::WebDriver::Error::UnexpectedAlertOpenError => e
223
+ browser.close
224
+
225
+ message = e.message[/{(.*?)}/m, 1]&.split(':')&.last&.squish
226
+
227
+ document_response.error = InvalidCredentialsError.new(message)
228
+ return document_response
229
+ end
230
+
231
+ browser.iframes(src: '../mainframe/MainFrame.jsp?bRedirect=true')
232
+ browser
233
+ .iframe(name: 'AppBody')
234
+ .frame(id: 'Header')
235
+ .select(name: 'column')
236
+ .select('Primary Reference')
237
+ browser
238
+ .iframe(name: 'AppBody')
239
+ .frame(id: 'Header')
240
+ .select(name: 'condition')
241
+ .select('=')
242
+ browser
243
+ .iframe(name: 'AppBody')
244
+ .frame(id: 'Header')
245
+ .text_field(name: 'filter')
246
+ .set(tracking_number)
247
+ browser
248
+ .iframe(name: 'AppBody')
249
+ .frame(id: 'Header')
250
+ .button(value: 'Find')
251
+ .click
252
+
253
+ begin
254
+ browser
255
+ .iframe(name: 'AppBody')
256
+ .frame(id: 'Detail')
257
+ .iframe(id: 'transportsWin')
258
+ .element(xpath: '/html/body/div/table/tbody/tr[2]/td[1]/span/a[2]')
259
+ .wait_until(&:present?)
260
+ .click
261
+
262
+ browser
263
+ .iframe(name: 'AppBody')
264
+ .frame(id: 'Detail')
265
+ .element(xpath: '/html/body/div[1]/div/div/div[1]/div[1]/div[2]/div/a[5]')
266
+ .wait_until(&:present?)
267
+ .click
268
+ rescue Watir::Wait::TimeoutError
269
+ # POD not yet available
270
+ browser.close
271
+
272
+ document_response.error = FreightKit::DocumentNotFoundError.new
273
+ return document_response
274
+ end
275
+
276
+ html = browser
277
+ .iframe(name: 'AppBody').frame(id: 'Detail').iframes[1]
278
+ .element(xpath: '/html/body/table[3]')
279
+ .html
280
+ html = Nokogiri::HTML(html)
281
+
282
+ browser.goto(logout_url)
283
+ browser.close
284
+
285
+ url = nil
286
+ html.css('tr').each do |tr|
287
+ tds = tr.css('td')
288
+ next if tds.size <= 1 || tds.blank?
289
+
290
+ text = tds[1].text
291
+ next unless text&.include?('http')
292
+
293
+ url = text if url.blank? || !url.include?('hubtran') # Prefer HubTran
294
+ end
295
+
296
+ if url.blank?
297
+ document_response.error = FreightKit::DocumentNotFoundError.new
298
+ return document_response
299
+ end
300
+
301
+ begin
302
+ response = HTTParty.get(url)
303
+ rescue StandardError => e
304
+ document_response.error = e
305
+ return document_response
306
+ end
307
+
308
+ unless response.code == 200
309
+ document_response.error = DocumentNotFoundError.new
310
+ return document_response
311
+ end
312
+
313
+ document_response.assign_attributes(content_type: response.headers['content-type'], data: response.body)
314
+ document_response
315
+ end
316
+
317
+ # Pickups
318
+
319
+ def build_pickup_request(
320
+ delivery_from:,
321
+ delivery_to:,
322
+ dispatcher:,
323
+ pickup_from:,
324
+ pickup_to:,
325
+ scac:,
326
+ service:,
327
+ shipment:
328
+ )
329
+ accessorials = build_accessorials(shipment:)
330
+
331
+ mode = @conf.dig(:services, :mappable, service.to_sym)
332
+
333
+ shipper_phone = shipment.origin.contact.phone.gsub(/\s+/, '').gsub(/[()-+.]/, '')
334
+ shipper_phone = shipper_phone[1..] if shipper_phone.length == 11
335
+
336
+ receiver_phone = shipment.destination.contact.phone.gsub(/\s+/, '').gsub(/[()-+.]/, '')
337
+ receiver_phone = receiver_phone[1..] if receiver_phone.length == 11
338
+
339
+ items = []
340
+ i = 0
341
+ shipment.packages.each do |package|
342
+ # package_type = package.type.pallet? ? 'PALLET' : ''
343
+ package_type = 'PALLET'
344
+
345
+ i += 1
346
+ items << {
347
+ Id: i.to_s,
348
+ FreightClasses: {
349
+ FreightClass: package.freight_class.to_s,
350
+ Type: ''
351
+ },
352
+ Dimensions: {
353
+ Height: package.height(:in).ceil,
354
+ Length: package.length(:in).ceil,
355
+ Uom: 'in',
356
+ Width: package.width(:in).ceil
357
+ },
358
+ HazardousMaterial: package.hazmat?,
359
+ Description: package.description,
360
+ Quantities: {
361
+ Actual: package.quantity,
362
+ Uom: package_type
363
+ },
364
+ Weights: {
365
+ Actual: package.pounds(:total).ceil,
366
+ Uom: 'lb'
367
+ }
368
+ }
369
+ end
370
+
371
+ body = {
372
+ Comments: {
373
+ Comment: '',
374
+ Type: 'SpecialInstructions'
375
+ },
376
+ Consignee: {
377
+ AddressLine1: shipment.destination.address1,
378
+ City: shipment.destination.city,
379
+ Contact: {
380
+ Name: shipment.destination.contact.name,
381
+ Phone: receiver_phone,
382
+ Fax: '',
383
+ Email: ''
384
+ },
385
+ CountryCode: shipment.destination.country.code(:alpha3),
386
+ IsResidential: shipment.accessorials.include?(:residential_pickup),
387
+ Name: shipment.destination.contact.company_name,
388
+ PostalCode: shipment.destination.postal_code,
389
+ StateProvince: shipment.destination.province
390
+ },
391
+ Dates: {
392
+ EarliestPickupDate: "#{pickup_from.iso8601[..-7]}Z",
393
+ LatestPickupDate: "#{pickup_to.iso8601[..-7]}Z",
394
+ EarliestDropDate: "#{delivery_from.iso8601[..-7]}Z",
395
+ LatestDropDate: "#{delivery_to.iso8601[..-7]}Z"
396
+ },
397
+ Items: items,
398
+ Payment: {
399
+ Address: {
400
+ IsResidential: false,
401
+ LocationCode: 'MNP9C1C',
402
+ PostalCode: '60490'
403
+ }
404
+ },
405
+ Pricesheets: [
406
+ {
407
+ IsSelected: true,
408
+ Mode: mode,
409
+ Scac: scac,
410
+ Type: 'Carrier'
411
+ },
412
+ ],
413
+ ReferenceNumbers: [
414
+ {
415
+ IsPrimary: true,
416
+ ReferenceNumber: shipment.order_number.to_s,
417
+ Type: 'Ship Ref'
418
+ },
419
+ {
420
+ IsPrimary: false, # must have one true
421
+ ReferenceNumber: shipment.po_number.to_s,
422
+ Type: 'PO Number'
423
+ },
424
+ ],
425
+ ServiceFlags: accessorials,
426
+ Shipper: {
427
+ AddressLine1: shipment.origin.address1,
428
+ City: shipment.origin.city,
429
+ Contact: {
430
+ Name: shipment.origin.contact.name,
431
+ Phone: shipper_phone,
432
+ Fax: '',
433
+ Email: ''
434
+ },
435
+ CountryCode: shipment.origin.country.code(:alpha3),
436
+ IsResidential: shipment.accessorials.include?(:residential_pickup),
437
+ Name: shipment.origin.contact.company_name,
438
+ PostalCode: shipment.origin.postal_code,
439
+ StateProvince: shipment.origin.province
440
+ },
441
+ Status: 'pending'
442
+ }.to_json
443
+
444
+ request = {
445
+ url: request_url(:pickup),
446
+ method: @conf.dig(:api, :methods, :pickup),
447
+ body:
448
+ }
449
+
450
+ request[:headers] = build_headers
451
+
452
+ save_request(request)
453
+ request
454
+ end
455
+
456
+ def parse_pickup_response(response)
457
+ pickup_response = PickupResponse.new(request: last_request, response:)
458
+
459
+ pickup_number = parse_response(response)&.dig('PrimaryReference')
460
+
461
+ if pickup_number.blank?
462
+ pickup_response.error = FreightKit::ResponseError.new('Unknown response')
463
+ return pickup_response
464
+ end
465
+
466
+ pickup_response.pickup_number = pickup_number
467
+ pickup_response
468
+ end
469
+
470
+ # Rates
471
+
472
+ def build_rate_request(shipment:)
473
+ accessorials = build_accessorials(shipment:)
474
+
475
+ items = []
476
+ shipment.packages.each do |package|
477
+ items << {
478
+ Name: package.description,
479
+ FreightClass: package.freight_class.to_s,
480
+ Weight: package.pounds(:total).ceil.to_s,
481
+ WeightUnits: 'lb',
482
+ Width: package.width(:in).ceil,
483
+ Length: package.length(:in).ceil,
484
+ Height: package.height(:in).ceil,
485
+ DimensionUnits: 'in',
486
+ Quantity: package.quantity,
487
+ QuantityUnits: 'PLT' # Check this
488
+ }
489
+ end
490
+
491
+ pickup_event_date = shipment.pickup_at.date_time_with_zone
492
+ drop_event_date = (pickup_event_date + 7.days).beginning_of_day + 12.hours
493
+
494
+ body = {
495
+ Constraints: {
496
+ ServiceFlags: accessorials
497
+ },
498
+ Items: items,
499
+ PickupEvent: {
500
+ City: shipment.origin.city.upcase,
501
+ Country: shipment.origin.country.code(:alpha3).value,
502
+ Date: pickup_event_date.strftime('%m/%d/%Y %I:%M:00 %p'),
503
+ LocationCode: 'PLocationCode',
504
+ State: shipment.origin.province.upcase,
505
+ Zip: shipment.origin.postal_code.to_s.upcase
506
+ },
507
+ DropEvent: {
508
+ City: shipment.destination.city.upcase,
509
+ Country: shipment.destination.country.code(:alpha3).value,
510
+ Date: drop_event_date.strftime('%m/%d/%Y %I:%M:00 %p'),
511
+ LocationCode: 'DLocationCode',
512
+ MaxPriceSheet: 6,
513
+ ShowInsurance: false,
514
+ State: shipment.destination.province.upcase,
515
+ Zip: shipment.destination.postal_code.to_s.upcase
516
+ }
517
+ }.to_json
518
+
519
+ request = {
520
+ url: request_url(:quote),
521
+ headers: build_headers,
522
+ method: @conf.dig(:api, :methods, :quote),
523
+ body:
524
+ }
525
+
526
+ save_request(request)
527
+ request
528
+ end
529
+
530
+ def parse_rate_response(shipment:, response:)
531
+ rate_response = RateResponse.new(request: last_request, response:)
532
+ response = parse_response(response)
533
+
534
+ if response.blank?
535
+ rate_response.error = UnserviceableError.new('No rates found')
536
+ return rate_response
537
+ end
538
+
539
+ rates = []
540
+
541
+ response.each do |response_line|
542
+ next if response_line['Message'] # Signifies error
543
+
544
+ cost = response_line['Total']
545
+ next if cost.blank?
546
+
547
+ cost = (cost.to_f * 100).to_i
548
+ service = response_line['Charges'].map { |charges| charges['Description'] }
549
+ service = case service
550
+ when service.any?('Standard LTL Guarantee')
551
+ :guaranteed
552
+ when service.any?('Guaranteed LTL Service AM')
553
+ :guaranteed_am
554
+ when service.any?('Guaranteed LTL Service PM')
555
+ :guaranteed_pm
556
+ else
557
+ :standard
558
+ end
559
+ transit_days = response_line['ServiceDays'].to_i
560
+
561
+ rates << Rate.new(
562
+ carrier_name: response_line['CarrierName'],
563
+ carrier: self,
564
+ currency: 'USD',
565
+ prices: [Price.new(blame: :api, cents: cost, description: response_line['CarrierName'])],
566
+ scac: response_line['Scac']&.upcase,
567
+ service_name: service,
568
+ shipment:,
569
+ transit_days:,
570
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees),
571
+ )
572
+ end
573
+
574
+ rate_response.rates = rates
575
+ rate_response
576
+ end
577
+
578
+ # Tracking
579
+ end
580
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class FCSY < CarrierLogistics
5
+ class << self
6
+ def maximum_height
7
+ Measured::Length.new(105, :inches)
8
+ end
9
+
10
+ def maximum_weight
11
+ Measured::Weight.new(10_000, :pounds)
12
+ end
13
+
14
+ def minimum_length_for_overlength_fees
15
+ Measured::Length.new(12, :feet)
16
+ end
17
+
18
+ def overlength_fees_require_tariff?
19
+ false
20
+ end
21
+ end
22
+
23
+ REACTIVE_FREIGHT_CARRIER = true
24
+
25
+ class << self
26
+ attr_reader :name, :scac
27
+ end
28
+ @name = 'STG'
29
+ @scac = 'FCSY'
30
+
31
+ # Documents
32
+
33
+ # Rates
34
+
35
+ def build_calculated_accessorials(shipment)
36
+ [].tap do |builder|
37
+ longest_dimension = shipment.packages.map { |package| [package.length(:in), package.width(:in)].max }.max.ceil
38
+
39
+ case longest_dimension
40
+ when (96..143) then builder << 'XTRM8'
41
+ when (144..191) then builder << 'XTRM12'
42
+ when (192..239) then builder << 'XTRM16'
43
+ when (240..311) then builder << 'XTRM20'
44
+ when (312..) then builder << 'XTRM27'
45
+ end
46
+ end
47
+ end
48
+
49
+ # Tracking
50
+
51
+ # protected
52
+
53
+ # Documents
54
+
55
+ # Rates
56
+ end
57
+ end