netsuite_rails 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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