huginn_acumen_product_agent 1.7.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f611c6b9e7567e84ec03753277031b227c70fee3afe17cfbf35be0c185932b4f
4
- data.tar.gz: ecfc5e4acee56876eee9b4c3de78db3f6e40ff1cce60d5a6e834c75dae97523f
3
+ metadata.gz: a0c47e2991b9e5c568b8b7762fbd9286b818401cc8d9f6cbc6b4e9af4d93c3a2
4
+ data.tar.gz: 994118245c433230a1b1ea15043bbb9e7d5f83af241c0c2bfae3362d833acb46
5
5
  SHA512:
6
- metadata.gz: 8ccf91cb1986314c492cbe9b0b189503504705bc6521a8f7861bbef9338bdb2e27ebb2036b5b595c55c33c52f1cb81e4534ca9686b3d2a67cd1fa6dfa2389e6b
7
- data.tar.gz: 63b33acb6744b016c1f08ddf409d6580c2e78e57b46c93ccfe91326b7233b2dc54db09cd5fb52ee6eb49f6178f0439ee0b9518d87633baefcd0e52a5a7af9e10
6
+ metadata.gz: 2349ea217eb714518d1c995703a9167527b8589106c38e13559835d621eadb155a90430d6c63927115d3d062369f52005f57b62f5d18a61c0bd2c501dae47401
7
+ data.tar.gz: 03f03158f7e226fd518c49d04867f1ae00424992f60aa71b31fc9ab9f7a5bd1aff366a49598580e404775f871ff74f57c22fb432449b946e5395e3778b25621a
@@ -1,6 +1,14 @@
1
1
  require 'huginn_agent'
2
2
 
3
- HuginnAgent.load 'huginn_acumen_product_agent/concerns/acumen_product_query_concern'
3
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/acumen_query_concern'
4
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/alternate_products_query_concern'
5
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/inv_product_query_concern'
6
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/inv_status_query_concern'
7
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/prod_mkt_query_concern'
8
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_categories_query_concern'
9
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_contributors_query_concern'
10
+
4
11
  HuginnAgent.load 'huginn_acumen_product_agent/acumen_client'
12
+ HuginnAgent.load 'huginn_acumen_product_agent/acumen_agent_error'
5
13
 
6
14
  HuginnAgent.register 'huginn_acumen_product_agent/acumen_product_agent'
@@ -0,0 +1,10 @@
1
+ class AcumenAgentError < StandardError
2
+ attr_reader :scope, :data, :original_error
3
+
4
+ def initialize(scope, message, data, original_error)
5
+ @scope = scope
6
+ @data = data
7
+ @original_error = original_error
8
+ super(message)
9
+ end
10
+ end
@@ -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,8 @@ 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>
100
108
  </requested_output>
101
109
  </acusoapRequest>
102
110
  XML
@@ -151,6 +159,28 @@ class AcumenClient
151
159
  XML
152
160
  end
153
161
 
162
+ def build_inv_status_query(skus)
163
+ <<~XML
164
+ <acusoapRequest>
165
+ #{build_acumen_query_auth()}
166
+ <query>
167
+ <statement>
168
+ <column_name>Inv_Status.ProdCode</column_name>
169
+ <comparator>in</comparator>
170
+ <value>#{skus.join(',')}</value>
171
+ </statement>
172
+ </query>
173
+ <requested_output>
174
+ <view_owner_table_name>Inv_Status</view_owner_table_name>
175
+ <view_name>Inv_StatusAllRead</view_name>
176
+ <column_name>Inv_Status.Warehouse</column_name>
177
+ <column_name>Inv_Status.ProdCode</column_name>
178
+ <column_name>Inv_Status.Available</column_name>
179
+ </requested_output>
180
+ </acusoapRequest>
181
+ XML
182
+ end
183
+
154
184
  def build_product_ids_since_request(since)
155
185
  <<~XML
156
186
  <acusoapRequest>
@@ -3,7 +3,12 @@
3
3
  module Agents
4
4
  class AcumenProductAgent < Agent
5
5
  include WebRequestConcern
6
- include AcumenProductQueryConcern
6
+ include AcumenQueryConcern
7
+ include InvProductQueryConcern
8
+ include ProdMktQueryConcern
9
+ include ProductContributorsQueryConcern
10
+ include ProductCategoriesQueryConcern
11
+ include AlternateProductsQueryConcern
7
12
 
8
13
  default_schedule '12h'
9
14
 
@@ -11,27 +16,86 @@ module Agents
11
16
  default_schedule 'never'
12
17
 
13
18
  description <<-MD
14
- Huginn agent for retrieving sane ACUMEN product data.
19
+ Huginn agent for retrieving sane ACUMEN product data.
15
20
 
16
- ## Agent Options
17
- The following outlines the available options in this agent
21
+ ## Agent Options
22
+ The following outlines the available options in this agent
18
23
 
19
- ### Acumen Connection
20
- * endpoint: The root URL for the Acumen API
21
- * site_code: The site code from Acumen
22
- * password: The Acumen API password
24
+ ### Acumen Connection
25
+ * endpoint: The root URL for the Acumen API
26
+ * site_code: The site code from Acumen
27
+ * password: The Acumen API password
23
28
 
24
- ### Variant Settings
25
- * physical_formats: A list of the formats associated with a physical product
26
- * digital_formats: A list of the formats associated with a digital product
29
+ ### Format Options
30
+ * digital_formats: A list of the formats associated with a digital product
27
31
 
28
- ### Product Attributes
29
- * attribute_to_property: An optional map linking Acumen attributes to Schema.org
30
- product properties.
32
+ ### Product Attributes
33
+ * attribute_to_property: An optional map linking Acumen attributes to Schema.org
34
+ product properties.
31
35
 
32
- ### Other Options
33
- * ignore_skus: An optional array of Acumen product skus that will be intentionally
34
- excluded from any output.
36
+ ### Other Options
37
+ * ignore_skus: An optional array of Acumen product skus that will be intentionally
38
+ excluded from any output.
39
+
40
+ ### Event Output
41
+ This agent will output one of two event types during processing:
42
+
43
+ * Product bundles
44
+ * Processing Errors
45
+
46
+ The product bundle payload will be structured as:
47
+
48
+ ```
49
+ {
50
+ products: [ { ... }, { ... }, ... ],
51
+ status: 200
52
+ }
53
+ ```
54
+
55
+ The processing error payload will be structured as:
56
+
57
+ ```
58
+ {
59
+ status: 500,
60
+ scope: '[Process Name]',
61
+ message: '[Error Message]',
62
+ data: { ... },
63
+ trace: [ ... ]
64
+ }
65
+ ```
66
+
67
+ ### Payload Status
68
+
69
+ `status: 200`: Indicates a true success. The agent has output the full
70
+ range of expected data.
71
+
72
+ `status: 206`: Indicates a partial success. The products within the bundle
73
+ are vaild, but the bundle _may_ be missing products that were somehow invalid.
74
+
75
+ `status: 500`: Indicates a processing error. This may represent a complete
76
+ process failure, but may also be issued in parallel to a `202` payload.
77
+
78
+ Because this agent receives an array of Product IDs as input, errors will be issued in
79
+ such a way that product processing can recover when possible. Errors that occur within
80
+ a specific product bundle will emit an error event, but the agent will then move
81
+ forward processing the next bundle.
82
+
83
+ For example, if this agent receives two products as input (`A` and `B`), and we fail to
84
+ load the Inv_Product record for product `A`, the agent would emit an error payload of:
85
+
86
+ ```
87
+ {
88
+ status: 500,
89
+ scope: 'Fetch Inv_Product Data',
90
+ message: 'Failed to lookup Inv_Product record for Product A',
91
+ data: { product_id: 123 },
92
+ trace: [ ... ]
93
+ }
94
+ ```
95
+
96
+ The goal of this approach is to ensure the agent outputs as much data as reasonably possible
97
+ with each execution. If there is an error in the Paperback version of a title, that shouldn't
98
+ prevent this agent from returning the Hardcover version.
35
99
 
36
100
  MD
37
101
 
@@ -40,7 +104,6 @@ module Agents
40
104
  'endpoint' => 'https://example.com',
41
105
  'site_code' => '',
42
106
  'password' => '',
43
- 'physical_formats' => [],
44
107
  'digital_formats' => [],
45
108
  'attribute_to_property' => {},
46
109
  }
@@ -59,10 +122,6 @@ module Agents
59
122
  errors.add(:base, 'password is a required field')
60
123
  end
61
124
 
62
- unless options['physical_formats'].present?
63
- errors.add(:base, "physical_formats is a required field")
64
- end
65
-
66
125
  unless options['digital_formats'].present?
67
126
  errors.add(:base, "digital_formats is a required field")
68
127
  end
@@ -72,9 +131,9 @@ module Agents
72
131
  end
73
132
 
74
133
  if options['ignore_skus']
75
- unless options['ignore_skus'].is_a?(Array)
76
- errors.add(:base, "if provided, ignore_skus must be an array")
77
- end
134
+ unless options['ignore_skus'].is_a?(Array)
135
+ errors.add(:base, "if provided, ignore_skus must be an array")
136
+ end
78
137
  end
79
138
  end
80
139
 
@@ -95,13 +154,14 @@ module Agents
95
154
  private
96
155
 
97
156
  def handle(event)
157
+ # Process agent options
98
158
  endpoint = interpolated['endpoint']
99
159
  site_code = interpolated['site_code']
100
160
  password = interpolated['password']
101
- physical_formats = interpolated['physical_formats']
102
161
  digital_formats = interpolated['digital_formats']
103
- ignore_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
162
+ ignored_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
104
163
 
164
+ # Configure the Acumen Client
105
165
  auth = {
106
166
  'site_code' => site_code,
107
167
  'password' => password,
@@ -110,32 +170,74 @@ module Agents
110
170
  client = AcumenClient.new(faraday, auth)
111
171
 
112
172
  ids = event.payload['ids']
113
- products = get_products_by_ids(client, ids)
114
- products = get_product_variants(client, products, physical_formats, digital_formats)
115
- products = get_product_categories(client, products)
116
- products = get_product_contributors(client, products)
117
173
 
118
- # map attributes
119
- products.map do |product|
120
- map_attributes(product)
174
+ # Load Products
175
+ fetch_product_bundles(client, ids, digital_formats, ignored_skus)
176
+ end
121
177
 
122
- product['model'].each do |model|
123
- map_attributes(model)
124
- end
178
+ private
125
179
 
126
- product
127
- end
180
+ # Returns an array of Product objects for the provided product_ids.
181
+ # Each object is a merged representation of all the individual Acumen tables
182
+ # that make up a product record with fields mapped to the schema.org/Product
183
+ # object definition.
184
+ def fetch_products(acumen_client, product_ids, digital_format_list)
185
+ products = fetch_inv_product_data(acumen_client, product_ids, digital_format_list)
186
+ products = fetch_product_marketing(acumen_client, products)
187
+ products = fetch_inv_status(acumen_client, products)
188
+ products = fetch_product_contributors(acumen_client, products)
189
+ products = fetch_product_categories(acumen_client, products)
128
190
 
129
191
  products.each do |product|
130
- unless ignore_skus.include?(product['sku'])
131
- create_event payload: product
132
- end
192
+ map_attributes(product)
193
+ update_availability(product)
133
194
  end
195
+
196
+ return products
134
197
  end
135
198
 
136
- private
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.
202
+ #
203
+ # NOTE: The generated bundles will contain both active and inactive products
204
+ # to facilitate product deletion in external systems.
205
+ def fetch_product_bundles(acumen_client, product_ids, digital_format_list, ignored_skus)
206
+
207
+ begin
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)
212
+
213
+ bundles = product_ids.map do |id|
214
+ bundle_ids = alternate_ids_map[id]
215
+ bundle_ids.append(id) unless bundle_ids.include?(id)
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
227
+
228
+ create_event payload: { products: bundle, status: 200 }
229
+ end
230
+ rescue AcumenAgentError => e
231
+ issue_error(e)
232
+ end
233
+ end
137
234
 
235
+ # Maps additional Acumen attributes to the `additionalProperty` array
236
+ # NOTE: Attributes mapped in this way will be _removed_ from the
237
+ # `acumenAttributes` array.
138
238
  def map_attributes(product)
239
+
240
+
139
241
  attribute_to_property = interpolated['attribute_to_property']
140
242
  attributes = product['acumenAttributes']
141
243
 
@@ -152,5 +254,23 @@ module Agents
152
254
  end
153
255
  end
154
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')
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
+
155
275
  end
156
276
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module contains the baseline utility methods used in the more specific
4
+ # data concerns
5
+ module AcumenQueryConcern
6
+ extend ActiveSupport::Concern
7
+
8
+ UNIT_MAP = {
9
+ 'oz.' => 'OZ',
10
+ 'Inches (US)' => 'INH',
11
+ }
12
+
13
+ protected
14
+
15
+ # Maps Acumen XML data to a hash object as specified in the provided `field_map`
16
+ # The field map is a hash of { source_field: target_field }
17
+ def response_mapper(data, field_map)
18
+ result = {}
19
+
20
+ field_map.each do |source_field, target_field|
21
+ result[target_field] = get_field_value(data, source_field)
22
+ end
23
+
24
+ return result
25
+ end
26
+
27
+ # Utility function to retrieve a value from an XML field
28
+ def get_field_value(data, field_name)
29
+ data[field_name]['__content__'] if data[field_name]
30
+ end
31
+
32
+ # Returns a quantitative field value (e.g. weight) as a Schema.org/QuantitativeValue
33
+ # object
34
+ def get_quantitative_value(value, unit)
35
+ {
36
+ '@type' => 'QuantitativeValue',
37
+ 'value' => value,
38
+ 'unitText' => unit,
39
+ 'unitCode' => (UNIT_MAP[unit] if unit),
40
+ } if value
41
+ end
42
+
43
+ # Emits an error payload event to facilitate better debugging/logging
44
+ # NOTE: The `error` here is expected to be an instance of AcumenAgentError
45
+ def issue_error(error, status = 500)
46
+ # NOTE: Status is intentionally included on the top-level payload so that other
47
+ # agents can look for a `payload[:status]` of either 200 or 500 to distinguish
48
+ # between success and failure states
49
+ create_event payload: {
50
+ status: status,
51
+ scope: error.scope,
52
+ message: error.message,
53
+ original_error: error.original_error,
54
+ data: error.data,
55
+ trace: error.original_error.backtrace,
56
+ }
57
+ end
58
+ end
@@ -0,0 +1,19 @@
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