huginn_bigcommerce_product_agent 1.11.1 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ddeb18b4e875ae897160512f80f8af033dbb4cc2e34295f46eb0114e0628298
4
- data.tar.gz: 37b77bfe861b477d65c0c42c359538a4fa9853b5a0840dfd9deb132946ccde49
3
+ metadata.gz: 89cfc257bee7acf8c2a48655ce32e2e4bd9aa91f09a0e17fb19a54224fc764f4
4
+ data.tar.gz: ea56efe75b854b60a42bcb6f26b7a105f0910ec7e629dc08e7d2c927d7a91900
5
5
  SHA512:
6
- metadata.gz: 89d31246d6961bdac0a37f698afb238b6d6820df667a36cbcfde9452733acc07eb0906a38dae6f04fc7be065733cfdcab0434b4b77d5fab9a7dc5a21025365b2
7
- data.tar.gz: 52b2ab013d9e885a14f684aa233b05c69fe1d73aaebf881d2f30da52ee4322fa6727054769dabaafe9e06ee69fde2a69432718f8022dd5051c203eb35dcd9b79
6
+ metadata.gz: 266955d42682aaf2e8f92f5685ac915767a75070b7cdb6957d7e6b0a23e11ed7854070b04a7218bea809b7269702a2f51fb33d4813e355debcc435684adcb5b6
7
+ data.tar.gz: 19fef7e12d26c6ac94945204e375d3c86bc016eae304f435851e222ef026e4eb1066159b4601b9d92ee546cb9f21803ee41cb809c0d958207408e5fbe5af7e8e
@@ -3,19 +3,48 @@ module BigcommerceProductAgent
3
3
  class CustomField < AbstractClient
4
4
  @uri_base = 'catalog/products/:product_id/custom-fields/:custom_field_id'
5
5
 
6
+ def get_for_product(product_id)
7
+ return [] if product_id.blank?
8
+
9
+ response = client.get(uri(product_id: product_id))
10
+ return response.body['data']
11
+ end
12
+
6
13
  def create(product_id, payload)
7
- response = client.post(uri(product_id: product_id), payload.to_json)
14
+ response = client.post(
15
+ uri(product_id: product_id),
16
+ payload.to_json
17
+ )
18
+
8
19
  return response.body['data']
9
20
  end
10
21
 
11
22
  def update(product_id, payload)
12
23
  id = payload.delete('id')
13
- response = client.put(uri(product_id: product_id, custom_field_id: id), payload.to_json)
24
+ response = client.put(
25
+ uri(product_id: product_id, custom_field_id: id),
26
+ payload.to_json
27
+ )
28
+
14
29
  return response.body['data']
15
30
  end
16
31
 
17
32
  def delete(product_id, custom_field_id)
33
+ begin
18
34
  client.delete(uri(product_id: product_id, custom_field_id: custom_field_id))
35
+ rescue Faraday::Error::ClientError => e
36
+ raise BigCommerceProductError.new(
37
+ e.response[:status],
38
+ 'delete custom field',
39
+ 'Failed to delete custom field',
40
+ product_id,
41
+ {
42
+ custom_field_id: custom_field_id,
43
+ errors: JSON.parse(e.response[:body])['errors']
44
+ },
45
+ e
46
+ )
47
+ end
19
48
  end
20
49
 
21
50
  def upsert(product_id, payload)
@@ -27,8 +56,19 @@ module BigcommerceProductAgent
27
56
  return create(product_id, payload)
28
57
  end
29
58
  rescue Faraday::Error::ClientError => e
30
- puts e.inspect
31
- raise e
59
+ # include the field ID and name in the error here as _create_ requests have no ID
60
+ raise BigCommerceProductError.new(
61
+ e.response[:status],
62
+ 'upsert custom field',
63
+ 'Failed to delete custom field',
64
+ product_id,
65
+ {
66
+ custom_field_id: payload['id'],
67
+ field_name: payload[:name],
68
+ errors: JSON.parse(e.response[:body])['errors']
69
+ },
70
+ e
71
+ )
32
72
  end
33
73
  end
34
74
  end
@@ -10,34 +10,48 @@ module BigcommerceProductAgent
10
10
  return response.body['data']
11
11
  end
12
12
 
13
- def create(meta_field)
13
+ def create(product_id, meta_field)
14
14
  response = client.post(
15
- uri(product_id: meta_field[:resource_id]),
15
+ uri(product_id: product_id),
16
16
  meta_field.to_json
17
17
  )
18
18
 
19
19
  return response.body['data']
20
20
  end
21
21
 
22
- def update(meta_field)
22
+ def update(product_id, meta_field)
23
+ id = meta_field.delete('id')
23
24
  response = client.put(
24
- uri(product_id: meta_field[:resource_id], meta_field_id: meta_field[:id]),
25
+ uri(product_id: product_id, meta_field_id: id),
25
26
  meta_field.to_json
26
27
  )
27
28
 
28
29
  return response.body['data']
29
30
  end
30
31
 
31
- def upsert(meta_field)
32
+ def upsert(product_id, meta_field)
33
+ meta_field['id'] = meta_field.delete(:id) unless meta_field[:id].nil?
34
+
32
35
  begin
33
- if meta_field[:id]
34
- return update(meta_field)
36
+ if meta_field['id']
37
+ return update(product_id, meta_field)
35
38
  else
36
- return create(meta_field)
39
+ return create(product_id, meta_field)
37
40
  end
38
41
  rescue Faraday::Error::ClientError => e
39
- puts e.inspect
40
- raise e
42
+ # include the field ID and name in the error here as _create_ requests have no ID
43
+ raise BigCommerceProductError.new(
44
+ e.response[:status],
45
+ 'upsert meta field',
46
+ 'Failed to upsert meta field field',
47
+ product_id,
48
+ {
49
+ meta_field_id: meta_field['id'],
50
+ field_name: meta_field['key'],
51
+ errors: JSON.parse(e.response[:body])['errors'],
52
+ },
53
+ e
54
+ )
41
55
  end
42
56
  end
43
57
 
@@ -45,7 +59,17 @@ module BigcommerceProductAgent
45
59
  begin
46
60
  client.delete(uri(product_id: product_id, meta_field_id: meta_field_id))
47
61
  rescue Faraday::Error::ClientError => e
48
- raise e, "\n#{e.message}\nFailed to delete meta_field with id = #{meta_field_id}\nfor product with id = #{product_id}\n", e.backtrace
62
+ raise BigCommerceProductError.new(
63
+ e.response[:status],
64
+ 'delete meta field',
65
+ 'Failed to delete meta field',
66
+ product_id,
67
+ {
68
+ meta_field_id: meta_field_id,
69
+ errors: JSON.parse(e.response[:body])['errors'],
70
+ },
71
+ e
72
+ )
49
73
  end
50
74
  end
51
75
  end
@@ -8,11 +8,44 @@ module BigcommerceProductAgent
8
8
  response = client.put(uri(product_id: id), payload.to_json) do |request|
9
9
  request.params.update(params) if params
10
10
  end
11
+
12
+ return response.body['data']
11
13
  rescue Faraday::Error::ClientError => e
12
- raise e, "\n#{e.message}\nFailed to update product with payload = #{payload.to_json}\n", e.backtrace
14
+ raise BigCommerceProductError.new(
15
+ e.response[:status],
16
+ 'update product',
17
+ 'Failed to update product',
18
+ id,
19
+ {
20
+ sku: payload[:sku],
21
+ errors: JSON.parse(e.response[:body])['errors'],
22
+ },
23
+ e
24
+ )
13
25
  end
26
+ end
14
27
 
15
- return response.body['data']
28
+ def update_batch(payload, params={})
29
+ begin
30
+ response = client.put(uri(), payload.to_json) do |request|
31
+ request.params.update(params) if params
32
+ end
33
+
34
+ return response.body['data']
35
+ rescue Faraday::Error::ClientError => e
36
+ raise BigCommerceProductError.new(
37
+ e.response[:status],
38
+ 'update product',
39
+ 'Failed to update product batch',
40
+ payload.map { |p|
41
+ { id: p["id"], sku: p[:sku] }
42
+ },
43
+ {
44
+ errors: JSON.parse(e.response[:body])['errors'],
45
+ },
46
+ e
47
+ )
48
+ end
16
49
  end
17
50
 
18
51
  def delete(id)
@@ -25,36 +58,79 @@ module BigcommerceProductAgent
25
58
  response = client.post(uri, payload.to_json) do |request|
26
59
  request.params.update(params) if params
27
60
  end
61
+
62
+ return response.body['data']
28
63
  rescue Faraday::Error::ClientError => e
29
- raise e, "\n#{e.message}\nFailed to create product with payload = #{payload.to_json}\n", e.backtrace
30
- end
31
64
 
32
- return response.body['data']
65
+ Rails.logger.info('RESPONSE OBJECT: ------------------------------------------')
66
+ Rails.logger.info(e.response.inspect)
67
+ Rails.logger.info('------------------------------------------------------------')
68
+
69
+ Rails.logger.info('RESPONSE METHODS: -----------------------------------------')
70
+ Rails.logger.info(e.response.methods)
71
+ Rails.logger.info('------------------------------------------------------------')
72
+
73
+ raise BigCommerceProductError.new(
74
+ e.response[:status],
75
+ 'create product',
76
+ 'Failed to create product',
77
+ nil,
78
+ {
79
+ sku: payload[:sku],
80
+ errors: JSON.parse(e.response[:body])['errors'],
81
+ },
82
+ e
83
+ )
84
+ end
33
85
  end
34
86
 
35
87
  def upsert(payload, params={})
36
- if payload[:id]
37
- return update(payload[:id], payload, params)
88
+ payload['id'] = payload.delete(:id) unless payload[:id].nil?
89
+ if payload['id']
90
+ return update(payload['id'], payload, params)
38
91
  else
39
92
  return create(payload, params)
40
93
  end
41
94
  end
42
95
 
43
- def get_by_skus(skus, include = %w[custom_fields modifiers])
44
- products = index({
45
- 'sku:in': skus.join(','),
46
- include: include.join(','),
47
- })
48
-
49
- map = {}
50
-
51
- products.each do |product|
52
- map[product['sku']] = product
96
+ # When using sku:in you must specify the fields you want returned.
97
+ def get_by_skus(skus, include = %w[custom_fields modifiers], include_fields = %w[sku categories])
98
+ products = []
99
+ skus.each do |sku|
100
+ begin
101
+ data = index({
102
+ 'sku': sku,
103
+ include: include.join(','),
104
+ include_fields: include_fields.join(','),
105
+ })
106
+ if not data.empty?
107
+ products.push(data[0])
108
+ end
109
+ rescue Faraday::Error::ClientError => e
110
+ raise BigCommerceProductError.new(
111
+ e.response[:status],
112
+ 'get by sku',
113
+ 'Failed to get existing product data',
114
+ sku,
115
+ {
116
+ related_skus: skus,
117
+ errors: JSON.parse(e.response[:body])['errors'],
118
+ },
119
+ e
120
+ )
121
+ end
53
122
  end
54
123
 
55
- map
124
+ return products
56
125
  end
57
126
 
127
+ def disable(productId)
128
+ upsert({ id: productId, is_visible: false })
129
+ end
130
+
131
+ def enable(productId)
132
+ upsert({ id: productId, is_visible: true })
133
+ end
58
134
  end
59
135
  end
60
136
  end
@@ -12,4 +12,5 @@ Dir[File.join(__dir__, 'mapper', '*.rb')].each do |file|
12
12
  HuginnAgent.load file
13
13
  end
14
14
 
15
+ HuginnAgent.load 'huginn_bigcommerce_product_agent/big_commerce_product_error'
15
16
  HuginnAgent.register 'huginn_bigcommerce_product_agent/bigcommerce_product_agent'
@@ -0,0 +1,13 @@
1
+ class BigCommerceProductError < StandardError
2
+ attr_reader :status, :scope, :product_identifier, :data, :original_error
3
+
4
+ def initialize(status, scope, message, product_identifier, data, original_error)
5
+ @status = status
6
+ @scope = scope
7
+ @product_identifier = product_identifier
8
+ @data = data
9
+ @original_error = original_error
10
+
11
+ super(message)
12
+ end
13
+ end
@@ -3,436 +3,534 @@
3
3
  require 'json'
4
4
 
5
5
  module Agents
6
- class BigcommerceProductAgent < Agent
7
- include WebRequestConcern
6
+ class BigcommerceProductAgent < Agent
7
+ include WebRequestConcern
8
+
9
+ can_dry_run!
10
+ default_schedule 'never'
11
+
12
+ # TODO: Provide a more detailed agent description. Including details of
13
+ # each option and how that option is used
14
+ description <<-MD
15
+ Takes an array of related products and upserts them into BigCommerce.
16
+ MD
17
+
18
+ def default_options
19
+ {
20
+ 'store_hash' => '',
21
+ 'client_id' => '',
22
+ 'access_token' => '',
23
+ 'custom_fields_map' => {},
24
+ 'meta_fields_map' => {},
25
+ 'meta_fields_namespace' => '',
26
+ 'not_purchasable_format_list' => [],
27
+ 'should_disambiguate' => false,
28
+
29
+ }
30
+ end
8
31
 
9
- can_dry_run!
10
- default_schedule 'never'
32
+ def validate_options
33
+ unless options['store_hash'].present?
34
+ errors.add(:base, 'store_hash is a required field')
35
+ end
11
36
 
12
- description <<-MD
13
- Takes a generic product interface && upserts that product in BigCommerce.
14
- MD
37
+ unless options['client_id'].present?
38
+ errors.add(:base, 'client_id is a required field')
39
+ end
15
40
 
16
- def modes
17
- %w[
18
- variants
19
- option_list
20
- ]
41
+ unless options['access_token'].present?
42
+ errors.add(:base, 'access_token is a required field')
21
43
  end
22
44
 
23
- def default_options
24
- {
25
- 'store_hash' => '',
26
- 'client_id' => '',
27
- 'access_token' => '',
28
- 'custom_fields_map' => {},
29
- 'meta_fields_map' => {},
30
- 'meta_fields_namespace' => '',
31
- 'mode' => modes[0],
32
- 'not_purchasable_format_list' => [],
33
- 'should_disambiguate' => false
34
- }
45
+ unless options['custom_fields_map'].is_a?(Hash)
46
+ errors.add(:base, 'if provided, custom_fields_map must be a hash')
35
47
  end
36
48
 
37
- def validate_options
38
- unless options['store_hash'].present?
39
- errors.add(:base, 'store_hash is a required field')
40
- end
49
+ unless options['meta_fields_map'].is_a?(Hash)
50
+ errors.add(:base, 'if provided, meta_fields_map must be a hash')
51
+ end
41
52
 
42
- unless options['client_id'].present?
43
- errors.add(:base, 'client_id is a required field')
44
- end
53
+ if options['meta_fields_map']
54
+ if options['meta_fields_namespace'].blank?
55
+ errors.add(:base, 'if meta_fields_map is provided, meta_fields_namespace is required')
56
+ end
57
+ end
45
58
 
46
- unless options['access_token'].present?
47
- errors.add(:base, 'access_token is a required field')
48
- end
59
+ if options['not_purchasable_format_list'].present? && !options['not_purchasable_format_list'].is_a?(Array)
60
+ errors.add(:base, 'not_purchasable_format_list must be an Array')
61
+ end
49
62
 
50
- unless options['custom_fields_map'].is_a?(Hash)
51
- errors.add(:base, 'if provided, custom_fields_map must be a hash')
52
- end
63
+ if options.has_key?('should_disambiguate') && boolify(options['should_disambiguate']).nil?
64
+ errors.add(:base, 'when provided, `should_disambiguate` must be either true or false')
65
+ end
53
66
 
54
- unless options['meta_fields_map'].is_a?(Hash)
55
- errors.add(:base, 'if provided, meta_fields_map must be a hash')
56
- end
67
+ if options.has_key?('track_inventory') && boolify(options['track_inventory']).nil?
68
+ errors.add(:base, 'when provided, `track_inventory` must be true or false')
69
+ end
57
70
 
58
- if options['meta_fields_map']
59
- if options['meta_fields_namespace'].blank?
60
- errors.add(:base, 'if meta_fields_map is provided, meta_fields_namespace is required')
61
- end
62
- end
71
+ end
63
72
 
64
- unless options['mode'].present? && modes.include?(options['mode'])
65
- errors.add(:base, "mode is a required field and must be one of: #{modes.join(', ')}")
66
- end
73
+ def working?
74
+ received_event_without_error?
75
+ end
67
76
 
68
- if options['not_purchasable_format_list'].present? && !options['not_purchasable_format_list'].is_a?(Array)
69
- errors.add(:base, 'not_purchasable_format_list must be an Array')
70
- end
77
+ def check
78
+ initialize_clients
79
+ handle interpolated['payload'].presence || {}
80
+ end
71
81
 
72
- if options.has_key?('should_disambiguate') && boolify(options['should_disambiguate']).nil?
73
- errors.add(:base, 'when provided, `should_disambiguate` must be either true or false')
74
- end
82
+ def receive(incoming_events)
83
+ initialize_clients
84
+ incoming_events.each do |event|
85
+ handle(event)
86
+ end
87
+ end
75
88
 
89
+ def handle(event)
90
+ data = event.payload
91
+ raw_products = data['products']
92
+ results = []
93
+
94
+ # Loop through the provided raw_products and perform the upsert
95
+ # This process will upsert the core product record and the custom/meta
96
+ # fields from the Acumen data.
97
+ additional_data = {
98
+ additional_search_terms: [],
99
+ }
100
+
101
+ raw_products.each do |raw_product|
102
+ additional_data[:additional_search_terms].push(raw_product['sku'])
103
+ end
104
+
105
+ bc_products = lookup_existing_products(raw_products)
106
+ existing_skus = bc_products.map { |p| p['sku'] }
107
+
108
+ # This agent requires us to make several requests due to limitations in the BigCommerce API.
109
+ # Specifically, Product records must be created and deleted individually, and, though meta fields can
110
+ # be managed in "bulk", the batch is limited to specific product IDs.
111
+ #
112
+ # Additionally, this agent sets a `related_product_ids` field as a CSV string of each product ID in
113
+ # the bundle. Because this field expects the BigCommerce ID, this has to happen _after_ product
114
+ # creation / deletion.
115
+ #
116
+ # For the sake of performance, we group products into three buckets: create, delete, update.
117
+ # From there, we run through the following process:
118
+ #
119
+ # * Create new products
120
+ # * Delete discontinued products
121
+ # * Update existing products & custom fields
122
+ # * Update meta fields
123
+ #
124
+ # The existing product update will include data for products created in step one. This is intentional
125
+ # because the update step allows us to populate custom fields in bulk (including the `related_product_ids`
126
+ # field), so we still come out ahead in terms of overall performance.
127
+
128
+ to_create = raw_products.select { |p| p['productAvailability'] != 'not available' && !existing_skus.include?(p['sku'])}
129
+ to_delete = raw_products.select { |p| p['productAvailability'] == 'not available' && existing_skus.include?(p['sku'])}
130
+ to_update = raw_products.select { |p| p['productAvailability'] != 'not available' && existing_skus.include?(p['sku'])}
131
+
132
+ mapped_products = [] # Contains an array of { :bc_payload, :raw_product } hashes
133
+
134
+ # Delete all inactive products
135
+ to_delete.each do |p|
136
+ bc_product = bc_products.find { |bc| bc['sku'] == p['sku'] }
137
+ delete_inactive_product(bc_product)
138
+ end
139
+
140
+ # A Note regarding the nil checks below. In order to improve the efficiency
141
+ # of this agent, we process requests in batch wherever possible. However,
142
+ # we don't want errors with one product to prevent others in the bundle from
143
+ # processing.
144
+ #
145
+ # Methods that process single records intentionally return `nil` in the event
146
+ # of an error to facilitate this, and `nil` entries are excluded from the
147
+ # batch processes. Additionally, any such processing errors are emitted as
148
+ # error events with `status: 500` to facilitate reporting.
149
+
150
+ #----- Handle the creation of any new products -----#
151
+ to_create.each do |raw_product|
152
+ bc_product = create_new_product(raw_product, additional_data)
153
+ unless bc_product.nil?
154
+ mapped_products.push({ bc_payload: bc_product, raw_product: raw_product })
76
155
  end
156
+ end
77
157
 
78
- def working?
79
- received_event_without_error?
80
- end
158
+ #----- Process updates for existing products -----#
159
+ to_update.each do |raw_product|
160
+ bc_product = bc_products.find { |bc| bc['sku'] == raw_product['sku'] }
161
+ mapped_product = process_updates(raw_product, bc_product, additional_data)
81
162
 
82
- def check
83
- initialize_clients
84
- handle interpolated['payload'].presence || {}
163
+ unless mapped_product.nil?
164
+ mapped_products.push(mapped_product)
85
165
  end
166
+ end
167
+
168
+ #----- Handle final upserts -----#
169
+ unless mapped_products.blank?
170
+ # NOTE: An empty array here likely indicates that a title has been removed
171
+ # from sale completely and is not available in any format. Most of the
172
+ # time, mapped_products should have at least one item.
173
+ #
174
+ # In rare cases, it may mean that all products resulted in a processing
175
+ # error, but since those are tracked individually, we don't need to issue
176
+ # any errors here.
177
+ upsert_products(mapped_products)
178
+ end
179
+ end
86
180
 
87
- def receive(incoming_events)
88
- initialize_clients
89
- incoming_events.each do |event|
90
- handle(event)
91
- end
92
- end
181
+ # Attempt to find an existing BigCommerce product by SKU
182
+ # Returns nil if no matching product is found.
183
+ def lookup_existing_products(raw_products)
184
+ begin
185
+ bc_products = @product_client.get_by_skus(raw_products.map { |r| r['sku'] })
186
+
187
+ return bc_products
188
+ rescue BigCommerceProductError => e
189
+ create_event payload: {
190
+ status: e.status,
191
+ scope: e.scope,
192
+ message: e.message,
193
+ data: e.data,
194
+ }
195
+
196
+ raise e
197
+ # This exception is intentionally rethrown because it means we were unable
198
+ # lookup existing BigCommerce records. (If there were no matching SKUs, the
199
+ # response would be an empty array). In this case, we don't have enough
200
+ # information to accurately process the incoming raw products, so we must
201
+ # fail.
202
+ end
203
+ end
93
204
 
94
- def handle(event)
95
- method_name = "handle_#{interpolated['mode']}"
96
- if self.respond_to?(method_name, true)
97
- self.public_send(method_name, event)
98
- else
99
- raise "'#{interpolated['mode']}' is not a supported mode"
100
- end
205
+ def delete_inactive_product(bc_product)
206
+ begin
207
+ @product_client.delete(bc_product['id'])
208
+ rescue BigCommerceProductError => e
209
+ emit_error(e)
210
+ rescue => e
211
+ emit_error(BigCommerceProductError.new(
212
+ 500,
213
+ 'delete inactive_product',
214
+ e.message,
215
+ bc_product['id'],
216
+ { sku: bc_product['sku'] },
217
+ e,
218
+ ))
219
+ end
220
+ end
221
+
222
+ # Handles the creation of new product records
223
+ def create_new_product(raw_product, additional_data)
224
+ begin
225
+ bc_payload = map_product(raw_product, nil, additional_data)
226
+ custom_fields = map_custom_fields(raw_product, nil)
227
+ bc_payload['custom_fields'] = custom_fields[:upsert]
228
+ return @product_client.create(bc_payload, { include: 'custom_fields' })
229
+ rescue BigCommerceProductError => e
230
+ emit_error(e)
231
+ rescue => e
232
+ emit_error(BigCommerceProductError.new(
233
+ 500,
234
+ 'create_new_product',
235
+ e.message,
236
+ nil,
237
+ { sku: raw_product['sku'] },
238
+ e
239
+ ))
240
+ end
241
+
242
+ return nil
243
+ end
244
+
245
+ # Generates an update payload for the provided product records
246
+ # Returns a hash containing { :bc_payload, :raw_product }
247
+ def process_updates(raw_product, bc_product, additional_data)
248
+ custom_fields = map_custom_fields(raw_product, bc_product)
249
+
250
+ custom_fields[:delete].each do |field|
251
+ begin
252
+ # Delete custom fields that are no longer used
253
+ @custom_field_client.delete(bc_product['id'], field['id'])
254
+ rescue BigCommerceProductError => e
255
+ emit_error(e)
256
+ rescue => e
257
+ emit_error(BigCommerceProductError.new(
258
+ 500,
259
+ 'delete_custom_fields',
260
+ e.message,
261
+ bc_product['id'],
262
+ { sku: bc_product['sku'], field_id: field['id'] },
263
+ e
264
+ ))
101
265
  end
266
+ end
102
267
 
103
- # 1. upsert product
104
- # 2. upsert option & option_values
105
- # 3. delete old option_values
106
- # - NOTE: deleting an option_value also deletes the variant
107
- # associated with the option_value
108
- # 4. upsert variants
109
- # - NOTE: because deleting option values deletes variants
110
- # we need to fetch the variants AFTER deletion has occurred.
111
- # - NOTE: by deleting variants in #3 if option_values on an
112
- # existing variant changes over time, we're effectively deleting
113
- # and then re-adding the variant. Could get weird.
114
- def handle_variants(event)
115
- product = event.payload
116
-
117
- split = get_mapper(:ProductMapper).split_digital_and_physical(
118
- product,
119
- interpolated['custom_fields_map']
120
- )
121
- physical = split[:physical]
122
- digital = split[:digital]
123
-
124
- wrapper_skus = {
125
- physical: get_mapper(:ProductMapper).get_wrapper_sku(physical),
126
- digital: get_mapper(:ProductMapper).get_wrapper_sku(digital),
127
- }
128
-
129
- bc_products = @product.get_by_skus(
130
- wrapper_skus.map {|k,v| v},
131
- %w[custom_fields options]
132
- )
133
- #save skus
134
- digital_skus = []
135
- physical_skus = []
136
- if split[:digital] and split[:physical]
137
- digital_skus.concat([wrapper_skus[:digital]])
138
- digital_skus.concat(get_mapper(:ProductMapper).get_product_skus(split[:digital])).join(",")
139
- physical_skus.concat([wrapper_skus[:physical]])
140
- physical_skus.concat(get_mapper(:ProductMapper).get_product_skus(split[:physical])).join(",")
141
- end
268
+ bc_payload = map_product(raw_product, bc_product, additional_data)
142
269
 
143
- # upsert wrapper products
144
- split.each do |type, product|
145
- is_digital = type == :digital ? true : false
146
-
147
- # modify digital
148
- if is_digital
149
- product['name'] = "#{product['name']} (Digital)"
150
- end
151
-
152
- # Ignatius Press -- some products have the same name and must be disambiguated.
153
- # ...by adding a list of the product types (hardback, paperback, etc.) to their names
154
- if boolify(options['should_disambiguate'])
155
- product['page_title'] = product['name']
156
- product['name'] += " |~ " + product['model'].map { |m|
157
- m['additionalProperty'].find { |p|
158
- p['propertyID'] == 'option'
159
- }['value']
160
- }.join(", ")
161
- end
162
-
163
- wrapper_sku = wrapper_skus[type]
164
- bc_product = bc_products[wrapper_sku]
165
- variant_option_name = get_mapper(:OptionMapper).variant_option_name
166
- bc_option = !bc_product.nil? ? bc_product['options'].select {|opt| opt['display_name'] === variant_option_name}.first : nil
167
-
168
- search_skus = is_digital ? physical_skus : digital_skus
169
- # ##############################
170
- # 1. update wrapper product
171
- # ##############################
172
- upsert_result = upsert_product(wrapper_sku, product, bc_product, is_digital, search_skus)
173
- bc_product = upsert_result[:product]
174
-
175
- # clean up custom/meta fields. there are not batch operations so we might as well do them here.
176
- custom_fields_delete = upsert_result[:custom_fields_delete].select {|field| field['name'] != 'related_product_id'}
177
- clean_up_custom_fields(custom_fields_delete)
178
- meta_fields = update_meta_fields(
179
- upsert_result[:meta_fields_upsert],
180
- upsert_result[:meta_fields_delete],
181
- )
182
-
183
- bc_product['meta_fields'] = meta_fields
184
-
185
- bc_products[wrapper_sku] = bc_product
186
- product_id = bc_products[wrapper_sku]['id']
187
-
188
- # ##############################
189
- # 2. upsert option & option_values
190
- # ##############################
191
- option_values_map = get_mapper(:ProductMapper).get_sku_option_label_map(product)
192
- option_values = option_values_map.map {|k,v| v}
193
- option_value_operations = get_mapper(:OptionMapper).option_value_operations(bc_option, option_values)
194
- option = get_mapper(:OptionMapper).map(product_id, bc_option, option_value_operations[:create])
195
- bc_option = @product_option.upsert(product_id, option)
196
-
197
- # ##############################
198
- # 3. delete old option_values
199
- # ##############################
200
- @product_option_value.delete_all(bc_option, option_value_operations[:delete])
201
-
202
- # ##############################
203
- # 4. upsert variants
204
- # ##############################
205
- variant_skus = get_mapper(:ProductMapper).get_product_skus(product)
206
- bc_variants = @product_variant.index(product_id)
207
- mapped_variants = product['model'].map do |variant|
208
- bc_variant = bc_variants.select {|v| v['sku'] === variant['sku']}.first
209
- opt = get_mapper(:ProductMapper).get_option(variant)
210
- bc_option_value = bc_option['option_values'].select {|ov| ov['label'] == opt}.first
211
-
212
- option_value = get_mapper(:VariantMapper).map_option_value(bc_option_value['id'], bc_option['id'])
213
-
214
- get_mapper(:VariantMapper).map(
215
- variant,
216
- [option_value],
217
- product_id,
218
- bc_variant.nil? ? nil : bc_variant['id'],
219
- interpolated['not_purchasable_format_list'],
220
- bc_option_value
221
- )
222
- end
223
-
224
- bc_product['variants'] = @variant.upsert(mapped_variants)
225
- end
270
+ if bc_payload.present?
271
+ bc_payload['custom_fields'] = custom_fields[:upsert]
272
+ return { bc_payload: bc_payload, raw_product: raw_product }
273
+ else
274
+ return nil
275
+ end
276
+ end
226
277
 
227
- bc_physical = bc_products[wrapper_skus[:physical]]
228
- bc_digital = bc_products[wrapper_skus[:digital]]
229
- is_delete_physical = split[:physical].nil? && bc_physical
230
- is_delete_digital = split[:digital].nil? && bc_digital
231
-
232
- # ##############################
233
- # clean up products that no longer exist
234
- # ##############################
235
- if is_delete_physical
236
- bc_product = bc_products[wrapper_skus[:physical]]
237
- @product.delete(bc_product['id'])
238
- bc_physical = false
239
- bc_product.delete(wrapper_skus[:physical])
240
- end
278
+ # Sends a batch update request for the provided products
279
+ # NOTE: This process also sets the `related_product_ids` custom field
280
+ def upsert_products(mapped_products)
281
+ product_ids = mapped_products.map { |p| p[:bc_payload]['id'] }
282
+ results = {}
241
283
 
242
- if is_delete_digital
243
- bc_product = bc_products[wrapper_skus[:digital]]
244
- @product.delete(bc_product['id'])
245
- bc_digital = false
246
- bc_product.delete(wrapper_skus[:digital])
247
- end
284
+ product_data = mapped_products.map do |p|
285
+ bc_payload = p[:bc_payload]
286
+ raw_product = p[:raw_product]
248
287
 
249
- # ##############################
250
- # clean up custom field relationships
251
- # ##############################
252
- if bc_physical && !bc_digital
253
- # clean up related_product_id on physical product
254
- bc_product = bc_physical
255
- related_custom_field = bc_product['custom_fields'].select {|field| field['name'] == 'related_product_id'}.first
256
- @custom_field.delete(bc_product['id'], related_custom_field['id']) unless related_custom_field.nil?
257
- elsif !bc_physical && bc_digital
258
- # clean up related_product_id on digital product
259
- bc_product = bc_digital
260
- related_custom_field = bc_product['custom_fields'].select {|field| field['name'] == 'related_product_id'}.first
261
- @custom_field.delete(bc_product['id'], related_custom_field['id']) unless related_custom_field.nil?
262
- elsif bc_physical && bc_digital
263
- # update/add related_product_id on both products
264
- bc_physical_related = get_mapper(:CustomFieldMapper).map_one(bc_physical, 'related_product_id', bc_digital['id'])
265
- bc_digital_related = get_mapper(:CustomFieldMapper).map_one(bc_digital, 'related_product_id', bc_physical['id'])
266
- @custom_field.upsert(bc_physical['id'], bc_physical_related)
267
- @custom_field.upsert(bc_digital['id'], bc_digital_related)
268
- end
288
+ meta_fields = update_meta_fields(raw_product, bc_payload)
289
+ results[raw_product['sku']] = {
290
+ raw_product: raw_product,
291
+ meta_fields: meta_fields[:upsert],
269
292
 
270
- # ##############################
271
- # emit events
272
- # ##############################
273
- if bc_physical
274
- create_event payload: {
275
- product: bc_physical
276
- }
277
- end
293
+ }
278
294
 
279
- if bc_digital
280
- create_event payload: {
281
- product: bc_digital
282
- }
283
- end
295
+ #----- Set related_product_ids -----#
296
+ related_product_ids = product_ids.select { |id| id != bc_payload['id'] }
297
+ field = {
298
+ 'name': 'related_product_ids',
299
+ 'value': related_product_ids * ',' # concatenate as a CSV
300
+ }
301
+
302
+ unless related_product_ids.empty?
303
+ if bc_payload['custom_fields'].blank?
304
+ bc_payload['custom_fields'] = []
305
+ end
306
+
307
+ bc_payload['custom_fields'].push(field)
284
308
  end
285
309
 
286
- def handle_option_list(event)
287
- product = event.payload
288
-
289
- skus = get_mapper(:ProductMapper).get_product_skus(product)
290
- wrapper_sku = get_mapper(:ProductMapper).get_wrapper_sku(product)
291
- all_skus = [].push(*skus).push(wrapper_sku)
292
- bc_products = @product.get_by_skus(all_skus)
293
-
294
- # upsert child products
295
- bc_children = []
296
- custom_fields_delete = []
297
- meta_fields_upsert = []
298
- meta_fields_delete = []
299
-
300
- skus.each do |sku|
301
- bc_product = bc_products[sku]
302
- result = upsert_product(sku, product, bc_product)
303
- custom_fields_delete += result[:custom_fields_delete]
304
- meta_fields_upsert += result[:meta_fields_upsert]
305
- meta_fields_delete += result[:meta_fields_delete]
306
- bc_children.push(result[:product])
307
- end
310
+ # return the finalized payload
311
+ bc_payload
312
+ end
308
313
 
309
- # upsert wrapper
310
- bc_wrapper_product = bc_products[wrapper_sku]
311
- result = upsert_product(wrapper_sku, product, bc_wrapper_product)
312
- custom_fields_delete += result[:custom_fields_delete]
313
- meta_fields_upsert += result[:meta_fields_upsert]
314
- meta_fields_delete += result[:meta_fields_delete]
315
-
316
- is_default_map = get_mapper(:ProductMapper).get_is_default(product)
317
-
318
- # update modifier
319
- sku_option_map = get_mapper(:ProductMapper).get_sku_option_label_map(product)
320
- modifier_updates = get_mapper(:ModifierMapper).map(
321
- bc_wrapper_product,
322
- bc_children,
323
- sku_option_map,
324
- is_default_map
325
- )
326
- @modifier.upsert(result[:product]['id'], modifier_updates[:upsert])
327
-
328
- clean_up_custom_fields(custom_fields_delete)
329
- clean_up_modifier_values(modifier_updates[:delete])
330
- meta_fields = update_meta_fields(meta_fields_upsert, meta_fields_delete)
331
-
332
- product['meta_fields'] = meta_fields
333
- product['modifiers'] = modifier_updates[:upsert]
334
- create_event payload: {
335
- product: product,
336
- parent: result[:product],
337
- children: bc_children
338
- }
314
+ begin
315
+ @product_client.update_batch(product_data, { include: 'custom_fields' }).each do |p|
316
+ result = results[p['sku']]
317
+ result[:custom_fields] = p['custom_fields']
318
+ result[:bc_product] = p
319
+
320
+ create_event payload: {
321
+ product: result,
322
+ status: 200,
323
+ }
339
324
  end
325
+ rescue BigCommerceProductError => e
326
+ emit_error(e)
327
+ rescue => e
328
+ emit_error(BigCommerceProductError.new(
329
+ 500,
330
+ 'upsert_products',
331
+ e.message,
332
+ nil,
333
+ { sku: product_data.map { |p| p[:sku] } },
334
+ e
335
+ ))
336
+ end
337
+ end
338
+
339
+ # Map the raw_product record to bc_product fields. The bc_product passed in may be null
340
+ # if the product does not exist yet in BigCommerce.
341
+ def map_product(raw_product, bc_product, additional_data)
342
+ bc_payload = nil
340
343
 
341
- private
342
-
343
- def initialize_clients
344
- @variant = initialize_client(:Variant)
345
- @product_variant = initialize_client(:ProductVariant)
346
- @product_option = initialize_client(:ProductOption)
347
- @product_option_value = initialize_client(:ProductOptionValue)
348
- @product = initialize_client(:Product)
349
- @custom_field = initialize_client(:CustomField)
350
- @meta_field = initialize_client(:MetaField)
351
- @modifier = initialize_client(:Modifier)
352
- @modifier_value = initialize_client(:ModifierValue)
344
+ begin
345
+ # if track inventory on the agent is true set track inventory to product level track inventory
346
+ track_inventory = boolify(options['track_inventory']).nil? ? true : boolify(options['track_inventory'])
347
+ if (track_inventory)
348
+ track_inventory = boolify(raw_product['trackInventory'])
353
349
  end
350
+ bc_payload = get_mapper(:ProductMapper).map_payload(raw_product, additional_data, track_inventory)
351
+ bc_payload['id'] = bc_product['id'] unless bc_product.nil? || bc_product['id'].nil?
352
+ # NOTE: bc_product will be nil when this is called with `to_create` products
354
353
 
355
- def initialize_client(class_name)
356
- klass = ::BigcommerceProductAgent::Client.const_get(class_name.to_sym)
357
- return klass.new(
358
- interpolated['store_hash'],
359
- interpolated['client_id'],
360
- interpolated['access_token']
361
- )
354
+ if bc_payload[:categories].empty?
355
+ # If categories is empty keep existing categories because categories should never be empty
356
+ bc_payload[:categories] = bc_product['categories'] unless bc_product.nil? || bc_product['categories'].nil?
362
357
  end
363
358
 
364
- def get_mapper(class_name)
365
- return ::BigcommerceProductAgent::Mapper.const_get(class_name.to_sym)
359
+ if bc_payload[:type] == 'digital'
360
+ bc_payload[:name].concat(' (Digital)')
366
361
  end
367
362
 
368
- def upsert_product(sku, product, bc_product = nil, is_digital=false, search_skus=[])
369
- custom_fields_updates = get_mapper(:CustomFieldMapper).map(
370
- interpolated['custom_fields_map'],
371
- product,
372
- bc_product
373
- )
374
-
375
- product_id = bc_product['id'] unless bc_product.nil?
376
-
377
- payload = get_mapper(:ProductMapper).payload(
378
- sku,
379
- product,
380
- product_id,
381
- {
382
- additional_search_terms: search_skus,
383
- custom_fields: custom_fields_updates[:upsert]
384
- },
385
- is_digital,
386
- )
387
-
388
- bc_product = @product.upsert(payload, {
389
- include: %w[custom_fields variants options].join(',')
390
- })
391
-
392
- # Metafields need to be managed separately. Intentionally get them _AFTER_
393
- # the upsert so that we have the necessary resource_id (bc_product.id)
394
- meta_fields_updates = get_mapper(:MetaFieldMapper).map(
395
- interpolated['meta_fields_map'],
396
- product,
397
- bc_product,
398
- @meta_field.get_for_product(bc_product['id']),
399
- interpolated['meta_fields_namespace']
400
- )
401
-
402
- {
403
- product: bc_product,
404
- custom_fields_delete: custom_fields_updates[:delete],
405
- meta_fields_upsert: meta_fields_updates[:upsert],
406
- meta_fields_delete: meta_fields_updates[:delete]
407
- }
363
+ # BigCommerce requires that product names be unique. In some cases, (like book titles from multiple sources),
364
+ # this may be hard to enforce. In those cases, the product SKUs should still be unique, so we append the SKU
365
+ # to the product title with a `|~` separator. We then set the `page_title` to the original product name so
366
+ # users don't see system values.
367
+ #
368
+ # page_title is the user-facing display value for product pages.
369
+ if boolify(options['should_disambiguate'])
370
+ bc_payload[:page_title] = bc_payload[:name]
371
+ bc_payload[:name] = bc_payload[:name] + " |~ " + raw_product['sku']
408
372
  end
409
373
 
410
- def clean_up_custom_fields(custom_fields)
411
- custom_fields.each do |field|
412
- @custom_field.delete(field['product_id'], field['id'])
374
+ return bc_payload
375
+ rescue BigCommerceProductError => e
376
+ emit_error(e)
377
+ rescue => e
378
+ emit_error(BigCommerceProductError.new(
379
+ 500,
380
+ 'map_product',
381
+ e.message,
382
+ bc_product['id'],
383
+ { sku: bc_product['sku'] },
384
+ e
385
+ ))
386
+ end
387
+
388
+ return nil
389
+ end
390
+
391
+ # Maps custom field values from the raw_product to the bc_payload
392
+ # NOTE: Because custom fields can be included in product upsert requests,
393
+ # this function is only _mapping_ the data.
394
+ def map_custom_fields(raw_product, bc_payload)
395
+ current_fields = bc_payload.nil? ? [] : bc_payload['custom_fields']
396
+
397
+ begin
398
+ return get_mapper(:CustomFieldMapper).map(options['custom_fields_map'], raw_product, bc_payload, current_fields, options['meta_fields_namespace'])
399
+ rescue BigCommerceProductError => e
400
+ emit_error(e)
401
+ rescue => e
402
+ emit_error(BigCommerceProductError.new(
403
+ 500,
404
+ 'map_custom_fields',
405
+ e.message,
406
+ bc_payload['id'],
407
+ { sku: bc_payload['sku'] },
408
+ e
409
+ ))
410
+ end
411
+
412
+ return nil
413
+ end
414
+
415
+ # Manages meta field values for the provided product records.
416
+ # NOTE: Because meta fields have to be managed separately, this function will
417
+ # map the raw_product data and also handle any delete/create/update requests.
418
+ def update_meta_fields(raw_product, bc_payload)
419
+ current_fields = nil
420
+
421
+ begin
422
+ current_fields = @meta_field_client.get_for_product(bc_payload['id'])
423
+ rescue BigCommerceProductError => e
424
+ emit_error(e)
425
+ return nil
426
+ rescue => e
427
+ emit_error(BigCommerceProductError.new(
428
+ 500,
429
+ 'get_meta_fields',
430
+ e.message,
431
+ bc_payload['id'],
432
+ { sku: bc_payload['sku'] },
433
+ e
434
+ ))
435
+ return nil
436
+ end
437
+
438
+ begin
439
+ fields = get_mapper(:MetaFieldMapper).map(options['meta_fields_map'], raw_product, bc_payload, current_fields, options['meta_fields_namespace'])
440
+
441
+ # Delete fields
442
+ fields[:delete].each do |field|
443
+ begin
444
+ @meta_field_client.delete(bc_payload['id'], field['id'])
445
+ rescue BigCommerceProductError => e
446
+ emit_error(e)
447
+ rescue => e
448
+ emit_error(BigCommerceProductError.new(
449
+ 500,
450
+ 'delete meta_fields',
451
+ e.message,
452
+ bc_payload['id'],
453
+ { sku: bc_payload['sku'] },
454
+ e
455
+ ))
413
456
  end
414
457
  end
415
458
 
416
- def clean_up_modifier_values(modifier_values)
417
- modifier_values.each do |field|
418
- @modifier_value.delete(field[:product_id], field[:modifier_id], field[:value_id])
419
- end
459
+ # Upsert fields
460
+ fields[:upsert].each do |field|
461
+ begin
462
+ @meta_field_client.upsert(bc_payload['id'], field)
463
+ rescue BigCommerceProductError => e
464
+ emit_error(e)
465
+ rescue => e
466
+ emit_error(BigCommerceProductError.new(
467
+ 500,
468
+ 'upsert_meta_fields',
469
+ e.message,
470
+ bc_payload['id'],
471
+ { sku: bc_payload['sku'] },
472
+ e
473
+ ))
474
+ end
420
475
  end
421
476
 
422
- def update_meta_fields(upsert_fields, delete_fields)
423
- meta_fields = []
477
+ return fields
478
+ rescue BigCommerceProductError => e
479
+ emit_error(e)
480
+ rescue => e
481
+ emit_error(BigCommerceProductError.new(
482
+ 500,
483
+ 'map_meta_fields',
484
+ e.message,
485
+ bc_payload['id'],
486
+ { sku: bc_payload['sku'] },
487
+ e
488
+ ))
489
+ end
490
+
491
+ return nil
492
+ end
424
493
 
425
- upsert_fields.each do |field|
426
- meta_fields << @meta_field.upsert(field)
427
- end
494
+ private
428
495
 
429
- delete_fields.each do |field|
430
- if field[:resource_id] && field[:id]
431
- @meta_field.delete(field[:resource_id], field[:id])
432
- end
433
- end
496
+ def initialize_clients
497
+ @product_client = initialize_client(:Product)
498
+ @custom_field_client = initialize_client(:CustomField)
499
+ @meta_field_client = initialize_client(:MetaField)
500
+ end
434
501
 
435
- meta_fields
436
- end
502
+ def initialize_client(class_name)
503
+ klass = ::BigcommerceProductAgent::Client.const_get(class_name.to_sym)
504
+ return klass.new(
505
+ interpolated['store_hash'],
506
+ interpolated['client_id'],
507
+ interpolated['access_token']
508
+ )
509
+ end
510
+
511
+ def get_mapper(class_name)
512
+ return ::BigcommerceProductAgent::Mapper.const_get(class_name.to_sym)
513
+ end
514
+
515
+ # Takes a BigCommerceProductError and emits the underlying data as an error payload
516
+ # to assist with error reporting. It is recommended that these errors be consolidated
517
+ # with a Digest Agent and reported as a summary.
518
+ def emit_error(error)
519
+
520
+ payload = {
521
+ status: error.status,
522
+ message: error.message,
523
+ scope: error.scope,
524
+ product_identifier: error.product_identifier,
525
+ data: error.data,
526
+ }
527
+
528
+ Rails.logger.debug({
529
+ error: payload,
530
+ trace: error.backtrace
531
+ })
532
+
533
+ create_event({ payload: payload })
437
534
  end
535
+ end
438
536
  end