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

fix: 4741 catalog variant inventory flags always false #4742

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de7b136
fix: generate inventory flags for variants and options
mikemurray Oct 16, 2018
316e986
Merge branch 'release-2.0.0-rc.5' into fix-4741-mikemurray-catalog-va…
mikemurray Oct 16, 2018
f4d56c7
test: update test result
mikemurray Oct 18, 2018
a166851
chore: add migration 42 for catalog item inventory
mikemurray Oct 18, 2018
942d5b1
test: add test migration 42 for catalog item inventory
mikemurray Oct 18, 2018
0d01b89
chore: add migration 42 for catalog item variant inventory flags
mikemurray Oct 18, 2018
f9e291e
Merge branch 'release-2.0.0-rc.5' into fix-4741-mikemurray-catalog-va…
mikemurray Oct 18, 2018
d093b93
test: add mock for fetch
mikemurray Oct 18, 2018
30b34c9
test: update usage of fetch mock for Products collection mock
mikemurray Oct 18, 2018
3add45e
Merge branch 'fix-4741-mikemurray-catalog-variant-inventory' of githu…
mikemurray Oct 18, 2018
d511155
Merge remote-tracking branch 'origin/release-2.0.0-rc.6' into fix-474…
mikemurray Oct 22, 2018
56f3f39
fix: migration order
mikemurray Oct 22, 2018
62d3176
Merge branch 'release-2.0.0-rc.7' into fix-4741-mikemurray-catalog-va…
mikemurray Nov 12, 2018
9ee8f17
fix: update inventory status variants and options as well
mikemurray Nov 13, 2018
3d581a0
tests: update tests
mikemurray Nov 13, 2018
049f838
Merge branch 'release-2.0.0-rc.7' into fix-4741-mikemurray-catalog-va…
nnnnat Nov 19, 2018
4951580
chore: updated migration to fix merge conflict
nnnnat Nov 19, 2018
c4221e3
Merge branch 'fix-4741-mikemurray-catalog-variant-inventory' of githu…
nnnnat Nov 19, 2018
b5ccaef
Merge branch 'release-2.0.0-rc.7' into fix-4741-mikemurray-catalog-va…
nnnnat Nov 26, 2018
70090e0
refactor: move migration to version 48
mikemurray Nov 26, 2018
ea1f281
fix: remove isTaxable from variant
mikemurray Nov 26, 2018
ef00d29
Merge branch 'release-2.0.0-rc.7' into fix-4741-mikemurray-catalog-va…
mikemurray Nov 26, 2018
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import isSoldOut from "./isSoldOut";
* @param {Object} variantPriceInfo The result of calling getPriceRange for this price or all child prices
* @param {String} shopCurrencyCode The shop currency code for the shop to which this product belongs
* @param {Object} variantMedia Media for this specific variant
* @param {Object} variantInventory Inventory flags for this variant
* @private
* @returns {Object} The transformed variant
*/
export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, variantMedia) {
export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, variantMedia, variantInventory) {
const primaryImage = variantMedia.find(({ toGrid }) => toGrid === 1) || null;

return {
Expand All @@ -26,8 +27,8 @@ export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, varian
index: variant.index || 0,
inventoryManagement: !!variant.inventoryManagement,
inventoryPolicy: !!variant.inventoryPolicy,
isLowQuantity: !!variant.isLowQuantity,
isSoldOut: !!variant.isSoldOut,
isLowQuantity: variantInventory.isLowQuantity,
isSoldOut: variantInventory.isSoldOut,
length: variant.length,
lowInventoryWarningThreshold: variant.lowInventoryWarningThreshold,
media: variantMedia,
Expand Down Expand Up @@ -95,22 +96,35 @@ export async function xformProduct({ collections, product, shop, variants }) {
.map((variant) => {
const variantOptions = options.get(variant._id);
let priceInfo;
let variantInventory;
if (variantOptions) {
const optionPrices = variantOptions.map((option) => option.price);
priceInfo = getPriceRange(optionPrices, shopCurrencyInfo);
variantInventory = {
isLowQuantity: isLowQuantity(variantOptions),
isSoldOut: isSoldOut(variantOptions)
};
} else {
priceInfo = getPriceRange([variant.price], shopCurrencyInfo);
variantInventory = {
isLowQuantity: isLowQuantity([variant]),
isSoldOut: isSoldOut([variant])
};
}
prices.push(priceInfo.min, priceInfo.max);

const variantMedia = catalogProductMedia.filter((media) => media.variantId === variant._id);

const newVariant = xformVariant(variant, priceInfo, shopCurrencyCode, variantMedia);
const newVariant = xformVariant(variant, priceInfo, shopCurrencyCode, variantMedia, variantInventory);

if (variantOptions) {
newVariant.options = variantOptions.map((option) => {
const optionMedia = catalogProductMedia.filter((media) => media.variantId === option._id);
return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia);
const optionInventory = {
isLowQuantity: isLowQuantity([option]),
isSoldOut: isSoldOut([option])
};
return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia, optionInventory);
});
}
return newVariant;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ const mockCatalogProduct = {
index: 0,
inventoryManagement: true,
inventoryPolicy: false,
isLowQuantity: true,
isLowQuantity: false,
isSoldOut: false,
length: 0,
lowInventoryWarningThreshold: 0,
Expand All @@ -311,7 +311,7 @@ const mockCatalogProduct = {
index: 0,
inventoryManagement: true,
inventoryPolicy: true,
isLowQuantity: true,
isLowQuantity: false,
isSoldOut: false,
length: 2,
lowInventoryWarningThreshold: 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Logger from "@reactioncommerce/logger";
import _ from "lodash";
import isBackorder from "./isBackorder";
import isLowQuantity from "./isLowQuantity";
import isSoldOut from "./isSoldOut";
Expand All @@ -13,6 +14,10 @@ import isSoldOut from "./isSoldOut";
* @return {Promise<boolean>} true on success, false on failure
*/
export default async function updateCatalogProductInventoryStatus(productId, collections) {
const baseKey = "product";
const topVariants = new Map();
const options = new Map();

const { Catalog, Products } = collections;
const catalogItem = await Catalog.findOne({ "product.productId": productId });

Expand All @@ -21,33 +26,74 @@ export default async function updateCatalogProductInventoryStatus(productId, col
return false;
}

const catalogProduct = catalogItem.product;

const variants = await Products.find({ ancestors: productId }).toArray();

const update = {
const modifier = {
"product.isSoldOut": isSoldOut(variants),
"product.isBackorder": isBackorder(variants),
"product.isLowQuantity": isLowQuantity(variants)
};

// Only apply changes if one of these fields have changed
if (
update["product.isSoldOut"] !== catalogProduct.isSoldOut ||
update["product.isBackorder"] !== catalogProduct.isBackorder ||
update["product.isLowQuantity"] !== catalogProduct.isLowQuantity
) {
const result = await Catalog.updateOne(
{
"product.productId": productId
},
{
$set: update
variants.forEach((variant) => {
if (variant.ancestors.length === 2) {
const parentId = variant.ancestors[1];
if (options.has(parentId)) {
options.get(parentId).push(variant);
} else {
options.set(parentId, [variant]);
}
} else {
topVariants.set(variant._id, variant);
}
});

const topVariantsFromCatalogItem = catalogItem.product.variants;

topVariantsFromCatalogItem.forEach((variant, topVariantIndex) => {
const catalogVariantOptions = variant.options || [];
const topVariantFromProductsCollection = topVariants.get(variant._id);
const variantOptionsFromProductsCollection = options.get(variant._id);
const catalogVariantOptionsMap = new Map();

catalogVariantOptions.forEach((catalogVariantOption) => {
catalogVariantOptionsMap.set(catalogVariantOption._id, catalogVariantOption);
});

// We only want the variant options that are currently published to the catalog.
// We need to be careful, not to publish variant or options to the catalog
// that an operator may not wish to be published yet.
const variantOptions = _.intersectionWith(
variantOptionsFromProductsCollection, // array to filter
catalogVariantOptions, // Items to exclude
({ _id: productVariantId }, { _id: catalogItemVariantOptionId }) => (
// Exclude options from the products collection that aren't in the catalog collection
productVariantId === catalogItemVariantOptionId
)
);

return result && result.result && result.result.ok === 1;
}
if (variantOptions) {
// Create a modifier for a variant and it's options
modifier[`${baseKey}.variants.${topVariantIndex}.isSoldOut`] = isSoldOut(variantOptions);
modifier[`${baseKey}.variants.${topVariantIndex}.isLowQuantity`] = isLowQuantity(variantOptions);
modifier[`${baseKey}.variants.${topVariantIndex}.isBackorder`] = isBackorder(variantOptions);

variantOptions.forEach((option, optionIndex) => {
modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.isSoldOut`] = isSoldOut([option]);
modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.isLowQuantity`] = isLowQuantity([option]);
modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.isBackorder`] = isBackorder([option]);
});
} else {
// Create a modifier for a top level variant only
modifier[`${baseKey}.variants.${topVariantIndex}.isSoldOut`] = isSoldOut([topVariantFromProductsCollection]);
modifier[`${baseKey}.variants.${topVariantIndex}.isLowQuantity`] = isLowQuantity([topVariantFromProductsCollection]);
modifier[`${baseKey}.variants.${topVariantIndex}.isBackorder`] = isBackorder([topVariantFromProductsCollection]);
}
});

const result = await Catalog.updateOne(
{ "product.productId": productId },
{ $set: modifier }
);

return false;
return (result && result.result && result.result.ok === 1) || false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const mockProduct = {
twitterMsg: "twitterMessage",
type: "product-simple",
updatedAt,
mockVariants,
variants: mockVariants,
vendor: "vendor",
weight: 15.6,
width: 8.4
Expand Down Expand Up @@ -211,15 +211,7 @@ test("expect true if a product's inventory has changed and is updated in the cat
expect(spec).toBe(true);
});

test("expect false if a product's inventory did not change and is not updated in the catalog collection", async () => {
mockCollections.Catalog.findOne.mockReturnValueOnce(Promise.resolve(mockCatalogItem));
mockCollections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants));
mockIsSoldOut.mockReturnValueOnce(false);
const spec = await updateCatalogProductInventoryStatus(mockProduct, mockCollections);
expect(spec).toBe(false);
});

test("expect false if a product's catalog item does not exsit", async () => {
test("expect false if a product's catalog item does not exist", async () => {
mockCollections.Catalog.findOne.mockReturnValueOnce(Promise.resolve(undefined));
const spec = await updateCatalogProductInventoryStatus(mockProduct, mockCollections);
expect(spec).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Migrations } from "meteor/percolate:migrations";
import { Catalog, Products } from "/lib/collections";
import { convertCatalogItemVariants } from "../util/convert48";
import findAndConvertInBatches from "../util/findAndConvertInBatches";

Migrations.add({
version: 48,
up() {
// Catalog
findAndConvertInBatches({
collection: Catalog,
converter: (catalogItem) => convertCatalogItemVariants(catalogItem, { Products })
});
}
});
1 change: 1 addition & 0 deletions imports/plugins/core/versions/server/migrations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ import "./44_tax_rates_pkg";
import "./45_tax_schema_changes";
import "./46_cart_item_props";
import "./47_order_ref";
import "./48_catalog_variant_inventory";
153 changes: 153 additions & 0 deletions imports/plugins/core/versions/server/util/convert48.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import _ from "lodash";

/**
*
* @method getProductQuantity
* @summary Get the number of product variants still avalible to purchase. This function can
* take only a top level variant object as a param to return the product's quantity.
* This method can also take a top level variant and an array of product variant options as
* params return the product's quantity.
* @memberof Catalog
* @param {Object} variant - A top level product variant object.
* @param {Object[]} variants - Array of product variant option objects.
* @return {number} Variant quantity
*/
export default function getProductQuantity(variant, variants = []) {
const options = variants.filter((option) => option.ancestors[1] === variant._id);
if (options && options.length) {
return options.reduce((sum, option) => sum + option.inventoryQuantity || 0, 0);
}
return variant.inventoryQuantity || 0;
}


/**
* @method isSoldOut
* @summary If all the product variants have a quantity of 0 return `true`.
* @memberof Catalog
* @param {Object[]} variants - Array with top-level variants
* @return {Boolean} true if quantity is zero.
*/
function isSoldOut(variants) {
const results = variants.map((variant) => {
const quantity = getProductQuantity(variant, variants);
return variant.inventoryManagement && quantity <= 0;
});
return results.every((result) => result);
}

/**
* @method isLowQuantity
* @summary If at least one of the product variants quantity is less than the low inventory threshold return `true`.
* @memberof Catalog
* @param {Object[]} variants - Array of child variants
* @return {boolean} low quantity or not
*/
function isLowQuantity(variants) {
const threshold = variants && variants.length && variants[0].lowInventoryWarningThreshold;
const results = variants.map((variant) => {
const quantity = getProductQuantity(variant, variants);
if (variant.inventoryManagement && quantity) {
return quantity <= threshold;
}
return false;
});
return results.some((result) => result);
}

/**
* @param {Object} item The catalog item to transform
* @param {Object} collections The catalog item to transform
* @returns {Object} The converted item document
*/
export function convertCatalogItemVariants(item, collections) {
const { Products } = collections;

// Get all variants of the product.
// All variants are needed as we need to match the currently published variants with
// their counterparts from the products collection. We don't want to the inventory numbers
// to be invalid just because a product has been set to not visible while that change has
// not yet been published to the catalog.
// The catalog will be used as the source of truth for the variants and options.
const variants = Products.find({
ancestors: item.product._id
}).fetch();

const topVariants = new Map();
const options = new Map();

variants.forEach((variant) => {
if (variant.ancestors.length === 2) {
const parentId = variant.ancestors[1];
if (options.has(parentId)) {
options.get(parentId).push(variant);
} else {
options.set(parentId, [variant]);
}
} else {
topVariants.set(variant._id, variant);
}
});

const catalogProductVariants = item.product.variants.map((variant) => {
const catalogVariantOptions = variant.options || [];
const topVariantFromProductsCollection = topVariants.get(variant._id);
const variantOptionsFromProductsCollection = options.get(variant._id);
const catalogVariantOptionsMap = new Map();

catalogVariantOptions.forEach((catalogVariantOption) => {
catalogVariantOptionsMap.set(catalogVariantOption._id, catalogVariantOption);
});

// We only want the variant options that are currently published to the catalog.
// We need to be careful, not to publish variant or options to the catalog
// that an operator may not wish to be published yet.
const variantOptions = _.intersectionWith(
variantOptionsFromProductsCollection, // array to filter
catalogVariantOptions, // Items to exclude
({ _id: productVariantId }, { _id: catalogItemVariantOptionId }) => (
// Exclude options from the products collection that aren't in the catalog collection
productVariantId === catalogItemVariantOptionId
)
);

let updatedVariantFields;
if (variantOptions) {
// For variants with options, update the inventory flags for the top-level variant and options
updatedVariantFields = {
isLowQuantity: isLowQuantity(variantOptions),
isSoldOut: isSoldOut(variantOptions),
options: variantOptions.map((option) => ({
...catalogVariantOptionsMap.get(option._id),
isLowQuantity: isLowQuantity([option]),
isSoldOut: isSoldOut([option])
}))
};
} else {
// For variants WITHOUT options, update the inventory flags for the top-level variant only
updatedVariantFields = {
isLowQuantity: isLowQuantity([topVariantFromProductsCollection]),
isSoldOut: isSoldOut([topVariantFromProductsCollection])
};
}

return {
...variant,
...updatedVariantFields
};
});

const catalogProduct = {
...item.product,
variants: catalogProductVariants
};

const doc = {
_id: item._id,
product: catalogProduct,
shopId: item.shopId,
createdAt: item.createdAt
};

return doc;
}
Loading