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.
@@ -0,0 +1,98 @@
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
+ # This function returns two data elements in a hash object.
16
+ # The `id_set` is the full collection of IDs to be fetched including the input
17
+ # product_ids and all of their alternate format ids. The goal of this value
18
+ # is to reduce the resource requirements of running each "bundle" individually
19
+ #
20
+ # The alternate_ids_map contains arrays of product IDs mapped to their master
21
+ # product id. This map will be used to assemble fetched product data into bundles
22
+ def fetch_alternate_format_ids(acumen_client, product_ids)
23
+ begin
24
+ link_data = acumen_client.get_linked_products(product_ids)
25
+
26
+ links = process_alternate_format_response(link_data)
27
+
28
+ mapped_ids = map_alternate_format_links(links, product_ids)
29
+
30
+ id_set = [] + product_ids
31
+ mapped_ids.each_value { |bundle| id_set += bundle }
32
+
33
+ return {id_set: id_set, alternate_ids_map: mapped_ids }
34
+ rescue => error
35
+ issue_error(AcumenAgentError.new(
36
+ 'fetch_alternate_format_ids',
37
+ 'Failed attempting to lookup alternate products',
38
+ product_ids,
39
+ error
40
+ ))
41
+ end
42
+ end
43
+
44
+ # This function parses the raw data returned from the Product_Link table
45
+ # The resulting array contains the set alternate format IDs associated with a
46
+ # single product
47
+ def process_alternate_format_response(raw_data)
48
+ results = []
49
+ raw_data.map do |link|
50
+
51
+ begin
52
+ mapped = response_mapper(link, {
53
+ 'Product_Link.Link_From_ID' => 'from_id',
54
+ 'Product_Link.Link_To_ID' => 'to_id',
55
+ 'Product_Link.Alt_Format' => 'alt_format',
56
+ })
57
+
58
+ if mapped['alt_format'].to_s != '0' && !mapped.in?(results)
59
+ results.push(mapped)
60
+ end
61
+
62
+ rescue => error
63
+ issue_error(AcumenAgentError.new(
64
+ 'process_alternate_format_response',
65
+ 'Failed while processing alternate format links',
66
+ raw_data,
67
+ error
68
+ ))
69
+ end
70
+ end
71
+
72
+ return results
73
+ end
74
+
75
+ # Returns a map that ties each provided `product_id` to an array of IDs for its
76
+ # other formats
77
+ def map_alternate_format_links(links, product_ids)
78
+ results = {}
79
+
80
+ product_ids.each do |id|
81
+
82
+ begin
83
+ alternates = links.select { |l| l['from_id'] == id }
84
+ results[id] = alternates.map { |l| l['to_id'] }
85
+
86
+ rescue => error
87
+ issue_error(AcumenAgentError.new(
88
+ 'map_alternate_format_links',
89
+ 'Failed while mapping alternate format links',
90
+ { id: id, links: links },
91
+ error
92
+ ))
93
+ end
94
+ end
95
+
96
+ return results
97
+ end
98
+ end
@@ -0,0 +1,140 @@
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
+ { raw_data: raw_data },
133
+ error,
134
+ ))
135
+
136
+ return
137
+ end
138
+ end
139
+ end
140
+ 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
+ inv_status,
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
+ { product: product, inventory_data: inventory_data },
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
+ product_marketing,
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
+ { product: product, marketing: marketing },
154
+ error,
155
+ ))
156
+ end
157
+
158
+ product
159
+ end
160
+
161
+ return products
162
+ end
163
+ end