netsuite_rails 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ Gemfile.lock
2
+ vendor/
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ # TODO would like to support easy NetSuite VCR recording in the future
5
+ # group :test do
6
+ # gem 'vcr'
7
+ # gem 'rspec', '~> 2.14'
8
+ # gem 'rack-test'
9
+ # gem 'webmock'
10
+ # gem 'shoulda-matchers'
11
+ # end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Michael Bianco
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # NetSuite Rails
2
+
3
+ **Note:** Documentation is horrible... look at the code for details.
4
+
5
+ Build custom rails application that sync to NetSuite.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem 'netsuite_rails'
11
+ ```
12
+
13
+ Install the database migration for poll timestamps
14
+
15
+ ```bash
16
+ rails g netsuite_rails:install
17
+ ```
18
+
19
+ ### Date & Time
20
+
21
+ ```ruby
22
+ NetSuiteRails.configure do
23
+
24
+ end
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ modes: :read, :read_write, :aggressive
30
+
31
+ When using a proc in a NS mapping, you are responsible for setting local and remote values
32
+
33
+ for pushing tasks to DJ https://github.com/collectiveidea/delayed_job/wiki/Rake-Task-as-a-Delayed-Job
34
+
35
+ ### Syncing
36
+
37
+ ```bash
38
+ rake netsuite:sync
39
+
40
+ rake netsuite:fresh_sync
41
+ ```
42
+
43
+ Caveats:
44
+
45
+ * If you have date time fields, or custom fields that will trigger `changed_attributes` this might cause issues when pulling an existing record
46
+ * `changed_attributes` doesn't work well with store
47
+
48
+ ## Testing
49
+
50
+ ```ruby
51
+ # in spec_helper.rb
52
+ require 'netsuite_rails/spec/spec_helper'
53
+ ```
54
+
55
+ ## Author
56
+
57
+ * Michael Bianco @iloveitaly
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,25 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'rails/generators/active_record'
4
+
5
+ module NetsuiteRails
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ # http://stackoverflow.com/questions/4141739/generators-and-migrations-in-plugins-rails-3
9
+
10
+ if Rails::VERSION::STRING.start_with?('3.2')
11
+ include Rails::Generators::Migration
12
+ extend ActiveRecord::Generators::Migration
13
+ else
14
+ include ActiveRecord::Generators::Migration
15
+ end
16
+
17
+ source_root File.expand_path('../templates', __FILE__)
18
+
19
+ def copy_migration
20
+ migration_template "create_netsuite_poll_timestamps.rb", "db/migrate/create_netsuite_poll_timestamps.rb"
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ class CreateNetsuitePollTimestamps < ActiveRecord::Migration
2
+ def change
3
+ create_table :netsuite_poll_timestamps do |t|
4
+ t.string :name, :limit => 100
5
+ t.text :value
6
+ t.string :key
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :netsuite_poll_timestamps, [:key], :name => 'index_netsuite_poll_timestamps_on_key', :unique => true
11
+ end
12
+ end
@@ -0,0 +1,54 @@
1
+ module NetSuiteRails
2
+ module Configuration
3
+ extend self
4
+
5
+ def reset!
6
+ attributes.clear
7
+ end
8
+
9
+ def attributes
10
+ @attributes ||= {}
11
+ end
12
+
13
+ def netsuite_sync_mode(mode = nil)
14
+ if mode.nil?
15
+ attributes[:sync_mode] ||= :none
16
+ else
17
+ attributes[:sync_mode] = mode
18
+ end
19
+ end
20
+
21
+ def netsuite_push_disabled(flag = nil)
22
+ if flag.nil?
23
+ attributes[:flag] ||= false
24
+ else
25
+ attributes[:flag] = flag
26
+ end
27
+ end
28
+
29
+ def netsuite_pull_disabled(flag = nil)
30
+ if flag.nil?
31
+ attributes[:flag] ||= false
32
+ else
33
+ attributes[:flag] = flag
34
+ end
35
+ end
36
+
37
+ def netsuite_instance_time_zone_offset(zone_offset = nil)
38
+ if zone_offset.nil?
39
+ attributes[:zone_offset] ||= -8
40
+ else
41
+ attributes[:zone_offset] = zone_offset
42
+ end
43
+ end
44
+
45
+ def polling_page_size(size = nil)
46
+ if size.nil?
47
+ attributes[:size] ||= 1000
48
+ else
49
+ attributes[:size] = size
50
+ end
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,33 @@
1
+ module NetSuiteRails
2
+ module ListSync
3
+
4
+ class PullManager
5
+ class << self
6
+
7
+ def poll(klass, opts = {})
8
+ custom_list = NetSuite::Records::CustomList.get(klass.netsuite_list_id)
9
+
10
+ process_results(custom_list.custom_value_list.custom_value)
11
+ end
12
+
13
+ def process_results(klass, opts, list)
14
+ list.each do |custom_value|
15
+ local_record = klass.where(netsuite_id: custom_value.attributes[:value_id]).first_or_initialize
16
+
17
+ if local_record.respond_to?(:value=)
18
+ local_record.value = custom_value.attributes[:value]
19
+ end
20
+
21
+ if local_record.respond_to?(:inactive=)
22
+ local_record.inactive = custom_value.attributes[:is_inactive]
23
+ end
24
+
25
+ local_record.save!
26
+ end
27
+ end
28
+
29
+ end
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ module NetSuiteRails
2
+ module ListSync
3
+
4
+ def self.included(klass)
5
+ klass.send(:extend, ClassMethods)
6
+
7
+ PollManager.attach(klass)
8
+ end
9
+
10
+ module ClassMethods
11
+ def netsuite_list_id(internal_id = nil)
12
+ if internal_id.nil?
13
+ @netsuite_list_id
14
+ else
15
+ @netsuite_list_id = internal_id
16
+ end
17
+ end
18
+
19
+ def netsuite_poll(opts = {})
20
+ NetSuiteRails::ListSync::PullManager.poll(self, opts)
21
+ end
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ require 'netsuite_rails/configuration'
2
+ require 'netsuite_rails/poll_timestamp'
3
+ require 'netsuite_rails/transformations'
4
+ require 'netsuite_rails/poll_manager'
5
+ require 'netsuite_rails/sync_trigger'
6
+ require 'netsuite_rails/sub_list_sync'
7
+ require 'netsuite_rails/record_sync'
8
+ require 'netsuite_rails/record_sync/pull_manager'
9
+ require 'netsuite_rails/record_sync/push_manager'
10
+ require 'netsuite_rails/list_sync'
11
+ require 'netsuite_rails/list_sync/pull_manager'
12
+ require 'netsuite_rails/url_helper'
13
+
14
+ module NetSuiteRails
15
+
16
+ class Railtie < ::Rails::Railtie
17
+ rake_tasks do
18
+ load 'netsuite_rails/tasks/netsuite.rb'
19
+ end
20
+ end
21
+
22
+ end
@@ -0,0 +1,62 @@
1
+ module NetSuiteRails
2
+ class PollManager
3
+
4
+ class << self
5
+
6
+ def attach(klass)
7
+ @record_models ||= []
8
+ @list_models ||= []
9
+
10
+ if klass.include? RecordSync
11
+ @record_models << klass
12
+ elsif klass.include? ListSync
13
+ @list_models << klass
14
+ end
15
+ end
16
+
17
+ def sync(opts = {})
18
+ record_models = opts[:record_models] || @record_models
19
+ list_models = opts[:list_models] || @list_models
20
+
21
+ list_models.each do |klass|
22
+ Rails.logger.info "NetSuite: Syncing #{klass}"
23
+ klass.netsuite_poll
24
+ end
25
+
26
+ record_models.each do |klass|
27
+ sync_frequency = klass.netsuite_sync_options[:frequency] || 1.day
28
+
29
+ if sync_frequency == :never
30
+ Rails.logger.info "Not syncing #{klass.to_s}"
31
+ next
32
+ end
33
+
34
+ Rails.logger.info "NetSuite: Syncing #{klass.to_s}"
35
+
36
+ preference = PollTimestamp.where(key: "netsuite_poll_#{klass.to_s.downcase}timestamp").first_or_initialize
37
+
38
+ # check if we've never synced before
39
+ if preference.new_record?
40
+ klass.netsuite_poll({ import_all: true }.merge(opts))
41
+ else
42
+ # TODO look into removing the conditional parsing; I don't think this is needed
43
+ last_poll_date = preference.value
44
+ last_poll_date = DateTime.parse(last_poll_date) unless last_poll_date.is_a?(DateTime)
45
+
46
+ if DateTime.now - last_poll_date > sync_frequency
47
+ Rails.logger.info "NetSuite: Syncing #{klass} modified since #{last_poll_date}"
48
+ klass.netsuite_poll({ last_poll: last_poll_date }.merge(opts))
49
+ else
50
+ Rails.logger.info "NetSuite: Skipping #{klass} because of syncing frequency"
51
+ end
52
+ end
53
+
54
+ preference.value = DateTime.now
55
+ preference.save!
56
+ end
57
+ end
58
+
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,11 @@
1
+ module NetSuiteRails
2
+ class PollTimestamp < ActiveRecord::Base
3
+ serialize :value
4
+
5
+ validates :key, presence: true, uniqueness: true
6
+
7
+ def self.table_name_prefix
8
+ 'netsuite_'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,147 @@
1
+ module NetSuiteRails
2
+ module RecordSync
3
+
4
+ class PullManager
5
+ class << self
6
+
7
+ def poll(klass, opts = {})
8
+ opts = {
9
+ import_all: false,
10
+ page_size: NetSuiteRails::Configuration.polling_page_size,
11
+ }.merge(opts)
12
+
13
+ opts[:netsuite_record_class] ||= klass.netsuite_record_class
14
+
15
+ search = opts[:netsuite_record_class].search(
16
+ poll_criteria(klass, opts).merge({
17
+ preferences: {
18
+ body_fields_only: false,
19
+ page_size: opts[:page_size]
20
+ }
21
+ })
22
+ )
23
+
24
+ # TODO more robust error reporting
25
+ unless search
26
+ raise 'error running netsuite sync'
27
+ end
28
+
29
+ process_search_results(klass, opts, search)
30
+ end
31
+
32
+ def poll_criteria(klass, opts)
33
+ search_criteria = {
34
+ criteria: {
35
+ basic: poll_basic_criteria(klass, opts)
36
+ }
37
+ }
38
+
39
+ saved_search_id = opts[:saved_search_id] || klass.netsuite_sync_options[:saved_search_id]
40
+
41
+ if saved_search_id
42
+ search_criteria[:criteria][:saved] = saved_search_id
43
+ end
44
+
45
+ if needs_get_list?(opts)
46
+ search_criteria[:columns] = {
47
+ 'listRel:basic' => [
48
+ 'platformCommon:internalId/' => {},
49
+ ],
50
+ }
51
+ end
52
+
53
+ search_criteria
54
+ end
55
+
56
+ def poll_basic_criteria(klass, opts)
57
+ opts = {
58
+ criteria: [],
59
+ # last_poll: DateTime
60
+ }.merge(opts)
61
+
62
+ # allow custom criteria to be passed directly to the sync call
63
+ criteria = opts[:criteria] || []
64
+
65
+ # allow custom criteria from the model level
66
+ criteria += klass.netsuite_sync_options[:criteria] || []
67
+
68
+ if opts[:netsuite_record_class] == NetSuite::Records::CustomRecord
69
+ opts[:netsuite_custom_record_type_id] ||= klass.netsuite_custom_record_type_id
70
+
71
+ criteria << {
72
+ field: 'recType',
73
+ operator: 'is',
74
+ value: NetSuite::Records::CustomRecordRef.new(internal_id: opts[:netsuite_custom_record_type_id])
75
+ }
76
+ end
77
+
78
+ unless opts[:import_all]
79
+ criteria << {
80
+ # CustomRecordSearchBasic uses lastModified instead of the standard lastModifiedDate
81
+ field: (klass.netsuite_custom_record?) ? 'lastModified' : 'lastModifiedDate',
82
+ operator: 'after',
83
+ value: opts[:last_poll]
84
+ }
85
+ end
86
+
87
+ criteria
88
+ end
89
+
90
+ def process_search_results(klass, opts, search)
91
+ opts = {
92
+ skip_existing: false,
93
+ full_record_data: -1,
94
+ }.merge(opts)
95
+
96
+ Rails.logger.info "NetSuite: Processing #{search.total_records} over #{search.total_pages} pages"
97
+
98
+ # TODO need to improve the conditional here to match the get_list call conditional belo
99
+ if opts[:import_all] && opts[:skip_existing]
100
+ synced_netsuite_list = klass.pluck(:netsuite_id)
101
+ end
102
+
103
+ search.results_in_batches do |batch|
104
+ # a saved search is processed as a advanced search; advanced search often does not allow you to retrieve
105
+ # all of the fields (ex: addressbooklist on customer) that a normal search does
106
+ # the only way to get those fields is to pull down the full record again using getAll
107
+
108
+ if needs_get_list?(opts)
109
+ filtered_netsuite_id_list = batch.map(&:internal_id)
110
+
111
+ if opts[:skip_existing] == true
112
+ filtered_netsuite_id_list.reject! { |netsuite_id| synced_netsuite_list.include?(netsuite_id) }
113
+ end
114
+
115
+ opts[:netsuite_record_class].get_list(list: batch.map(&:internal_id))
116
+ else
117
+ batch
118
+ end.each do |netsuite_record|
119
+ self.process_search_result_item(klass, opts, netsuite_record)
120
+ end
121
+ end
122
+ end
123
+
124
+ def process_search_result_item(klass, opts, netsuite_record)
125
+ local_record = klass.where(netsuite_id: netsuite_record.internal_id).first_or_initialize
126
+
127
+ # when importing lots of records during an import_all skipping imported records is important
128
+ return if opts[:skip_existing] == true && !local_record.new_record?
129
+
130
+ local_record.netsuite_extract_from_record(netsuite_record)
131
+
132
+ # TODO optionally throw fatal errors; we want to skip fatal errors on intial import
133
+
134
+ unless local_record.save
135
+ Rails.logger.error "NetSuite: Error pulling record #{klass} NS ID #{netsuite_record.internal_id} #{local_record.errors.full_messages}"
136
+ end
137
+ end
138
+
139
+ def needs_get_list?(opts)
140
+ (opts[:saved_search_id].present? && opts[:full_record_data] != false) || opts[:full_record_data] == true
141
+ end
142
+
143
+ end
144
+ end
145
+
146
+ end
147
+ end
@@ -0,0 +1,186 @@
1
+ module NetSuiteRails
2
+ module RecordSync
3
+
4
+ class PushManager
5
+ class << self
6
+
7
+ def push(local_record, opts)
8
+ # TODO check to see if anything is changed before moving forward
9
+ # if changes_keys.blank? && local_record.netsuite_manual_fields
10
+
11
+ netsuite_record = build_netsuite_record(local_record)
12
+
13
+ local_record.netsuite_execute_callbacks(local_record.class.before_netsuite_push, netsuite_record)
14
+
15
+ if !local_record.new_netsuite_record?
16
+ push_update(local_record, netsuite_record)
17
+ else
18
+ push_add(local_record, netsuite_record)
19
+ end
20
+
21
+ # :aggressive is for custom fields which are based on input – need pull updated values after
22
+ # the push to netsuite to retrieve the calculated values
23
+
24
+ if local_record.netsuite_sync == :aggressive
25
+ local_record.netsuite_pull
26
+ end
27
+
28
+ local_record.netsuite_execute_callbacks(local_record.class.after_netsuite_push, netsuite_record)
29
+
30
+ true
31
+ end
32
+
33
+ def push_add(local_record, netsuite_record)
34
+ if netsuite_record.add
35
+ # update_column to avoid triggering another save
36
+ local_record.update_column(:netsuite_id, netsuite_record.internal_id)
37
+ else
38
+ # TODO use NS error class
39
+ raise "NetSuite: error creating record #{netsuite_record.errors}"
40
+ end
41
+ end
42
+
43
+ def push_update(local_record, netsuite_record)
44
+ # build change hash to limit the number of fields pushed to NS on change
45
+ # NS could have logic which could change field functionality depending on
46
+ # input data; it's safest to limit the number of field changes pushed to NS
47
+
48
+ custom_field_list = local_record.netsuite_field_map[:custom_field_list] || {}
49
+ all_field_list = eligible_local_fields(local_record)
50
+
51
+ update_list = {}
52
+
53
+ all_field_list.each do |local_field, netsuite_field|
54
+ if custom_field_list.keys.include?(local_field)
55
+ # if custom field has changed, mark and copy over customFieldList later
56
+ update_list[:custom_field_list] = true
57
+ else
58
+ update_list[netsuite_field] = netsuite_record.send(netsuite_field)
59
+ end
60
+ end
61
+
62
+ # manual field list is for fields manually defined on the NS record
63
+ # outside the context of ActiveRecord (e.g. in a before_netsuite_push)
64
+
65
+ (local_record.netsuite_manual_fields || []).each do |netsuite_field|
66
+ if netsuite_field == :custom_field_list
67
+ update_list[:custom_field_list] = true
68
+ else
69
+ update_list[netsuite_field] = netsuite_record.send(netsuite_field)
70
+ end
71
+ end
72
+
73
+ if update_list[:custom_field_list]
74
+ update_list[:custom_field_list] = netsuite_record.custom_field_list
75
+ end
76
+
77
+ if local_record.netsuite_custom_record?
78
+ update_list[:rec_type] = netsuite_record.rec_type
79
+ end
80
+
81
+ # TODO consider using upsert here
82
+
83
+ if netsuite_record.update(update_list)
84
+ true
85
+ else
86
+ raise "NetSuite: error updating record #{netsuite_record.errors}"
87
+ end
88
+ end
89
+
90
+ def build_netsuite_record(local_record)
91
+ netsuite_record = build_netsuite_record_reference(local_record)
92
+
93
+ # TODO need to normalize datetime fields
94
+
95
+ all_field_list = eligible_local_fields(local_record)
96
+ custom_field_list = local_record.netsuite_field_map[:custom_field_list] || {}
97
+ field_hints = local_record.netsuite_field_hints
98
+
99
+ all_field_list.each do |local_field, netsuite_field|
100
+ # allow Procs as field mapping in the record definition for custom mapping
101
+ if netsuite_field.is_a?(Proc)
102
+ netsuite_field.call(local_record, netsuite_record, :push)
103
+ next
104
+ end
105
+
106
+ # TODO pretty sure this will break if we are dealing with has_many
107
+
108
+ netsuite_field_value = if local_record.reflections.has_key?(local_field)
109
+ if (remote_internal_id = local_record.send(local_field).try(:netsuite_id)).present?
110
+ { internal_id: remote_internal_id }
111
+ else
112
+ nil
113
+ end
114
+ else
115
+ local_record.send(local_field)
116
+ end
117
+
118
+ if field_hints.has_key?(local_field) && netsuite_field_value.present?
119
+ netsuite_field_value = NetSuiteRails::Transformations.transform(field_hints[local_field], netsuite_field_value)
120
+ end
121
+
122
+ # TODO should we skip setting nil values completely? What if we want to nil out fields on update?
123
+
124
+ # be wary of API version issues: https://github.com/NetSweet/netsuite/issues/61
125
+
126
+ if custom_field_list.keys.include?(local_field)
127
+ netsuite_record.custom_field_list.send(:"#{netsuite_field}=", netsuite_field_value)
128
+ else
129
+ netsuite_record.send(:"#{netsuite_field}=", netsuite_field_value)
130
+ end
131
+ end
132
+
133
+ netsuite_record
134
+ end
135
+
136
+ def build_netsuite_record_reference(local_record)
137
+ # must set internal_id for records on new; will be set to nil if new record
138
+
139
+ netsuite_record = local_record.netsuite_record_class.new(internal_id: local_record.netsuite_id)
140
+
141
+ if local_record.netsuite_custom_record?
142
+ netsuite_record.rec_type = NetSuite::Records::CustomRecord.new(internal_id: local_record.class.netsuite_custom_record_type_id)
143
+ end
144
+
145
+ netsuite_record
146
+ end
147
+
148
+ def eligible_local_fields(local_record)
149
+ custom_field_list = local_record.netsuite_field_map[:custom_field_list] || {}
150
+ all_field_list = local_record.netsuite_field_map.except(:custom_field_list) || {}
151
+
152
+ all_field_list.merge!(custom_field_list)
153
+
154
+ changed_keys = changed_attributes(local_record)
155
+
156
+ # filter out unchanged keys when updating record
157
+ unless local_record.new_netsuite_record?
158
+ all_field_list.select! { |k,v| changed_keys.include?(k) }
159
+ end
160
+
161
+ all_field_list
162
+ end
163
+
164
+ def changed_attributes(local_record)
165
+ # otherwise filter only by attributes that have been changed
166
+ # limiting the delta sent to NS will reduce hitting edge cases
167
+
168
+ # TODO think about has_many / join table changes
169
+
170
+ association_field_key_mapping = local_record.reflections.values.reject(&:collection?).inject({}) do |h, a|
171
+ h[a.association_foreign_key.to_sym] = a.name
172
+ h
173
+ end
174
+
175
+ # convert relationship symbols from :object_id to :object
176
+ local_record.changed_attributes.keys.map do |k|
177
+ association_field_key_mapping[k.to_sym] || k.to_sym
178
+ end
179
+ end
180
+
181
+
182
+ end
183
+ end
184
+
185
+ end
186
+ end