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 +4 -4
- data/lib/client/custom_field.rb +21 -2
- data/lib/client/meta_field.rb +11 -8
- data/lib/client/product.rb +22 -13
- data/lib/huginn_bigcommerce_product_agent/bigcommerce_product_agent.rb +428 -411
- data/lib/mapper/custom_field_mapper.rb +32 -24
- data/lib/mapper/meta_field_mapper.rb +10 -10
- data/lib/mapper/product_mapper.rb +38 -184
- metadata +2 -9
- data/lib/client/modifier.rb +0 -35
- data/lib/client/modifier_value.rb +0 -11
- data/lib/client/product_option.rb +0 -45
- data/lib/client/product_option_value.rb +0 -72
- data/lib/client/product_variant.rb +0 -40
- data/lib/client/variant.rb +0 -40
- data/lib/mapper/modifier_mapper.rb +0 -83
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b2d3a13bdac789b3b1714c68e8794c30f0f15390c0161dd8a473d36f097f4422
|
4
|
+
data.tar.gz: ecc6fe4feb48b5bf7d8528951fd0bcccd41b095f9b1070f73e4d41b5b31908cd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db7b3003393f5b1c4e741a6be04b3bfb8bfd7be7aaf0cd006d7dd03112da997edb0efac33f2d5e41d5bc1f2d128a67c6be5c68b76b9be9b20bad4e7fbc397680
|
7
|
+
data.tar.gz: 1d18a0e667d3216a194816ded4f4c523dffe4eeda9d7a84186285f6a6e53ea4d14f2f8e28b2333b5de65b517e5e1bb2149c77cd7a53f659f8a65e352b266257a
|
data/lib/client/custom_field.rb
CHANGED
@@ -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(
|
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(
|
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)
|
data/lib/client/meta_field.rb
CHANGED
@@ -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:
|
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:
|
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[
|
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
|
data/lib/client/product.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
37
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
7
|
-
|
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
|
-
|
10
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
37
|
+
unless options['client_id'].present?
|
38
|
+
errors.add(:base, 'client_id is a required field')
|
39
|
+
end
|
15
40
|
|
16
|
-
|
17
|
-
|
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
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
73
|
+
def working?
|
74
|
+
received_event_without_error?
|
75
|
+
end
|
67
76
|
|
68
|
-
|
69
|
-
|
70
|
-
|
77
|
+
def check
|
78
|
+
initialize_clients
|
79
|
+
handle interpolated['payload'].presence || {}
|
80
|
+
end
|
71
81
|
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
79
|
-
|
80
|
-
|
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
|
-
|
83
|
-
|
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
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
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
|
-
|
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
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
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
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
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
|
-
|
248
|
-
|
249
|
-
|
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
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
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
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
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
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
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
|
-
|
307
|
-
|
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
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
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
|
-
|
358
|
-
product
|
359
|
-
|
360
|
-
|
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
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
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
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
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
|
-
|
390
|
-
|
341
|
+
if bc_payload[:type] == 'digital'
|
342
|
+
bc_payload[:name].concat(' (Digital)')
|
391
343
|
end
|
392
344
|
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
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
|
-
|
442
|
-
|
443
|
-
|
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
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
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
|
-
|
454
|
-
|
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
|
-
|
457
|
-
meta_fields << @meta_field.upsert(field)
|
458
|
-
end
|
465
|
+
private
|
459
466
|
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
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
|
-
|
467
|
-
|
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
|