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.
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