huginn_acumen_product_agent 2.0.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c918a4763fbc8b84145f83215cf46d9b162adeb32d025d206d7f7fa51ded41
4
- data.tar.gz: 77b5d58d8de8f15e3908b920b04882fb0706974009c9fa1ea1fe93bb66b7f1d6
3
+ metadata.gz: 8324cf31340926d8867293c1431e596dc0da9f328f317e31924a27a9cbac3f6f
4
+ data.tar.gz: 2c486a789956c1b6e67a70162c63c6b68c55a4a4fed8f3c2831f5d78c8084c42
5
5
  SHA512:
6
- metadata.gz: e2fe59ce492e910b0ca2c063938141a9f0ef6b64639fff28f34c3e01d577e5388d6007e1a135a51f01579677e60f2ee0e79e3aa2a449bfffba0e44242258953a
7
- data.tar.gz: 92563dd015ec11f237a6a91d740d3ff682ddcb73e6598f1768c3dfa51c2c4e75cb8355c3ee7228a5f63a0f1470a483d21adb5bd324283f4efa45b7c77ccada50
6
+ metadata.gz: 8906fd96d8b41256e97b3810ebeb57c7d8e692a051f71f11ed8b9d6219371a83071b5fe72d57c2b8008910e1d89ac65ae2ef65b8ff366e444dbafc27407461e8
7
+ data.tar.gz: 89fa26696143748c0496c7d31a3c4f4d2466ecf0ebe873b57f838a0f8f92c20a6dbaf70a2e70f391b4c467581693b728ebb49a4d6694404793147c4141f17c45
@@ -1,5 +1,6 @@
1
1
  class AcumenAgentError < StandardError
2
- attr_reader :thing
2
+ attr_reader :scope, :data, :original_error
3
+
3
4
  def initialize(scope, message, data, original_error)
4
5
  @scope = scope
5
6
  @data = data
@@ -19,6 +19,12 @@ class AcumenClient
19
19
  get_results(response, 'ProdMkt')
20
20
  end
21
21
 
22
+ def get_inv_status(skus)
23
+ body = build_inv_status_query(skus)
24
+ response = execute_in_list_query(body, {})
25
+ get_results(response, 'Inv_Status')
26
+ end
27
+
22
28
  def get_linked_products(ids)
23
29
  body = build_linked_product_query(ids)
24
30
  response = execute_in_list_query(body, {})
@@ -97,6 +103,10 @@ class AcumenClient
97
103
  <column_name>Inv_Product.BO_Reason</column_name>
98
104
  <column_name>Inv_Product.Not_On_Website</column_name>
99
105
  <column_name>Inv_Product.Not_Active</column_name>
106
+ <column_name>Inv_Product.Disable_Web_Purchase</column_name>
107
+ <column_name>Inv_Product.No_Backorder_Fill</column_name>
108
+ <column_name>Inv_Product.Non_Inventory</column_name>
109
+ <column_name>Inv_Product.Assembly</column_name>
100
110
  </requested_output>
101
111
  </acusoapRequest>
102
112
  XML
@@ -151,6 +161,28 @@ class AcumenClient
151
161
  XML
152
162
  end
153
163
 
164
+ def build_inv_status_query(skus)
165
+ <<~XML
166
+ <acusoapRequest>
167
+ #{build_acumen_query_auth()}
168
+ <query>
169
+ <statement>
170
+ <column_name>Inv_Status.ProdCode</column_name>
171
+ <comparator>in</comparator>
172
+ <value>#{skus.join(',')}</value>
173
+ </statement>
174
+ </query>
175
+ <requested_output>
176
+ <view_owner_table_name>Inv_Status</view_owner_table_name>
177
+ <view_name>Inv_StatusAllRead</view_name>
178
+ <column_name>Inv_Status.Warehouse</column_name>
179
+ <column_name>Inv_Status.ProdCode</column_name>
180
+ <column_name>Inv_Status.Available</column_name>
181
+ </requested_output>
182
+ </acusoapRequest>
183
+ XML
184
+ end
185
+
154
186
  def build_product_ids_since_request(since)
155
187
  <<~XML
156
188
  <acusoapRequest>
@@ -207,6 +239,7 @@ class AcumenClient
207
239
  <column_name>Product_Link.Link_From_ID</column_name>
208
240
  <column_name>Product_Link.Link_To_ID</column_name>
209
241
  <column_name>Product_Link.Alt_Format</column_name>
242
+ <column_name>Product_Link.Inactive</column_name>
210
243
  </requested_output>
211
244
  </acusoapRequest>
212
245
  XML
@@ -184,34 +184,46 @@ module Agents
184
184
  def fetch_products(acumen_client, product_ids, digital_format_list)
185
185
  products = fetch_inv_product_data(acumen_client, product_ids, digital_format_list)
186
186
  products = fetch_product_marketing(acumen_client, products)
187
+ products = fetch_inv_status(acumen_client, products)
187
188
  products = fetch_product_contributors(acumen_client, products)
188
189
  products = fetch_product_categories(acumen_client, products)
189
190
 
190
191
  products.each do |product|
191
192
  map_attributes(product)
193
+ update_availability(product)
192
194
  end
193
195
 
194
196
  return products
195
197
  end
196
198
 
197
- # Returns an array of product bundles for the provided `products` array.
198
- # Each bundle will contain an array of all the product definitions for each
199
- # format of a given title.
199
+ # Loads product bundles for the provided `product_ids` array and emits
200
+ # a unique event payload for each bundle. Emitted events will contain an
201
+ # array of all the product definitions for each format of a given title.
200
202
  #
201
203
  # NOTE: The generated bundles will contain both active and inactive products
202
204
  # to facilitate product deletion in external systems.
203
205
  def fetch_product_bundles(acumen_client, product_ids, digital_format_list, ignored_skus)
204
206
 
205
207
  begin
206
- alternate_ids_map = fetch_alternate_format_ids(acumen_client, product_ids)
208
+ data = fetch_alternate_format_ids(acumen_client, product_ids)
209
+ full_id_set = data[:id_set]
210
+ alternate_ids_map = data[:alternate_ids_map]
211
+ product_data = fetch_products(acumen_client, full_id_set, digital_format_list)
207
212
 
208
213
  bundles = product_ids.map do |id|
209
214
  bundle_ids = alternate_ids_map[id]
210
215
  bundle_ids.append(id) unless bundle_ids.include?(id)
211
- bundle = fetch_products(acumen_client, bundle_ids.sort, digital_format_list)
212
-
213
- # Filter out any products that are explicitly ignored by SKU
214
- bundle.select { |p| !ignored_skus.include?(p['sku']) }
216
+ bundle_ids.sort()
217
+
218
+ bundle = []
219
+ bundle_ids.each() do |b_id|
220
+ # Filter out any products that are explicitly ignored by SKU
221
+ product = product_data.find { |p| p['identifier'] == b_id.to_s }
222
+ bundle << product unless product.nil? || ignored_skus.include?(product['sku'])
223
+ # NOTE: The product.nil? check is designed to handle cases where a product link
224
+ # points to a non existent product. Conventionally this shouldn't happen, but
225
+ # we've seen it, and need to account for it.
226
+ end
215
227
 
216
228
  create_event payload: { products: bundle, status: 200 }
217
229
  end
@@ -242,5 +254,23 @@ module Agents
242
254
  end
243
255
  end
244
256
  end
257
+
258
+ def update_availability(product)
259
+ stock_quantity = product['acumenAttributes']['stock_quantity']
260
+ publication_date = product['datePublished']
261
+ no_backorder_fill = product['noBackorderFill']
262
+ stock_quantity = stock_quantity.present? ? stock_quantity.to_i : 0
263
+
264
+ if (!product['isDigital'] && product['productAvailability'] == 'available' && product['trackInventory'])
265
+ if ((publication_date && publication_date.to_datetime > DateTime.current().end_of_day) || (!no_backorder_fill && stock_quantity < 1))
266
+ product['productAvailability'] = 'preorder'
267
+ end
268
+
269
+ if (no_backorder_fill && stock_quantity < 1)
270
+ product['productAvailability'] = 'not available'
271
+ end
272
+ end
273
+ end
274
+
245
275
  end
246
276
  end
@@ -50,9 +50,7 @@ module AcumenQueryConcern
50
50
  status: status,
51
51
  scope: error.scope,
52
52
  message: error.message,
53
- original_error: error.original_error,
54
53
  data: error.data,
55
- trace: error.original_error.backtrace,
56
54
  }
57
55
  end
58
56
  end
@@ -12,14 +12,25 @@
12
12
  module AlternateProductsQueryConcern
13
13
  extend AcumenQueryConcern
14
14
 
15
- # Fetches the Inv_Product.ID for alternate formats of the provided product_ids
15
+ # This function returns two data elements in a hash object.
16
+ # The `id_set` is the full collection of IDs to be fetched including the input
17
+ # product_ids and all of their alternate format ids. The goal of this value
18
+ # is to reduce the resource requirements of running each "bundle" individually
19
+ #
20
+ # The alternate_ids_map contains arrays of product IDs mapped to their master
21
+ # product id. This map will be used to assemble fetched product data into bundles
16
22
  def fetch_alternate_format_ids(acumen_client, product_ids)
17
23
  begin
18
24
  link_data = acumen_client.get_linked_products(product_ids)
19
25
 
20
26
  links = process_alternate_format_response(link_data)
21
27
 
22
- return map_alternate_format_links(links, product_ids)
28
+ mapped_ids = map_alternate_format_links(links, product_ids)
29
+
30
+ id_set = [] + product_ids
31
+ mapped_ids.each_value { |bundle| id_set += bundle }
32
+
33
+ return {id_set: id_set, alternate_ids_map: mapped_ids }
23
34
  rescue => error
24
35
  issue_error(AcumenAgentError.new(
25
36
  'fetch_alternate_format_ids',
@@ -42,9 +53,10 @@ module AlternateProductsQueryConcern
42
53
  'Product_Link.Link_From_ID' => 'from_id',
43
54
  'Product_Link.Link_To_ID' => 'to_id',
44
55
  'Product_Link.Alt_Format' => 'alt_format',
56
+ 'Product_Link.Inactive' => 'inactive',
45
57
  })
46
58
 
47
- if mapped['alt_format'].to_s != '0' && !mapped.in?(results)
59
+ if mapped['inactive'] == '0' && mapped['alt_format'].to_s != '0' && !mapped.in?(results)
48
60
  results.push(mapped)
49
61
  end
50
62
 
@@ -52,7 +64,7 @@ module AlternateProductsQueryConcern
52
64
  issue_error(AcumenAgentError.new(
53
65
  'process_alternate_format_response',
54
66
  'Failed while processing alternate format links',
55
- raw_data,
67
+ { product_id: get_field_value(link, 'Product_Link.Link_From_ID') },
56
68
  error
57
69
  ))
58
70
  end
@@ -33,9 +33,20 @@ module InvProductQueryConcern
33
33
  'Inv_Product.Next_Release' => 'releaseDate',
34
34
  })
35
35
 
36
+ # Nullify blank dates
37
+ if product['datePublished'] == '0000-00-00T00:00:00'
38
+ product['datePublished'] = nil
39
+ end
40
+
41
+ if product['releaseDate'] == '0000-00-00T00:00:00'
42
+ product['releaseDate'] = nil
43
+ end
44
+
36
45
  product['@type'] = 'Product'
37
46
  product['isTaxable'] = get_field_value(p, 'Inv_Product.Taxable') == '1'
38
- product['isAvailableForPurchase'] = get_field_value(p, 'Inv_Product.Not_On_Website') == '0'
47
+ product['productAvailability'] = get_field_value(p, 'Inv_Product.Not_On_Website') == '0'
48
+ product['noBackorderFill'] = get_field_value(p, 'Inv_Product.No_Backorder_Fill') == '1'
49
+ product['trackInventory'] = !(get_field_value(p, 'Inv_Product.Non_Inventory') == '1' || get_field_value(p, 'Inv_Product.Assembly') == '1')
39
50
  product['acumenAttributes'] = {
40
51
  'info_alpha_1' => get_field_value(p, 'Inv_Product.Info_Alpha_1'),
41
52
  'info_boolean_1' => get_field_value(p, 'Inv_Product.Info_Boolean_1'), # is_available_on_formed
@@ -45,6 +56,12 @@ module InvProductQueryConcern
45
56
  '@type' => 'PropertyValue',
46
57
  'propertyID' => 'is_master',
47
58
  'value' => get_field_value(p, 'Inv_Product.OnWeb_LinkOnly') == '0',
59
+ },
60
+ {
61
+ # NOTE: This is different than isAvailableForPurchase. This
62
+ '@type' => 'PropertyValue',
63
+ 'propertyID' => 'disable_web_purchase',
64
+ 'value' => get_field_value(p, 'Inv_Product.Disable_Web_Purchase'),
48
65
  }
49
66
  ]
50
67
 
@@ -62,6 +79,22 @@ module InvProductQueryConcern
62
79
  })
63
80
  end
64
81
 
82
+ not_on_website = get_field_value(p, 'Inv_Product.Not_On_Website')
83
+ disable_web_purchase = get_field_value(p, 'Inv_Product.Disable_Web_Purchase')
84
+
85
+ product_availability = 'available'
86
+
87
+
88
+ if (disable_web_purchase == '1')
89
+ product_availability = 'disabled'
90
+ end
91
+
92
+ if (not_on_website == '1')
93
+ product_availability = 'not available'
94
+ end
95
+
96
+ product['productAvailability'] = product_availability
97
+
65
98
  weight = get_field_value(p, 'Inv_Product.Weight')
66
99
  product['weight'] = get_quantitative_value(weight, 'oz.')
67
100
 
@@ -97,7 +130,10 @@ module InvProductQueryConcern
97
130
  issue_error(AcumenAgentError.new(
98
131
  'process_inv_product_response',
99
132
  'Failed to load Inventory Product Records',
100
- { product_ids: product_ids },
133
+ {
134
+ product_id: get_field_value(p, 'Inv_Product.ID'),
135
+ sku: get_field_value(p, 'Inv_Product.ProdCode')
136
+ },
101
137
  error,
102
138
  ))
103
139
 
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is responsible for reading/processing the Inv_Status table. This table
4
+ # contains stock information for the product and is tied to product availability.
5
+ module ProdMktQueryConcern
6
+ extend AcumenQueryConcern
7
+
8
+ # Update the provided products with their associated marketing data
9
+ # NOTE: The `products` here are Shema.org/Product records mapped from Inv_Product
10
+ # data
11
+ def fetch_inv_status(acumen_client, products)
12
+
13
+ product_skus = products.map { |p| p['sku'] }
14
+ inventory_data = acumen_client.get_inv_status(product_skus)
15
+ inventory_data = process_inv_status_response(inventory_data)
16
+
17
+ return map_inv_status_data(products, inventory_data)
18
+ end
19
+
20
+ # This function parses the raw data returned from the Prod_Mkt table
21
+ def process_inv_status_response(raw_data)
22
+ results = []
23
+ raw_data.map do |inv_status|
24
+
25
+ begin
26
+ mapped = response_mapper(inv_status, {
27
+ 'Inv_Status.Warehouse' => 'warehouse',
28
+ 'Inv_Status.ProdCode' => 'sku',
29
+ 'Inv_Status.Available' => 'quantity',
30
+ })
31
+
32
+ if (mapped['warehouse'] === 'Main Warehouse')
33
+ results << mapped
34
+ end
35
+ rescue => error
36
+ issue_error(AcumenAgentError.new(
37
+ 'process_inv_status_response',
38
+ 'Failed while processing Prod_Mkt record',
39
+ { sku: get_field_value(inv_status, 'Inv_Status.ProdCode') },
40
+ error,
41
+ ))
42
+ end
43
+ end
44
+
45
+ results
46
+ end
47
+
48
+ # This function maps parsed Prod_Mkt records to their matching product record
49
+ # and updates the product object with the additional data
50
+ def map_inv_status_data(products, inventory)
51
+ products.map do |product|
52
+ inventory_data = inventory.select { |i| i['sku'] == product['sku'] }
53
+ begin
54
+
55
+ if inventory_data
56
+ quantity = 0
57
+ inventory_data.each do |i|
58
+ quantity = quantity + i['quantity'].to_i if quantity.present?
59
+ end
60
+
61
+ product['acumenAttributes']['stock_quantity'] = quantity
62
+ end
63
+
64
+ rescue => error
65
+ issue_error(AcumenAgentError.new(
66
+ 'map_inv_status_data',
67
+ 'Failed to map inventory data for product',
68
+ { sku: product['sku'] },
69
+ error,
70
+ ))
71
+ end
72
+
73
+ product
74
+ end
75
+
76
+ return products
77
+ end
78
+ end
@@ -34,7 +34,7 @@ module ProdMktQueryConcern
34
34
  'ProdMkt.Description_Long' => 'description_long',
35
35
  'ProdMkt.Height' => 'height',
36
36
  'ProdMkt.Width' => 'width',
37
- 'ProdMkt.Thickness' => 'depth',
37
+ 'ProdMkt.Thickness' => 'thickness',
38
38
  'ProdMkt.Meta_Keywords' => 'meta_keywords',
39
39
  'ProdMkt.Meta_Description' => 'meta_description',
40
40
  'ProdMkt.Extent_Unit' => 'extent_unit',
@@ -62,7 +62,7 @@ module ProdMktQueryConcern
62
62
  issue_error(AcumenAgentError.new(
63
63
  'process_prod_mkt_response',
64
64
  'Failed while processing Prod_Mkt record',
65
- product_marketing,
65
+ { sku: get_field_value(product_marketing, 'ProdMkt.Product_Code') },
66
66
  error,
67
67
  ))
68
68
  end
@@ -150,7 +150,7 @@ module ProdMktQueryConcern
150
150
  issue_error(AcumenAgentError.new(
151
151
  'map_marketing_data',
152
152
  'Failed to map marketing data for product',
153
- { product: product, marketing: marketing },
153
+ { id: product['identifier'], sku: marketing['sku'] },
154
154
  error,
155
155
  ))
156
156
  end
@@ -41,7 +41,7 @@ module ProductCategoriesQueryConcern
41
41
  results[product_sku] = [mapped] if mapped['inactive'] == '0'
42
42
  end
43
43
  rescue => error
44
- issue_errpr(AcumenAgentError.new(
44
+ issue_error(AcumenAgentError.new(
45
45
  'process_product_category_response',
46
46
  'Failed while processing category data',
47
47
  product_category,
@@ -104,7 +104,7 @@ module ProductContributorsQueryConcern
104
104
  issue_error(AcumenAgentError.new(
105
105
  'map_contributor_data',
106
106
  'Failed while mapping contributor data to products',
107
- { product: product, contributors: contributor_data, contributor_types: contributor_type_data },
107
+ { id: product['identifier'], sku: product['sku'] },
108
108
  error
109
109
  ))
110
110
  end
@@ -3,6 +3,7 @@ require 'huginn_agent'
3
3
  HuginnAgent.load 'huginn_acumen_product_agent/concerns/acumen_query_concern'
4
4
  HuginnAgent.load 'huginn_acumen_product_agent/concerns/alternate_products_query_concern'
5
5
  HuginnAgent.load 'huginn_acumen_product_agent/concerns/inv_product_query_concern'
6
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/inv_status_query_concern'
6
7
  HuginnAgent.load 'huginn_acumen_product_agent/concerns/prod_mkt_query_concern'
7
8
  HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_categories_query_concern'
8
9
  HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_contributors_query_concern'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: huginn_acumen_product_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacob Spizziri
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-01-14 00:00:00.000000000 Z
11
+ date: 2022-01-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -67,9 +67,9 @@ files:
67
67
  - lib/huginn_acumen_product_agent/acumen_client.rb
68
68
  - lib/huginn_acumen_product_agent/acumen_product_agent.rb
69
69
  - lib/huginn_acumen_product_agent/concerns/acumen_query_concern.rb
70
- - lib/huginn_acumen_product_agent/concerns/agent_error_concern.rb
71
70
  - lib/huginn_acumen_product_agent/concerns/alternate_products_query_concern.rb
72
71
  - lib/huginn_acumen_product_agent/concerns/inv_product_query_concern.rb
72
+ - lib/huginn_acumen_product_agent/concerns/inv_status_query_concern.rb
73
73
  - lib/huginn_acumen_product_agent/concerns/prod_mkt_query_concern.rb
74
74
  - lib/huginn_acumen_product_agent/concerns/product_categories_query_concern.rb
75
75
  - lib/huginn_acumen_product_agent/concerns/product_contributors_query_concern.rb
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AgentErrorConcern
4
- extend ActiveSupport::Concern
5
-
6
- def issue_error(error, status = 500)
7
- # NOTE: Status is intentionally included on the top-level payload so that other
8
- # agents can look for a `payload[:status]` of either 200 or 500 to distinguish
9
- # between success and failure states
10
- create_event payload: {
11
- status: status,
12
- scope: error.scope,
13
- message: error.message,
14
- original_error: error.original_error,
15
- data: error.data,
16
- trace: error.original_error.backtrace,
17
- }
18
- end
19
- end