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 +4 -4
- data/lib/huginn_acumen_product_agent.rb +8 -1
- data/lib/huginn_acumen_product_agent/acumen_agent_error.rb +9 -0
- data/lib/huginn_acumen_product_agent/acumen_client.rb +1 -0
- data/lib/huginn_acumen_product_agent/acumen_product_agent.rb +139 -33
- data/lib/huginn_acumen_product_agent/concerns/acumen_query_concern.rb +58 -0
- data/lib/huginn_acumen_product_agent/concerns/agent_error_concern.rb +19 -0
- data/lib/huginn_acumen_product_agent/concerns/alternate_products_query_concern.rb +87 -0
- data/lib/huginn_acumen_product_agent/concerns/inv_product_query_concern.rb +108 -0
- data/lib/huginn_acumen_product_agent/concerns/prod_mkt_query_concern.rb +163 -0
- data/lib/huginn_acumen_product_agent/concerns/product_categories_query_concern.rb +74 -0
- data/lib/huginn_acumen_product_agent/concerns/product_contributors_query_concern.rb +116 -0
- metadata +10 -3
- data/lib/huginn_acumen_product_agent/concerns/acumen_product_query_concern.rb +0 -456
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 30c918a4763fbc8b84145f83215cf46d9b162adeb32d025d206d7f7fa51ded41
|
4
|
+
data.tar.gz: 77b5d58d8de8f15e3908b920b04882fb0706974009c9fa1ea1fe93bb66b7f1d6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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/
|
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'
|
@@ -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
|
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
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
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
|
-
#
|
103
|
-
|
104
|
-
|
174
|
+
# Load Products
|
175
|
+
fetch_product_bundles(client, ids, digital_formats, ignored_skus)
|
176
|
+
end
|
105
177
|
|
106
|
-
|
107
|
-
map_attributes(model)
|
108
|
-
end
|
178
|
+
private
|
109
179
|
|
110
|
-
|
111
|
-
|
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
|
-
|
115
|
-
create_event payload: product
|
116
|
-
end
|
191
|
+
map_attributes(product)
|
117
192
|
end
|
193
|
+
|
194
|
+
return products
|
118
195
|
end
|
119
196
|
|
120
|
-
|
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
|