Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new site preference for indexing out-of-stock products #218

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1b3ddc4
feat:indexoutofstock
htuzel Jan 10, 2025
fd6f0aa
- Cleaned up redundant code related to the `AlgoliaLocalizedProduct` …
htuzel Jan 10, 2025
a003010
Apply suggestions from code review
htuzel Jan 16, 2025
edcd392
fix: correct typo in preference setting for out of stock indexing
htuzel Jan 20, 2025
deeddea
fix: enhance product filtering for Algolia indexing
htuzel Jan 20, 2025
3ce475d
refactor: streamline Algolia data handling and improve test coverage
htuzel Jan 20, 2025
225e26d
fix: improve product filtering and indexing logic for Algolia
htuzel Jan 20, 2025
2ef8208
fix: update mock handling for Algolia record model in tests
htuzel Jan 20, 2025
00b71c7
test: enhance algolia product filtering tests for out-of-stock logic
htuzel Jan 20, 2025
135082f
Merge branch 'develop' into feature/SFCC-269
htuzel Jan 21, 2025
48cd8ab
fix: simplify availability status check in algoliaLocalizedProduct.js
htuzel Jan 21, 2025
ace7106
fix: standardize 'IndexOutOfStock' preference key across files
htuzel Jan 21, 2025
09aff99
fix: added back removed comment
htuzel Jan 27, 2025
50341fd
refactor: improve variant filtering with product filter module
htuzel Jan 27, 2025
2f3185e
test: add comprehensive test cases for master-level indexing with Ind…
htuzel Jan 27, 2025
ad7ea14
chore: increase Cypress page load timeout for more stable testing
htuzel Jan 27, 2025
437ccb3
chore: update Cypress test configuration
htuzel Jan 27, 2025
c411c27
chore: update Cypress CI workflow for browser testing
htuzel Jan 27, 2025
648ad10
fix: remove extra closing brace in algoliaProductIndex.js
htuzel Jan 27, 2025
dfe7048
chore: remove Cypress page load timeout configuration
htuzel Jan 27, 2025
5336413
refactor: improve product stock filtering and indexing logic
htuzel Jan 28, 2025
064e74a
refactor: clean up product stock availability method
htuzel Jan 28, 2025
b4cab2b
feat: enhance product indexing with stock availability filtering
htuzel Jan 28, 2025
663304d
refactor: remove unused stock-related imports in variant record gener…
htuzel Jan 28, 2025
07e9442
chore: restore Cypress page load timeout to 15 seconds
htuzel Jan 29, 2025
d05b4fa
Merge branch 'develop' into feature/SFCC-269
htuzel Jan 30, 2025
921e61c
fix: improve product stock availability indexing logic
htuzel Jan 30, 2025
626cb02
refactor: simplify stock availability check in localized product inde…
htuzel Jan 30, 2025
13c9a33
test: improve Cypress test stability for modal handling
htuzel Jan 30, 2025
d702d6f
refactor: optimize product indexing with stock availability filtering
htuzel Jan 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,19 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v3

- name: Install Dependencies for browser
run: |
sudo apt-get install -y \
libnss3 \
libgbm1 \
xvfb
htuzel marked this conversation as resolved.
Show resolved Hide resolved

- name: Run Cypress Tests
uses: cypress-io/github-action@v6
with:
install: npm ci
browser: electron
headless: true
headed: false
browser: chrome
config-file: cypress.config.js
env:
CYPRESS_SANDBOX_HOST: ${{ vars.SANDBOX_HOST }}
Expand Down
1 change: 1 addition & 0 deletions cartridges/bm_algolia/cartridge/controllers/AlgoliaBM.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function handleSettings() {
algoliaData.setPreference('EnableContentSearch', algoliaEnableContentSearch);
algoliaData.setPreference('EnableRecommend', algoliaEnableRecommend);
algoliaData.setPreference('EnablePricingLazyLoad', algoliaEnablePricingLazyLoad);
algoliaData.setPreference('IndexOutOfStock', params.IndexOutOfStock.submitted);
} catch (error) {
Logger.error(error);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,18 @@
</td>
</tr>

<iscomment> Algolia_IndexOutOfStock </iscomment>
<tr>
<td class="table_detail w s" colspan="1">
<isprint value="${Resource.msg('algolia.label.preference.indexoutofstock', 'algolia', null)}" />
<i class="fa fa-info-circle dw-nc-text-info info-hover"></i>
<div class="tooltip">${Resource.msg('algolia.label.preference.indexoutofstock.help', 'algolia', null)}</div>
</td>
<td class="table_detail w e s">
<input type="checkbox" ${pdict.algoliaData.getPreference('IndexOutOfStock') ? "checked": ""} id="IndexOutOfStock" name="IndexOutOfStock" />
</td>
</tr>

<iscomment> Apply button </iscomment>
<tr>
<td class="w e s buttonspacing" align="right" colspan="2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ algolia.label.preference.enablessr=Enable server-side rendering
algolia.label.preference.enablecontentsearch=Enable content search
algolia.label.preference.enablerecommend=Recommend
algolia.label.preference.enablePricingLazyLoad=Lazy Loading for prices
algolia.label.preference.indexoutofstock=Index out of stock products
algolia.label.preference.clientid=OCAPI Client ID
algolia.label.preference.clientpassword=OCAPI Client password
algolia.label.button.apply=Apply
Expand All @@ -43,7 +44,7 @@ algolia.label.preference.enablessr.help=Server-side rendering for SFRA's CLP (ca
algolia.label.preference.enablecontentsearch.help=Enable Content Search on the storefront
algolia.label.preference.enablerecommend.help=Enable Algolia Recommend on the SFRA storefront cartridge. See the documentation for additional setup steps
algolia.label.preference.enablePricingLazyLoad.help=When enabled, prices are fetched from SFCC when search results are displayed. Permits to display the most up-to-date promotions without having to index them.

algolia.label.preference.indexoutofstock.help=Index out of stock products, when disabled, only in-stock (ATS higher than the InStock Threshold) products are indexed.

# v2 job report table
algolia.label.jobtype=job type:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,27 @@ function isInclude(product) {
return true;
}

module.exports.isInclude = isInclude;
/**
* Returns `true` if the product’s ATS >= threshold, false otherwise.
* This function is “pure” (it only checks product data).
*
* @param {dw.catalog.Product} product - The SFCC product or variant to check
* @param {number} threshold - The min ATS to consider in-stock
* @returns {boolean}
*/
function isInStock(product, threshold) {
var availabilityModel = product.getAvailabilityModel();
if (!availabilityModel) {
return false;
}
var invRecord = availabilityModel.getInventoryRecord();
var atsValue = invRecord ? invRecord.getATS().getValue() : 0;
return atsValue >= threshold;
}



module.exports = {
isInStock: isInStock,
isInclude: isInclude,
};
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,6 @@ function updateCPObjectFromXML(xmlFile, changedProducts, resourceType) {
*/
function generateVariantRecords(parameters) {
const AlgoliaLocalizedProduct = require('*/cartridge/scripts/algolia/model/algoliaLocalizedProduct');
const productFilter = require('*/cartridge/scripts/algolia/filters/productFilter');

const attributesComputedFromBaseProduct = parameters.attributesComputedFromBaseProduct || [];
const variants = parameters.masterProduct.getVariants();
Expand All @@ -623,15 +622,14 @@ function generateVariantRecords(parameters) {

for (let v = 0; v < variants.size(); ++v) {
var variant = variants[v];
if (!productFilter.isInclude(variant)) {
continue;
}

var baseModel = new AlgoliaLocalizedProduct({
product: variant,
locale: 'default',
attributeList: parameters.nonLocalizedAttributes,
fullRecordUpdate: parameters.fullRecordUpdate
});

for (let l = 0; l < parameters.locales.size(); ++l) {
let locale = parameters.locales[l];
// Add shared attributes in the base model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ try {
}

const ALGOLIA_IN_STOCK_THRESHOLD = algoliaData.getPreference('InStockThreshold');
const INDEX_OUT_OF_STOCK = algoliaData.getPreference('IndexOutOfStock');

/**
* Get the lowest promotional price for product
Expand Down Expand Up @@ -356,15 +357,23 @@ var aggregatedValueHandlers = {
return pricebooks;
},
in_stock: function (product) {
let availabilityModel = product.getAvailabilityModel();
const availabilityModel = product.getAvailabilityModel();

if (product.isMaster() || product.isVariationGroup()) {
return availabilityModel.availabilityStatus === 'IN_STOCK';
}

let inventoryRecord = availabilityModel.getInventoryRecord();
let inStock = (inventoryRecord ? inventoryRecord.getATS().getValue() >= ALGOLIA_IN_STOCK_THRESHOLD : false);
return inStock;
const productFilter = require('*/cartridge/scripts/algolia/filters/productFilter');

const inStock = productFilter.isInStock(product, ALGOLIA_IN_STOCK_THRESHOLD);

if (!inStock && !INDEX_OUT_OF_STOCK) {
return undefined;
}

const inventoryRecord = availabilityModel.getInventoryRecord();
const atsValue = inventoryRecord ? inventoryRecord.getATS().getValue() : 0;
return atsValue >= ALGOLIA_IN_STOCK_THRESHOLD;
},
image_groups: function (product) {
var imageGroupsArr = [];
Expand Down Expand Up @@ -436,6 +445,7 @@ var aggregatedValueHandlers = {
return promotionalPrices;
},
variants: function(product, parameters) {
const productFilter = require('*/cartridge/scripts/algolia/filters/productFilter');
if (!product.isMaster() && !product.isVariationGroup()) {
return null;
}
Expand All @@ -444,6 +454,13 @@ var aggregatedValueHandlers = {
const variantsIt = product.variants.iterator();
while (variantsIt.hasNext()) {
var variant = variantsIt.next();

const inStock = productFilter.isInStock(variant, ALGOLIA_IN_STOCK_THRESHOLD);

if (!inStock && !INDEX_OUT_OF_STOCK) {
continue;
}

var localizedVariant = new algoliaLocalizedProduct({
product: variant,
locale: request.getLocale(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ var fullRecordUpdate = false;

const VARIANT_LEVEL = 'variant-level';
const MASTER_LEVEL = 'master-level';
var ALGOLIA_IN_STOCK_THRESHOLD;
var INDEX_OUT_OF_STOCK;

/*
* Rough algorithm of chunk-oriented script module execution:
Expand Down Expand Up @@ -72,6 +74,9 @@ exports.beforeStep = function(parameters, stepExecution) {
CPObjectIterator = require('*/cartridge/scripts/algolia/helper/CPObjectIterator');
AlgoliaJobReport = require('*/cartridge/scripts/algolia/helper/AlgoliaJobReport');

ALGOLIA_IN_STOCK_THRESHOLD = algoliaData.getPreference('InStockThreshold');
INDEX_OUT_OF_STOCK = algoliaData.getPreference('IndexOutOfStock');

try {
extendedProductAttributesConfig = require('*/cartridge/configuration/productAttributesConfig.js');
logger.info('Configuration file "productAttributesConfig.js" loaded')
Expand Down Expand Up @@ -359,25 +364,35 @@ exports.process = function(cpObj, parameters, stepExecution) {
let indexName = algoliaData.calculateIndexName('products', locale);
let records = recordsPerLocale[locale];
processedVariantsToSend = records.length;

records.forEach(function(record) {
algoliaOperations.push(new jobHelper.AlgoliaOperation(baseIndexingOperation, record, indexName))
if (INDEX_OUT_OF_STOCK || record.in_stock) {
algoliaOperations.push(new jobHelper.AlgoliaOperation(baseIndexingOperation, record, indexName))
} else {
algoliaOperations.push(new jobHelper.AlgoliaOperation(deleteIndexingOperation, { objectID: record.objectID }, indexName));
}
});
}
} else {
// Master-level indexing
let baseModel = new AlgoliaLocalizedProduct({ product: product, locale: 'default', attributeList: nonLocalizedMasterAttributes });
for (let l = 0; l < siteLocales.size(); ++l) {
let locale = siteLocales[l];
let indexName = algoliaData.calculateIndexName('products', locale);
let localizedMaster = new AlgoliaLocalizedProduct({
product: product,
locale: locale,
attributeList: masterAttributes,
variantAttributes: variantAttributes,
baseModel: baseModel,
});
processedVariantsToSend = localizedMaster.variants ? localizedMaster.variants.length : 0;
algoliaOperations.push(new jobHelper.AlgoliaOperation(baseIndexingOperation, localizedMaster, indexName));
let inStock = productFilter.isInStock(product, ALGOLIA_IN_STOCK_THRESHOLD);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't work! As I explained in #218 (comment) your filtering function differs from the logic in algoliaLocalizedProduct.js
You had improved the logic yourself in #217 to take into account master products!

For master products, your isInStock function ALWAYS returns false
The consequence is that you ALWAYS end up in the else condition where indexName is not defined...

So here is what you would have seen if you had just run the delta job one time:

ERROR CustomJobThread|1976382993|AlgoliaProductDeltaIndex_v2|algoliaProductDeltaIndex
{"message":"No indexName found for this batch request near line:1 column:70","status":400}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really sorry for the back-and-forths and taking your time unnecessarily with these type errors. I will be more careful before requesting your review, and for each PR, I will create detailed test scenarios. Hopefully, it is working now. I also added my test scenarios; there is only two scenarios (similar) that we can think about, but I believe we can ignore them.

Please let me know what you think.

if (inStock || INDEX_OUT_OF_STOCK) {
let baseModel = new AlgoliaLocalizedProduct({ product: product, locale: 'default', attributeList: nonLocalizedMasterAttributes });
for (let l = 0; l < siteLocales.size(); ++l) {
let locale = siteLocales[l];
let indexName = algoliaData.calculateIndexName('products', locale);
let localizedMaster = new AlgoliaLocalizedProduct({
product: product,
locale: locale,
attributeList: masterAttributes,
variantAttributes: variantAttributes,
baseModel: baseModel,
});
processedVariantsToSend = localizedMaster.variants ? localizedMaster.variants.length : 0;
algoliaOperations.push(new jobHelper.AlgoliaOperation(baseIndexingOperation, localizedMaster, indexName));
}
} else {
algoliaOperations.push(new jobHelper.AlgoliaOperation(deleteIndexingOperation, { objectID: cpObj.productID }, indexName));
}
}

Expand All @@ -387,23 +402,32 @@ exports.process = function(cpObj, parameters, stepExecution) {
}
}

// Pre-fetch a partial model containing all non-localized attributes, to avoid re-fetching them for each locale
if (productFilter.isInclude(product)) {

// Pre-fetch a partial model containing all non-localized attributes, to avoid re-fetching them for each locale
htuzel marked this conversation as resolved.
Show resolved Hide resolved
var baseModel = new AlgoliaLocalizedProduct({ product: product, locale: 'default', attributeList: nonLocalizedAttributes, fullRecordUpdate: fullRecordUpdate });
for (let l = 0; l < siteLocales.size(); l++) {
let locale = siteLocales[l];
let indexName = algoliaData.calculateIndexName('products', locale);
let localizedProduct = new AlgoliaLocalizedProduct({ product: product, locale: locale, attributeList: attributesToSend, baseModel: baseModel, fullRecordUpdate: fullRecordUpdate });
algoliaOperations.push(new jobHelper.AlgoliaOperation(baseIndexingOperation, localizedProduct, indexName));
let inStock = productFilter.isInStock(product, ALGOLIA_IN_STOCK_THRESHOLD);
if (inStock || INDEX_OUT_OF_STOCK) {
var baseModel = new AlgoliaLocalizedProduct({ product: product, locale: 'default', attributeList: nonLocalizedAttributes, fullRecordUpdate: fullRecordUpdate });
for (let l = 0; l < siteLocales.size(); l++) {
let locale = siteLocales[l];
let indexName = algoliaData.calculateIndexName('products', locale);
let localizedProduct = new AlgoliaLocalizedProduct({ product: product, locale: locale, attributeList: attributesToSend, baseModel: baseModel, fullRecordUpdate: fullRecordUpdate });
algoliaOperations.push(new jobHelper.AlgoliaOperation(baseIndexingOperation, localizedProduct, indexName));
}
jobReport.processedItemsToSend++;
} else {
// => product is out-of-stock and IndexOutOfStock=false => must delete from Algolia
for (let l = 0; l < siteLocales.size(); l++) {
var locale = siteLocales[l];
var indexName = algoliaData.calculateIndexName('products', locale);
algoliaOperations.push(new jobHelper.AlgoliaOperation(deleteIndexingOperation, { objectID: cpObj.productID }, indexName));
}
jobReport.processedItemsToSend++;
}
jobReport.processedItemsToSend++;
}
} else {
for (let l = 0; l < siteLocales.size(); l++) {
var locale = siteLocales[l];
var indexName = algoliaData.calculateIndexName('products', locale);
algoliaOperations.push(new jobHelper.AlgoliaOperation(deleteIndexingOperation, { objectID: cpObj.productID }, indexName));
let localeIndexName = algoliaData.calculateIndexName('products', siteLocales[l]);
algoliaOperations.push(new jobHelper.AlgoliaOperation(deleteIndexingOperation, { objectID: cpObj.productID }, localeIndexName));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ok, but this part of the code is only run when the job is in variant-level mode AND there are no attributes computed from the base product.

When there are attributes computed from the base product such as colorVariations, we don't pass here, we enter in the if (paramRecordModel === MASTER_LEVEL || attributesComputedFromBaseProduct.length > 0) { condition above.
So the logic needs to be updated above too.

}
jobReport.processedItemsToSend++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ var extendedProductAttributesConfig;

const VARIANT_LEVEL = 'variant-level';
const MASTER_LEVEL = 'master-level';
var ALGOLIA_IN_STOCK_THRESHOLD;
var INDEX_OUT_OF_STOCK;

/*
* Rough algorithm of chunk-oriented script module execution:
Expand Down Expand Up @@ -64,6 +66,8 @@ exports.beforeStep = function(parameters, stepExecution) {
productFilter = require('*/cartridge/scripts/algolia/filters/productFilter');
algoliaProductConfig = require('*/cartridge/scripts/algolia/lib/algoliaProductConfig');
AlgoliaJobReport = require('*/cartridge/scripts/algolia/helper/AlgoliaJobReport');
ALGOLIA_IN_STOCK_THRESHOLD = algoliaData.getPreference('InStockThreshold');
INDEX_OUT_OF_STOCK = algoliaData.getPreference('IndexOutOfStock');

try {
extendedProductAttributesConfig = require('*/cartridge/configuration/productAttributesConfig.js');
Expand Down Expand Up @@ -270,7 +274,9 @@ exports.process = function(product, parameters, stepExecution) {
var records = recordsPerLocale[locale];
processedVariantsToSend = records.length;
records.forEach(function(record) {
algoliaOperations.push(new jobHelper.AlgoliaOperation(indexingOperation, record, indexName));
if (INDEX_OUT_OF_STOCK || record.in_stock) {
algoliaOperations.push(new jobHelper.AlgoliaOperation(indexingOperation, record, indexName));
}
});
}
} else {
Expand Down Expand Up @@ -301,6 +307,11 @@ exports.process = function(product, parameters, stepExecution) {
}

if (productFilter.isInclude(product)) {
const inStock = productFilter.isInStock(product, ALGOLIA_IN_STOCK_THRESHOLD);
if (!inStock && !INDEX_OUT_OF_STOCK) {
return [];
}

var algoliaOperations = [];

// Pre-fetch a partial model containing all non-localized attributes, to avoid re-fetching them for each locale
Expand Down
1 change: 1 addition & 0 deletions cypress.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ module.exports = defineConfig({
// Important: return the updated config
return config;
},
pageLoadTimeout: 15000,
},
});
9 changes: 9 additions & 0 deletions metadata/algolia/meta/system-objecttype-extensions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ Setting this preference replaces the first two segments, the final index name be
</value-definitions>
</attribute-definition>

<attribute-definition attribute-id="Algolia_IndexOutOfStock">
<display-name xml:lang="x-default">Index Out of Stock</display-name>
<description xml:lang="x-default">Index out of stock products</description>
<type>boolean</type>
<mandatory-flag>false</mandatory-flag>
<externally-managed-flag>false</externally-managed-flag>
<default-value>true</default-value>
</attribute-definition>

<attribute-definition attribute-id="Algolia_LocalesForIndexing">
<display-name xml:lang="x-default">Locales for indexing</display-name>
<description xml:lang="x-default">List of locales that will be used by the indexing jobs</description>
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
"watch": "sgmf-scripts --watch",
"watch:static": "sgmf-scripts --watch static",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"deploy:code": "node scripts/deployCode.js",
"import:preferences": "node scripts/importPreferences.js",
"run:sfcc-job": "node scripts/runSFCCJob.js",
"test:frontend": "cypress run",
"test:frontend": "cypress run --browser chrome --headed",
"test:e2e": "./e2e.sh",
"test:variationindex": "jest --testPathPattern='test/e2e/variation_index.test.js'"
"test:variationindex": "jest --testPathPattern='test/e2e/variation_index.test.js'",
"test:algoliaProductDeltaIndex": "jest --testPathPattern='test/unit/int_algolia/scripts/algolia/steps/algoliaProductDeltaIndex.test.js'"
},
"repository": {
"type": "git",
Expand Down
Loading