tracking_number 0.10.5 → 3.0.0

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 (60) hide show
  1. checksums.yaml +5 -5
  2. data/.github/FUNDING.yml +2 -0
  3. data/.github/dependabot.yml +22 -0
  4. data/.github/workflows/release.yml +36 -0
  5. data/.github/workflows/ruby.yml +35 -0
  6. data/.gitmodules +3 -0
  7. data/.tool-versions +1 -0
  8. data/CHANGELOG.md +318 -0
  9. data/Gemfile +4 -2
  10. data/README.md +112 -15
  11. data/Rakefile +4 -1
  12. data/bin/console +14 -0
  13. data/lib/data/couriers/amazon.json +45 -0
  14. data/lib/data/couriers/canadapost.json +33 -0
  15. data/lib/data/couriers/canpar.json +29 -0
  16. data/lib/data/couriers/dhl.json +97 -0
  17. data/lib/data/couriers/dpd.json +345 -0
  18. data/lib/data/couriers/fedex.json +291 -0
  19. data/lib/data/couriers/gofo.json +24 -0
  20. data/lib/data/couriers/landmark.json +26 -0
  21. data/lib/data/couriers/lasership.json +105 -0
  22. data/lib/data/couriers/old_dominion.json +65 -0
  23. data/lib/data/couriers/ontrac.json +74 -0
  24. data/lib/data/couriers/purolator.json +50 -0
  25. data/lib/data/couriers/s10.json +1443 -0
  26. data/lib/data/couriers/speedee.json +26 -0
  27. data/lib/data/couriers/ups.json +197 -0
  28. data/lib/data/couriers/usps.json +272 -0
  29. data/lib/data/couriers/yodel.json +29 -0
  30. data/lib/data/couriers/yunexpress.json +25 -0
  31. data/lib/tracking_number/active_model_validator.rb +1 -1
  32. data/lib/tracking_number/base.rb +291 -22
  33. data/lib/tracking_number/checksum_validations.rb +119 -0
  34. data/lib/tracking_number/info.rb +26 -0
  35. data/lib/tracking_number/loader.rb +82 -0
  36. data/lib/tracking_number/partnership.rb +7 -0
  37. data/lib/tracking_number/unknown.rb +45 -0
  38. data/lib/tracking_number/version.rb +1 -1
  39. data/lib/tracking_number.rb +69 -17
  40. data/package.json +77 -0
  41. data/test/partnership_test.rb +71 -0
  42. data/test/renamed_types_test.rb +44 -0
  43. data/test/test_helper.rb +14 -7
  44. data/test/tracking_number_meta_test.rb +145 -0
  45. data/test/tracking_number_test.rb +191 -0
  46. data/tracking_number.gemspec +53 -22
  47. data/yarn.lock +3425 -0
  48. metadata +131 -46
  49. data/.travis.yml +0 -12
  50. data/VERSION +0 -1
  51. data/lib/tracking_number/dhl.rb +0 -34
  52. data/lib/tracking_number/fedex.rb +0 -156
  53. data/lib/tracking_number/ontrac.rb +0 -34
  54. data/lib/tracking_number/ups.rb +0 -108
  55. data/lib/tracking_number/usps.rb +0 -166
  56. data/test/dhl_tracking_number_test.rb +0 -35
  57. data/test/fedex_tracking_number_test.rb +0 -75
  58. data/test/ontrac_tracking_number_test.rb +0 -24
  59. data/test/ups_tracking_number_test.rb +0 -36
  60. data/test/usps_tracking_number_test.rb +0 -73
@@ -1,15 +1,16 @@
1
1
  module TrackingNumber
2
2
  class Base
3
- attr_accessor :tracking_number
4
- attr_accessor :original_number
3
+ attr_accessor :tracking_number, :original_number, :partner, :partner_data
4
+
5
+ PartnerStruct = Struct.new(:shipper, :carrier)
5
6
 
6
7
  def initialize(tracking_number)
7
8
  @original_number = tracking_number
8
- @tracking_number = tracking_number.strip.gsub(" ", "").upcase
9
+ @tracking_number = tracking_number.strip.gsub(' ', '').upcase
9
10
  end
10
11
 
11
12
  def self.search(body)
12
- valids = self.scan(body).uniq.collect { |possible| new(possible) }.select { |t| t.valid? }
13
+ valids = scan(body).uniq.collect { |possible| new(possible) }.select { |t| t.valid? }
13
14
 
14
15
  uniques = {}
15
16
  valids.each do |t|
@@ -20,48 +21,316 @@ module TrackingNumber
20
21
  end
21
22
 
22
23
  def self.scan(body)
23
- patterns = [self.const_get("SEARCH_PATTERN")].flatten
24
- possibles = patterns.collect do |pattern|
25
- body.scan(pattern).uniq.flatten
24
+ # matches with match groups within the match data
25
+ matches = []
26
+
27
+ body.upcase.scan(const_get(:SEARCH_PATTERN)) do
28
+ # get the match data instead, which is needed with these types of regexes
29
+ matches << $~
30
+ end
31
+
32
+ if matches
33
+ matches.collect { |m| m[0] }
34
+ else
35
+ []
36
+ end
37
+ end
38
+
39
+ def serial_number
40
+ return match_group('SerialNumber') unless self.class.const_get('VALIDATION')
41
+
42
+ format_info = self.class.const_get(:VALIDATION)[:serial_number_format]
43
+ raw_serial = match_group('SerialNumber')
44
+
45
+ if format_info && format_info[:prepend_if] && raw_serial.match(Regexp.new(format_info[:prepend_if][:matches_regex]))
46
+ return "#{format_info[:prepend_if][:content]}#{raw_serial}"
47
+ # elsif format_info && format_info[:prepend_if_missing]
48
+
49
+ end
50
+
51
+ raw_serial
52
+ end
53
+
54
+ def check_digit
55
+ match_group('CheckDigit')
56
+ end
57
+
58
+ def decode
59
+ decoded = {}
60
+ (matches.try(:names) || []).each do |name|
61
+ sym = name.underscore.to_sym
62
+ decoded[sym] = matches[name]
26
63
  end
27
64
 
28
- possibles.flatten.compact.uniq
65
+ decoded
29
66
  end
30
67
 
31
68
  def valid?
32
69
  return false unless valid_format?
33
70
  return false unless valid_checksum?
34
- return true
71
+ return false unless valid_optional_checks?
72
+
73
+ true
35
74
  end
36
75
 
37
76
  def valid_format?
38
- !matches.nil? && !matches.empty?
77
+ !matches.nil?
39
78
  end
40
79
 
41
- def decode
42
- {}
43
- end
80
+ def valid_optional_checks?
81
+ additional_check = self.class.const_get('VALIDATION')[:additional]
82
+ return true unless additional_check
44
83
 
45
- def matches
46
- []
84
+ exist_checks = (additional_check[:exists] ||= [])
85
+ exist_checks.all? { |w| matching_additional[w] }
47
86
  end
48
87
 
49
88
  def valid_checksum?
50
- false
89
+ return false unless valid_format?
90
+
91
+ checksum_info = self.class.const_get(:VALIDATION)[:checksum]
92
+ return true unless checksum_info
93
+
94
+ name = checksum_info[:name]
95
+ method_name = "validates_#{name}?"
96
+
97
+ TrackingNumber::ChecksumValidations.send(method_name, serial_number, check_digit, checksum_info)
98
+ end
99
+
100
+ def checksum?
101
+ !!self.class.const_get(:VALIDATION)[:checksum]
102
+ end
103
+
104
+ LENGTH_WEIGHT = 0.1
105
+ CHECKSUM_WEIGHT = 5.0
106
+
107
+ def confidence
108
+ (checksum? ? CHECKSUM_WEIGHT : 0) + tracking_number.length * LENGTH_WEIGHT
51
109
  end
52
110
 
53
111
  def to_s
54
- self.tracking_number
112
+ tracking_number
55
113
  end
56
114
 
57
115
  def inspect
58
- "#<%s:%#0x %s>" % [self.class.to_s, self.object_id, tracking_number]
116
+ format('#<%s:%#0x %s%s>', self.class.to_s, object_id, tracking_number, partnership_inspect)
117
+ end
118
+
119
+ def partnership_inspect
120
+ if shipper? ^ carrier?
121
+ ' (partnership)'
122
+ else
123
+ ''
124
+ end
125
+ end
126
+
127
+ def info
128
+ Info.new({
129
+ courier: courier_info,
130
+ service_type: service_type,
131
+ service_description: service_description,
132
+ destination_zip: destination_zip,
133
+ shipper_id: shipper_id,
134
+ package_type: package_type,
135
+ tracking_url: tracking_url,
136
+ partners: partners,
137
+ decode: decode
138
+ })
139
+ end
140
+
141
+ def courier_code
142
+ self.class.const_get(:COURIER_CODE).to_sym
143
+ end
144
+
145
+ def courier_name
146
+ if matching_additional['Courier']
147
+ matching_additional['Courier'][:courier]
148
+ elsif self.class.constants.include?(:COURIER_INFO)
149
+ self.class.const_get(:COURIER_INFO)[:name]
150
+ end
151
+ end
152
+
153
+ alias carrier courier_code # OG tracking_number gem used :carrier.
154
+ alias carrier_code courier_code
155
+ alias carrier_name courier_name
156
+
157
+ def courier_info
158
+ basics = { name: courier_name, code: courier_code }
159
+
160
+ if info = matching_additional['Courier']
161
+ basics.merge!(name: info[:courier], url: info[:courier_url], country: info[:country])
162
+ end
163
+
164
+ @courier ||= Info.new(basics)
165
+ end
166
+
167
+ def partnership?
168
+ partners.present?
169
+ end
170
+
171
+ def shipper?
172
+ return true unless partnership?
173
+
174
+ partners.shipper == self
175
+ end
176
+
177
+ def carrier?
178
+ return true unless partnership?
179
+
180
+ partners.carrier == self
59
181
  end
60
- end
61
182
 
62
- class Unknown < Base
63
- def carrier
64
- :unknown
183
+ def partners
184
+ return unless self.class.const_defined?(:PARTNERS)
185
+
186
+ partner_hash = {}
187
+
188
+ return unless (partner_tn = find_matching_partner)
189
+ possible_twin = partner_tn.send(:find_matching_partner, self.class.const_get("ID"))
190
+
191
+ if possible_twin.instance_of?(self.class) && possible_twin.tracking_number == tracking_number
192
+ partner_hash[partner_data[:partner_type].to_sym] = partner_tn
193
+ partner_hash[partner_tn.partner_data[:partner_type].to_sym] = self
194
+ end
195
+
196
+ PartnerStruct.new(partner_hash[:shipper], partner_hash[:carrier]) if partner_hash.keys.any?
197
+ end
198
+
199
+ def service_type
200
+ @service_type ||= Info.new(matching_additional['Service Type']).name if matching_additional['Service Type']
201
+ end
202
+
203
+ def service_description
204
+ @service_description ||= Info.new(matching_additional['Service Type']).description if matching_additional['Service Type']
205
+ end
206
+
207
+ def package_type
208
+ @package_type ||= Info.new(matching_additional['Container Type']).name if matching_additional['Container Type']
209
+ end
210
+
211
+ def destination_zip
212
+ match_group('DestinationZip')
213
+ end
214
+
215
+ def shipper_id
216
+ match_group('ShipperId')
217
+ end
218
+
219
+ def tracking_url
220
+ url = nil
221
+ if matching_additional['Courier']
222
+ url = matching_additional['Courier'][:tracking_url]
223
+ elsif self.class.const_defined?(:TRACKING_URL)
224
+ url = self.class.const_get(:TRACKING_URL)
225
+ end
226
+
227
+ url.sub('%s', tracking_number) if url
228
+ end
229
+
230
+ def matching_additional
231
+ additional = self.class.const_get(:ADDITIONAL) || []
232
+
233
+ relevant_sections = {}
234
+
235
+ additional.each do |additional_info|
236
+ next unless matches && matches.length > 0 # skip if no match groups
237
+
238
+ value = matches[additional_info[:regex_group_name]].gsub(/\s/, '') # match is empty
239
+ next unless value
240
+ next unless additional_info[:lookup]
241
+
242
+ matches = additional_info[:lookup].find do |i|
243
+ if i[:matches]
244
+ value == i[:matches]
245
+ elsif i[:matches_regex]
246
+ value =~ Regexp.new(i[:matches_regex])
247
+ end
248
+ end
249
+
250
+ # has matching value
251
+ relevant_sections[additional_info[:name]] = matches
252
+ end
253
+
254
+ relevant_sections
255
+ end
256
+
257
+ protected
258
+
259
+ def match_all(items)
260
+ items.all? do |i|
261
+ if i[:matches] && matches[i[:regex_group_name]]
262
+ matches[i[:regex_group_name]] == i[:matches]
263
+ elsif i[:matches_regex] && matches[i[:regex_group_name]]
264
+ matches[i[:regex_group_name]] =~ Regexp.new(i[:matches_regex])
265
+ else
266
+ false
267
+ end
268
+ end
269
+ end
270
+
271
+ def match_any(items)
272
+ items.find do |i|
273
+ if i[:matches] && matches[i[:regex_group_name]]
274
+ matches[i[:regex_group_name]] == i[:matches]
275
+ elsif i[:matches_regex] && matches[i[:regex_group_name]]
276
+ matches[i[:regex_group_name]] =~ Regexp.new(i[:matches_regex])
277
+ else
278
+ false
279
+ end
280
+ end
281
+ end
282
+
283
+ def matches
284
+ @matches ||= if self.class.constants.include?(:VERIFY_PATTERN)
285
+ tracking_number.match(self.class.const_get(:VERIFY_PATTERN))
286
+ else
287
+ []
288
+ end
289
+ end
290
+
291
+ def match_group(name)
292
+ matches[name].gsub(/\s/, '')
293
+ rescue StandardError
294
+ nil
295
+ end
296
+
297
+ def find_matching_partner(partner_id = nil)
298
+ partner_info = if partner_id
299
+ (self.class.const_get(:PARTNERS) || []).select { |info| info[:partner_id]&.to_sym == partner_id.to_sym }
300
+ else
301
+ self.class.const_get(:PARTNERS) || []
302
+ end
303
+
304
+ partner_info.each do |partner_data|
305
+ klass = find_tracking_class_by_id(partner_data[:partner_id])
306
+
307
+ return false unless klass
308
+
309
+ tn = klass.new(tracking_number)
310
+ valid = if partner_data.dig(:validation, :matches_all)
311
+ tn.valid? && match_all(partner_data.dig(:validation, :matches_all))
312
+ elsif partner_data.dig(:validation, :matches_any)
313
+ tn.valid? && match_any(partner_data.dig(:validation, :matches_any))
314
+ else
315
+ tn.valid?
316
+ end
317
+
318
+ next unless valid
319
+
320
+ @partner = tn
321
+ @partner_data = partner_data
322
+ break
323
+ end
324
+
325
+ return @partner
326
+ end
327
+
328
+ def find_tracking_class_by_id(id)
329
+ return unless id
330
+
331
+ TrackingNumber::TYPES.detect do |type|
332
+ type.const_get('ID') == id
333
+ end
65
334
  end
66
335
  end
67
336
  end
@@ -0,0 +1,119 @@
1
+ module TrackingNumber
2
+ module ChecksumValidations
3
+ class << self
4
+ def validates_s10?(sequence, check_digit, extras = {})
5
+ weighting = [8,6,4,2,3,5,9,7]
6
+
7
+ total = 0
8
+ sequence.chars.to_a.zip(weighting).each do |(a,b)|
9
+ total += a.to_i * b.to_i
10
+ end
11
+
12
+ remainder = total % 11
13
+ check = case remainder
14
+ when 1
15
+ 0
16
+ when 0
17
+ 5
18
+ else
19
+ 11 - remainder
20
+ end
21
+
22
+ return check.to_i == check_digit.to_i
23
+ end
24
+
25
+ def validates_sum_product_with_weightings_and_modulo?(sequence, check_digit, extras = {})
26
+ weighting = extras[:weightings] || []
27
+
28
+ total = 0
29
+ sequence.chars.to_a.zip(weighting).each do |(a,b)|
30
+ total += a.to_i * b
31
+ end
32
+ return (total % extras[:modulo1] % extras[:modulo2]) == check_digit.to_i
33
+ end
34
+
35
+ def validates_mod10?(sequence, check_digit, extras = {})
36
+ total = 0
37
+ characters = if extras[:reverse]
38
+ sequence.chars.reverse
39
+ else
40
+ sequence.chars
41
+ end
42
+
43
+ characters.each_with_index do |c, i|
44
+ x = if c[/[0-9]/] # numeric
45
+ c.to_i
46
+ else
47
+ (c[0].ord - 3) % 10
48
+ end
49
+
50
+ if extras[:odds_multiplier] && i.odd?
51
+ x *= extras[:odds_multiplier].to_i
52
+ elsif extras[:evens_multiplier] && i.even?
53
+ x *= extras[:evens_multiplier].to_i
54
+ end
55
+
56
+ total += x
57
+ end
58
+
59
+ check = (total % 10)
60
+ check = (10 - check) unless (check.zero?)
61
+
62
+ return (check.to_i == check_digit.to_i)
63
+ end
64
+
65
+ def validates_mod7?(sequence, check_digit, extras = {})
66
+ # standard mod 7 check
67
+ return true if sequence.to_i % 7 == check_digit.to_i
68
+ end
69
+
70
+ def validates_mod_37_36?(sequence, check_digit, extras = {})
71
+ # From https://esolutions.dpd.com/dokumente/DPD_Parcel_Label_Specification_2.4.1_EN.pdf
72
+
73
+ mod = 36
74
+ weights = {A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 18, J: 19, K: 20, L: 21, M: 22, N: 23, O: 24, P: 25, Q: 26, R: 27, S: 28, T: 29, U: 30, V: 31, W: 32, X: 33, Y: 34, Z: 35}
75
+ cd = mod
76
+ sequence.chars.to_a.each do |char, i|
77
+ val = (char =~ /[A-Za-z]/ ? weights[char.to_sym] : char.to_i)
78
+
79
+ cd = val + cd
80
+ cd = cd - mod if cd > mod
81
+ cd = cd * 2
82
+ cd = cd - (mod + 1) if cd > mod
83
+ end
84
+
85
+ cd = (mod + 1) - cd
86
+ cd = 0 if cd == mod
87
+ computed = if cd >= 10
88
+ weights.find { |a, val| val == cd }[0].to_s
89
+ else
90
+ cd.to_s
91
+ end
92
+
93
+ computed == check_digit
94
+ end
95
+
96
+ def validates_luhn?(sequence, check_digit, extras = {})
97
+ total = 0
98
+ sequence.chars.reverse.each_with_index do |c, i|
99
+ x = c.to_i
100
+
101
+ if i.even?
102
+ x *= 2
103
+ end
104
+
105
+ if x > 9
106
+ x -= 9
107
+ end
108
+
109
+ total += x
110
+ end
111
+
112
+ check = (total % 10)
113
+ check = (10 - check) unless (check.zero?)
114
+
115
+ return (check.to_i == check_digit.to_i)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,26 @@
1
+ module TrackingNumber
2
+ class Info
3
+ def initialize(info_hash = {})
4
+ info_hash.keys.each do |key|
5
+ self.instance_variable_set("@#{key}", info_hash[key])
6
+ self.class_eval { attr_accessor key }
7
+ end
8
+
9
+ if info_hash.keys.size == 1
10
+ @default = info_hash[info_hash.keys.first]
11
+ end
12
+ end
13
+
14
+ def method_missing(name, *args)
15
+ self.instance_variable_get("@#{name}")
16
+ end
17
+
18
+ def to_s
19
+ @default || @name
20
+ end
21
+
22
+ def to_json
23
+
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,82 @@
1
+ module TrackingNumber
2
+ module Loader
3
+ class << self
4
+ def load_tracking_number_data(couriers_path = File.join(File.dirname(__FILE__), '../data/couriers/'))
5
+ mapping_data = []
6
+ tracking_number_types = []
7
+ Dir.glob(File.join(couriers_path, '/*.json')).each do |file|
8
+ courier_info = read_courier_info(file)
9
+
10
+ courier_info[:tracking_numbers].each do |tracking_info|
11
+ klass = create_class(courier_info, tracking_info)
12
+
13
+ # Do some basic checks on the data file
14
+ throw 'missing test numbers' unless has_test_numbers?(tracking_info)
15
+ throw 'missing regex match groups' unless test_numbers_return_required_groups?(tracking_info,
16
+ Regexp.new(klass::VERIFY_PATTERN))
17
+ const = register_class(klass, tracking_info[:name])
18
+
19
+ tracking_number_types.push(const)
20
+ mapping_data << {
21
+ class: const,
22
+ name: courier_info[:name],
23
+ courier_code: courier_info[:courier_code],
24
+ info: tracking_info
25
+ }
26
+ end
27
+ end
28
+
29
+ TrackingNumber.const_set('TYPES', tracking_number_types)
30
+
31
+ mapping_data
32
+ end
33
+
34
+ private
35
+
36
+ def has_test_numbers?(tracking)
37
+ tracking[:test_numbers] && tracking[:test_numbers][:valid]
38
+ end
39
+
40
+ def test_numbers_return_required_groups?(tracking, regex)
41
+ test_number = tracking[:test_numbers][:valid][0]
42
+ matches = test_number.match(regex)
43
+
44
+ matches['SerialNumber']
45
+ end
46
+
47
+ def read_courier_info(file)
48
+ JSON.parse(File.read(file)).deep_symbolize_keys!
49
+ end
50
+
51
+ def create_class(courier_info, tracking_info)
52
+ klass = Class.new(TrackingNumber::Base)
53
+ klass.const_set('COURIER_CODE', courier_info[:courier_code])
54
+ info = courier_info.dup
55
+ info.delete(:tracking_numbers)
56
+ klass.const_set('COURIER_INFO', info)
57
+
58
+ pattern = tracking_info[:regex]
59
+ pattern = tracking_info[:regex].join if tracking_info[:regex].is_a?(Array)
60
+
61
+ verify_pattern = "^#{pattern}$"
62
+ search_pattern = "\\b#{pattern}\\b"
63
+
64
+ klass.const_set('SEARCH_PATTERN', Regexp.new(search_pattern))
65
+ klass.const_set('VERIFY_PATTERN', Regexp.new(verify_pattern))
66
+
67
+ klass.const_set('VALIDATION', tracking_info[:validation])
68
+ klass.const_set('ADDITIONAL', tracking_info[:additional])
69
+ klass.const_set('TRACKING_URL', tracking_info[:tracking_url])
70
+ klass.const_set('PARTNERS', tracking_info[:partners])
71
+ klass.const_set('ID', tracking_info[:id])
72
+
73
+ klass
74
+ end
75
+
76
+ def register_class(klass, tracking_name)
77
+ klass_name = tracking_name.gsub(/[^0-9A-Za-z]/, '')
78
+ TrackingNumber.const_set(klass_name, klass)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,7 @@
1
+ module TrackingNumber
2
+ class Partnership
3
+ def initialize(tracking_numbers)
4
+
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ module TrackingNumber
2
+ class Unknown < Base
3
+ COURIER_CODE = :unknown
4
+
5
+ def carrier
6
+ COURIER_CODE
7
+ end
8
+
9
+ def courier_name
10
+ "Unknown"
11
+ end
12
+
13
+ def valid?
14
+ false
15
+ end
16
+
17
+ def valid_format?
18
+ false
19
+ end
20
+
21
+ def valid_checksum?
22
+ false
23
+ end
24
+
25
+ def shipper?
26
+ false
27
+ end
28
+
29
+ def carrier?
30
+ false
31
+ end
32
+
33
+ def partnership?
34
+ false
35
+ end
36
+
37
+ def decode
38
+ {}
39
+ end
40
+
41
+ def matching_additional
42
+ {}
43
+ end
44
+ end
45
+ end
@@ -1,3 +1,3 @@
1
1
  module TrackingNumber
2
- VERSION = "0.10.5"
2
+ VERSION = "3.0.0"
3
3
  end