Skip to content

Commit

Permalink
Merge pull request #4742 from reactioncommerce/fix-4741-mikemurray-ca…
Browse files Browse the repository at this point in the history
…talog-variant-inventory

fix: 4741 catalog variant inventory flags always false
  • Loading branch information
nnnnat authored Nov 26, 2018
2 parents c575fa3 + ef00d29 commit 4e59e02
Show file tree
Hide file tree
Showing 9 changed files with 908 additions and 35 deletions.
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

0 comments on commit 4e59e02

Please sign in to comment.