huginn_bigcommerce_product_agent 1.12.0 → 2.0.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: 75db6b836de4031aa33df47c393b0db0b59f99813fa96e98ee15ec10b39b8f91
4
- data.tar.gz: d5c2bcc87f0fd3f95e489aa651f06297b07225398edfb6dfc8869c976f63caf7
3
+ metadata.gz: b2d3a13bdac789b3b1714c68e8794c30f0f15390c0161dd8a473d36f097f4422
4
+ data.tar.gz: ecc6fe4feb48b5bf7d8528951fd0bcccd41b095f9b1070f73e4d41b5b31908cd
5
5
  SHA512:
6
- metadata.gz: 96cc182ce0413cbb9843e4ab435d47ca85540e0025a9cb60579a200f37a3c9c299df0583b20454a83adf23f516e917f75e75942b555245761e21918af79d76b4
7
- data.tar.gz: 463354a063899f6cb03c253cf65b692ecb2258525e04ce107320f41e03ea6ee4251eda5c8bd4147596a9d6a2fdb90e1e85837b13aa7a05936e7f026d763e22fd
6
+ metadata.gz: db7b3003393f5b1c4e741a6be04b3bfb8bfd7be7aaf0cd006d7dd03112da997edb0efac33f2d5e41d5bc1f2d128a67c6be5c68b76b9be9b20bad4e7fbc397680
7
+ data.tar.gz: 1d18a0e667d3216a194816ded4f4c523dffe4eeda9d7a84186285f6a6e53ea4d14f2f8e28b2333b5de65b517e5e1bb2149c77cd7a53f659f8a65e352b266257a
@@ -3,19 +3,38 @@ 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 e, "\n#{e.message}\nFailed to delete custom_field with id = #{custom_field_id}\nfor product with id = #{product_id}\n", e.backtrace
37
+ end
19
38
  end
20
39
 
21
40
  def upsert(product_id, payload)
@@ -10,30 +10,33 @@ 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
42
  puts e.inspect
@@ -8,11 +8,23 @@ 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
14
  raise e, "\n#{e.message}\nFailed to update product with payload = #{payload.to_json}\n", e.backtrace
13
15
  end
16
+ end
14
17
 
15
- return response.body['data']
18
+ def update_batch(payload, params={})
19
+ begin
20
+ response = client.put(uri(), payload.to_json) do |request|
21
+ request.params.update(params) if params
22
+ end
23
+
24
+ return response.body['data']
25
+ rescue Faraday::Error::ClientError => e
26
+ raise e, "\n#{e.message}\nFailed to update product batch with payload = #{payload.to_json}\n", e.backtrace
27
+ end
16
28
  end
17
29
 
18
30
  def delete(id)
@@ -25,34 +37,31 @@ module BigcommerceProductAgent
25
37
  response = client.post(uri, payload.to_json) do |request|
26
38
  request.params.update(params) if params
27
39
  end
40
+
41
+ return response.body['data']
28
42
  rescue Faraday::Error::ClientError => e
29
43
  raise e, "\n#{e.message}\nFailed to create product with payload = #{payload.to_json}\n", e.backtrace
30
44
  end
31
-
32
- return response.body['data']
33
45
  end
34
46
 
35
47
  def upsert(payload, params={})
36
- if payload[:id]
37
- return update(payload[:id], payload, params)
48
+ payload['id'] = payload.delete(:id) unless payload[:id].nil?
49
+ if payload['id']
50
+ return update(payload['id'], payload, params)
38
51
  else
39
52
  return create(payload, params)
40
53
  end
41
54
  end
42
55
 
43
- def get_by_skus(skus, include = %w[custom_fields modifiers])
56
+ # When using sku:in you must specify the fields you want returned.
57
+ def get_by_skus(skus, include = %w[custom_fields modifiers], include_fields = %w[sku])
44
58
  products = index({
45
59
  'sku:in': skus.join(','),
46
60
  include: include.join(','),
61
+ include_fields: include_fields.join(','),
47
62
  })
48
63
 
49
- map = {}
50
-
51
- products.each do |product|
52
- map[product['sku']] = product
53
- end
54
-
55
- map
64
+ return products
56
65
  end
57
66
 
58
67
  def disable(productId)
@@ -3,467 +3,484 @@
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 => e
189
+ create_event payload: {
190
+ status: 500,
191
+ scope: 'lookup_existing_products',
192
+ message: e.message,
193
+ trace: e.backtrace.join('\n'),
194
+ raw_product: raw_products,
195
+ }
196
+
197
+ raise e
198
+ # This exception is intentionally rethrown because it means we were unable
199
+ # lookup existing BigCommerce records. (If there were no matching SKUs, the
200
+ # response would be an empty array). In this case, we don't have enough
201
+ # information to accurately process the incoming raw products, so we must
202
+ # fail.
203
+ end
204
+ end
93
205
 
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
206
+ def delete_inactive_product(bc_product)
207
+ begin
208
+ @product_client.delete(bc_product['id'])
209
+ rescue => e
210
+ create_event payload: {
211
+ status: 500,
212
+ scope: 'delete_inactive_product',
213
+ message: e.message,
214
+ trace: e.backtrace.join('\n'),
215
+ bc_product: bc_product,
216
+ }
217
+ end
218
+ end
219
+
220
+ # Handles the creation of new product records
221
+ def create_new_product(raw_product, additional_data)
222
+ begin
223
+ bc_payload = map_product(raw_product, nil, additional_data)
224
+ custom_fields = map_custom_fields(raw_product, nil)
225
+ bc_payload['custom_fields'] = custom_fields[:upsert]
226
+ return @product_client.create(bc_payload, { include: 'custom_fields' })
227
+ rescue => e
228
+ create_event payload: {
229
+ status: 500,
230
+ scope: 'create_product',
231
+ message: e.message(),
232
+ trace: e.backtrace.join('\n'),
233
+ raw_product: raw_product,
234
+ product_data: bc_payload,
235
+ }
236
+
237
+ return nil
238
+ end
239
+ end
240
+
241
+ # Generates an update payload for the provided product records
242
+ # Returns a hash containing { :bc_payload, :raw_product }
243
+ def process_updates(raw_product, bc_product, additional_data)
244
+ custom_fields = map_custom_fields(raw_product, bc_product)
245
+
246
+ custom_fields[:delete].each do |field|
247
+ begin
248
+ # Delete custom fields that are no longer used
249
+ @custom_field_client.delete(bc_product['id'], field['id'])
250
+ rescue => e
251
+ create_event payload: {
252
+ status: 500,
253
+ scope: 'delete_custom_fields',
254
+ message: e.message(),
255
+ trace: e.backtrace.join('\n'),
256
+ product_id: bc_product['id'],
257
+ deletes: custom_fields[:delete]
258
+ }
101
259
  end
260
+ end
102
261
 
103
- # 1. Upsert the core product (Wrapper Product)
104
- # - NOTE: This initial upsert intentionally disables the product.
105
- # Doing so prevents content hiccups with variants particularly
106
- # for products going off sale.
107
- # 2. Upsert option & option_values (BigCommerce Variants)
108
- # 3. Delete old option_values
109
- # - NOTE: deleting an option_value also deletes the variant
110
- # associated with the option_value
111
- # 4. Upsert variants
112
- # - NOTE: because deleting option values deletes variants
113
- # we need to fetch the variants AFTER deletion has occurred.
114
- # - NOTE: by deleting variants in #3 if option_values on an
115
- # existing variant changes over time, we're effectively deleting
116
- # and then re-adding the variant. Could get weird.
117
- # 5. Re-enable the updated product
118
- # - NOTE: If the product no longer has any variants, it will remain
119
- # disabled as it can no longer be purchased.
120
- def handle_variants(event)
121
- product = event.payload
122
-
123
- split = get_mapper(:ProductMapper).split_digital_and_physical(
124
- product,
125
- interpolated['custom_fields_map']
126
- )
127
- physical = split[:physical]
128
- digital = split[:digital]
129
-
130
- base_sku = product['additionalProperty'].find { |p|
131
- p['propertyID'] == 'baseSku'
132
- }['value']
133
-
134
- wrapper_skus = {
135
- # Use the provided base_sku if it exists -- otherwise, infer the SKU from the variant list
136
- physical: base_sku ? "#{base_sku}-P" : get_mapper(:ProductMapper).get_wrapper_sku(physical),
137
- digital: base_sku ? "#{base_sku}-D" : get_mapper(:ProductMapper).get_wrapper_sku(digital),
138
- }
262
+ bc_payload = map_product(raw_product, bc_product, additional_data)
139
263
 
140
- bc_products = @product.get_by_skus(
141
- wrapper_skus.map {|k,v| v},
142
- %w[custom_fields options]
143
- )
144
-
145
- #save skus
146
- digital_skus = []
147
- physical_skus = []
148
- if split[:digital] and split[:physical]
149
- digital_skus.concat([wrapper_skus[:digital]])
150
- digital_skus.concat(get_mapper(:ProductMapper).get_product_skus(split[:digital])).join(",")
151
- physical_skus.concat([wrapper_skus[:physical]])
152
- physical_skus.concat(get_mapper(:ProductMapper).get_product_skus(split[:physical])).join(",")
153
- end
264
+ if bc_payload.present?
265
+ bc_payload['custom_fields'] = custom_fields[:upsert]
266
+ return { bc_payload: bc_payload, raw_product: raw_product }
267
+ else
268
+ return nil
269
+ end
270
+ end
154
271
 
155
- # upsert wrapper products
156
- split.each do |type, product|
157
- is_digital = type == :digital ? true : false
158
-
159
- # modify digital
160
- if is_digital
161
- product['name'] = "#{product['name']} (Digital)"
162
- end
163
-
164
- # BigCommerce requires that product names be unique. In some cases, (like book titles from multiple sources),
165
- # this may be hard to enforce. In those cases, the product SKUs should still be unique, so we append the SKU
166
- # to the product title with a `|~` separator. We then set the `page_title` to the original product name so
167
- # users don't see system values.
168
- if boolify(options['should_disambiguate'])
169
- product['page_title'] = product['name']
170
- product['name'] += " |~ " + (is_digital ? wrapper_skus[:digital] : wrapper_skus[:physical])
171
- end
172
-
173
- wrapper_sku = wrapper_skus[type]
174
- bc_product = bc_products[wrapper_sku]
175
- variant_option_name = get_mapper(:OptionMapper).variant_option_name
176
- bc_option = !bc_product.nil? ? bc_product['options'].select {|opt| opt['display_name'] === variant_option_name}.first : nil
177
-
178
- search_skus = is_digital ? physical_skus : digital_skus
179
-
180
-
181
- # ##############################
182
- # 1. update wrapper product
183
- # ##############################
184
- upsert_result = upsert_product(wrapper_sku, product, bc_product, is_digital, search_skus)
185
- bc_product = upsert_result[:product]
186
-
187
- # clean up custom/meta fields. there are not batch operations so we might as well do them here.
188
- custom_fields_delete = upsert_result[:custom_fields_delete].select {|field| field['name'] != 'related_product_id'}
189
- clean_up_custom_fields(custom_fields_delete)
190
- meta_fields = update_meta_fields(
191
- upsert_result[:meta_fields_upsert],
192
- upsert_result[:meta_fields_delete],
193
- )
194
-
195
- bc_product['meta_fields'] = meta_fields
196
-
197
- bc_products[wrapper_sku] = bc_product
198
- product_id = bc_products[wrapper_sku]['id']
199
-
200
- # ##############################
201
- # 2. upsert option & option_values
202
- # ##############################
203
- option_values_map = get_mapper(:ProductMapper).get_sku_option_label_map(product)
204
- option_values = option_values_map.map {|k,v| v}
205
- option_value_operations = get_mapper(:OptionMapper).option_value_operations(bc_option, option_values)
206
- option = get_mapper(:OptionMapper).map(product_id, bc_option, option_value_operations[:create])
207
- bc_option = @product_option.upsert(product_id, option)
208
-
209
- # ##############################
210
- # 3. delete old option_values
211
- # ##############################
212
- @product_option_value.delete_all(bc_option, option_value_operations[:delete])
213
-
214
- # ##############################
215
- # 4. upsert variants
216
- # ##############################
217
- variant_skus = get_mapper(:ProductMapper).get_product_skus(product)
218
- bc_variants = @product_variant.index(product_id)
219
- mapped_variants = product['model'].select { |m| m['isAvailableForPurchase'] }.map do |variant|
220
- bc_variant = bc_variants.select {|v| v['sku'] === variant['sku']}.first
221
- opt = get_mapper(:ProductMapper).get_option(variant)
222
- bc_option_value = bc_option['option_values'].select {|ov| ov['label'] == opt}.first
223
-
224
- option_value = get_mapper(:VariantMapper).map_option_value(bc_option_value['id'], bc_option['id'])
225
-
226
- get_mapper(:VariantMapper).map(
227
- variant,
228
- [option_value],
229
- product_id,
230
- bc_variant.nil? ? nil : bc_variant['id'],
231
- interpolated['not_purchasable_format_list'],
232
- bc_option_value
233
- )
234
- end
235
-
236
-
237
- unless (mapped_variants.blank?)
238
- bc_product['variants'] = @variant.upsert(mapped_variants)
239
-
240
- # ##############################
241
- # 5. Re-enable the updated product
242
- # ##############################
243
- @product.enable(product_id)
244
- end
245
- end
272
+ # Sends a batch update request for the provided products
273
+ # NOTE: This process also sets the `related_product_ids` custom field
274
+ def upsert_products(mapped_products)
275
+ product_ids = mapped_products.map { |p| p[:bc_payload]['id'] }
276
+ results = {}
246
277
 
247
- bc_physical = bc_products[wrapper_skus[:physical]]
248
- bc_digital = bc_products[wrapper_skus[:digital]]
249
- is_delete_physical = split[:physical].nil? && bc_physical
250
- is_delete_digital = split[:digital].nil? && bc_digital
251
-
252
- # ##############################
253
- # clean up products that no longer exist
254
- # ##############################
255
- if is_delete_physical
256
- bc_product = bc_products[wrapper_skus[:physical]]
257
- @product.delete(bc_product['id'])
258
- bc_physical = false
259
- bc_product.delete(wrapper_skus[:physical])
260
- end
278
+ product_data = mapped_products.map do |p|
279
+ bc_payload = p[:bc_payload]
280
+ raw_product = p[:raw_product]
261
281
 
262
- if is_delete_digital
263
- bc_product = bc_products[wrapper_skus[:digital]]
264
- @product.delete(bc_product['id'])
265
- bc_digital = false
266
- bc_product.delete(wrapper_skus[:digital])
267
- end
282
+ meta_fields = update_meta_fields(raw_product, bc_payload)
283
+ results[raw_product['sku']] = {
284
+ raw_product: raw_product,
285
+ meta_fields: meta_fields[:upsert],
268
286
 
269
- # ##############################
270
- # clean up custom field relationships
271
- # ##############################
272
- if bc_physical && !bc_digital
273
- # clean up related_product_id on physical product
274
- bc_product = bc_physical
275
- related_custom_field = bc_product['custom_fields'].select {|field| field['name'] == 'related_product_id'}.first
276
- @custom_field.delete(bc_product['id'], related_custom_field['id']) unless related_custom_field.nil?
277
- elsif !bc_physical && bc_digital
278
- # clean up related_product_id on digital product
279
- bc_product = bc_digital
280
- related_custom_field = bc_product['custom_fields'].select {|field| field['name'] == 'related_product_id'}.first
281
- @custom_field.delete(bc_product['id'], related_custom_field['id']) unless related_custom_field.nil?
282
- elsif bc_physical && bc_digital
283
- # update/add related_product_id on both products
284
- bc_physical_related = get_mapper(:CustomFieldMapper).map_one(bc_physical, 'related_product_id', bc_digital['id'])
285
- bc_digital_related = get_mapper(:CustomFieldMapper).map_one(bc_digital, 'related_product_id', bc_physical['id'])
286
- @custom_field.upsert(bc_physical['id'], bc_physical_related)
287
- @custom_field.upsert(bc_digital['id'], bc_digital_related)
288
- end
287
+ }
289
288
 
290
- # ##############################
291
- # emit events
292
- # ##############################
293
- if bc_physical
294
- create_event payload: {
295
- product: bc_physical
296
- }
297
- end
289
+ #----- Set related_product_ids -----#
290
+ related_product_ids = product_ids.select { |id| id != bc_payload['id'] }
291
+ field = {
292
+ 'name': 'related_product_ids',
293
+ 'value': related_product_ids * ',' # concatenate as a CSV
294
+ }
298
295
 
299
- if bc_digital
300
- create_event payload: {
301
- product: bc_digital
302
- }
303
- end
296
+ unless related_product_ids.empty?
297
+ if bc_payload['custom_fields'].blank?
298
+ bc_payload['custom_fields'] = []
299
+ end
300
+
301
+ bc_payload['custom_fields'].push(field)
304
302
  end
305
303
 
306
- def handle_option_list(event)
307
- product = event.payload
308
-
309
- skus = get_mapper(:ProductMapper).get_product_skus(product)
310
- wrapper_sku = get_mapper(:ProductMapper).get_wrapper_sku(product)
311
- all_skus = [].push(*skus).push(wrapper_sku)
312
- bc_products = @product.get_by_skus(all_skus)
313
-
314
- # upsert child products
315
- bc_children = []
316
- custom_fields_delete = []
317
- meta_fields_upsert = []
318
- meta_fields_delete = []
319
-
320
- skus.each do |sku|
321
- bc_product = bc_products[sku]
322
- result = upsert_product(sku, product, bc_product)
323
- custom_fields_delete += result[:custom_fields_delete]
324
- meta_fields_upsert += result[:meta_fields_upsert]
325
- meta_fields_delete += result[:meta_fields_delete]
326
- bc_children.push(result[:product])
327
- end
304
+ # return the finalized payload
305
+ bc_payload
306
+ end
328
307
 
329
- # upsert wrapper
330
- bc_wrapper_product = bc_products[wrapper_sku]
331
- result = upsert_product(wrapper_sku, product, bc_wrapper_product)
332
- custom_fields_delete += result[:custom_fields_delete]
333
- meta_fields_upsert += result[:meta_fields_upsert]
334
- meta_fields_delete += result[:meta_fields_delete]
335
-
336
- is_default_map = get_mapper(:ProductMapper).get_is_default(product)
337
-
338
- # update modifier
339
- sku_option_map = get_mapper(:ProductMapper).get_sku_option_label_map(product)
340
- modifier_updates = get_mapper(:ModifierMapper).map(
341
- bc_wrapper_product,
342
- bc_children,
343
- sku_option_map,
344
- is_default_map
345
- )
346
- @modifier.upsert(result[:product]['id'], modifier_updates[:upsert])
347
-
348
- clean_up_custom_fields(custom_fields_delete)
349
- clean_up_modifier_values(modifier_updates[:delete])
350
- meta_fields = update_meta_fields(meta_fields_upsert, meta_fields_delete)
351
-
352
- if product['is_visible']
353
- # If the product should be enabled, re-enable it
354
- @product.enable(result[:product]['id'])
355
- end
308
+ begin
309
+ @product_client.update_batch(product_data, { include: 'custom_fields' }).each do |p|
310
+ result = results[p['sku']]
311
+ result[:custom_fields] = p['custom_fields']
312
+ result[:bc_product] = p
356
313
 
357
- product['meta_fields'] = meta_fields
358
- product['modifiers'] = modifier_updates[:upsert]
359
- create_event payload: {
360
- product: product,
361
- parent: result[:product],
362
- children: bc_children
363
- }
314
+ create_event payload: {
315
+ product: result,
316
+ status: 200,
317
+ }
364
318
  end
319
+ rescue => e
320
+ create_event payload: {
321
+ status: 500,
322
+ scope: 'upsert_products',
323
+ message: e.message,
324
+ trace: e.backtrace.join('\n'),
325
+ product_data: product_data,
326
+ }
327
+ end
328
+ end
365
329
 
366
- private
367
-
368
- def initialize_clients
369
- @variant = initialize_client(:Variant)
370
- @product_variant = initialize_client(:ProductVariant)
371
- @product_option = initialize_client(:ProductOption)
372
- @product_option_value = initialize_client(:ProductOptionValue)
373
- @product = initialize_client(:Product)
374
- @custom_field = initialize_client(:CustomField)
375
- @meta_field = initialize_client(:MetaField)
376
- @modifier = initialize_client(:Modifier)
377
- @modifier_value = initialize_client(:ModifierValue)
378
- end
330
+ # Map the raw_product record to bc_product fields. The bc_product passed in may be null
331
+ # if the product does not exist yet in BigCommerce.
332
+ def map_product(raw_product, bc_product, additional_data)
333
+ bc_payload = nil
379
334
 
380
- def initialize_client(class_name)
381
- klass = ::BigcommerceProductAgent::Client.const_get(class_name.to_sym)
382
- return klass.new(
383
- interpolated['store_hash'],
384
- interpolated['client_id'],
385
- interpolated['access_token']
386
- )
387
- end
335
+ begin
336
+ track_inventory = boolify(options['track_inventory']).nil? ? true : boolify(options['track_inventory'])
337
+ bc_payload = get_mapper(:ProductMapper).map_payload(raw_product, additional_data, track_inventory)
338
+ bc_payload['id'] = bc_product['id'] unless bc_product.nil? || bc_product['id'].nil?
339
+ # NOTE: bc_product will be nil when this is called with `to_create` products
388
340
 
389
- def get_mapper(class_name)
390
- return ::BigcommerceProductAgent::Mapper.const_get(class_name.to_sym)
341
+ if bc_payload[:type] == 'digital'
342
+ bc_payload[:name].concat(' (Digital)')
391
343
  end
392
344
 
393
- def upsert_product(sku, product, bc_product = nil, is_digital=false, search_skus=[])
394
- custom_fields_updates = get_mapper(:CustomFieldMapper).map(
395
- interpolated['custom_fields_map'],
396
- product,
397
- bc_product
398
- )
399
-
400
- product_id = bc_product['id'] unless bc_product.nil?
401
-
402
- payload = get_mapper(:ProductMapper).payload(
403
- sku,
404
- product,
405
- product_id,
406
- {
407
- additional_search_terms: search_skus,
408
- custom_fields: custom_fields_updates[:upsert]
409
- },
410
- is_digital,
411
- )
412
-
413
- payload[:is_visible] = false
414
- # NOTE: Products are intentionally upserted as disabled so that users
415
- # don't see an incomplete listing (particularly when leveraging variants)
416
- # The variant and option_list methods will re-enable products after the
417
- # upsert is complete.
418
-
419
- bc_product = @product.upsert(payload, {
420
- include: %w[custom_fields variants options].join(',')
421
- })
422
-
423
- # Metafields need to be managed separately. Intentionally get them _AFTER_
424
- # the upsert so that we have the necessary resource_id (bc_product.id)
425
- meta_fields_updates = get_mapper(:MetaFieldMapper).map(
426
- interpolated['meta_fields_map'],
427
- product,
428
- bc_product,
429
- @meta_field.get_for_product(bc_product['id']),
430
- interpolated['meta_fields_namespace']
431
- )
432
-
433
- {
434
- product: bc_product,
435
- custom_fields_delete: custom_fields_updates[:delete],
436
- meta_fields_upsert: meta_fields_updates[:upsert],
437
- meta_fields_delete: meta_fields_updates[:delete]
438
- }
345
+ # BigCommerce requires that product names be unique. In some cases, (like book titles from multiple sources),
346
+ # this may be hard to enforce. In those cases, the product SKUs should still be unique, so we append the SKU
347
+ # to the product title with a `|~` separator. We then set the `page_title` to the original product name so
348
+ # users don't see system values.
349
+ #
350
+ # page_title is the user-facing display value for product pages.
351
+ if boolify(options['should_disambiguate'])
352
+ bc_payload[:page_title] = bc_payload[:name]
353
+ bc_payload[:name] = bc_payload[:name] + " |~ " + raw_product['sku']
439
354
  end
440
355
 
441
- def clean_up_custom_fields(custom_fields)
442
- custom_fields.each do |field|
443
- @custom_field.delete(field['product_id'], field['id'])
356
+ return bc_payload
357
+ rescue => e
358
+ create_event payload: {
359
+ status: 500,
360
+ scope: 'map_product',
361
+ message: e.message(),
362
+ trace: e.backtrace.join('\n'),
363
+ product_payload: bc_payload,
364
+ }
365
+ end
366
+
367
+ return nil
368
+ end
369
+
370
+ # Maps custom field values from the raw_product to the bc_payload
371
+ # NOTE: Because custom fields can be included in product upsert requests,
372
+ # this function is only _mapping_ the data.
373
+ def map_custom_fields(raw_product, bc_payload)
374
+ current_fields = bc_payload.nil? ? [] : bc_payload['custom_fields']
375
+
376
+ begin
377
+ return get_mapper(:CustomFieldMapper).map(options['custom_fields_map'], raw_product, bc_payload, current_fields, options['meta_fields_namespace'])
378
+ rescue => e
379
+ create_event payload: {
380
+ status: 500,
381
+ scope: 'map_custom_fields',
382
+ message: e.message(),
383
+ trace: e.backtrace.join('\n'),
384
+ field_map: options['custom_fields_map'],
385
+ raw_product: raw_product,
386
+ product_payload: bc_payload,
387
+ }
388
+
389
+ return nil
390
+ end
391
+ end
392
+
393
+ # Manages meta field values for the provided product records.
394
+ # NOTE: Because meta fields have to be managed separately, this function will
395
+ # map the raw_product data and also handle any delete/create/update requests.
396
+ def update_meta_fields(raw_product, bc_payload)
397
+ current_fields = nil
398
+
399
+ begin
400
+ current_fields = @meta_field_client.get_for_product(bc_payload['id'])
401
+ rescue => e
402
+ create_event payload: {
403
+ status: 500,
404
+ scope: 'update_meta_fields',
405
+ message: "Failed to lookup existing meta fields: #{e.message()}",
406
+ trace: e.backtrace.join('\n'),
407
+ product_id: bc_payload['id'],
408
+ }
409
+
410
+ return nil
411
+ end
412
+
413
+ begin
414
+ fields = get_mapper(:MetaFieldMapper).map(options['meta_fields_map'], raw_product, bc_payload, current_fields, options['meta_fields_namespace'])
415
+
416
+ # Delete fields
417
+ fields[:delete].each do |field|
418
+ begin
419
+ @meta_field_client.delete(bc_payload['id'], field['id'])
420
+ rescue => e
421
+ create_event payload: {
422
+ status: 500,
423
+ scope: 'update_meta_fields',
424
+ message: "Failed to delete meta field: #{e.message()}",
425
+ trace: e.backtrace.join('\n'),
426
+ product_id: bc_payload['id'],
427
+ field: field,
428
+ }
444
429
  end
445
430
  end
446
431
 
447
- def clean_up_modifier_values(modifier_values)
448
- modifier_values.each do |field|
449
- @modifier_value.delete(field[:product_id], field[:modifier_id], field[:value_id])
450
- end
432
+ # Upsert fields
433
+ fields[:upsert].each do |field|
434
+ begin
435
+ @meta_field_client.upsert(bc_payload['id'], field)
436
+ rescue => e
437
+ create_event payload: {
438
+ status: 500,
439
+ scope: 'update_meta_fields',
440
+ message: "Failed to update meta field: #{e.message()}",
441
+ trace: e.backtrace.join('\n'),
442
+ product_id: bc_payload['id'],
443
+ field: field,
444
+ }
445
+ end
451
446
  end
452
447
 
453
- def update_meta_fields(upsert_fields, delete_fields)
454
- meta_fields = []
448
+ return fields
449
+ rescue => e
450
+ create_event payload: {
451
+ status: 500,
452
+ scope: 'update_meta_fields',
453
+ message: "Failed to map meta field data: #{e.message()}",
454
+ trace: e.backtrace.join('\n'),
455
+ field_map: options['meta_fields_map'],
456
+ raw_product: raw_product,
457
+ bc_payload: bc_payload,
458
+ current_fields: current_fields,
459
+ }
460
+
461
+ return nil
462
+ end
463
+ end
455
464
 
456
- upsert_fields.each do |field|
457
- meta_fields << @meta_field.upsert(field)
458
- end
465
+ private
459
466
 
460
- delete_fields.each do |field|
461
- if field[:resource_id] && field[:id]
462
- @meta_field.delete(field[:resource_id], field[:id])
463
- end
464
- end
467
+ def initialize_clients
468
+ @product_client = initialize_client(:Product)
469
+ @custom_field_client = initialize_client(:CustomField)
470
+ @meta_field_client = initialize_client(:MetaField)
471
+ end
465
472
 
466
- meta_fields
467
- end
473
+ def initialize_client(class_name)
474
+ klass = ::BigcommerceProductAgent::Client.const_get(class_name.to_sym)
475
+ return klass.new(
476
+ interpolated['store_hash'],
477
+ interpolated['client_id'],
478
+ interpolated['access_token']
479
+ )
480
+ end
481
+
482
+ def get_mapper(class_name)
483
+ return ::BigcommerceProductAgent::Mapper.const_get(class_name.to_sym)
468
484
  end
485
+ end
469
486
  end