netsuite_rails 0.1.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.
@@ -0,0 +1,235 @@
1
+ module NetSuiteRails
2
+ module RecordSync
3
+
4
+ def self.included(klass)
5
+ klass.send(:extend, ClassMethods)
6
+
7
+ SyncTrigger.attach(klass)
8
+ PollManager.attach(klass)
9
+ end
10
+
11
+ module ClassMethods
12
+ def netsuite_poll(opts = {})
13
+ RecordSync::PullManager.poll(self, opts)
14
+ end
15
+
16
+ attr_accessor :netsuite_custom_record_type_id
17
+ attr_accessor :netsuite_sync_options
18
+ attr_accessor :netsuite_credentials
19
+
20
+ # TODO is there a better way to implement callback chains?
21
+ # https://github.com/rails/rails/blob/0c0f278ab20f3042cdb69604166e18a61f8605ad/activesupport/lib/active_support/callbacks.rb#L491
22
+
23
+ def before_netsuite_push(callback = nil, &block)
24
+ @before_netsuite_push ||= []
25
+ @before_netsuite_push << (callback || block) if callback || block
26
+ @before_netsuite_push
27
+ end
28
+
29
+ def after_netsuite_push(callback = nil, &block)
30
+ @after_netsuite_push ||= []
31
+ @after_netsuite_push << (callback || block) if callback || block
32
+ @after_netsuite_push
33
+ end
34
+
35
+ def after_netsuite_pull(callback = nil, &block)
36
+ @after_netsuite_pull ||= []
37
+ @after_netsuite_pull << (callback || block) if callback || block
38
+ @after_netsuite_pull
39
+ end
40
+
41
+ def netsuite_field_map(field_mapping = nil)
42
+ if field_mapping.nil?
43
+ @netsuite_field_map ||= {}
44
+ else
45
+ @netsuite_field_map = field_mapping
46
+ end
47
+
48
+ @netsuite_field_map
49
+ end
50
+
51
+ def netsuite_local_fields
52
+ @netsuite_field_map.except(:custom_field_list).keys + (@netsuite_field_map[:custom_field_list] || {}).keys
53
+ end
54
+
55
+ def netsuite_field_hints(list = nil)
56
+ if list.nil?
57
+ @netsuite_field_hints ||= {}
58
+ else
59
+ @netsuite_field_hints = list
60
+ end
61
+ end
62
+
63
+ # TODO persist type for CustomRecordRef
64
+ def netsuite_record_class(record_class = nil, custom_record_type_id = nil)
65
+ if record_class.nil?
66
+ @netsuite_record_class
67
+ else
68
+ @netsuite_record_class = record_class
69
+ @netsuite_custom_record_type_id = custom_record_type_id
70
+ end
71
+ end
72
+
73
+ # there is a model level of this method in order to be based on the model level record class
74
+ def netsuite_custom_record?
75
+ self.netsuite_record_class == NetSuite::Records::CustomRecord
76
+ end
77
+
78
+ # :read_only, :aggressive (push & update on save), :write_only, :read_write
79
+ def netsuite_sync(flag = nil, opts = {})
80
+ if flag.nil?
81
+ @netsuite_sync_options ||= {}
82
+ @netsuite_sync ||= :read_only
83
+ else
84
+ @netsuite_sync = flag
85
+ @netsuite_sync_options = opts
86
+ end
87
+ end
88
+ end
89
+
90
+ attr_accessor :netsuite_manual_fields
91
+
92
+ # these methods are here for easy model override
93
+
94
+ def netsuite_sync_options
95
+ self.class.netsuite_sync_options
96
+ end
97
+
98
+ def netsuite_sync
99
+ self.class.netsuite_sync
100
+ end
101
+
102
+ def netsuite_record_class
103
+ self.class.netsuite_record_class
104
+ end
105
+
106
+ def netsuite_field_map
107
+ self.class.netsuite_field_map
108
+ end
109
+
110
+ def netsuite_field_hints
111
+ self.class.netsuite_field_hints
112
+ end
113
+
114
+ # assumes netsuite_id field on activerecord
115
+
116
+ def netsuite_pulling?
117
+ @netsuite_pulling ||= false
118
+ end
119
+
120
+ def netsuite_pulled?
121
+ @netsuite_pulled ||= false
122
+ end
123
+
124
+ def netsuite_pull
125
+ netsuite_extract_from_record(netsuite_pull_record)
126
+ end
127
+
128
+ def netsuite_pull_record
129
+ if netsuite_custom_record?
130
+ NetSuite::Records::CustomRecord.get(
131
+ internal_id: self.netsuite_id,
132
+ type_id: self.class.netsuite_custom_record_type_id
133
+ )
134
+ else
135
+ self.netsuite_record_class.get(self.netsuite_id)
136
+ end
137
+ end
138
+
139
+ def netsuite_push(opts = {})
140
+ NetSuiteRails::RecordSync::PushManager.push(self, opts)
141
+ end
142
+
143
+ def netsuite_extract_from_record(netsuite_record)
144
+ @netsuite_pulling = true
145
+
146
+ field_hints = self.netsuite_field_hints
147
+
148
+ custom_field_list = self.netsuite_field_map[:custom_field_list] || {}
149
+
150
+ all_field_list = self.netsuite_field_map.except(:custom_field_list) || {}
151
+ all_field_list.merge!(custom_field_list)
152
+
153
+ # self.netsuite_normalize_datetimes(:pull)
154
+
155
+ # handle non-collection associations
156
+ association_keys = self.reflections.values.reject(&:collection?).map(&:name)
157
+
158
+ all_field_list.each do |local_field, netsuite_field|
159
+ is_custom_field = custom_field_list.keys.include?(local_field)
160
+
161
+ if netsuite_field.is_a?(Proc)
162
+ netsuite_field.call(self, netsuite_record, :pull)
163
+ next
164
+ end
165
+
166
+ field_value = if is_custom_field
167
+ netsuite_record.custom_field_list.send(netsuite_field).value rescue ""
168
+ else
169
+ netsuite_record.send(netsuite_field)
170
+ end
171
+
172
+ if field_value.blank?
173
+ # TODO possibly nil out the local value?
174
+ next
175
+ end
176
+
177
+ if association_keys.include?(local_field)
178
+ field_value = self.reflections[local_field].klass.where(netsuite_id: field_value.internal_id).first_or_initialize
179
+ elsif is_custom_field
180
+ # TODO I believe this only handles a subset of all the possibly CustomField values
181
+ if field_value.present? && field_value.is_a?(Hash) && field_value.has_key?(:name)
182
+ field_value = field_value[:name]
183
+ end
184
+
185
+ if field_value.present? && field_value.is_a?(NetSuite::Records::CustomRecordRef)
186
+ field_value = field_value.attributes[:name]
187
+ end
188
+ else
189
+ # then it's not a custom field
190
+ end
191
+
192
+ # TODO should we just check for nil? vs present?
193
+
194
+ # TODO should be moved to Transformations with a direction flag
195
+ if field_hints.has_key?(local_field) && field_value.present?
196
+ case field_hints[local_field]
197
+ when :datetime
198
+ field_value = field_value.change(offset: "00:00") - (Time.zone.utc_offset / 3600).hours + (8 + NetSuiteRails::Configuration.netsuite_instance_time_zone_offset).hours
199
+ end
200
+ end
201
+
202
+ self.send(:"#{local_field}=", field_value)
203
+ end
204
+
205
+ netsuite_execute_callbacks(self.class.after_netsuite_pull, netsuite_record)
206
+
207
+ @netsuite_pulling = false
208
+ @netsuite_pulled = true
209
+
210
+ # return netsuite record for debugging
211
+ netsuite_record
212
+ end
213
+
214
+ def new_netsuite_record?
215
+ self.netsuite_id.blank?
216
+ end
217
+
218
+ def netsuite_custom_record?
219
+ self.netsuite_record_class == NetSuite::Records::CustomRecord
220
+ end
221
+
222
+ # TODO this should be protected; it needs to be pushed down to the Push/Pull manager level
223
+
224
+ def netsuite_execute_callbacks(list, record)
225
+ list.each do |callback|
226
+ if callback.is_a?(Symbol)
227
+ self.send(callback, record)
228
+ else
229
+ instance_exec(record, &callback)
230
+ end
231
+ end
232
+ end
233
+
234
+ end
235
+ end
@@ -0,0 +1,56 @@
1
+ require 'netsuite'
2
+
3
+ module NetSuiteRails::TestHelpers
4
+
5
+ def self.included(base)
6
+ base.before { netsuite_timestamp(DateTime.now) }
7
+ end
8
+
9
+ def netsuite_timestamp(stamp = nil)
10
+ if stamp.nil?
11
+ @netsuite_timestamp ||= (Time.now - (60 * 2)).to_datetime
12
+ else
13
+ @netsuite_timestamp ||= stamp
14
+ end
15
+ end
16
+
17
+ def get_last_netsuite_object(record)
18
+ search = record.netsuite_record_class.search({
19
+ criteria: {
20
+ basic:
21
+ if record.netsuite_custom_record?
22
+ [
23
+ {
24
+ field: 'recType',
25
+ operator: 'is',
26
+ value: NetSuite::Records::CustomRecordRef.new(internal_id: record.class.netsuite_custom_record_type_id)
27
+ },
28
+ {
29
+ field: 'lastModified',
30
+ operator: 'after',
31
+ value: netsuite_timestamp
32
+ }
33
+ ]
34
+ else
35
+ [
36
+ {
37
+ field: 'lastModifiedDate',
38
+ operator: 'after',
39
+ value: netsuite_timestamp
40
+ }
41
+ ]
42
+ end
43
+ }
44
+ })
45
+
46
+ if record.netsuite_custom_record?
47
+ NetSuite::Records::CustomRecord.get(
48
+ internal_id: search.results.first.internal_id.to_i,
49
+ type_id: record.class.netsuite_custom_record_type_id
50
+ )
51
+ else
52
+ record.netsuite_record_class.get(search.results.first.internal_id.to_i)
53
+ end
54
+ end
55
+
56
+ end
@@ -0,0 +1,31 @@
1
+ module NetSuiteRails
2
+ module SubListSync
3
+
4
+ def self.included(klass)
5
+ klass.send(:extend, ClassMethods)
6
+
7
+ NetSuiteRails::SyncTrigger.attach(klass)
8
+ end
9
+
10
+ # one issue here is that sublist items dont' have an internal ID until
11
+ # they are created, but they are created in the context of a parent record
12
+
13
+ # some sublists don't have an internal ID at all, from the docs:
14
+ # "...non-keyed sublists contain no referencing keys (or handles)"
15
+ # "...Instead, you must interact with the sublist as a whole.
16
+ # In non-keyed sublists, the replaceAll attribute is ignored and behaves as if
17
+ # it were set to TRUE for all requests. Consequently, an update operation is
18
+ # similar to the add operation with respect to non-keyed sublists."
19
+
20
+ module ClassMethods
21
+ def netsuite_sublist_parent(parent = nil)
22
+ if parent.nil?
23
+ @netsuite_sublist_parent
24
+ else
25
+ @netsuite_sublist_parent = parent
26
+ end
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,104 @@
1
+ module NetSuiteRails
2
+ class SyncTrigger
3
+ class << self
4
+
5
+ # TODO think about a flag to push to NS on after_validation vs after_commit
6
+ # TODO think about background async record syncing (re: multiple sales order updates)
7
+ # TODO need to add hook for custom proc to determine if data should be pushed to netsuite
8
+ # if a model has a pending/complete state we might want to only push on complete
9
+
10
+ def attach(klass)
11
+ if klass.include?(SubListSync)
12
+ klass.after_save { SyncTrigger.sublist_trigger(self) }
13
+ klass.after_destroy { SyncTrigger.sublist_trigger(self) }
14
+ elsif klass.include?(RecordSync)
15
+
16
+ # during the initial pull we don't want to push changes up
17
+ klass.before_save do
18
+ @netsuite_sync_record_import = self.new_record? && self.netsuite_id.present?
19
+
20
+ # if false record will not save
21
+ true
22
+ end
23
+
24
+ klass.after_save do
25
+ # need to implement this conditional on the save hook level
26
+ # because the coordination class doesn't know about model persistence state
27
+
28
+ if @netsuite_sync_record_import
29
+ # pull the record down if it has't been pulled yet
30
+ # this is useful when this is triggered by a save on a parent record which has this
31
+ # record as a related record
32
+
33
+ unless self.netsuite_pulled?
34
+ SyncTrigger.record_pull_trigger(self)
35
+ end
36
+ else
37
+ SyncTrigger.record_push_trigger(self)
38
+ end
39
+
40
+ @netsuite_sync_record_import = false
41
+ end
42
+ end
43
+
44
+ # TODO think on NetSuiteRails::ListSync
45
+ end
46
+
47
+ def record_pull_trigger(local)
48
+ return if NetSuiteRails::Configuration.netsuite_pull_disabled
49
+
50
+ record_trigger_action(local, :netsuite_pull)
51
+ end
52
+
53
+ def record_push_trigger(netsuite_record_rep)
54
+ # don't update when fields are updated because of a netsuite_pull
55
+ return if netsuite_record_rep.netsuite_pulling?
56
+
57
+ return if NetSuiteRails::Configuration.netsuite_push_disabled
58
+
59
+ # don't update if a read only record
60
+ return if netsuite_record_rep.netsuite_sync == :read
61
+
62
+ sync_options = netsuite_record_rep.netsuite_sync_options
63
+
64
+ # :if option is a block that returns a boolean
65
+ return if sync_options.has_key?(:if) && !netsuite_record_rep.instance_exec(&sync_options[:if])
66
+
67
+ record_trigger_action(netsuite_record_rep, :netsuite_push)
68
+ end
69
+
70
+ def record_trigger_action(local, action)
71
+ sync_options = local.netsuite_sync_options
72
+
73
+ credentials = if sync_options.has_key?(:credentials)
74
+ local.instance_exec(&sync_options[:credentials])
75
+ end
76
+
77
+ # TODO need to pass off the credentials to the NS push command
78
+
79
+ # You can force sync mode in different envoirnments with the global configuration variables
80
+
81
+ if sync_options[:mode] == :sync || NetSuiteRails::Configuration.netsuite_sync_mode == :sync
82
+ local.send(action)
83
+ else
84
+ # TODO support the rails4 DJ implementation
85
+
86
+ if local.respond_to?(:delay)
87
+ local.delay.send(action)
88
+ else
89
+ raise 'no supported delayed job method found'
90
+ end
91
+ end
92
+ end
93
+
94
+ def sublist_trigger(sublist_item_rep)
95
+ parent = sublist_item_rep.send(sublist_item_rep.class.netsuite_sublist_parent)
96
+
97
+ if parent.class.include?(RecordSync)
98
+ record_push_trigger(parent)
99
+ end
100
+ end
101
+
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,52 @@
1
+ namespace :netsuite do
2
+
3
+ desc "Sync all NetSuite records using import_all"
4
+ task :fresh_sync => :environment do
5
+ NetSuiteRails::PollTimestamp.delete_all
6
+
7
+ ENV['SKIP_EXISTING'] = "true"
8
+
9
+ Rake::Task["netsuite:sync"].invoke
10
+ end
11
+
12
+ desc "sync all netsuite records"
13
+ task :sync => :environment do
14
+ # need to eager load to ensure that all classes are loaded into the poll manager
15
+ Rails.application.eager_load!
16
+
17
+ opts = {
18
+ skip_existing: ENV['SKIP_EXISTING'].present?
19
+ }
20
+
21
+ if ENV['RECORD_MODELS'].present?
22
+ opts[:record_models] = ENV['RECORD_MODELS'].split(',').map(&:constantize)
23
+ end
24
+
25
+ if ENV['LIST_MODELS'].present?
26
+ opts[:list_models] = ENV['LIST_MODELS'].split(',').map(&:constantize)
27
+ end
28
+
29
+ # TODO make push disabled configurable
30
+
31
+ # field values might change on import because of remote data structure changes
32
+ # stop all pushes on sync & fresh_sync to avoid pushing up data that really hasn't
33
+ # changed for each record
34
+
35
+ NetSuiteRails::Configuration.netsuite_push_disabled true
36
+
37
+ NetSuiteRails::PollManager.sync(opts)
38
+ end
39
+
40
+ end
41
+
42
+ # TODO could use this for a "updates local records with a netsuite_id with remote NS data"
43
+ # Model.select([:netsuite_id, :id]).find_in_batches do |batch|
44
+ # NetSuite::Records::CustomRecord.get_list(
45
+ # list: batch.map(&:netsuite_id),
46
+ # type_id: Model::NETSUITE_RECORD_TYPE_ID
47
+ # ).each do |record|
48
+ # model = Model.find_by_netsuite_id(record.internal_id)
49
+ # model.extract_from_netsuite_record(record)
50
+ # model.save!
51
+ # end
52
+ # end
@@ -0,0 +1,44 @@
1
+ module NetSuiteRails
2
+ module Transformations
3
+ class << self
4
+
5
+ def transform(type, value)
6
+ self.send(type, value)
7
+ end
8
+
9
+ # NS limits firstname fields to 33 characters
10
+ def firstname(firstname)
11
+ firstname[0..33]
12
+ end
13
+
14
+ def phone(phone)
15
+ formatted_phone = phone.strip
16
+ .gsub(/ext(ension)?/, 'x')
17
+ .gsub(/[^0-9x ]/, '')
18
+ .gsub(/[ ]{2,}/m, ' ')
19
+
20
+ formatted_phone.gsub!(/x.*$/, '') if formatted_phone.size > 22
21
+
22
+ formatted_phone
23
+ end
24
+
25
+ # NS will throw an error if whitespace bumpers the email string
26
+ def email(email)
27
+ email.strip
28
+ end
29
+
30
+ # https://www.reinteractive.net/posts/168-dealing-with-timezones-effectively-in-rails
31
+ # http://stackoverflow.com/questions/16818180/ruby-rails-how-do-i-change-the-timezone-of-a-time-without-changing-the-time
32
+ # http://alwayscoding.ca/momentos/2013/08/22/handling-dates-and-timezones-in-ruby-and-rails/
33
+
34
+ def date(date)
35
+ date.change(offset: "-07:00", hour: 24 - (8 + NetSuiteRails::Configuration.netsuite_instance_time_zone_offset))
36
+ end
37
+
38
+ def datetime(datetime)
39
+ datetime.change(offset: "-08:00") - (8 + NetSuiteRails::Configuration.netsuite_instance_time_zone_offset).hours
40
+ end
41
+
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ module NetSuiteRails
2
+ module UrlHelper
3
+
4
+ # TODO create a xxx_netsuite_url helper generator
5
+
6
+ def self.netsuite_url(record = self)
7
+ prefix = "https://system#{".sandbox" if NetSuite::Configuration.sandbox}.netsuite.com/app"
8
+
9
+ record_class = record.netsuite_record_class
10
+ internal_id = record.netsuite_id
11
+
12
+ # https://system.sandbox.netsuite.com/app/common/scripting/scriptrecordlist.nl
13
+ # https://system.sandbox.netsuite.com/app/common/scripting/script.nl
14
+
15
+ if record.netsuite_custom_record?
16
+ "#{prefix}/common/custom/custrecordentry.nl?id=#{internal_id}&rectype=#{record.class.netsuite_custom_record_type_id}"
17
+ elsif [ NetSuite::Records::InventoryItem, NetSuite::Records::NonInventorySaleItem, NetSuite::Records::AssemblyItem].include?(record_class)
18
+ "#{prefix}/common/item/item.nl?id=#{internal_id}"
19
+ elsif record_class == NetSuite::Records::Task
20
+ "#{prefix}/crm/calendar/task.nl?id=#{internal_id}"
21
+ elsif record_class == NetSuite::Records::Customer
22
+ "#{prefix}/common/entity/custjob.nl?id=#{internal_id}"
23
+ elsif record_class == NetSuite::Records::Contact
24
+ "#{prefix}/common/entity/contact.nl?id=#{internal_id}"
25
+ elsif [ NetSuite::Records::SalesOrder, NetSuite::Records::Invoice, NetSuite::Records::CustomerRefund ].include?(record_class)
26
+ "#{prefix}/accounting/transactions/transaction.nl?id=#{internal_id}"
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1 @@
1
+ require 'netsuite_rails/netsuite_rails'
@@ -0,0 +1,25 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "netsuite_rails"
7
+ s.version = "0.1.0"
8
+ s.authors = ["Michael Bianco"]
9
+ s.email = ["mike@cliffsidemedia.com"]
10
+ s.summary = %q{Write Rails applications that integrate with NetSuite}
11
+ s.homepage = "http://github.com/netsweet/netsuite_rails"
12
+ s.license = "MIT"
13
+
14
+ s.files = `git ls-files -z`.split("\x0")
15
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
16
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
17
+ s.require_paths = ["lib"]
18
+
19
+ s.add_dependency 'netsuite', '~> 0.3'
20
+ s.add_dependency 'rails', '>= 3.2.16'
21
+
22
+ s.add_development_dependency "bundler", "~> 1.6"
23
+ s.add_development_dependency "rake"
24
+ s.add_development_dependency "rspec"
25
+ end
@@ -0,0 +1,9 @@
1
+ RSpec.configure do |config|
2
+ config.expect_with :rspec do |expectations|
3
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
4
+ end
5
+
6
+ config.mock_with :rspec do |mocks|
7
+ mocks.verify_partial_doubles = true
8
+ end
9
+ end