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 +4 -4
- data/lib/huginn_acumen_product_agent.rb +9 -1
- data/lib/huginn_acumen_product_agent/acumen_agent_error.rb +10 -0
- data/lib/huginn_acumen_product_agent/acumen_client.rb +30 -0
- data/lib/huginn_acumen_product_agent/acumen_product_agent.rb +163 -43
- 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 +98 -0
- data/lib/huginn_acumen_product_agent/concerns/inv_product_query_concern.rb +140 -0
- data/lib/huginn_acumen_product_agent/concerns/inv_status_query_concern.rb +76 -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 +11 -3
- data/lib/huginn_acumen_product_agent/concerns/acumen_product_query_concern.rb +0 -478
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a0c47e2991b9e5c568b8b7762fbd9286b818401cc8d9f6cbc6b4e9af4d93c3a2
|
4
|
+
data.tar.gz: 994118245c433230a1b1ea15043bbb9e7d5f83af241c0c2bfae3362d833acb46
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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/
|
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'
|
@@ -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
|
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
|
-
|
19
|
+
Huginn agent for retrieving sane ACUMEN product data.
|
15
20
|
|
16
|
-
|
17
|
-
|
21
|
+
## Agent Options
|
22
|
+
The following outlines the available options in this agent
|
18
23
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
25
|
-
|
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
|
-
|
29
|
-
|
30
|
-
|
32
|
+
### Product Attributes
|
33
|
+
* attribute_to_property: An optional map linking Acumen attributes to Schema.org
|
34
|
+
product properties.
|
31
35
|
|
32
|
-
|
33
|
-
|
34
|
-
|
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
|
-
|
76
|
-
|
77
|
-
|
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
|
-
|
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
|
-
#
|
119
|
-
|
120
|
-
|
174
|
+
# Load Products
|
175
|
+
fetch_product_bundles(client, ids, digital_formats, ignored_skus)
|
176
|
+
end
|
121
177
|
|
122
|
-
|
123
|
-
map_attributes(model)
|
124
|
-
end
|
178
|
+
private
|
125
179
|
|
126
|
-
|
127
|
-
|
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
|
-
|
131
|
-
|
132
|
-
end
|
192
|
+
map_attributes(product)
|
193
|
+
update_availability(product)
|
133
194
|
end
|
195
|
+
|
196
|
+
return products
|
134
197
|
end
|
135
198
|
|
136
|
-
|
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
|