huginn_bigcommerce_product_agent 1.10.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: e4b3e2536acd4ff18f83977f51c923c42e1a3eb53c3795d4f97ac08794c25682
4
- data.tar.gz: a58f917ba53ae85c77b9b27f3e7874d5f0aec59239c16e2d6ccdc37b1d30b7b0
3
+ metadata.gz: b2d3a13bdac789b3b1714c68e8794c30f0f15390c0161dd8a473d36f097f4422
4
+ data.tar.gz: ecc6fe4feb48b5bf7d8528951fd0bcccd41b095f9b1070f73e4d41b5b31908cd
5
5
  SHA512:
6
- metadata.gz: 3cb5d5996d73be6a66c06ade306ee2a6e79a0f95169aeca83df39df6db17c855cce8c6620541999d256a6d164c239d83b72515921ca3897534849c2ed39d2e74
7
- data.tar.gz: 4f9576be2448f833da6c077865538769e8a2553d3abcfa1fd0c6d9bdb2f887b7ae464c0577d0d0f2c6a852ad880284bc3431b5ab89c2839db9a091a88ab20693
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
17
+
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
14
23
 
15
- return response.body['data']
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,36 +37,40 @@ 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
64
+ return products
65
+ end
54
66
 
55
- map
67
+ def disable(productId)
68
+ upsert({ id: productId, is_visible: false })
56
69
  end
57
70
 
71
+ def enable(productId)
72
+ upsert({ id: productId, is_visible: true })
73
+ end
58
74
  end
59
75
  end
60
76
  end
@@ -3,434 +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 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
- }
262
+ bc_payload = map_product(raw_product, bc_product, additional_data)
128
263
 
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
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
142
271
 
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
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 = {}
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
+ product_data = mapped_products.map do |p|
279
+ bc_payload = p[:bc_payload]
280
+ raw_product = p[:raw_product]
241
281
 
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
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],
248
286
 
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
287
+ }
269
288
 
270
- # ##############################
271
- # emit events
272
- # ##############################
273
- if bc_physical
274
- create_event payload: {
275
- product: bc_physical
276
- }
277
- 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
+ }
278
295
 
279
- if bc_digital
280
- create_event payload: {
281
- product: bc_digital
282
- }
283
- 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)
284
302
  end
285
303
 
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
304
+ # return the finalized payload
305
+ bc_payload
306
+ end
308
307
 
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
- }
339
- 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
340
313
 
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)
314
+ create_event payload: {
315
+ product: result,
316
+ status: 200,
317
+ }
353
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
354
329
 
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
- )
362
- 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
363
334
 
364
- def get_mapper(class_name)
365
- return ::BigcommerceProductAgent::Mapper.const_get(class_name.to_sym)
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
340
+
341
+ if bc_payload[:type] == 'digital'
342
+ bc_payload[:name].concat(' (Digital)')
366
343
  end
367
344
 
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
- }
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']
408
354
  end
409
355
 
410
- def clean_up_custom_fields(custom_fields)
411
- custom_fields.each do |field|
412
- @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
+ }
413
429
  end
414
430
  end
415
431
 
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
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
420
446
  end
421
447
 
422
- def update_meta_fields(upsert_fields, delete_fields)
423
- 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
424
464
 
425
- upsert_fields.each do |field|
426
- meta_fields << @meta_field.upsert(field)
427
- end
465
+ private
428
466
 
429
- delete_fields.each do |field|
430
- @meta_field.delete(field[:resource_id], field[:id])
431
- 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
432
472
 
433
- meta_fields
434
- 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)
435
484
  end
485
+ end
436
486
  end