huginn_acumen_product_agent 1.7.1 → 2.2.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 +56 -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 +143 -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 +10 -3
- data/lib/huginn_acumen_product_agent/concerns/acumen_product_query_concern.rb +0 -479
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing data recrods from the Inv_Product
|
4
|
+
# table in Acumen. This table contains the baseline product information -- name, sku,
|
5
|
+
# format, etc.
|
6
|
+
module InvProductQueryConcern
|
7
|
+
extend AcumenQueryConcern
|
8
|
+
|
9
|
+
# Fetch/Process the Acumen data,
|
10
|
+
def fetch_inv_product_data(acumen_client, product_ids, digital_format_list)
|
11
|
+
product_data = acumen_client.get_products(product_ids)
|
12
|
+
|
13
|
+
return process_inv_product_response(product_data, digital_format_list)
|
14
|
+
end
|
15
|
+
|
16
|
+
# This function returns an array of Acumen products mapped to Schema.org/Product
|
17
|
+
# objects. We've added additional fields of:
|
18
|
+
#
|
19
|
+
# * `isAvailableForPurchase` -- used to control product deletion in external systems
|
20
|
+
# * `acumenAttributes` -- Additional acumen data that doesn't have a direct 1:1 field
|
21
|
+
# on the Product, but may be useful in other platforms
|
22
|
+
def process_inv_product_response(raw_data, digital_format_list)
|
23
|
+
raw_data.map do |p|
|
24
|
+
|
25
|
+
begin
|
26
|
+
product = response_mapper(p, {
|
27
|
+
'Inv_Product.ID' => 'identifier',
|
28
|
+
'Inv_Product.ProdCode' => 'sku',
|
29
|
+
'Inv_Product.Full_Title' => 'name',
|
30
|
+
'Inv_Product.SubTitle' => 'disambiguatingDescription',
|
31
|
+
'Inv_Product.ISBN_UPC' => 'isbn',
|
32
|
+
'Inv_Product.Pub_Date' => 'datePublished',
|
33
|
+
'Inv_Product.Next_Release' => 'releaseDate',
|
34
|
+
})
|
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
|
+
|
45
|
+
product['@type'] = 'Product'
|
46
|
+
product['isTaxable'] = get_field_value(p, 'Inv_Product.Taxable') == '1'
|
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['acumenAttributes'] = {
|
50
|
+
'info_alpha_1' => get_field_value(p, 'Inv_Product.Info_Alpha_1'),
|
51
|
+
'info_boolean_1' => get_field_value(p, 'Inv_Product.Info_Boolean_1'), # is_available_on_formed
|
52
|
+
}
|
53
|
+
product['additionalProperty'] = [
|
54
|
+
{
|
55
|
+
'@type' => 'PropertyValue',
|
56
|
+
'propertyID' => 'is_master',
|
57
|
+
'value' => get_field_value(p, 'Inv_Product.OnWeb_LinkOnly') == '0',
|
58
|
+
},
|
59
|
+
{
|
60
|
+
# NOTE: This is different than isAvailableForPurchase. This
|
61
|
+
'@type' => 'PropertyValue',
|
62
|
+
'propertyID' => 'disable_web_purchase',
|
63
|
+
'value' => get_field_value(p, 'Inv_Product.Disable_Web_Purchase'),
|
64
|
+
}
|
65
|
+
]
|
66
|
+
|
67
|
+
product['offers'] = [{
|
68
|
+
'@type' => 'Offer',
|
69
|
+
'price' => get_field_value(p, 'Inv_Product.Price_1'),
|
70
|
+
'availability' => get_field_value(p, 'Inv_Product.BO_Reason')
|
71
|
+
}]
|
72
|
+
|
73
|
+
if get_field_value(p, 'Inv_Product.Price_2')
|
74
|
+
product['offers'].push({
|
75
|
+
'@type' => 'Offer',
|
76
|
+
'price' => get_field_value(p, 'Inv_Product.Price_2'),
|
77
|
+
'availability' => get_field_value(p, 'Inv_Product.BO_Reason')
|
78
|
+
})
|
79
|
+
end
|
80
|
+
|
81
|
+
not_on_website = get_field_value(p, 'Inv_Product.Not_On_Website')
|
82
|
+
disable_web_purchase = get_field_value(p, 'Inv_Product.Disable_Web_Purchase')
|
83
|
+
|
84
|
+
product_availability = 'available'
|
85
|
+
|
86
|
+
|
87
|
+
if (disable_web_purchase == '1')
|
88
|
+
product_availability = 'disabled'
|
89
|
+
end
|
90
|
+
|
91
|
+
if (not_on_website == '1')
|
92
|
+
product_availability = 'not available'
|
93
|
+
end
|
94
|
+
|
95
|
+
product['productAvailability'] = product_availability
|
96
|
+
|
97
|
+
weight = get_field_value(p, 'Inv_Product.Weight')
|
98
|
+
product['weight'] = get_quantitative_value(weight, 'oz.')
|
99
|
+
|
100
|
+
# The category used here is the Acumen Product category. Functionally, this
|
101
|
+
# serves as the product's _format_. This field is different from Web Categories
|
102
|
+
# which behave more like traditional category taxonomies.
|
103
|
+
category = get_field_value(p, 'Inv_Product.Category')
|
104
|
+
if category
|
105
|
+
product['acumenAttributes']['category'] = category
|
106
|
+
product['isDigital'] = digital_format_list.find { |f| f == category } ? true : false
|
107
|
+
|
108
|
+
if category == 'Paperback'
|
109
|
+
product['additionalType'] = 'Book'
|
110
|
+
product['bookFormat'] = "http://schema.org/Paperback"
|
111
|
+
product['accessMode'] = "textual"
|
112
|
+
elsif category == 'Hardcover'
|
113
|
+
product['additionalType'] = 'Book'
|
114
|
+
product['bookFormat'] = "http://schema.org/Hardcover"
|
115
|
+
product['accessMode'] = "textual"
|
116
|
+
elsif category == 'eBook'
|
117
|
+
product['additionalType'] = 'Book'
|
118
|
+
product['bookFormat'] = "http://schema.org/EBook"
|
119
|
+
product['accessMode'] = "textual"
|
120
|
+
elsif category == 'CD'
|
121
|
+
product['additionalType'] = 'CreativeWork'
|
122
|
+
product['accessMode'] = "auditory"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
product
|
127
|
+
|
128
|
+
rescue => error
|
129
|
+
issue_error(AcumenAgentError.new(
|
130
|
+
'process_inv_product_response',
|
131
|
+
'Failed to load Inventory Product Records',
|
132
|
+
{
|
133
|
+
product_id: get_field_value(p, 'Inv_Product.ID'),
|
134
|
+
sku: get_field_value(p, 'Inv_Product.ProdCode')
|
135
|
+
},
|
136
|
+
error,
|
137
|
+
))
|
138
|
+
|
139
|
+
return
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,76 @@
|
|
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
|
+
results << mapped
|
33
|
+
rescue => error
|
34
|
+
issue_error(AcumenAgentError.new(
|
35
|
+
'process_inv_status_response',
|
36
|
+
'Failed while processing Prod_Mkt record',
|
37
|
+
{ sku: get_field_value(inv_status, 'Inv_Status.ProdCode') },
|
38
|
+
error,
|
39
|
+
))
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
results
|
44
|
+
end
|
45
|
+
|
46
|
+
# This function maps parsed Prod_Mkt records to their matching product record
|
47
|
+
# and updates the product object with the additional data
|
48
|
+
def map_inv_status_data(products, inventory)
|
49
|
+
products.map do |product|
|
50
|
+
inventory_data = inventory.select { |i| i['sku'] == product['sku'] }
|
51
|
+
begin
|
52
|
+
|
53
|
+
if inventory_data
|
54
|
+
quantity = 0
|
55
|
+
inventory_data.each do |i|
|
56
|
+
quantity = quantity + i['quantity'].to_i if quantity.present?
|
57
|
+
end
|
58
|
+
|
59
|
+
product['acumenAttributes']['stock_quantity'] = quantity
|
60
|
+
end
|
61
|
+
|
62
|
+
rescue => error
|
63
|
+
issue_error(AcumenAgentError.new(
|
64
|
+
'map_inv_status_data',
|
65
|
+
'Failed to map inventory data for product',
|
66
|
+
{ sku: product['sku'] },
|
67
|
+
error,
|
68
|
+
))
|
69
|
+
end
|
70
|
+
|
71
|
+
product
|
72
|
+
end
|
73
|
+
|
74
|
+
return products
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing the ProdMkt table. This table
|
4
|
+
# contains more detailed product meta information: Publisher, page counts, age range,
|
5
|
+
# description, etc.
|
6
|
+
module ProdMktQueryConcern
|
7
|
+
extend AcumenQueryConcern
|
8
|
+
|
9
|
+
# Update the provided products with their associated marketing data
|
10
|
+
# NOTE: The `products` here are Shema.org/Product records mapped from Inv_Product
|
11
|
+
# data
|
12
|
+
def fetch_product_marketing(acumen_client, products)
|
13
|
+
|
14
|
+
product_ids = products.map { |p| p['identifier'] }
|
15
|
+
marketing_data = acumen_client.get_products_marketing(product_ids)
|
16
|
+
marketing_data = process_prod_mkt_response(marketing_data)
|
17
|
+
|
18
|
+
return map_marketing_data(products, marketing_data)
|
19
|
+
end
|
20
|
+
|
21
|
+
# This function parses the raw data returned from the Prod_Mkt table
|
22
|
+
def process_prod_mkt_response(raw_data)
|
23
|
+
results = {}
|
24
|
+
raw_data.map do |product_marketing|
|
25
|
+
|
26
|
+
begin
|
27
|
+
mapped = response_mapper(product_marketing, {
|
28
|
+
'ProdMkt.Product_ID' => 'product_id',
|
29
|
+
'ProdMkt.Product_Code' => 'sku',
|
30
|
+
'ProdMkt.ID' => 'id',
|
31
|
+
'ProdMkt.Pages' => 'pages',
|
32
|
+
'ProdMkt.Publisher' => 'publisher',
|
33
|
+
'ProdMkt.Description_Short' => 'description_short',
|
34
|
+
'ProdMkt.Description_Long' => 'description_long',
|
35
|
+
'ProdMkt.Height' => 'height',
|
36
|
+
'ProdMkt.Width' => 'width',
|
37
|
+
'ProdMkt.Thickness' => 'thickness',
|
38
|
+
'ProdMkt.Meta_Keywords' => 'meta_keywords',
|
39
|
+
'ProdMkt.Meta_Description' => 'meta_description',
|
40
|
+
'ProdMkt.Extent_Unit' => 'extent_unit',
|
41
|
+
'ProdMkt.Extent_Value' => 'extent_value',
|
42
|
+
'ProdMkt.Age_Highest' => 'age_highest',
|
43
|
+
'ProdMkt.Age_Lowest' => 'age_lowest',
|
44
|
+
'ProdMkt.Awards' => 'awards',
|
45
|
+
'ProdMkt.Dimensions_Unit_Measure' => 'dimensions_unit_measure',
|
46
|
+
'ProdMkt.Excerpt' => 'excerpt',
|
47
|
+
'ProdMkt.Grade_Highest' => 'grade_highest',
|
48
|
+
'ProdMkt.Grade_Lowest' => 'grade_lowest',
|
49
|
+
'ProdMkt.Status' => 'status',
|
50
|
+
'ProdMkt.UPC' => 'upc',
|
51
|
+
'ProdMkt.Weight_Unit_Measure' => 'weight_unit_measure',
|
52
|
+
'ProdMkt.Weight' => 'weight',
|
53
|
+
'ProdMkt.Info_Text_01' => 'info_text_01',
|
54
|
+
'ProdMkt.Info_Text_02' => 'info_text_02',
|
55
|
+
'ProdMkt.Religious_Text_Identifier' => 'religious_text_identifier',
|
56
|
+
'ProdMkt.Info_Alpha_07' => 'info_alpha_07',
|
57
|
+
})
|
58
|
+
|
59
|
+
results[mapped['product_id']] = mapped
|
60
|
+
# NOTE: In this case, product_id matches the Inv_Product.ID fields
|
61
|
+
rescue => error
|
62
|
+
issue_error(AcumenAgentError.new(
|
63
|
+
'process_prod_mkt_response',
|
64
|
+
'Failed while processing Prod_Mkt record',
|
65
|
+
{ sku: get_field_value(product_marketing, 'ProdMkt.Product_Code') },
|
66
|
+
error,
|
67
|
+
))
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
results
|
72
|
+
end
|
73
|
+
|
74
|
+
# This function maps parsed Prod_Mkt records to their matching product record
|
75
|
+
# and updates the product object with the additional data
|
76
|
+
def map_marketing_data(products, marketing_data)
|
77
|
+
products.map do |product|
|
78
|
+
marketing = marketing_data[product['identifier']]
|
79
|
+
|
80
|
+
begin
|
81
|
+
|
82
|
+
if marketing
|
83
|
+
product['acumenAttributes']['product_marketing_id'] = marketing['id']
|
84
|
+
|
85
|
+
product['publisher'] = {
|
86
|
+
'@type': 'Organization',
|
87
|
+
'name' => marketing['publisher']
|
88
|
+
};
|
89
|
+
product['description'] = marketing['description_long']
|
90
|
+
product['abstract'] = marketing['description_short']
|
91
|
+
product['keywords'] = marketing['meta_keywords']
|
92
|
+
product['text'] = marketing['excerpt']
|
93
|
+
|
94
|
+
if marketing['age_lowest'] && marketing['age_highest']
|
95
|
+
product['typicalAgeRange'] = "#{marketing['age_lowest']}-#{marketing['age_highest']}"
|
96
|
+
end
|
97
|
+
|
98
|
+
#---------- Product Page Attributes ----------#
|
99
|
+
if marketing['grade_lowest'] || marketing['grade_highest']
|
100
|
+
# educationalUse? educationalAlignment?
|
101
|
+
product['additionalProperty'].push({
|
102
|
+
'@type' => 'PropertyValue',
|
103
|
+
'name' => 'Grade',
|
104
|
+
'propertyID' => 'grade_range',
|
105
|
+
'minValue' => marketing['grade_lowest'],
|
106
|
+
'maxValue' => marketing['grade_highest'],
|
107
|
+
'value' => "#{marketing['grade_lowest']}-#{marketing['grade_highest']}",
|
108
|
+
})
|
109
|
+
end
|
110
|
+
|
111
|
+
if marketing['awards']
|
112
|
+
product['additionalProperty'].push({
|
113
|
+
'@type' => 'PropertyValue',
|
114
|
+
'propertyID' => 'awards',
|
115
|
+
'name' => 'Awards',
|
116
|
+
'value' => marketing['awards'],
|
117
|
+
})
|
118
|
+
end
|
119
|
+
|
120
|
+
#---------- Acumen Specific Properties ----------#
|
121
|
+
product['acumenAttributes']['extent_unit'] = marketing['extent_unit']
|
122
|
+
product['acumenAttributes']['extent_value'] = marketing['extent_value']
|
123
|
+
product['acumenAttributes']['info_text_01'] = marketing['info_text_01'] # editorial_reviews
|
124
|
+
product['acumenAttributes']['info_text_02'] = marketing['info_text_02'] # product_samples
|
125
|
+
product['acumenAttributes']['info_alpha_07'] = marketing['info_alpha_07'] # video_urls
|
126
|
+
product['acumenAttributes']['meta_description'] = marketing['meta_description']
|
127
|
+
product['acumenAttributes']['religious_text_identifier'] = marketing['religious_text_identifier']
|
128
|
+
product['acumenAttributes']['status'] = marketing['status']
|
129
|
+
|
130
|
+
product['gtin12'] = marketing['upc']
|
131
|
+
product['numberOfPages'] = marketing['pages']
|
132
|
+
|
133
|
+
product['height'] = get_quantitative_value(
|
134
|
+
marketing['height'], marketing['dimensions_unit_measure']
|
135
|
+
)
|
136
|
+
product['width'] = get_quantitative_value(
|
137
|
+
marketing['width'], marketing['dimensions_unit_measure']
|
138
|
+
)
|
139
|
+
product['depth'] = get_quantitative_value(
|
140
|
+
marketing['thickness'], marketing['dimensions_unit_measure']
|
141
|
+
)
|
142
|
+
if product['weight']['value'] == '0'
|
143
|
+
product['weight'] = get_quantitative_value(
|
144
|
+
marketing['weight'], marketing['weight_unit_measure']
|
145
|
+
)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
rescue => error
|
150
|
+
issue_error(AcumenAgentError.new(
|
151
|
+
'map_marketing_data',
|
152
|
+
'Failed to map marketing data for product',
|
153
|
+
{ id: product['identifier'], sku: marketing['sku'] },
|
154
|
+
error,
|
155
|
+
))
|
156
|
+
end
|
157
|
+
|
158
|
+
product
|
159
|
+
end
|
160
|
+
|
161
|
+
return products
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing data from the ProdMkt_WPC table
|
4
|
+
# NOTE: Records on this table are linked to products by SKU
|
5
|
+
module ProductCategoriesQueryConcern
|
6
|
+
extend AcumenQueryConcern
|
7
|
+
|
8
|
+
# Updates the provided products with their associated category data
|
9
|
+
def fetch_product_categories(acumen_client, products)
|
10
|
+
|
11
|
+
product_skus = products.map { |p| p['sku'] }
|
12
|
+
category_data = acumen_client.get_product_categories(product_skus)
|
13
|
+
category_data = process_product_category_response(category_data)
|
14
|
+
|
15
|
+
return map_category_data(products, category_data)
|
16
|
+
end
|
17
|
+
|
18
|
+
# This function parses the raw data returned from the ProdMkt_WPC table
|
19
|
+
# This table holds the relationship between products and Web Categories
|
20
|
+
# The resulting data contains arrays of Category records keyed to product SKUs
|
21
|
+
#
|
22
|
+
# NOTE: In the Acumen data, some Category assignments are marked as inactive.
|
23
|
+
# This function only returns _active_ categories in the resulting data.
|
24
|
+
def process_product_category_response(raw_data)
|
25
|
+
results = {}
|
26
|
+
|
27
|
+
raw_data.map do |product_category|
|
28
|
+
|
29
|
+
begin
|
30
|
+
mapped = response_mapper(product_category, {
|
31
|
+
'ProdMkt_WPC.ProdCode' => 'sku',
|
32
|
+
'ProdMkt_WPC.WPC_ID' => 'category_id',
|
33
|
+
'ProdMkt_WPC.Inactive' => 'inactive',
|
34
|
+
})
|
35
|
+
|
36
|
+
product_sku = mapped['sku']
|
37
|
+
|
38
|
+
if results[product_sku]
|
39
|
+
results[product_sku].push(mapped) if mapped['inactive'] == '0'
|
40
|
+
else
|
41
|
+
results[product_sku] = [mapped] if mapped['inactive'] == '0'
|
42
|
+
end
|
43
|
+
rescue => error
|
44
|
+
issue_error(AcumenAgentError.new(
|
45
|
+
'process_product_category_response',
|
46
|
+
'Failed while processing category data',
|
47
|
+
product_category,
|
48
|
+
error,
|
49
|
+
))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
results
|
54
|
+
end
|
55
|
+
|
56
|
+
# This function maps parsed Web Category records to their matching Product
|
57
|
+
def map_category_data(products, category_data)
|
58
|
+
products.each do |product|
|
59
|
+
product['categories'] = []
|
60
|
+
categories = category_data[product['sku']]
|
61
|
+
|
62
|
+
unless categories.nil?
|
63
|
+
categories.map do |cat|
|
64
|
+
product['categories'].push({
|
65
|
+
'@type' => 'Thing',
|
66
|
+
'identifier' => cat['category_id']
|
67
|
+
})
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
return products
|
73
|
+
end
|
74
|
+
end
|