huginn_bigcommerce_product_agent 1.12.0 → 2.0.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.
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