huginn_acumen_product_agent 1.6.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 158ba4f844cb9b68a1e554d322c1cdde320fe46bb3740b22f97c490ef7a4f35f
4
- data.tar.gz: c567ef95d19590b6df91f6b961f8f68fa8efc28151c3e446245165b084b273aa
3
+ metadata.gz: 30c918a4763fbc8b84145f83215cf46d9b162adeb32d025d206d7f7fa51ded41
4
+ data.tar.gz: 77b5d58d8de8f15e3908b920b04882fb0706974009c9fa1ea1fe93bb66b7f1d6
5
5
  SHA512:
6
- metadata.gz: d4d0b0dd3eb5bc6e7490922d37bb7760f9590c0e6737ec377f84b9ff1e743ac388c3676ab15b7ff4e5a1a4898f5c20451b7ab346bbcc5202afecb0f40b576244
7
- data.tar.gz: a2422b4b261212d2ae098c71da5ec0adb823704798bb852157019d293fc8ecf656437777ba17b8a4fb9bc5b89f4bbf138ee01c9d126768e5cb9e9faf0a96a03e
6
+ metadata.gz: e2fe59ce492e910b0ca2c063938141a9f0ef6b64639fff28f34c3e01d577e5388d6007e1a135a51f01579677e60f2ee0e79e3aa2a449bfffba0e44242258953a
7
+ data.tar.gz: 92563dd015ec11f237a6a91d740d3ff682ddcb73e6598f1768c3dfa51c2c4e75cb8355c3ee7228a5f63a0f1470a483d21adb5bd324283f4efa45b7c77ccada50
@@ -1,6 +1,13 @@
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/prod_mkt_query_concern'
7
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_categories_query_concern'
8
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_contributors_query_concern'
9
+
4
10
  HuginnAgent.load 'huginn_acumen_product_agent/acumen_client'
11
+ HuginnAgent.load 'huginn_acumen_product_agent/acumen_agent_error'
5
12
 
6
13
  HuginnAgent.register 'huginn_acumen_product_agent/acumen_product_agent'
@@ -0,0 +1,9 @@
1
+ class AcumenAgentError < StandardError
2
+ attr_reader :thing
3
+ def initialize(scope, message, data, original_error)
4
+ @scope = scope
5
+ @data = data
6
+ @original_error = original_error
7
+ super(message)
8
+ end
9
+ end
@@ -96,6 +96,7 @@ class AcumenClient
96
96
  <column_name>Inv_Product.Next_Release</column_name>
97
97
  <column_name>Inv_Product.BO_Reason</column_name>
98
98
  <column_name>Inv_Product.Not_On_Website</column_name>
99
+ <column_name>Inv_Product.Not_Active</column_name>
99
100
  </requested_output>
100
101
  </acusoapRequest>
101
102
  XML
@@ -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,7 +16,87 @@ module Agents
11
16
  default_schedule 'never'
12
17
 
13
18
  description <<-MD
14
- Huginn agent for sane ACUMEN product data.
19
+ Huginn agent for retrieving sane ACUMEN product data.
20
+
21
+ ## Agent Options
22
+ The following outlines the available options in this agent
23
+
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
28
+
29
+ ### Format Options
30
+ * digital_formats: A list of the formats associated with a digital product
31
+
32
+ ### Product Attributes
33
+ * attribute_to_property: An optional map linking Acumen attributes to Schema.org
34
+ product properties.
35
+
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.
99
+
15
100
  MD
16
101
 
17
102
  def default_options
@@ -19,10 +104,8 @@ module Agents
19
104
  'endpoint' => 'https://example.com',
20
105
  'site_code' => '',
21
106
  'password' => '',
22
- 'physical_formats' => [],
23
107
  'digital_formats' => [],
24
108
  'attribute_to_property' => {},
25
- 'contributor_types_map' => {},
26
109
  }
27
110
  end
28
111
 
@@ -39,10 +122,6 @@ module Agents
39
122
  errors.add(:base, 'password is a required field')
40
123
  end
41
124
 
42
- unless options['physical_formats'].present?
43
- errors.add(:base, "physical_formats is a required field")
44
- end
45
-
46
125
  unless options['digital_formats'].present?
47
126
  errors.add(:base, "digital_formats is a required field")
48
127
  end
@@ -51,14 +130,10 @@ module Agents
51
130
  errors.add(:base, "if provided, attribute_to_property must be a hash")
52
131
  end
53
132
 
54
- unless options['contributor_types_map'].is_a?(Hash)
55
- errors.add(:base, "if provided, contributor_types_map must be a hash")
56
- end
57
-
58
133
  if options['ignore_skus']
59
- unless options['ignore_skus'].is_a?(Array)
60
- errors.add(:base, "if provided, ignore_skus must be an array")
61
- end
134
+ unless options['ignore_skus'].is_a?(Array)
135
+ errors.add(:base, "if provided, ignore_skus must be an array")
136
+ end
62
137
  end
63
138
  end
64
139
 
@@ -79,13 +154,14 @@ module Agents
79
154
  private
80
155
 
81
156
  def handle(event)
157
+ # Process agent options
82
158
  endpoint = interpolated['endpoint']
83
159
  site_code = interpolated['site_code']
84
160
  password = interpolated['password']
85
- physical_formats = interpolated['physical_formats']
86
161
  digital_formats = interpolated['digital_formats']
87
- ignore_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
162
+ ignored_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
88
163
 
164
+ # Configure the Acumen Client
89
165
  auth = {
90
166
  'site_code' => site_code,
91
167
  'password' => password,
@@ -94,32 +170,62 @@ module Agents
94
170
  client = AcumenClient.new(faraday, auth)
95
171
 
96
172
  ids = event.payload['ids']
97
- products = get_products_by_ids(client, ids)
98
- products = get_product_variants(client, products, physical_formats, digital_formats)
99
- products = get_product_categories(client, products)
100
- products = get_product_contributors(client, products)
101
173
 
102
- # map attributes
103
- products.map do |product|
104
- map_attributes(product)
174
+ # Load Products
175
+ fetch_product_bundles(client, ids, digital_formats, ignored_skus)
176
+ end
105
177
 
106
- product['model'].each do |model|
107
- map_attributes(model)
108
- end
178
+ private
109
179
 
110
- product
111
- 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_product_contributors(acumen_client, products)
188
+ products = fetch_product_categories(acumen_client, products)
112
189
 
113
190
  products.each do |product|
114
- unless ignore_skus.include?(product['sku'])
115
- create_event payload: product
116
- end
191
+ map_attributes(product)
117
192
  end
193
+
194
+ return products
118
195
  end
119
196
 
120
- private
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.
200
+ #
201
+ # NOTE: The generated bundles will contain both active and inactive products
202
+ # to facilitate product deletion in external systems.
203
+ def fetch_product_bundles(acumen_client, product_ids, digital_format_list, ignored_skus)
204
+
205
+ begin
206
+ alternate_ids_map = fetch_alternate_format_ids(acumen_client, product_ids)
207
+
208
+ bundles = product_ids.map do |id|
209
+ bundle_ids = alternate_ids_map[id]
210
+ 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']) }
121
215
 
216
+ create_event payload: { products: bundle, status: 200 }
217
+ end
218
+ rescue AcumenAgentError => e
219
+ issue_error(e)
220
+ end
221
+ end
222
+
223
+ # Maps additional Acumen attributes to the `additionalProperty` array
224
+ # NOTE: Attributes mapped in this way will be _removed_ from the
225
+ # `acumenAttributes` array.
122
226
  def map_attributes(product)
227
+
228
+
123
229
  attribute_to_property = interpolated['attribute_to_property']
124
230
  attributes = product['acumenAttributes']
125
231
 
@@ -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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is responsible for reading/processing data from the Product_Link
4
+ # table. This table defines the link between product records.
5
+ #
6
+ # It's important to note that this table defines links between related products
7
+ # as well as product formats. The `Alt_Format` field determines whether the linked
8
+ # product is an alternate format for a given title.
9
+ #
10
+ # For the purposes of this concern, we only care about Product_Link records where
11
+ # the `Alt_Format` field is `true`
12
+ module AlternateProductsQueryConcern
13
+ extend AcumenQueryConcern
14
+
15
+ # Fetches the Inv_Product.ID for alternate formats of the provided product_ids
16
+ def fetch_alternate_format_ids(acumen_client, product_ids)
17
+ begin
18
+ link_data = acumen_client.get_linked_products(product_ids)
19
+
20
+ links = process_alternate_format_response(link_data)
21
+
22
+ return map_alternate_format_links(links, product_ids)
23
+ rescue => error
24
+ issue_error(AcumenAgentError.new(
25
+ 'fetch_alternate_format_ids',
26
+ 'Failed attempting to lookup alternate products',
27
+ product_ids,
28
+ error
29
+ ))
30
+ end
31
+ end
32
+
33
+ # This function parses the raw data returned from the Product_Link table
34
+ # The resulting array contains the set alternate format IDs associated with a
35
+ # single product
36
+ def process_alternate_format_response(raw_data)
37
+ results = []
38
+ raw_data.map do |link|
39
+
40
+ begin
41
+ mapped = response_mapper(link, {
42
+ 'Product_Link.Link_From_ID' => 'from_id',
43
+ 'Product_Link.Link_To_ID' => 'to_id',
44
+ 'Product_Link.Alt_Format' => 'alt_format',
45
+ })
46
+
47
+ if mapped['alt_format'].to_s != '0' && !mapped.in?(results)
48
+ results.push(mapped)
49
+ end
50
+
51
+ rescue => error
52
+ issue_error(AcumenAgentError.new(
53
+ 'process_alternate_format_response',
54
+ 'Failed while processing alternate format links',
55
+ raw_data,
56
+ error
57
+ ))
58
+ end
59
+ end
60
+
61
+ return results
62
+ end
63
+
64
+ # Returns a map that ties each provided `product_id` to an array of IDs for its
65
+ # other formats
66
+ def map_alternate_format_links(links, product_ids)
67
+ results = {}
68
+
69
+ product_ids.each do |id|
70
+
71
+ begin
72
+ alternates = links.select { |l| l['from_id'] == id }
73
+ results[id] = alternates.map { |l| l['to_id'] }
74
+
75
+ rescue => error
76
+ issue_error(AcumenAgentError.new(
77
+ 'map_alternate_format_links',
78
+ 'Failed while mapping alternate format links',
79
+ { id: id, links: links },
80
+ error
81
+ ))
82
+ end
83
+ end
84
+
85
+ return results
86
+ end
87
+ end