huginn_bigcommerce_product_agent 1.10.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,40 @@
1
+ module BigcommerceProductAgent
2
+ module Client
3
+ class Variant < AbstractClient
4
+ @uri_base = 'catalog/variants'
5
+
6
+ def update(payload)
7
+ raise "this endpoint only has upsert available"
8
+ end
9
+
10
+ def create(payload)
11
+ raise "this endpoint only has upsert available"
12
+ end
13
+
14
+ def upsert(payload)
15
+ begin
16
+ response = client.put(uri, payload.to_json)
17
+ return response.body['data']
18
+ rescue Faraday::Error::ClientError => e
19
+ raise e, "\n#{e.message}\nFailed to upsert variant with payload = #{payload.to_json}\n", e.backtrace
20
+ end
21
+ end
22
+
23
+ def get_by_skus(skus, include = %w[custom_fields modifiers])
24
+ variants = index({
25
+ 'sku:in': skus.join(','),
26
+ include: include.join(','),
27
+ })
28
+
29
+ map = {}
30
+
31
+ variants.each do |variant|
32
+ map[variant['sku']] = variant
33
+ end
34
+
35
+ map
36
+ end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ require 'huginn_agent'
2
+ require 'json'
3
+
4
+ # load clients
5
+ HuginnAgent.load 'abstract_client'
6
+ Dir[File.join(__dir__, 'client', '*.rb')].each do |file|
7
+ HuginnAgent.load file
8
+ end
9
+
10
+ # load mappers
11
+ Dir[File.join(__dir__, 'mapper', '*.rb')].each do |file|
12
+ HuginnAgent.load file
13
+ end
14
+
15
+ HuginnAgent.register 'huginn_bigcommerce_product_agent/bigcommerce_product_agent'
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Agents
6
+ class BigcommerceProductAgent < Agent
7
+ include WebRequestConcern
8
+
9
+ can_dry_run!
10
+ default_schedule 'never'
11
+
12
+ description <<-MD
13
+ Takes a generic product interface && upserts that product in BigCommerce.
14
+ MD
15
+
16
+ def modes
17
+ %w[
18
+ variants
19
+ option_list
20
+ ]
21
+ end
22
+
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
+ }
35
+ end
36
+
37
+ def validate_options
38
+ unless options['store_hash'].present?
39
+ errors.add(:base, 'store_hash is a required field')
40
+ end
41
+
42
+ unless options['client_id'].present?
43
+ errors.add(:base, 'client_id is a required field')
44
+ end
45
+
46
+ unless options['access_token'].present?
47
+ errors.add(:base, 'access_token is a required field')
48
+ end
49
+
50
+ unless options['custom_fields_map'].is_a?(Hash)
51
+ errors.add(:base, 'if provided, custom_fields_map must be a hash')
52
+ end
53
+
54
+ unless options['meta_fields_map'].is_a?(Hash)
55
+ errors.add(:base, 'if provided, meta_fields_map must be a hash')
56
+ end
57
+
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
63
+
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
67
+
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
71
+
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
75
+
76
+ end
77
+
78
+ def working?
79
+ received_event_without_error?
80
+ end
81
+
82
+ def check
83
+ initialize_clients
84
+ handle interpolated['payload'].presence || {}
85
+ end
86
+
87
+ def receive(incoming_events)
88
+ initialize_clients
89
+ incoming_events.each do |event|
90
+ handle(event)
91
+ end
92
+ end
93
+
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
101
+ end
102
+
103
+ # 1. upsert product
104
+ # 2. upsert option & option_values
105
+ # 3. delete old option_values
106
+ # - NOTE: deleting an option_value also deletes the variant
107
+ # associated with the option_value
108
+ # 4. upsert variants
109
+ # - NOTE: because deleting option values deletes variants
110
+ # we need to fetch the variants AFTER deletion has occurred.
111
+ # - NOTE: by deleting variants in #3 if option_values on an
112
+ # existing variant changes over time, we're effectively deleting
113
+ # and then re-adding the variant. Could get weird.
114
+ def handle_variants(event)
115
+ product = event.payload
116
+
117
+ split = get_mapper(:ProductMapper).split_digital_and_physical(
118
+ product,
119
+ interpolated['custom_fields_map']
120
+ )
121
+ physical = split[:physical]
122
+ digital = split[:digital]
123
+
124
+ wrapper_skus = {
125
+ physical: get_mapper(:ProductMapper).get_wrapper_sku(physical),
126
+ digital: get_mapper(:ProductMapper).get_wrapper_sku(digital),
127
+ }
128
+
129
+ bc_products = @product.get_by_skus(
130
+ wrapper_skus.map {|k,v| v},
131
+ %w[custom_fields options]
132
+ )
133
+ #save skus
134
+ digital_skus = []
135
+ physical_skus = []
136
+ if split[:digital] and split[:physical]
137
+ digital_skus.concat([wrapper_skus[:digital]])
138
+ digital_skus.concat(get_mapper(:ProductMapper).get_product_skus(split[:digital])).join(",")
139
+ physical_skus.concat([wrapper_skus[:physical]])
140
+ physical_skus.concat(get_mapper(:ProductMapper).get_product_skus(split[:physical])).join(",")
141
+ end
142
+
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
226
+
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
241
+
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
248
+
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
269
+
270
+ # ##############################
271
+ # emit events
272
+ # ##############################
273
+ if bc_physical
274
+ create_event payload: {
275
+ product: bc_physical
276
+ }
277
+ end
278
+
279
+ if bc_digital
280
+ create_event payload: {
281
+ product: bc_digital
282
+ }
283
+ end
284
+ end
285
+
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
308
+
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
340
+
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)
353
+ end
354
+
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
363
+
364
+ def get_mapper(class_name)
365
+ return ::BigcommerceProductAgent::Mapper.const_get(class_name.to_sym)
366
+ end
367
+
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
+ }
408
+ end
409
+
410
+ def clean_up_custom_fields(custom_fields)
411
+ custom_fields.each do |field|
412
+ @custom_field.delete(field['product_id'], field['id'])
413
+ end
414
+ end
415
+
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
420
+ end
421
+
422
+ def update_meta_fields(upsert_fields, delete_fields)
423
+ meta_fields = []
424
+
425
+ upsert_fields.each do |field|
426
+ meta_fields << @meta_field.upsert(field)
427
+ end
428
+
429
+ delete_fields.each do |field|
430
+ if field[:resource_id] && field[:id]
431
+ @meta_field.delete(field[:resource_id], field[:id])
432
+ end
433
+ end
434
+
435
+ meta_fields
436
+ end
437
+ end
438
+ end