From 81acae20ad23827896365cf1c1c6a84afb5dfa62 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 30 Apr 2019 15:25:32 -0500 Subject: [PATCH 01/55] feat: remove unused Inventory collection Signed-off-by: Eric Dobbertin --- imports/collections/schemas/index.js | 1 - imports/collections/schemas/inventory.js | 83 ------------------- .../node-app/core/util/defineCollections.js | 1 - .../server/migrations/59_drop_indexes.js | 6 +- imports/test-utils/helpers/mockContext.js | 1 - lib/collections/collections.js | 11 --- 6 files changed, 5 insertions(+), 98 deletions(-) delete mode 100644 imports/collections/schemas/inventory.js diff --git a/imports/collections/schemas/index.js b/imports/collections/schemas/index.js index 7f4259719ff..3bdc33e46d3 100644 --- a/imports/collections/schemas/index.js +++ b/imports/collections/schemas/index.js @@ -21,7 +21,6 @@ export * from "./catalog"; export * from "./cart"; export * from "./core"; export * from "./emails"; -export * from "./inventory"; export * from "./layouts"; export * from "./metafield"; export * from "./navigationItems"; diff --git a/imports/collections/schemas/inventory.js b/imports/collections/schemas/inventory.js deleted file mode 100644 index 9fb8b7f29d7..00000000000 --- a/imports/collections/schemas/inventory.js +++ /dev/null @@ -1,83 +0,0 @@ -import SimpleSchema from "simpl-schema"; -import { registerSchema } from "@reactioncommerce/schemas"; -import { createdAtAutoValue, updatedAtAutoValue } from "./helpers"; -import { Document, Notes } from "./orders"; -import { Metafield } from "./metafield"; -import { Workflow } from "./workflow"; - -/** - * @name Inventory - * @memberof Schemas - * @type {SimpleSchema} - * @property {String} _id optional, inserted by Mongo, we need it for schema validation - * @property {String} shopId required, Inventory shopId - * @property {String} productId required - * @property {String} variantId required - * @property {String} orderItemId optional - * @property {Workflow} workflow optional - * @property {String} sku optional - * @property {Metafield[]} metafields optional - * @property {Document[]} documents optional - * @property {Notes[]} notes optional - * @property {Date} createdAt optional, but consider it temporary: schema validation failing in method with required - * @property {Date} updatedAt optional - */ -export const Inventory = new SimpleSchema({ - "_id": { - type: String, - optional: true - }, - "shopId": { - type: String, - label: "Inventory ShopId" - }, - "productId": String, - "variantId": String, - "orderItemId": { - type: String, - optional: true - }, - "workflow": { - type: Workflow, - optional: true, - defaultValue: {} - }, - "sku": { - label: "sku", - type: String, - optional: true - }, - "metafields": { - type: Array, - optional: true - }, - "metafields.$": { - type: Metafield - }, - "documents": { - type: Array, - optional: true - }, - "documents.$": { - type: Document - }, - "notes": { - type: Array, - optional: true - }, - "notes.$": { - type: Notes - }, - "createdAt": { - type: Date, - optional: true, - autoValue: createdAtAutoValue - }, - "updatedAt": { - type: Date, - autoValue: updatedAtAutoValue, - optional: true - } -}); - -registerSchema("Inventory", Inventory); diff --git a/imports/node-app/core/util/defineCollections.js b/imports/node-app/core/util/defineCollections.js index 16ebecd3fe4..c65c9c808d3 100644 --- a/imports/node-app/core/util/defineCollections.js +++ b/imports/node-app/core/util/defineCollections.js @@ -17,7 +17,6 @@ export default function defineCollections(db, collections) { Discounts: db.collection("Discounts"), Emails: db.collection("Emails"), Groups: db.collection("Groups"), - Inventory: db.collection("Inventory"), MediaRecords: db.collection("cfs.Media.filerecord"), NavigationItems: db.collection("NavigationItems"), NavigationTrees: db.collection("NavigationTrees"), diff --git a/imports/plugins/core/versions/server/migrations/59_drop_indexes.js b/imports/plugins/core/versions/server/migrations/59_drop_indexes.js index 7b6e466370e..21f41811eba 100644 --- a/imports/plugins/core/versions/server/migrations/59_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/59_drop_indexes.js @@ -1,12 +1,16 @@ import { Migrations } from "meteor/percolate:migrations"; +import { MongoInternals } from "meteor/mongo"; import Logger from "@reactioncommerce/logger"; import rawCollections from "/imports/collections/rawCollections"; +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +const Inventory = db.collection("Inventory"); + const { Accounts, Cart, Discounts, - Inventory, Orders, Packages, Products, diff --git a/imports/test-utils/helpers/mockContext.js b/imports/test-utils/helpers/mockContext.js index 374faf76080..702e63da8f0 100644 --- a/imports/test-utils/helpers/mockContext.js +++ b/imports/test-utils/helpers/mockContext.js @@ -62,7 +62,6 @@ export function mockCollection(collectionName) { "Catalog", "Emails", "Groups", - "Inventory", "MediaRecords", "NavigationItems", "NavigationTrees", diff --git a/lib/collections/collections.js b/lib/collections/collections.js index 97a1102234b..c7e65597af2 100644 --- a/lib/collections/collections.js +++ b/lib/collections/collections.js @@ -69,17 +69,6 @@ export const Emails = new Mongo.Collection("Emails"); Emails.attachSchema(Schemas.Emails); - -/** - * @name Inventory - * @memberof Collections - * @type {MongoCollection} - */ -export const Inventory = new Mongo.Collection("Inventory"); - -Inventory.attachSchema(Schemas.Inventory); - - /** * @name Orders * @memberof Collections From 22805b6f89c8be5fc11676de0313c89b3c8ad291 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 30 Apr 2019 16:53:04 -0500 Subject: [PATCH 02/55] refactor: remove setting inventory on Product Signed-off-by: Eric Dobbertin --- .../core/catalog/server/methods/catalog.js | 111 ++---------------- 1 file changed, 9 insertions(+), 102 deletions(-) diff --git a/imports/plugins/core/catalog/server/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js index 54e7e33d5b7..ba9ccb90204 100644 --- a/imports/plugins/core/catalog/server/methods/catalog.js +++ b/imports/plugins/core/catalog/server/methods/catalog.js @@ -15,10 +15,6 @@ import getGraphQLContextInMeteorMethod from "/imports/plugins/core/graphql/serve import hashProduct from "../no-meteor/mutations/hashProduct"; import getProductPriceRange from "../no-meteor/utils/getProductPriceRange"; import getVariants from "../no-meteor/utils/getVariants"; -import hasChildVariant from "../no-meteor/utils/hasChildVariant"; -import isSoldOut from "/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut"; -import isLowQuantity from "/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity"; -import isBackorder from "/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder"; /* eslint new-cap: 0 */ /* eslint no-loop-func: 0 */ @@ -51,11 +47,7 @@ function updateVariantProductField(variants, field, value) { * @type {string[]} */ const toDenormalize = [ - "price", - "inventoryInStock", - "lowInventoryWarningThreshold", - "inventoryPolicy", - "inventoryManagement" + "price" ]; /** @@ -211,55 +203,21 @@ function copyMedia(newId, variantOldId, variantNewId) { * @function denormalize * @private * @description With flattened model we do not want to get variant docs in - * `products` publication, but we need some data from variants to display price, - * quantity, etc. That's why we are denormalizing these properties into product + * `products` publication, but we need some data from variants to display price. + * That's why we are denormalizing these properties into product * doc. Also, this way should have a speed benefit comparing the way where we * could dynamically build denormalization inside `products` publication. * @summary update product denormalized properties if variant was updated or * removed * @param {String} id - product _id - * @param {String} field - type of field. Could be: - * "price", - * "inventoryInStock", - * "inventoryManagement", - * "inventoryPolicy", - * "lowInventoryWarningThreshold" * @since 0.11.0 * @return {Number} - number of successful update operations. Should be "1". */ -function denormalize(id, field) { - const doc = Products.findOne(id); - let variants; - if (doc.type === "simple") { - variants = Promise.await(getVariants(id, rawCollections, true)); - } else if (doc.type === "variant" && doc.ancestors.length === 1) { - variants = Promise.await(getVariants(id, rawCollections)); - } +function denormalize(id) { const update = {}; - switch (field) { - case "inventoryPolicy": - case "inventoryInStock": - case "inventoryManagement": - Object.assign(update, { - isSoldOut: Promise.await(isSoldOut(variants, rawCollections)), - isLowQuantity: Promise.await(isLowQuantity(variants, rawCollections)), - isBackorder: Promise.await(isBackorder(variants, rawCollections)) - }); - break; - case "lowInventoryWarningThreshold": - Object.assign(update, { - isLowQuantity: Promise.await(isLowQuantity(variants, rawCollections)) - }); - break; - default: { - // "price" is object with range, min, max - const priceObject = Promise.await(getProductPriceRange(id, rawCollections)); - Object.assign(update, { - price: priceObject - }); - } - } + // "price" is object with range, min, max + update.price = Promise.await(getProductPriceRange(id, rawCollections)); Products.update( id, @@ -274,43 +232,6 @@ function denormalize(id, field) { ); } -/** - * flushQuantity - * @private - * @summary if variant `inventoryInStock` not zero, function update it to - * zero. This needed in case then option with it's own `inventoryInStock` - * creates to top-level variant. In that case top-level variant should display - * sum of his options `inventoryInStock` fields. - * @param {String} id - variant _id - * @return {Number} - collection update results - */ -function flushQuantity(id) { - const variant = Products.findOne(id); - // if variant already have descendants, quantity should be 0, and we don't - // need to do all next actions - if (variant.inventoryInStock === 0) { - return 1; // let them think that we have one successful operation here - } - - const productUpdate = Products.update( - { - _id: id - }, - { - $set: { - inventoryInStock: 0 - } - }, - { - selector: { - type: "variant" - } - } - ); - - return productUpdate; -} - /** * @function createProduct * @private @@ -484,8 +405,6 @@ Meteor.methods({ } delete clone.updatedAt; delete clone.createdAt; - delete clone.inventoryInStock; - delete clone.lowInventoryWarningThreshold; copyMedia(productId, oldId, clone._id); @@ -556,18 +475,12 @@ Meteor.methods({ const isOption = ancestors.length > 1; if (isOption) { Object.assign(newVariant, { - title: `${parent.title} - Untitled option`, - price: 0.0 + optionTitle: "Untitled", + price: 0.0, + title: `${parent.title} - Untitled` }); } - // if we are inserting child variant to top-level variant, we need to remove - // all top-level's variant inventory records and flush it's quantity, - // because it will be hold sum of all it descendants quantities. - if (ancestors.length === 2) { - flushQuantity(parentId); - } - createProduct(newVariant, { product, parentVariant, isOption }); Logger.debug(`products/createVariant: created variant: ${newVariantId} for ${parentId}`); @@ -970,12 +883,6 @@ Meteor.methods({ throw new ReactionError("access-denied", "Access Denied"); } - if (field === "inventoryInStock" && value === "") { - if (!Promise.await(hasChildVariant(_id, rawCollections))) { - throw new ReactionError("invalid", "Inventory Quantity is required when no child variants"); - } - } - const { type } = doc; let update; // handle booleans with correct typing From 49e24caf6d73f2953bd2281b7579316e4e9d9e40 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 30 Apr 2019 18:03:52 -0500 Subject: [PATCH 03/55] refactor: move inventory field catalog publish to inventory core plugin Signed-off-by: Eric Dobbertin --- .../no-meteor/mutations/hashProduct.test.js | 3 - .../mutations/publishProducts.test.js | 14 --- .../no-meteor/utils/createCatalogProduct.js | 56 ++------- .../utils/createCatalogProduct.test.js | 44 ------- imports/plugins/core/inventory/register.js | 4 + .../server/no-meteor/queries/index.js | 7 ++ .../inventoryForProductConfiguration.js | 22 ++++ .../inventoryForProductConfigurations.js | 118 ++++++++++++++++++ .../utils/publishProductToCatalog.js | 27 ++++ 9 files changed, 188 insertions(+), 107 deletions(-) create mode 100644 imports/plugins/core/inventory/server/no-meteor/queries/index.js create mode 100644 imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfiguration.js create mode 100644 imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js create mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js index c3eccf352e3..89ca3a578fc 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js @@ -30,9 +30,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js index bd75b602e35..a27469ec1b3 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js @@ -41,8 +41,6 @@ const mockVariants = [ inventoryManagement: true, inventoryPolicy: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, lowInventoryWarningThreshold: 0, @@ -79,8 +77,6 @@ const mockVariants = [ inventoryManagement: true, inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, lowInventoryWarningThreshold: 0, @@ -120,9 +116,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ @@ -195,8 +188,6 @@ const expectedOptionsResponse = [ index: 0, inventoryManagement: true, inventoryPolicy: true, - isLowQuantity: true, - isSoldOut: false, length: 2, lowInventoryWarningThreshold: 0, metafields: [ @@ -245,8 +236,6 @@ const expectedVariantsResponse = [ index: 0, inventoryManagement: true, inventoryPolicy: false, - isLowQuantity: true, - isSoldOut: false, length: 0, lowInventoryWarningThreshold: 0, metafields: [ @@ -302,9 +291,6 @@ const expectedItemsResponse = { createdAt: createdAt.toISOString(), description: "description", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index b61aec614dc..2a53945cd14 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -1,10 +1,6 @@ import Logger from "@reactioncommerce/logger"; -import canBackorder from "./canBackorder"; import getCatalogProductMedia from "./getCatalogProductMedia"; import getPriceRange from "./getPriceRange"; -import isBackorder from "/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder"; -import isLowQuantity from "/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity"; -import isSoldOut from "/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut"; /** * @method @@ -13,27 +9,21 @@ import isSoldOut from "/imports/plugins/core/inventory/server/no-meteor/utils/is * @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, variantInventory) { +export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, variantMedia) { const primaryImage = variantMedia.find(({ toGrid }) => toGrid === 1) || null; return { _id: variant._id, barcode: variant.barcode, - canBackorder: variantInventory.canBackorder, + canBackorder: !variant.inventoryPolicy, createdAt: variant.createdAt || new Date(), height: variant.height, index: variant.index || 0, - inventoryAvailableToSell: variantInventory.inventoryAvailableToSell || 0, - inventoryInStock: variantInventory.inventoryInStock || 0, inventoryManagement: !!variant.inventoryManagement, inventoryPolicy: !!variant.inventoryPolicy, - isBackorder: variantInventory.isBackorder, - isLowQuantity: variantInventory.isLowQuantity, - isSoldOut: variantInventory.isSoldOut, length: variant.length, lowInventoryWarningThreshold: variant.lowInventoryWarningThreshold, media: variantMedia, @@ -66,13 +56,15 @@ export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, varian /** * @summary The core function for transforming a Product to a CatalogProduct * @param {Object} data Data obj - * @param {Object} data.collections Map of MongoDB collections by name + * @param {Object} data.context App context * @param {Object} data.product The source product * @param {Object} data.shop The Shop document for the shop that owns the product * @param {Object[]} data.variants The Product documents for all variants of this product * @returns {Object} The CatalogProduct document */ -export async function xformProduct({ collections, product, shop, variants }) { +export async function xformProduct({ context, product, shop, variants }) { + const { collections } = context; + const shopCurrencyCode = shop.currency; const shopCurrencyInfo = shop.currencies[shopCurrencyCode]; @@ -99,49 +91,24 @@ export async function xformProduct({ collections, product, shop, variants }) { const catalogProductVariants = topVariants // We want to explicitly map everything so that new properties added to variant are not published to a catalog unless we want them .map((variant) => { - const variantOptions = options.get(variant._id); let priceInfo; - let variantInventory; + const variantOptions = options.get(variant._id); if (variantOptions) { const optionPrices = variantOptions.map((option) => option.price); priceInfo = getPriceRange(optionPrices, shopCurrencyInfo); - variantInventory = { - canBackorder: canBackorder(variantOptions), - inventoryAvailableToSell: variant.inventoryAvailableToSell || 0, - inventoryInStock: variant.inventoryInStock || 0, - isBackorder: isBackorder(variantOptions), - isLowQuantity: isLowQuantity(variantOptions), - isSoldOut: isSoldOut(variantOptions) - }; } else { priceInfo = getPriceRange([variant.price], shopCurrencyInfo); - variantInventory = { - canBackorder: canBackorder([variant]), - inventoryAvailableToSell: variant.inventoryAvailableToSell || 0, - inventoryInStock: variant.inventoryInStock || 0, - isBackorder: isBackorder([variant]), - 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, variantInventory); + const newVariant = xformVariant(variant, priceInfo, shopCurrencyCode, variantMedia); if (variantOptions) { newVariant.options = variantOptions.map((option) => { const optionMedia = catalogProductMedia.filter((media) => media.variantId === option._id); - const optionInventory = { - canBackorder: canBackorder([option]), - inventoryAvailableToSell: option.inventoryAvailableToSell, - inventoryInStock: option.inventoryInStock, - isBackorder: isBackorder([option]), - isLowQuantity: isLowQuantity([option]), - isSoldOut: isSoldOut([option]) - }; - return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia, optionInventory); + return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia); }); } return newVariant; @@ -158,10 +125,7 @@ export async function xformProduct({ collections, product, shop, variants }) { height: product.height, inventoryAvailableToSell: product.inventoryAvailableToSell || 0, inventoryInStock: product.inventoryInStock || 0, - isBackorder: isBackorder(variants), isDeleted: !!product.isDeleted, - isLowQuantity: isLowQuantity(variants), - isSoldOut: isSoldOut(variants), isVisible: !!product.isVisible, length: product.length, lowInventoryWarningThreshold: product.lowInventoryWarningThreshold, @@ -249,7 +213,7 @@ export default async function createCatalogProduct(product, context) { isVisible: { $ne: false } }).toArray(); - const catalogProduct = await xformProduct({ collections, product, shop, variants }); + const catalogProduct = await xformProduct({ context, product, shop, variants }); // Apply custom transformations from plugins. for (const customPublishFn of getFunctionsOfType("publishProductToCatalog")) { diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js index bc8b9a84c84..b84d953c770 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js @@ -3,9 +3,6 @@ import { rewire as rewire$getCatalogProductMedia, restore as restore$getCatalogProductMedia } from "./getCatalogProductMedia"; -import { rewire as rewire$isBackorder, restore as restore$isBackorder } from "/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder"; -import { rewire as rewire$isLowQuantity, restore as restore$isLowQuantity } from "/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity"; -import { rewire as rewire$isSoldOut, restore as restore$isSoldOut } from "/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut"; import createCatalogProduct, { restore as restore$createCatalogProduct, rewire$xformProduct } from "./createCatalogProduct"; const internalShopId = "123"; @@ -35,10 +32,7 @@ const mockVariants = [ inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: false, - isBackorder: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, lowInventoryWarningThreshold: 0, @@ -76,10 +70,7 @@ const mockVariants = [ inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: true, - isBackorder: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, lowInventoryWarningThreshold: 0, @@ -119,9 +110,6 @@ const mockProduct = { height: 11.23, inventoryAvailableToSell: 10, inventoryInStock: 0, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ @@ -204,10 +192,7 @@ const mockCatalogProduct = { height: 11.23, inventoryAvailableToSell: 10, inventoryInStock: 0, - isBackorder: false, isDeleted: false, - isLowQuantity: false, - isSoldOut: false, isVisible: false, length: 5.67, lowInventoryWarningThreshold: 2, @@ -299,13 +284,8 @@ const mockCatalogProduct = { createdAt, height: 0, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - isBackorder: false, inventoryManagement: true, inventoryPolicy: false, - isLowQuantity: false, - isSoldOut: false, length: 0, lowInventoryWarningThreshold: 0, media: [], @@ -326,13 +306,8 @@ const mockCatalogProduct = { createdAt, height: 2, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: true, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 2, lowInventoryWarningThreshold: 0, media: [{ @@ -435,30 +410,11 @@ const mockGeCatalogProductMedia = jest } ])); -const mockIsBackorder = jest - .fn() - .mockName("isBackorder") - .mockReturnValue(false); -const mockIsLowQuantity = jest - .fn() - .mockName("isLowQuantity") - .mockReturnValue(false); -const mockIsSoldOut = jest - .fn() - .mockName("isSoldOut") - .mockReturnValue(false); - beforeAll(() => { rewire$getCatalogProductMedia(mockGeCatalogProductMedia); - rewire$isBackorder(mockIsBackorder); - rewire$isLowQuantity(mockIsLowQuantity); - rewire$isSoldOut(mockIsSoldOut); }); afterAll(() => { - restore$isBackorder(); - restore$isLowQuantity(); - restore$isSoldOut(); restore$getCatalogProductMedia(); restore$createCatalogProduct(); }); diff --git a/imports/plugins/core/inventory/register.js b/imports/plugins/core/inventory/register.js index 95c02df7283..27a9bd15a99 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -1,7 +1,9 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; import config from "./server/config"; +import queries from "./server/no-meteor/queries"; import startup from "./server/no-meteor/startup"; import schemas from "./server/no-meteor/schemas"; +import publishProductToCatalog from "./server/no-meteor/utils/publishProductToCatalog"; import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; const publishedProductFields = []; @@ -38,9 +40,11 @@ Reaction.registerPackage({ name: "reaction-inventory", autoEnable: true, functionsByType: { + publishProductToCatalog: [publishProductToCatalog], startup: [startup], xformCatalogBooleanFilters: [xformCatalogBooleanFilters] }, + queries, graphQL: { schemas }, diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/index.js b/imports/plugins/core/inventory/server/no-meteor/queries/index.js new file mode 100644 index 00000000000..9f017788c73 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/queries/index.js @@ -0,0 +1,7 @@ +import inventoryForProductConfiguration from "./inventoryForProductConfiguration"; +import inventoryForProductConfigurations from "./inventoryForProductConfigurations"; + +export default { + inventoryForProductConfiguration, + inventoryForProductConfigurations +}; diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfiguration.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfiguration.js new file mode 100644 index 00000000000..4b5b5b1c4e1 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfiguration.js @@ -0,0 +1,22 @@ +/** + * @summary Returns an object with inventory information for a single + * product configuration. Convenience wrapper for `inventoryForProductConfigurations`. + * For performance, it is better to call `inventoryForProductConfigurations` once + * rather than calling this function in a loop. + * @param {Object} context App context + * @param {Object} input Additional input arguments + * @param {Object} input.productConfiguration A ProductConfiguration object + * @param {String[]} [input.fields] Optional array of fields you need. If you don't need all, + * you can pass this to skip some calculations and database lookups, improving speed. + * @return {Promise} InventoryInfo + */ +export default async function inventoryForProductConfiguration(context, input) { + const { fields, productConfiguration } = input; + + const result = await context.queries.inventoryForProductConfigurations(context, { + fields, + productConfigurations: [productConfiguration] + }); + + return result[0].inventoryInfo; +} diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js new file mode 100644 index 00000000000..6f686210acb --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -0,0 +1,118 @@ +import getVariantInventoryAvailableToSellQuantity from "../utils/getVariantInventoryAvailableToSellQuantity"; +import getVariantInventoryInStockQuantity from "../utils/getVariantInventoryInStockQuantity"; +import getVariantInventoryNotAvailableToSellQuantity from "../utils/getVariantInventoryNotAvailableToSellQuantity"; +import isBackorderFn from "../utils/isBackorder"; +import isLowQuantityFn from "../utils/isLowQuantity"; +import isSoldOutFn from "../utils/isSoldOut"; + +const ALL_FIELDS = [ + "inventoryAvailableToSell", + "inventoryInStock", + "inventoryReserved", + "isBackorder", + "isLowQuantity", + "isSoldOut" +]; + +const DEFAULT_INFO = { + inventoryAvailableToSell: 0, + inventoryInStock: 0, + inventoryReserved: 0, + isBackorder: true, + isLowQuantity: true, + isSoldOut: true +}; + +/** + * @summary Returns an object with inventory information for one or more + * product configurations. For performance, it is better to call this + * function once rather than calling `inventoryForProductConfiguration` + * (singular) in a loop. + * @param {Object} context App context + * @param {Object} input Additional input arguments + * @param {Object[]} input.productConfigurations An array of ProductConfiguration objects + * @param {String[]} [input.fields] Optional array of fields you need. If you don't need all, + * you can pass this to skip some calculations and database lookups, improving speed. + * @param {Object[]} [input.variants] Optionally pass an array of the relevant variants if + * you have already looked them up. This will save a database query. + * @return {Promise} Array of responses, in same order as `input.productConfigurations` array. + */ +export default async function inventoryForProductConfigurations(context, input) { + const { collections } = context; + const { Products } = collections; + const { + fields = ALL_FIELDS, + productConfigurations + } = input; + let { variants } = input; + + const variantIds = productConfigurations.map(({ variantId }) => variantId); + + if (!variants) { + variants = await Products.find({ + $or: [ + { _id: { $in: variantIds } }, + { ancestors: { $in: variantIds } } + ] + }).toArray(); + } + + return Promise.all(productConfigurations.map(async (productConfiguration) => { + const { variantId } = productConfiguration; + + const variant = variants.find((listVariant) => listVariant._id === variantId); + if (!variant) { + return { + inventoryInfo: DEFAULT_INFO, + productConfiguration + }; + } + + let inventoryAvailableToSell = null; + let inventoryInStock = null; + let inventoryReserved = null; + let isBackorder = null; + let isLowQuantity = null; + let isSoldOut = null; + + if (fields.includes("inventoryAvailableToSell")) { + inventoryAvailableToSell = await getVariantInventoryAvailableToSellQuantity(variant, collections, variants); + } + + if (fields.includes("inventoryInStock")) { + inventoryInStock = await getVariantInventoryInStockQuantity(variant, collections, variants); + } + + if (fields.includes("inventoryReserved")) { + inventoryReserved = await getVariantInventoryNotAvailableToSellQuantity(variant, collections); + } + + if (fields.includes("isBackorder") || fields.includes("isLowQuantity") || fields.includes("isSoldOut")) { + const variantOptions = variants.filter((listVariant) => listVariant.ancestors.includes(variantId)); + + if (fields.includes("isBackorder")) { + isBackorder = variantOptions.length ? isBackorderFn(variantOptions) : isBackorderFn([variant]); + } + + if (fields.includes("isLowQuantity")) { + isLowQuantity = variantOptions.length ? isLowQuantityFn(variantOptions) : isLowQuantityFn([variant]); + } + + if (fields.includes("isSoldOut")) { + isSoldOut = variantOptions.length ? isSoldOutFn(variantOptions) : isSoldOutFn([variant]); + } + } + + return { + inventoryInfo: { + inventoryAvailableToSell, + inventoryInStock, + inventoryReserved, + isBackorder, + isLowQuantity, + isSoldOut + }, + productConfiguration + }; + })); +} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js new file mode 100644 index 00000000000..7d270a918e4 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js @@ -0,0 +1,27 @@ +/** + * @summary Publishes our plugin-specific product fields to the catalog + * @param {Object} catalogProduct The catalog product that is being built. Should mutate this. + * @param {Object} input Input data + * @returns {undefined} + */ +export default async function publishProductToCatalog(catalogProduct, { context, variants }) { + // Most inventory information is looked up and included at read time, when + // preparing a response to a GraphQL query, but we need to store these + // three boolean flags in the Catalog collection to enable sorting + // catalogItems query results by them. + const topVariants = variants.filter((variant) => variant.ancestors.length === 1); + + const topVariantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { + productConfigurations: topVariants.map((option) => ({ + productId: option.ancestors[0], + variantId: option._id + })), + fields: ["isBackorder", "isLowQuantity", "isSoldOut"], + variants + }); + + // Mutate the catalog product to be saved + catalogProduct.isBackorder = topVariantsInventoryInfo.every(({ inventoryInfo }) => inventoryInfo.isBackorder); + catalogProduct.isLowQuantity = topVariantsInventoryInfo.some(({ inventoryInfo }) => inventoryInfo.isLowQuantity); + catalogProduct.isSoldOut = topVariantsInventoryInfo.every(({ inventoryInfo }) => inventoryInfo.isSoldOut); +} From ed20a40b75cb72ad245123eaff9d99ef395c00c2 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 06:26:27 -0500 Subject: [PATCH 04/55] fix: remove moment use to address errors Signed-off-by: Eric Dobbertin --- .../included/jobcontrol/server/jobs/cleanup.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/imports/plugins/included/jobcontrol/server/jobs/cleanup.js b/imports/plugins/included/jobcontrol/server/jobs/cleanup.js index 5b4dfc6d1ab..a91fcc1c09d 100644 --- a/imports/plugins/included/jobcontrol/server/jobs/cleanup.js +++ b/imports/plugins/included/jobcontrol/server/jobs/cleanup.js @@ -2,12 +2,6 @@ import Logger from "@reactioncommerce/logger"; import appEvents from "/imports/node-app/core/util/appEvents"; import { Job, Jobs } from "/imports/utils/jobs"; -let moment; -async function lazyLoadMoment() { - if (moment) return; - moment = await import("moment").default; -} - /** * @summary Adds a "jobServerStart" event consumer, which registers * a job to remove stale jobs. @@ -32,6 +26,10 @@ export function addCleanupJobControlHook() { }); } +/** + * @summary Cleanup job worker + * @return {undefined} + */ export function cleanupJob() { const removeStaleJobs = Jobs.processJobs("jobControl/removeStaleJobs", { pollInterval: 60 * 60 * 1000, // backup polling, see observer below @@ -40,8 +38,8 @@ export function cleanupJob() { Logger.debug("Processing jobControl/removeStaleJobs..."); // TODO: set this interval in the admin UI - Promise.await(lazyLoadMoment()); - const olderThan = moment().subtract(3, "days")._d; + const threeDays = 3 * 24 * 60 * 60 * 1000; + const olderThan = new Date(Date.now() - threeDays); const ids = Jobs.find({ type: { @@ -57,7 +55,7 @@ export function cleanupJob() { fields: { _id: 1 } - }).map((d) => d._id); + }).map((jobDoc) => jobDoc._id); let success; if (ids.length > 0) { From bdae5b5cb56d76477166c71e78c907c71094920c Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 09:24:48 -0500 Subject: [PATCH 05/55] refactor: move Catalog inventory fields to inventory plugin Signed-off-by: Eric Dobbertin --- imports/collections/schemas/catalog.js | 51 ------------------- .../core/inventory/lib/extendCoreSchemas.js | 12 +++++ .../plugins/core/inventory/server/index.js | 1 + tests/mocks/mockCatalogProducts.js | 6 --- 4 files changed, 13 insertions(+), 57 deletions(-) create mode 100644 imports/plugins/core/inventory/lib/extendCoreSchemas.js create mode 100644 imports/plugins/core/inventory/server/index.js diff --git a/imports/collections/schemas/catalog.js b/imports/collections/schemas/catalog.js index a123f2a2e04..be679e8f73f 100644 --- a/imports/collections/schemas/catalog.js +++ b/imports/collections/schemas/catalog.js @@ -126,13 +126,8 @@ export const SocialMetadata = new SimpleSchema({ * @property {Date} createdAt required * @property {Number} height optional, default value: `0` * @property {Number} index required - * @property {Boolean} inventoryAvailableToSell required, The quantity of this item currently available to sell. This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). If this is a variant, this number is created by summing all child option inventory numbers. This is most likely the quantity to display in the storefront UI. - * @property {Boolean} inventoryInStock required, The quantity of this item currently in stock. This number is updated when an order is processed by the operator. This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). If this is a variant, this number is created by summing all child option inventory numbers. This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI. * @property {Boolean} inventoryManagement required, True if inventory management is enabled for this variant * @property {Boolean} inventoryPolicy required, True if inventory policy is enabled for this variant - * @property {Boolean} isBackorder required, Indicates when a product is currently backordered - * @property {Boolean} isLowQuantity required, Indicates that the product quantity is too low - * @property {Boolean} isSoldOut required, Indicates when the product quantity is zero * @property {Number} length optional, default value: `0` * @property {Number} lowInventoryWarningThreshold optional, default value: `0` * @property {ImageInfo[]} media optional @@ -180,14 +175,6 @@ export const VariantBaseSchema = new SimpleSchema({ type: SimpleSchema.Integer, label: "The position of this variant among other variants at the same level of the product-variant-option hierarchy" }, - "inventoryAvailableToSell": { - type: SimpleSchema.Integer, - label: "Inventory available to sell" - }, - "inventoryInStock": { - type: SimpleSchema.Integer, - label: "Inventory in stock" - }, "inventoryManagement": { type: Boolean, label: "Inventory management" @@ -196,19 +183,6 @@ export const VariantBaseSchema = new SimpleSchema({ type: Boolean, label: "Inventory policy" }, - "isBackorder": { - type: Boolean, - label: "Is backordered", - defaultValue: false - }, - "isLowQuantity": { - type: Boolean, - label: "Is low quantity" - }, - "isSoldOut": { - type: Boolean, - label: "Is sold out" - }, "length": { type: Number, label: "Length", @@ -334,11 +308,6 @@ export const CatalogVariantSchema = VariantBaseSchema.clone().extend({ * @property {Date} createdAt required * @property {String} description optional * @property {Number} height optional, default value: `0` - * @property {Boolean} inventoryAvailableToSell required, The quantity of this item currently available to sell. This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). If this is a variant, this number is created by summing all child option inventory numbers. This is most likely the quantity to display in the storefront UI. - * @property {Boolean} inventoryInStock required, The quantity of this item currently in stock. This number is updated when an order is processed by the operator. This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). If this is a variant, this number is created by summing all child option inventory numbers. This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI. - * @property {Boolean} isBackorder required, Indicates when a product is currently backordered - * @property {Boolean} isLowQuantity required, Indicates that the product quantity is too low - * @property {Boolean} isSoldOut required, Indicates when the product quantity is zero * @property {Boolean} isVisible required, default value: `false` * @property {Number} length optional, default value: `0` * @property {Number} lowInventoryWarningThreshold optional, default value: `0` @@ -394,31 +363,11 @@ export const CatalogProduct = new SimpleSchema({ optional: true, defaultValue: 0 }, - "inventoryAvailableToSell": { - type: SimpleSchema.Integer, - label: "Inventory available to sell" - }, - "inventoryInStock": { - type: SimpleSchema.Integer, - label: "Inventory in stock" - }, - "isBackorder": { - type: Boolean, - label: "Is backorder" - }, "isDeleted": { type: Boolean, label: "Is deleted", defaultValue: false }, - "isLowQuantity": { - type: Boolean, - label: "Is low quantity" - }, - "isSoldOut": { - type: Boolean, - label: "Is sold out" - }, "isVisible": { type: Boolean, label: "Indicates if a product is visible to non-admin users", diff --git a/imports/plugins/core/inventory/lib/extendCoreSchemas.js b/imports/plugins/core/inventory/lib/extendCoreSchemas.js new file mode 100644 index 00000000000..5f04cce1b82 --- /dev/null +++ b/imports/plugins/core/inventory/lib/extendCoreSchemas.js @@ -0,0 +1,12 @@ +import { CatalogProduct } from "/imports/collections/schemas"; + +/** + * @property {Boolean} isBackorder required, Indicates when a product is currently backordered + * @property {Boolean} isLowQuantity required, Indicates that the product quantity is too low + * @property {Boolean} isSoldOut required, Indicates when the product quantity is zero + */ +CatalogProduct.extend({ + isBackorder: Boolean, + isLowQuantity: Boolean, + isSoldOut: Boolean +}); diff --git a/imports/plugins/core/inventory/server/index.js b/imports/plugins/core/inventory/server/index.js new file mode 100644 index 00000000000..848f1abfaf2 --- /dev/null +++ b/imports/plugins/core/inventory/server/index.js @@ -0,0 +1 @@ +import "../lib/extendCoreSchemas"; diff --git a/tests/mocks/mockCatalogProducts.js b/tests/mocks/mockCatalogProducts.js index e051f6bf901..30bb135ff86 100644 --- a/tests/mocks/mockCatalogProducts.js +++ b/tests/mocks/mockCatalogProducts.js @@ -357,9 +357,6 @@ export const mockInternalCatalogProducts = [ createdAt: createdAt.toISOString(), description: "description", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, isVisible: true, length: 5.67, lowInventoryWarningThreshold: 2, @@ -453,9 +450,6 @@ export const mockInternalCatalogProducts = [ createdAt: createdAt.toISOString(), description: "description", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, isVisible: true, length: 5.67, lowInventoryWarningThreshold: 2, From de84b56ce747b0d891821cf42640fa3b2b9ceadf Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 09:25:07 -0500 Subject: [PATCH 06/55] test: update test mocks Signed-off-by: Eric Dobbertin --- .../utils/getProductPriceRange.test.js | 3 -- .../utils/getTopLevelProduct.test.js | 3 -- .../utils/publishProductToCatalog.test.js | 33 ------------------- .../utils/publishProductToCatalogById.test.js | 3 -- .../utils/publishProductsToCatalog.test.js | 3 -- 5 files changed, 45 deletions(-) diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js index 3b2274be980..03e19765fe2 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js @@ -109,9 +109,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js index a7a09fa9799..1df4e71bda9 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js @@ -108,9 +108,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js index 7ce3198a75c..0200bace45b 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js @@ -3,9 +3,6 @@ import { rewire as rewire$getCatalogProductMedia, restore as restore$getCatalogProductMedia } from "./getCatalogProductMedia"; -import { rewire as rewire$isBackorder, restore as restore$isBackorder } from "/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder"; -import { rewire as rewire$isLowQuantity, restore as restore$isLowQuantity } from "/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity"; -import { rewire as rewire$isSoldOut, restore as restore$isSoldOut } from "/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut"; import { rewire as rewire$createCatalogProduct, restore as restore$createCatalogProduct } from "./createCatalogProduct"; import publishProductToCatalog from "./publishProductToCatalog"; @@ -33,9 +30,6 @@ const mockVariants = [ inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: false, - isBackorder: false, - isLowQuantity: true, - isSoldOut: false, length: 0, lowInventoryWarningThreshold: 0, metafields: [ @@ -74,9 +68,6 @@ const mockVariants = [ inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: false, - isBackorder: false, - isLowQuantity: true, - isSoldOut: false, length: 5, lowInventoryWarningThreshold: 8, metafields: [ @@ -114,10 +105,7 @@ const mockProduct = { height: 11.23, inventoryAvailableToSell: 0, inventoryInStock: 0, - isBackorder: false, isDeleted: false, - isLowQuantity: false, - isSoldOut: false, isVisible: true, length: 5.67, lowInventoryWarningThreshold: 2, @@ -178,9 +166,6 @@ const updatedMockProduct = { height: 11.23, inventoryAvailableToSell: 0, inventoryInStock: 0, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ @@ -277,18 +262,6 @@ const mockGeCatalogProductMedia = jest } ])); -const mockIsBackorder = jest - .fn() - .mockName("isBackorder") - .mockReturnValue(false); -const mockIsLowQuantity = jest - .fn() - .mockName("isLowQuantity") - .mockReturnValue(false); -const mockIsSoldOut = jest - .fn() - .mockName("isSoldOut") - .mockReturnValue(false); const mockCreateCatalogProduct = jest .fn() .mockName("createCatalogProduct") @@ -296,16 +269,10 @@ const mockCreateCatalogProduct = jest beforeAll(() => { rewire$getCatalogProductMedia(mockGeCatalogProductMedia); - rewire$isBackorder(mockIsBackorder); - rewire$isLowQuantity(mockIsLowQuantity); - rewire$isSoldOut(mockIsSoldOut); rewire$createCatalogProduct(mockCreateCatalogProduct); }); afterAll(() => { - restore$isBackorder(); - restore$isLowQuantity(); - restore$isSoldOut(); restore$getCatalogProductMedia(); restore$createCatalogProduct(); }); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js index da99cb86542..7206c93fec3 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js @@ -109,9 +109,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js index 233df3edc7f..9af1c6a17b0 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js @@ -109,9 +109,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ From 2cc6c61f71021dede367e7d3de7be045add74b06 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 09:25:59 -0500 Subject: [PATCH 07/55] refactor: updates to inventory client UI code Signed-off-by: Eric Dobbertin --- .../core/ui/client/components/badge/index.js | 1 - .../client/components/badge/inventoryBadge.js | 22 ------- .../core/ui/client/containers/index.js | 1 - .../ui/client/containers/inventoryBadge.js | 59 ------------------- .../client/components/variant.js | 1 - .../components/productGridItems.js | 25 +------- .../containers/gridItemNoticeContainer.js | 32 +--------- 7 files changed, 3 insertions(+), 138 deletions(-) delete mode 100644 imports/plugins/core/ui/client/components/badge/inventoryBadge.js delete mode 100644 imports/plugins/core/ui/client/containers/inventoryBadge.js diff --git a/imports/plugins/core/ui/client/components/badge/index.js b/imports/plugins/core/ui/client/components/badge/index.js index 023d1b04c7d..1247b3a3f4e 100644 --- a/imports/plugins/core/ui/client/components/badge/index.js +++ b/imports/plugins/core/ui/client/components/badge/index.js @@ -1,2 +1 @@ export { default as Badge } from "./badge"; -export { default as InventoryBadge } from "./inventoryBadge"; diff --git a/imports/plugins/core/ui/client/components/badge/inventoryBadge.js b/imports/plugins/core/ui/client/components/badge/inventoryBadge.js deleted file mode 100644 index 7d7928d42a6..00000000000 --- a/imports/plugins/core/ui/client/components/badge/inventoryBadge.js +++ /dev/null @@ -1,22 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Components, registerComponent } from "@reactioncommerce/reaction-components"; - -class InventoryBadge extends Component { - render() { - if (this.props.label) { - return ( - - ); - } - return null; - } -} - -InventoryBadge.propTypes = { - label: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) -}; - -registerComponent("InventoryBadge", InventoryBadge); - -export default InventoryBadge; diff --git a/imports/plugins/core/ui/client/containers/index.js b/imports/plugins/core/ui/client/containers/index.js index 4b7851c91c1..93c5078eb75 100644 --- a/imports/plugins/core/ui/client/containers/index.js +++ b/imports/plugins/core/ui/client/containers/index.js @@ -2,7 +2,6 @@ export { default as EditContainer } from "./edit"; export { default as AlertContainer } from "./alerts"; export { default as AppContainer } from "./appContainer"; export { default as ReactionAvatarContainer } from "./avatar"; -export { default as InventoryBadgeContainer } from "./inventoryBadge"; export { default as SortableItem } from "./sortableItem"; export { default as MediaGalleryContainer } from "./mediaGallery"; export { default as TagListContainer } from "./tagListContainer"; diff --git a/imports/plugins/core/ui/client/containers/inventoryBadge.js b/imports/plugins/core/ui/client/containers/inventoryBadge.js deleted file mode 100644 index f1721289924..00000000000 --- a/imports/plugins/core/ui/client/containers/inventoryBadge.js +++ /dev/null @@ -1,59 +0,0 @@ -import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; -import { InventoryBadge } from "../components/badge"; -import { Reaction } from "/client/api"; - -const composer = (props, onData) => { - const { variant, soldOut } = props; - const { - inventoryManagement, - inventoryPolicy, - lowInventoryWarningThreshold, - isSoldOut, - isBackorder, - isLowQuantity - } = variant; - let label = null; - let i18nKeyLabel = null; - let status = null; - - // Admins pull variants from the Products collection - if (Reaction.hasPermission(["createProduct"], Reaction.getShopId())) { - // Product collection variant - if (inventoryManagement && !inventoryPolicy && variant.inventoryAvailableToSell <= 0) { - status = "info"; - label = "Backorder"; - i18nKeyLabel = "productDetail.backOrder"; - } else if (soldOut) { - status = "danger"; - label = "Sold Out!"; - i18nKeyLabel = "productDetail.soldOut"; - } else if (inventoryManagement) { - if (lowInventoryWarningThreshold <= variant.inventoryAvailableToSell) { - status = "warning"; - label = "Limited Supply"; - i18nKeyLabel = "productDetail.limitedSupply"; - } - } - } else if (inventoryManagement) { // Customers pull variants from the Catalog collection - // Catalog item variant - if (isBackorder) { - status = "info"; - label = "Backorder"; - i18nKeyLabel = "productDetail.backOrder"; - } else if (isSoldOut) { - status = "danger"; - label = "Sold Out!"; - i18nKeyLabel = "productDetail.soldOut"; - } else if (isLowQuantity) { - status = "warning"; - label = "Limited Supply"; - i18nKeyLabel = "productDetail.limitedSupply"; - } - } - - onData(null, { ...props, label, i18nKeyLabel, status }); -}; - -registerComponent("InventoryBadge", InventoryBadge, composeWithTracker(composer)); - -export default composeWithTracker(composer)(InventoryBadge); diff --git a/imports/plugins/included/product-detail-simple/client/components/variant.js b/imports/plugins/included/product-detail-simple/client/components/variant.js index 7a465053bf3..c0686cc7deb 100644 --- a/imports/plugins/included/product-detail-simple/client/components/variant.js +++ b/imports/plugins/included/product-detail-simple/client/components/variant.js @@ -147,7 +147,6 @@ class Variant extends Component {
{this.renderDeletionStatus()} - {this.renderValidationButton()} {this.props.editButton}
diff --git a/imports/plugins/included/product-variant/components/productGridItems.js b/imports/plugins/included/product-variant/components/productGridItems.js index 1acc3415135..0ffbdbc2f22 100644 --- a/imports/plugins/included/product-variant/components/productGridItems.js +++ b/imports/plugins/included/product-variant/components/productGridItems.js @@ -16,6 +16,7 @@ class ProductGridItems extends Component { isSelected: PropTypes.func, onClick: PropTypes.func, onDoubleClick: PropTypes.func, + onSelect: PropTypes.func, pdpPath: PropTypes.func, product: PropTypes.object, productMedia: PropTypes.object @@ -38,19 +39,6 @@ class ProductGridItems extends Component { this.props.onClick(event); } - renderVisible() { - return this.props.product.isVisible ? "" : "not-visible"; - } - - renderOverlay() { - if (this.props.product.isVisible === false) { - return ( -
- ); - } - return null; - } - renderMedia() { const { productMedia } = this.props; @@ -68,17 +56,6 @@ class ProductGridItems extends Component { ); } - renderNotices() { - const { product } = this.props; - - return ( -
- - -
- ); - } - renderPublishStatus() { const { product } = this.props; diff --git a/imports/plugins/included/product-variant/containers/gridItemNoticeContainer.js b/imports/plugins/included/product-variant/containers/gridItemNoticeContainer.js index 36411991249..c0d9989dea6 100644 --- a/imports/plugins/included/product-variant/containers/gridItemNoticeContainer.js +++ b/imports/plugins/included/product-variant/containers/gridItemNoticeContainer.js @@ -1,7 +1,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { registerComponent } from "@reactioncommerce/reaction-components"; -import { ReactionProduct } from "/lib/api"; import GridItemNotice from "../components/gridItemNotice"; const wrapComponent = (Comp) => ( @@ -10,36 +9,9 @@ const wrapComponent = (Comp) => ( product: PropTypes.object } - constructor() { - super(); + isLowQuantity = () => this.props.product.isLowQuantity - this.isLowQuantity = this.isLowQuantity.bind(this); - this.isSoldOut = this.isSoldOut.bind(this); - this.isBackorder = this.isBackorder.bind(this); - } - - isLowQuantity = () => { - const topVariants = ReactionProduct.getTopVariants(this.props.product._id); - - for (const topVariant of topVariants) { - const inventoryThreshold = topVariant.lowInventoryWarningThreshold; - if (topVariant.inventoryAvailableToSell > 0 && inventoryThreshold > topVariant.inventoryAvailableToSell) { - return true; - } - } - return false; - } - - isSoldOut = () => { - const topVariants = ReactionProduct.getTopVariants(this.props.product._id); - - for (const topVariant of topVariants) { - if (topVariant.inventoryAvailableToSell > 0) { - return false; - } - } - return true; - } + isSoldOut = () => this.props.product.isSoldOut isBackorder = () => this.props.product.isBackorder From 7a56c892052cd901b31dd68316e7aaaa06434f41 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 09:40:15 -0500 Subject: [PATCH 08/55] feat: remove inventory fields from Product Signed-off-by: Eric Dobbertin --- imports/collections/schemas/products.js | 81 ------------------- .../no-meteor/utils/createCatalogProduct.js | 2 - .../utils/createCatalogProduct.test.js | 8 -- .../utils/publishProductToCatalog.test.js | 8 -- .../plugins/core/core/server/fixtures/cart.js | 4 +- .../core/core/server/fixtures/products.js | 3 - .../graphql/server/no-meteor/xforms/cart.js | 5 -- private/data/Products.json | 23 ++---- tests/meteor/orders.app-test.js | 44 ---------- 9 files changed, 8 insertions(+), 170 deletions(-) diff --git a/imports/collections/schemas/products.js b/imports/collections/schemas/products.js index 765c57a122c..da932630e7e 100644 --- a/imports/collections/schemas/products.js +++ b/imports/collections/schemas/products.js @@ -58,15 +58,9 @@ registerSchema("VariantMedia", VariantMedia); * @property {Event[]} eventLog optional, Variant Event Log * @property {Number} height optional, default value: `0` * @property {Number} index optional, Variant position number in list. Keep array index for moving variants in a list. - * @property {Boolean} inventoryAvailableToSell required - * @property {Boolean} inventoryInStock required * @property {Boolean} inventoryManagement, default value: `true` * @property {Boolean} inventoryPolicy, default value: `false`, If disabled, item can be sold even if it not in stock. - * @property {Number} inventoryInStock, default value: `0` - * @property {Boolean} isBackorder denormalized, `true` if product not in stock, but customers anyway could order it * @property {Boolean} isDeleted, default value: `false` - * @property {Boolean} isLowQuantity optional, true when at least 1 variant is below `lowInventoryWarningThreshold` - * @property {Boolean} isSoldOut optional, denormalized field, indicates when all variants `inventoryInStock` is 0 * @property {Boolean} isVisible, default value: `false` * @property {Number} length optional, default value: `0` * @property {Number} lowInventoryWarningThreshold, default value: `0`, Warn of low inventory at this number @@ -171,45 +165,10 @@ export const ProductVariant = new SimpleSchema({ } } }, - "inventoryAvailableToSell": { - type: SimpleSchema.Integer, - label: "The quantity of this item currently available to sell." + - "This number is updated when an order is placed by the customer." + - "This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator)." + - "If this is a variant, this number is created by summing all child option inventory numbers." + - "This is most likely the quantity to display in the storefront UI.", - optional: true, - defaultValue: 0 - }, - "inventoryInStock": { - type: SimpleSchema.Integer, - label: "The quantity of this item currently in stock." + - "This number is updated when an order is processed by the operator." + - "This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator)." + - "If this is a variant, this number is created by summing all child option inventory numbers." + - "This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI.", - optional: true, - defaultValue: 0 - }, - "isBackorder": { - label: "Indicates when a product is currently backordered", - type: Boolean, - optional: true - }, "isDeleted": { type: Boolean, defaultValue: false }, - "isLowQuantity": { - label: "Indicates that the product quantity is too low", - type: Boolean, - optional: true - }, - "isSoldOut": { - label: "Indicates when the product quantity is zero", - type: Boolean, - optional: true - }, "isVisible": { type: Boolean, defaultValue: false @@ -352,12 +311,7 @@ registerSchema("PriceRange", PriceRange); * @property {String} googleplusMsg optional * @property {String} handle optional, slug * @property {String[]} hashtags optional - * @property {Boolean} inventoryAvailableToSell required - * @property {Boolean} inventoryInStock required - * @property {Boolean} isBackorder denormalized, `true` if product not in stock, but customers anyway could order it * @property {Boolean} isDeleted, default value: `false` - * @property {Boolean} isLowQuantity denormalized, true when at least 1 variant is below `lowInventoryWarningThreshold` - * @property {Boolean} isSoldOut denormalized, Indicates when all variants `inventoryInStock` is zero * @property {Boolean} isVisible, default value: `false` * @property {String} metaDescription optional * @property {Metafield[]} metafields optional @@ -425,45 +379,10 @@ export const Product = new SimpleSchema({ "hashtags.$": { type: String }, - "inventoryAvailableToSell": { - type: SimpleSchema.Integer, - label: "The quantity of this item currently available to sell." + - "This number is updated when an order is placed by the customer." + - "This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator)." + - "If this is a variant, this number is created by summing all child option inventory numbers." + - "This is most likely the quantity to display in the storefront UI.", - optional: true, - defaultValue: 0 - }, - "inventoryInStock": { - type: SimpleSchema.Integer, - label: "The quantity of this item currently in stock." + - "This number is updated when an order is processed by the operator." + - "This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator)." + - "If this is a variant, this number is created by summing all child option inventory numbers." + - "This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI.", - optional: true, - defaultValue: 0 - }, - "isBackorder": { - label: "Indicates when a product is currently backordered", - type: Boolean, - optional: true - }, "isDeleted": { type: Boolean, defaultValue: false }, - "isLowQuantity": { - label: "Indicates that the product quantity is too low", - type: Boolean, - optional: true - }, - "isSoldOut": { - label: "Indicates when the product quantity is zero", - type: Boolean, - optional: true - }, "isVisible": { type: Boolean, defaultValue: false diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index 2a53945cd14..21140007bd8 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -123,8 +123,6 @@ export async function xformProduct({ context, product, shop, variants }) { createdAt: product.createdAt || new Date(), description: product.description, height: product.height, - inventoryAvailableToSell: product.inventoryAvailableToSell || 0, - inventoryInStock: product.inventoryInStock || 0, isDeleted: !!product.isDeleted, isVisible: !!product.isVisible, length: product.length, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js index b84d953c770..419256322e8 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js @@ -28,8 +28,6 @@ const mockVariants = [ compareAtPrice: 1100, height: 0, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: false, isDeleted: false, @@ -66,8 +64,6 @@ const mockVariants = [ createdAt, height: 2, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: true, isDeleted: false, @@ -108,8 +104,6 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - inventoryAvailableToSell: 10, - inventoryInStock: 0, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ @@ -190,8 +184,6 @@ const mockCatalogProduct = { createdAt, description: "description", height: 11.23, - inventoryAvailableToSell: 10, - inventoryInStock: 0, isDeleted: false, isVisible: false, length: 5.67, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js index 0200bace45b..70608a2cf2a 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js @@ -26,8 +26,6 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryAvailableToSell: 0, - inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: false, length: 0, @@ -64,8 +62,6 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryAvailableToSell: 0, - inventoryInStock: 0, inventoryManagement: true, inventoryPolicy: false, length: 5, @@ -103,8 +99,6 @@ const mockProduct = { createdAt, description: "Mock product description", height: 11.23, - inventoryAvailableToSell: 0, - inventoryInStock: 0, isDeleted: false, isVisible: true, length: 5.67, @@ -164,8 +158,6 @@ const updatedMockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - inventoryAvailableToSell: 0, - inventoryInStock: 0, length: 5.67, lowInventoryWarningThreshold: 2, metafields: [ diff --git a/imports/plugins/core/core/server/fixtures/cart.js b/imports/plugins/core/core/server/fixtures/cart.js index 006a8912e4c..cbc4c6f96ae 100755 --- a/imports/plugins/core/core/server/fixtures/cart.js +++ b/imports/plugins/core/core/server/fixtures/cart.js @@ -38,7 +38,7 @@ export function getCartItem(options = {}) { ] }).fetch(); const selectedOption = Random.choice(childVariants); - const quantity = _.random(1, selectedOption.inventoryInStock); + const quantity = 1; const defaults = { _id: Random.id(), addedAt: new Date(), @@ -98,7 +98,7 @@ export function createCart(productId, variantId) { const variant = Products.findOne(variantId); const user = Factory.create("user"); const account = Factory.create("account", { userId: user._id }); - const quantity = _.random(1, variant.inventoryInStock); + const quantity = 1; const cartItem = { _id: Random.id(), addedAt: new Date(), diff --git a/imports/plugins/core/core/server/fixtures/products.js b/imports/plugins/core/core/server/fixtures/products.js index 47a7e398932..f1ec60c74f6 100755 --- a/imports/plugins/core/core/server/fixtures/products.js +++ b/imports/plugins/core/core/server/fixtures/products.js @@ -34,7 +34,6 @@ export function metaField(options = {}) { * @param {String} [options.inventoryManagement] - Track inventory for this product? * @param {String} [options.inventoryPolicy] - Allow overselling of this product? * @param {String} [options.lowInventoryWarningThreshold] - Qty left of inventory that sets off warning - * @param {String} [options.inventoryInStock] - Inventory Quantity * @param {String} [options.price] - productVariant price * @param {String} [options.title] - productVariant title * @param {String} [options.optionTitle] - productVariant option title @@ -51,7 +50,6 @@ export function productVariant(options = {}) { inventoryManagement: faker.random.boolean(), inventoryPolicy: faker.random.boolean(), lowInventoryWarningThreshold: _.random(1, 5), - inventoryInStock: _.random(0, 100), isTaxable: faker.random.boolean(), isVisible: true, price: _.random(10, 1000), @@ -87,7 +85,6 @@ export function productVariant(options = {}) { * @param {String} [options.inventoryManagement] - Track inventory for this product? * @param {String} [options.inventoryPolicy] - Allow overselling of this product? * @param {String} [options.lowInventoryWarningThreshold] - Qty left of inventory that sets off warning - * @param {String} [options.inventoryInStock] - Inventory Quantity * @param {String} [options.price] - productVariant price * @param {String} [options.title] - productVariant title * @param {String} [options.optionTitle] - productVariant option title diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js index b6ab16c9ff4..e7d06c2cf6d 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js @@ -72,14 +72,9 @@ async function xformCartItem(context, catalogItems, products, cartItem) { media = await xformCatalogProductMedia(media, context); } - const variantSourceProduct = products.find((product) => product._id === variantId); - return { ...cartItem, - currentQuantity: variantSourceProduct && variantSourceProduct.inventoryInStock, imageURLs: media && media.URLs, - inventoryAvailableToSell: variantSourceProduct && variantSourceProduct.inventoryInStock, - inventoryInStock: variantSourceProduct && variantSourceProduct.inventoryInStock, isBackorder: variant.isBackorder || false, isLowQuantity: variant.isLowQuantity || false, isSoldOut: variant.isSoldOut || false, diff --git a/private/data/Products.json b/private/data/Products.json index 6d33a259ba1..a8154baa721 100644 --- a/private/data/Products.json +++ b/private/data/Products.json @@ -14,12 +14,7 @@ "price.range": "12.99 - 19.99", "price.min": 12.99, "price.max": 19.99, - "inventoryAvailableToSell": 35, - "inventoryInStock": 35, "isVisible": true, - "isLowQuantity": false, - "isSoldOut": false, - "isBackorder": false, "metafields": [{ "key": "Material", "value": "Cotton" @@ -41,10 +36,8 @@ ], "title": "Basic Example Variant", "price": 19.99, - "inventoryManagement": true, - "inventoryPolicy": true, - "inventoryAvailableToSell": 35, - "inventoryInStock": 35, + "inventoryManagement": false, + "inventoryPolicy": false, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" @@ -69,10 +62,8 @@ "title": "Option 1 - Red Dwarf", "optionTitle": "Red", "price": 19.99, - "inventoryManagement": true, - "inventoryPolicy": true, - "inventoryAvailableToSell": 20, - "inventoryInStock": 20, + "inventoryManagement": false, + "inventoryPolicy": false, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" @@ -100,10 +91,8 @@ "title": "Option 2 - Green Tomato", "optionTitle": "Green", "price": 12.99, - "inventoryManagement": true, - "inventoryPolicy": true, - "inventoryAvailableToSell": 10, - "inventoryInStock": 10, + "inventoryManagement": false, + "inventoryPolicy": false, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" diff --git a/tests/meteor/orders.app-test.js b/tests/meteor/orders.app-test.js index 46987843b17..f4ca5b8e606 100644 --- a/tests/meteor/orders.app-test.js +++ b/tests/meteor/orders.app-test.js @@ -89,50 +89,6 @@ describe("orders test", function () { expect(cancelOrder).to.throw(ReactionError, /Access Denied/); }); - it("should increase inventory with number of items canceled when returnToStock option is selected", function () { - const orderItemId = order.shipping[0].items[0].variantId; - sandbox.stub(Reaction, "hasPermission", () => true); // Mock user permissions - - const { inventoryInStock } = Products.findOne({ _id: orderItemId }) || {}; - - // approve the order (inventory decreases) - spyOnMethod("approvePayment", order.userId); - Meteor.call("orders/approvePayment", order); - - // Since we update Order info inside the `orders/approvePayment` Meteor call, - // we need to re-find the order with the updated info - const updatedOrder = Orders.findOne({ _id: order._id }); - - // cancel order with returnToStock option (which should increment inventory) - spyOnMethod("cancelOrder", updatedOrder.userId); - Meteor.call("orders/cancelOrder", updatedOrder, true); // returnToStock = true; - - const product = Products.findOne({ _id: orderItemId }); - const inventoryAfterRestock = product.inventoryInStock; - - expect(inventoryInStock).to.equal(inventoryAfterRestock); - }); - - it("should NOT increase/decrease inventory when returnToStock option is false", function () { - const orderItemId = order.shipping[0].items[0].variantId; - sandbox.stub(Reaction, "hasPermission", () => true); // Mock user permissions - - // approve the order (inventory decreases) - spyOnMethod("approvePayment", order.userId); - Meteor.call("orders/approvePayment", order); - - const { inventoryInStock } = Products.findOne({ _id: orderItemId }) || {}; - - // cancel order with NO returnToStock option (which should leave inventory untouched) - spyOnMethod("cancelOrder", order.userId); - Meteor.call("orders/cancelOrder", order, false); // returnToStock = false; - - const product = Products.findOne({ _id: orderItemId }); - const inventoryAfterNoRestock = product.inventoryInStock; - - expect(inventoryInStock).to.equal(inventoryAfterNoRestock); - }); - it("should notify owner of the order, if the order is canceled", function () { sandbox.stub(Reaction, "hasPermission", () => true); const returnToStock = true; From a4330085fe080bf105badf2bf64cad942b39decb Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 09:54:05 -0500 Subject: [PATCH 09/55] feat: split inventory plugin to simple-inventory Signed-off-by: Eric Dobbertin --- imports/plugins/core/inventory/register.js | 28 +-- .../updateCatalogProductInventoryStatus.js | 108 --------- ...pdateCatalogProductInventoryStatus.test.js | 226 ------------------ .../included/simple-inventory/register.js | 18 ++ .../simple-inventory}/server/config.js | 0 .../server/no-meteor/startup.js | 39 --- ...ProductInventoryAvailableToSellQuantity.js | 0 ...ctInventoryAvailabletoSellQuantity.test.js | 0 .../getProductInventoryInStockQuantity.js | 0 ...getProductInventoryInStockQuantity.test.js | 0 .../no-meteor/utils/getTopLevelVariant.js | 0 ...VariantInventoryAvailableToSellQuantity.js | 0 ...ntInventoryAvailableToSellQuantity.test.js | 0 .../getVariantInventoryInStockQuantity.js | 0 ...getVariantInventoryInStockQuantity.test.js | 0 ...iantInventoryNotAvailableToSellQuantity.js | 0 ...nventoryNotAvailableToSellQuantity.test.js | 0 .../utils/updateParentInventoryFields.js | 0 18 files changed, 22 insertions(+), 397 deletions(-) delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js create mode 100644 imports/plugins/included/simple-inventory/register.js rename imports/plugins/{core/inventory => included/simple-inventory}/server/config.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/startup.js (76%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getProductInventoryInStockQuantity.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getTopLevelVariant.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getVariantInventoryInStockQuantity.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js (100%) rename imports/plugins/{core/inventory => included/simple-inventory}/server/no-meteor/utils/updateParentInventoryFields.js (100%) diff --git a/imports/plugins/core/inventory/register.js b/imports/plugins/core/inventory/register.js index 27a9bd15a99..6d5efbda28e 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -1,36 +1,18 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; import config from "./server/config"; import queries from "./server/no-meteor/queries"; -import startup from "./server/no-meteor/startup"; import schemas from "./server/no-meteor/schemas"; import publishProductToCatalog from "./server/no-meteor/utils/publishProductToCatalog"; import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; -const publishedProductFields = []; +const publishedProductVariantFields = []; -// These require manual publication always -const publishedProductVariantFields = [ - "inventoryManagement", - "inventoryPolicy" -]; - -// Additional fields require manual publication only if they are +// These fields require manual publication only if they are // not auto-published on every variant update. if (!config.AUTO_PUBLISH_INVENTORY_FIELDS) { - publishedProductFields.push( - "inventoryAvailableToSell", - "inventoryInStock", - "isBackorder", - "isLowQuantity", - "isSoldOut" - ); - publishedProductVariantFields.push( - "inventoryAvailableToSell", - "inventoryInStock", - "isBackorder", - "isLowQuantity", - "isSoldOut", + "inventoryManagement", + "inventoryPolicy", "lowInventoryWarningThreshold" ); } @@ -41,7 +23,6 @@ Reaction.registerPackage({ autoEnable: true, functionsByType: { publishProductToCatalog: [publishProductToCatalog], - startup: [startup], xformCatalogBooleanFilters: [xformCatalogBooleanFilters] }, queries, @@ -49,7 +30,6 @@ Reaction.registerPackage({ schemas }, catalog: { - publishedProductFields, publishedProductVariantFields } }); diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.js b/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.js deleted file mode 100644 index ab360838f9d..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.js +++ /dev/null @@ -1,108 +0,0 @@ -import Logger from "@reactioncommerce/logger"; -import _ from "lodash"; -import isBackorder from "./isBackorder"; -import isLowQuantity from "./isLowQuantity"; -import isSoldOut from "./isSoldOut"; - -/** - * - * @method updateCatalogProductInventoryStatus - * @summary Update inventory status for a single Catalog Product. - * @memberof Catalog - * @param {string} productId - A string product id - * @param {Object} collections - Raw mongo collections - * @return {Promise} 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 }); - - if (!catalogItem) { - Logger.info("Cannot publish inventory status changes to catalog product"); - return false; - } - - const product = await Products.findOne({ _id: productId }); - const variants = await Products.find({ ancestors: productId }).toArray(); - - const modifier = { - "product.inventoryAvailableToSell": product.inventoryAvailableToSell, - "product.inventoryInStock": product.inventoryInStock, - "product.isSoldOut": isSoldOut(variants), - "product.isBackorder": isBackorder(variants), - "product.isLowQuantity": isLowQuantity(variants) - }; - - 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 - ) - ); - - 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); - modifier[`${baseKey}.variants.${topVariantIndex}.inventoryAvailableToSell`] = topVariantFromProductsCollection.inventoryAvailableToSell; - modifier[`${baseKey}.variants.${topVariantIndex}.inventoryInStock`] = topVariantFromProductsCollection.inventoryInStock; - - 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]); - modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.inventoryAvailableToSell`] = option.inventoryAvailableToSell; - modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.inventoryInStock`] = option.inventoryInStock; - }); - } 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]); - modifier[`${baseKey}.variants.${topVariantIndex}.inventoryAvailableToSell`] = topVariantFromProductsCollection.inventoryAvailableToSell; - modifier[`${baseKey}.variants.${topVariantIndex}.inventoryInStock`] = topVariantFromProductsCollection.inventoryInStock; - } - }); - - const result = await Catalog.updateOne( - { "product.productId": productId }, - { $set: modifier } - ); - - return (result && result.result && result.result.ok === 1) || false; -} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js deleted file mode 100644 index 63f514c218c..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js +++ /dev/null @@ -1,226 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$isBackorder, restore as restore$isBackorder } from "./isBackorder"; -import { rewire as rewire$isLowQuantity, restore as restore$isLowQuantity } from "./isLowQuantity"; -import { rewire as rewire$isSoldOut, restore as restore$isSoldOut } from "./isSoldOut"; -import updateCatalogProductInventoryStatus from "./updateCatalogProductInventoryStatus"; - -const mockCollections = { ...mockContext.collections }; - -const internalShopId = "123"; -const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 -const internalCatalogItemId = "999"; -const internalCatalogProductId = "999"; -const internalProductId = "999"; -const internalTagIds = ["923", "924"]; -const internalVariantIds = ["875", "874"]; - -const productSlug = "fake-product"; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 10, - inventoryManagement: true, - inventoryPolicy: false, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - price: 0, - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId], - barcode: "barcode", - height: 2, - index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 10, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - price: 992.0, - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockProduct = { - _id: internalCatalogItemId, - shopId: internalShopId, - barcode: "barcode", - createdAt, - description: "description", - facebookMsg: "facebookMessage", - fulfillmentService: "fulfillmentService", - googleplusMsg: "googlePlusMessage", - height: 11.23, - inventoryAvailableToSell: 20, - inventoryInStock: 20, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, - length: 5.67, - lowInventoryWarningThreshold: 2, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - metaDescription: "metaDescription", - minOrderQuantity: 5, - originCountry: "originCountry", - pageTitle: "pageTitle", - parcel: { - containers: "containers", - length: 4.44, - width: 5.55, - height: 6.66, - weight: 7.77 - }, - pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, - media: [ - { - metadata: { - toGrid: 1, - priority: 1, - productId: internalProductId, - variantId: null - }, - thumbnail: "http://localhost/thumbnail", - small: "http://localhost/small", - medium: "http://localhost/medium", - large: "http://localhost/large", - image: "http://localhost/original" - } - ], - productId: internalProductId, - productType: "productType", - shop: { - _id: opaqueShopId - }, - sku: "ABC123", - supportedFulfillmentTypes: ["shipping"], - handle: productSlug, - hashtags: internalTagIds, - title: "Fake Product Title", - twitterMsg: "twitterMessage", - type: "product-simple", - updatedAt, - variants: mockVariants, - vendor: "vendor", - weight: 15.6, - width: 8.4 -}; - -const mockCatalogItem = { - _id: internalCatalogItemId, - shopId: internalShopId, - product: mockProduct -}; - -const mockIsBackorder = jest - .fn() - .mockName("isBackorder") - .mockReturnValue(false); -const mockIsLowQuantity = jest - .fn() - .mockName("isLowQuantity") - .mockReturnValue(false); -const mockIsSoldOut = jest.fn().mockName("isSoldOut"); - -beforeAll(() => { - rewire$isBackorder(mockIsBackorder); - rewire$isLowQuantity(mockIsLowQuantity); - rewire$isSoldOut(mockIsSoldOut); -}); - -afterAll(() => { - restore$isBackorder(); - restore$isLowQuantity(); - restore$isSoldOut(); -}); - -test("expect true if a product's inventory has changed and is updated in the catalog collection", async () => { - mockCollections.Catalog.findOne.mockReturnValueOnce(Promise.resolve(mockCatalogItem)); - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); - mockCollections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); - mockIsSoldOut.mockReturnValueOnce(true); - mockCollections.Catalog.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); - const spec = await updateCatalogProductInventoryStatus(mockProduct, mockCollections); - expect(spec).toBe(true); -}); - -test("expect false if a product's catalog item does not exist", async () => { - mockCollections.Catalog.findOne.mockReturnValueOnce(Promise.resolve(undefined)); - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); - const spec = await updateCatalogProductInventoryStatus(mockProduct, mockCollections); - expect(spec).toBe(false); -}); diff --git a/imports/plugins/included/simple-inventory/register.js b/imports/plugins/included/simple-inventory/register.js new file mode 100644 index 00000000000..233b2741d58 --- /dev/null +++ b/imports/plugins/included/simple-inventory/register.js @@ -0,0 +1,18 @@ +import Reaction from "/imports/plugins/core/core/server/Reaction"; +// import queries from "./server/no-meteor/queries"; +import startup from "./server/no-meteor/startup"; + +/** + * Simple Inventory plugin + * Isolates the get/set of inventory data to this plugin. + */ + +Reaction.registerPackage({ + label: "Simple Inventory", + name: "reaction-simple-inventory", + functionsByType: { + startup: [startup] + }, + graphQL: {} + // queries +}); diff --git a/imports/plugins/core/inventory/server/config.js b/imports/plugins/included/simple-inventory/server/config.js similarity index 100% rename from imports/plugins/core/inventory/server/config.js rename to imports/plugins/included/simple-inventory/server/config.js diff --git a/imports/plugins/core/inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js similarity index 76% rename from imports/plugins/core/inventory/server/no-meteor/startup.js rename to imports/plugins/included/simple-inventory/server/no-meteor/startup.js index a7d9ac8b67a..05723d10049 100644 --- a/imports/plugins/core/inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -1,6 +1,4 @@ -import config from "../config"; import getVariantInventoryNotAvailableToSellQuantity from "./utils/getVariantInventoryNotAvailableToSellQuantity"; -import updateCatalogProductInventoryStatus from "./utils/updateCatalogProductInventoryStatus"; import updateParentInventoryFields from "./utils/updateParentInventoryFields"; /** @@ -52,14 +50,6 @@ export default function startup(context) { } ); }); - - // Publish inventory updates to the Catalog - // Since variants share the same productId, we only want to update each product once - // So we use a Set to get all unique productIds that were affected, then loop through that data - const productIds = [...new Set(orderItems.map((item) => item.productId))]; - productIds.forEach(async (productId) => { - await updateCatalogProductInventoryStatus(productId, collections); - }); } // If order is not approved, the inventory hasn't been taken away from `inventoryInStock`, but has been taken away from `inventoryAvailableToSell` @@ -93,14 +83,6 @@ export default function startup(context) { } ); }); - - // Publish inventory updates to the Catalog - // Since variants share the same productId, we only want to update each product once - // So we use a Set to get all unique productIds that were affected, then loop through that data - const productIds = [...new Set(orderItems.map((item) => item.productId))]; - productIds.forEach(async (productId) => { - await updateCatalogProductInventoryStatus(productId, collections); - }); } }); @@ -138,14 +120,6 @@ export default function startup(context) { } ); }); - - // Publish inventory updates to the Catalog - // Since variants share the same productId, we only want to update each product once - // So we use a Set to get all unique productIds that were affected, then loop through that data - const productIds = [...new Set(orderItems.map((item) => item.productId))]; - productIds.forEach(async (productId) => { - await updateCatalogProductInventoryStatus(productId, collections); - }); }); appEvents.on("afterOrderApprovePayment", async ({ order }) => { @@ -186,14 +160,6 @@ export default function startup(context) { } ); }); - - // Publish inventory updates to the Catalog - // Since variants share the same productId, we only want to update each product once - // So we use a Set to get all unique productIds that were affected, then loop through that data - const productIds = [...new Set(orderItems.map((item) => item.productId))]; - productIds.forEach(async (productId) => { - await updateCatalogProductInventoryStatus(productId, collections); - }); }); appEvents.on("afterVariantUpdate", async ({ _id, field }) => { @@ -220,11 +186,6 @@ export default function startup(context) { // Update `inventoryInStock` and `inventoryAvailableToSell` on all parents of this variant / option await updateParentInventoryFields(doc, collections); - - // Publish inventory to catalog - if (config.AUTO_PUBLISH_INVENTORY_FIELDS) { - await updateCatalogProductInventoryStatus(doc.ancestors[0], collections); - } } }); } diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getTopLevelVariant.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getTopLevelVariant.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getTopLevelVariant.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getTopLevelVariant.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/updateParentInventoryFields.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/updateParentInventoryFields.js similarity index 100% rename from imports/plugins/core/inventory/server/no-meteor/utils/updateParentInventoryFields.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/updateParentInventoryFields.js From 2fe5bfba7dd3b50bd042e853ecc0dd02878b4e5b Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 11:03:24 -0500 Subject: [PATCH 10/55] feat: remove inventory auto-publish Signed-off-by: Eric Dobbertin --- imports/plugins/core/inventory/register.js | 16 ---------- .../simple-inventory/server/config.js | 5 ---- .../server/no-meteor/startup.js | 30 ------------------- 3 files changed, 51 deletions(-) delete mode 100644 imports/plugins/included/simple-inventory/server/config.js diff --git a/imports/plugins/core/inventory/register.js b/imports/plugins/core/inventory/register.js index 6d5efbda28e..b049360e621 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -1,22 +1,9 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; -import config from "./server/config"; import queries from "./server/no-meteor/queries"; import schemas from "./server/no-meteor/schemas"; import publishProductToCatalog from "./server/no-meteor/utils/publishProductToCatalog"; import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; -const publishedProductVariantFields = []; - -// These fields require manual publication only if they are -// not auto-published on every variant update. -if (!config.AUTO_PUBLISH_INVENTORY_FIELDS) { - publishedProductVariantFields.push( - "inventoryManagement", - "inventoryPolicy", - "lowInventoryWarningThreshold" - ); -} - Reaction.registerPackage({ label: "Inventory", name: "reaction-inventory", @@ -28,8 +15,5 @@ Reaction.registerPackage({ queries, graphQL: { schemas - }, - catalog: { - publishedProductVariantFields } }); diff --git a/imports/plugins/included/simple-inventory/server/config.js b/imports/plugins/included/simple-inventory/server/config.js deleted file mode 100644 index cba43861dac..00000000000 --- a/imports/plugins/included/simple-inventory/server/config.js +++ /dev/null @@ -1,5 +0,0 @@ -import envalid, { bool } from "envalid"; - -export default envalid.cleanEnv(process.env, { - AUTO_PUBLISH_INVENTORY_FIELDS: bool({ default: true }) -}); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js index 05723d10049..4c55dc7bf84 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -1,6 +1,3 @@ -import getVariantInventoryNotAvailableToSellQuantity from "./utils/getVariantInventoryNotAvailableToSellQuantity"; -import updateParentInventoryFields from "./utils/updateParentInventoryFields"; - /** * @summary Called on startup * @param {Object} context Startup context @@ -161,31 +158,4 @@ export default function startup(context) { ); }); }); - - appEvents.on("afterVariantUpdate", async ({ _id, field }) => { - // If the updated field was `inventoryInStock`, adjust `inventoryAvailableToSell` quantities - if (field === "inventoryInStock" || field === "lowInventoryWarningThreshold") { - const doc = await collections.Products.findOne({ _id }); - - // Get reserved inventory - the inventory currently in an unprocessed order - const reservedInventory = await getVariantInventoryNotAvailableToSellQuantity(doc, collections); - - // Compute `inventoryAvailableToSell` as the inventory in stock minus the reserved inventory - const computedInventoryAvailableToSell = doc.inventoryInStock - reservedInventory; - - await collections.Products.updateOne( - { - _id: doc._id - }, - { - $set: { - inventoryAvailableToSell: computedInventoryAvailableToSell - } - } - ); - - // Update `inventoryInStock` and `inventoryAvailableToSell` on all parents of this variant / option - await updateParentInventoryFields(doc, collections); - } - }); } From c159cea3e5376911c5c3c63483f00384622fff5e Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 11:04:04 -0500 Subject: [PATCH 11/55] refactor: delegate inventoryForProductConfigurations to simple-inventory plugin Signed-off-by: Eric Dobbertin --- .../inventoryForProductConfigurations.js | 122 ++++++------------ .../included/simple-inventory/register.js | 4 +- .../inventoryForProductConfigurations.js | 109 ++++++++++++++++ 3 files changed, 149 insertions(+), 86 deletions(-) create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js index 6f686210acb..40a60722a8e 100644 --- a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -1,10 +1,3 @@ -import getVariantInventoryAvailableToSellQuantity from "../utils/getVariantInventoryAvailableToSellQuantity"; -import getVariantInventoryInStockQuantity from "../utils/getVariantInventoryInStockQuantity"; -import getVariantInventoryNotAvailableToSellQuantity from "../utils/getVariantInventoryNotAvailableToSellQuantity"; -import isBackorderFn from "../utils/isBackorder"; -import isLowQuantityFn from "../utils/isLowQuantity"; -import isSoldOutFn from "../utils/isSoldOut"; - const ALL_FIELDS = [ "inventoryAvailableToSell", "inventoryInStock", @@ -18,9 +11,9 @@ const DEFAULT_INFO = { inventoryAvailableToSell: 0, inventoryInStock: 0, inventoryReserved: 0, - isBackorder: true, - isLowQuantity: true, - isSoldOut: true + isBackorder: false, + isLowQuantity: false, + isSoldOut: false }; /** @@ -35,84 +28,45 @@ const DEFAULT_INFO = { * you can pass this to skip some calculations and database lookups, improving speed. * @param {Object[]} [input.variants] Optionally pass an array of the relevant variants if * you have already looked them up. This will save a database query. - * @return {Promise} Array of responses, in same order as `input.productConfigurations` array. + * @return {Promise} Array of responses. Order is not guaranteed to be the same + * as `input.productConfigurations` array. */ export default async function inventoryForProductConfigurations(context, input) { - const { collections } = context; - const { Products } = collections; - const { - fields = ALL_FIELDS, - productConfigurations - } = input; - let { variants } = input; - - const variantIds = productConfigurations.map(({ variantId }) => variantId); - - if (!variants) { - variants = await Products.find({ - $or: [ - { _id: { $in: variantIds } }, - { ancestors: { $in: variantIds } } - ] - }).toArray(); - } - - return Promise.all(productConfigurations.map(async (productConfiguration) => { - const { variantId } = productConfiguration; - - const variant = variants.find((listVariant) => listVariant._id === variantId); - if (!variant) { - return { - inventoryInfo: DEFAULT_INFO, - productConfiguration - }; - } - - let inventoryAvailableToSell = null; - let inventoryInStock = null; - let inventoryReserved = null; - let isBackorder = null; - let isLowQuantity = null; - let isSoldOut = null; - - if (fields.includes("inventoryAvailableToSell")) { - inventoryAvailableToSell = await getVariantInventoryAvailableToSellQuantity(variant, collections, variants); - } - - if (fields.includes("inventoryInStock")) { - inventoryInStock = await getVariantInventoryInStockQuantity(variant, collections, variants); - } - - if (fields.includes("inventoryReserved")) { - inventoryReserved = await getVariantInventoryNotAvailableToSellQuantity(variant, collections); - } - - if (fields.includes("isBackorder") || fields.includes("isLowQuantity") || fields.includes("isSoldOut")) { - const variantOptions = variants.filter((listVariant) => listVariant.ancestors.includes(variantId)); - - if (fields.includes("isBackorder")) { - isBackorder = variantOptions.length ? isBackorderFn(variantOptions) : isBackorderFn([variant]); + const { getFunctionsOfType } = context; + const { fields = ALL_FIELDS } = input; + let { productConfigurations } = input; + + // If there are multiple plugins providing inventory, we use the first one that has a response + // for each product configuration. + const results = []; + for (const inventoryFn of getFunctionsOfType("inventoryForProductConfigurations")) { + // Functions of type "publishProductToCatalog" are expected to mutate the provided catalogProduct. + // eslint-disable-next-line no-await-in-loop + const pluginResults = await inventoryFn(context, { ...input, fields, productConfigurations }); + + // Add only those with inventory info to final results. + // Otherwise add to productConfigurations for next run + productConfigurations = []; + for (const pluginResult of pluginResults) { + if (pluginResult.inventoryInfo) { + results.push(pluginResult); + } else { + productConfigurations.push(pluginResult.productConfiguration); } + } - if (fields.includes("isLowQuantity")) { - isLowQuantity = variantOptions.length ? isLowQuantityFn(variantOptions) : isLowQuantityFn([variant]); - } + if (productConfigurations.length === 0) break; // found inventory info for every product config + } - if (fields.includes("isSoldOut")) { - isSoldOut = variantOptions.length ? isSoldOutFn(variantOptions) : isSoldOutFn([variant]); - } - } + // If no inventory info was found for some of the product configs, such as + // if there are no plugins providing inventory info, then use default info + // that allows the product to be purchased always. + for (const productConfiguration of productConfigurations) { + results.push({ + productConfiguration, + inventoryInfo: DEFAULT_INFO + }); + } - return { - inventoryInfo: { - inventoryAvailableToSell, - inventoryInStock, - inventoryReserved, - isBackorder, - isLowQuantity, - isSoldOut - }, - productConfiguration - }; - })); + return results; } diff --git a/imports/plugins/included/simple-inventory/register.js b/imports/plugins/included/simple-inventory/register.js index 233b2741d58..022e56a8cff 100644 --- a/imports/plugins/included/simple-inventory/register.js +++ b/imports/plugins/included/simple-inventory/register.js @@ -1,5 +1,5 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; -// import queries from "./server/no-meteor/queries"; +import inventoryForProductConfigurations from "./server/no-meteor/utils/inventoryForProductConfigurations"; import startup from "./server/no-meteor/startup"; /** @@ -11,8 +11,8 @@ Reaction.registerPackage({ label: "Simple Inventory", name: "reaction-simple-inventory", functionsByType: { + inventoryForProductConfigurations: [inventoryForProductConfigurations], startup: [startup] }, graphQL: {} - // queries }); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js new file mode 100644 index 00000000000..a911e289411 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js @@ -0,0 +1,109 @@ +import getVariantInventoryAvailableToSellQuantity from "./getVariantInventoryAvailableToSellQuantity"; +import getVariantInventoryInStockQuantity from "./getVariantInventoryInStockQuantity"; +import getVariantInventoryNotAvailableToSellQuantity from "./getVariantInventoryNotAvailableToSellQuantity"; +import isBackorderFn from "./isBackorder"; +import isLowQuantityFn from "./isLowQuantity"; +import isSoldOutFn from "./isSoldOut"; + +const DEFAULT_INFO = { + inventoryAvailableToSell: 0, + inventoryInStock: 0, + inventoryReserved: 0, + isBackorder: true, + isLowQuantity: true, + isSoldOut: true +}; + +/** + * @summary Returns an object with inventory information for one or more + * product configurations. For performance, it is better to call this + * function once rather than calling `inventoryForProductConfiguration` + * (singular) in a loop. + * @param {Object} context App context + * @param {Object} input Additional input arguments + * @param {Object[]} input.productConfigurations An array of ProductConfiguration objects + * @param {String[]} [input.fields] Optional array of fields you need. If you don't need all, + * you can pass this to skip some calculations and database lookups, improving speed. + * @param {Object[]} [input.variants] Optionally pass an array of the relevant variants if + * you have already looked them up. This will save a database query. + * @return {Promise} Array of responses, in same order as `input.productConfigurations` array. + */ +export default async function inventoryForProductConfigurations(context, input) { + const { collections } = context; + const { Products } = collections; + const { + fields, + productConfigurations + } = input; + let { variants } = input; + + const variantIds = productConfigurations.map(({ variantId }) => variantId); + + if (!variants) { + variants = await Products.find({ + $or: [ + { _id: { $in: variantIds } }, + { ancestors: { $in: variantIds } } + ] + }).toArray(); + } + + return Promise.all(productConfigurations.map(async (productConfiguration) => { + const { variantId } = productConfiguration; + + const variant = variants.find((listVariant) => listVariant._id === variantId); + if (!variant) { + return { + inventoryInfo: DEFAULT_INFO, + productConfiguration + }; + } + + let inventoryAvailableToSell = null; + let inventoryInStock = null; + let inventoryReserved = null; + let isBackorder = null; + let isLowQuantity = null; + let isSoldOut = null; + + if (fields.includes("inventoryAvailableToSell")) { + inventoryAvailableToSell = await getVariantInventoryAvailableToSellQuantity(variant, collections, variants); + } + + if (fields.includes("inventoryInStock")) { + inventoryInStock = await getVariantInventoryInStockQuantity(variant, collections, variants); + } + + if (fields.includes("inventoryReserved")) { + inventoryReserved = await getVariantInventoryNotAvailableToSellQuantity(variant, collections); + } + + if (fields.includes("isBackorder") || fields.includes("isLowQuantity") || fields.includes("isSoldOut")) { + const variantOptions = variants.filter((listVariant) => listVariant.ancestors.includes(variantId)); + + if (fields.includes("isBackorder")) { + isBackorder = variantOptions.length ? isBackorderFn(variantOptions) : isBackorderFn([variant]); + } + + if (fields.includes("isLowQuantity")) { + isLowQuantity = variantOptions.length ? isLowQuantityFn(variantOptions) : isLowQuantityFn([variant]); + } + + if (fields.includes("isSoldOut")) { + isSoldOut = variantOptions.length ? isSoldOutFn(variantOptions) : isSoldOutFn([variant]); + } + } + + return { + inventoryInfo: { + inventoryAvailableToSell, + inventoryInStock, + inventoryReserved, + isBackorder, + isLowQuantity, + isSoldOut + }, + productConfiguration + }; + })); +} From b0487880729af41a6797beed66852d8122db030f Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 11:41:10 -0500 Subject: [PATCH 12/55] fix: adjust inventory field calcs Signed-off-by: Eric Dobbertin --- .../inventoryForProductConfigurations.js | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js index a911e289411..eeb9e5de244 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js @@ -1,9 +1,6 @@ import getVariantInventoryAvailableToSellQuantity from "./getVariantInventoryAvailableToSellQuantity"; import getVariantInventoryInStockQuantity from "./getVariantInventoryInStockQuantity"; import getVariantInventoryNotAvailableToSellQuantity from "./getVariantInventoryNotAvailableToSellQuantity"; -import isBackorderFn from "./isBackorder"; -import isLowQuantityFn from "./isLowQuantity"; -import isSoldOutFn from "./isSoldOut"; const DEFAULT_INFO = { inventoryAvailableToSell: 0, @@ -66,7 +63,7 @@ export default async function inventoryForProductConfigurations(context, input) let isLowQuantity = null; let isSoldOut = null; - if (fields.includes("inventoryAvailableToSell")) { + if (fields.includes("inventoryAvailableToSell") || fields.includes("isBackorder") || fields.includes("isLowQuantity") || fields.includes("isSoldOut")) { inventoryAvailableToSell = await getVariantInventoryAvailableToSellQuantity(variant, collections, variants); } @@ -78,19 +75,24 @@ export default async function inventoryForProductConfigurations(context, input) inventoryReserved = await getVariantInventoryNotAvailableToSellQuantity(variant, collections); } - if (fields.includes("isBackorder") || fields.includes("isLowQuantity") || fields.includes("isSoldOut")) { - const variantOptions = variants.filter((listVariant) => listVariant.ancestors.includes(variantId)); - - if (fields.includes("isBackorder")) { - isBackorder = variantOptions.length ? isBackorderFn(variantOptions) : isBackorderFn([variant]); - } + if (fields.includes("isSoldOut")) { + isSoldOut = inventoryAvailableToSell <= 0; + } - if (fields.includes("isLowQuantity")) { - isLowQuantity = variantOptions.length ? isLowQuantityFn(variantOptions) : isLowQuantityFn([variant]); - } + if (fields.includes("isBackorder")) { + isBackorder = inventoryAvailableToSell <= 0; + } - if (fields.includes("isSoldOut")) { - isSoldOut = variantOptions.length ? isSoldOutFn(variantOptions) : isSoldOutFn([variant]); + if (fields.includes("isLowQuantity")) { + const variantOptions = variants.filter((listVariant) => listVariant.ancestors.includes(variantId)); + if (variantOptions.length) { + const optionInfo = await Promise.all(variantOptions.map(async (option) => ({ + inventoryAvailableToSell: await getVariantInventoryAvailableToSellQuantity(variant, collections, variants), + lowInventoryWarningThreshold: option.lowInventoryWarningThreshold + }))); + isLowQuantity = optionInfo.some((option) => option.inventoryAvailableToSell <= variant.lowInventoryWarningThreshold); + } else { + isLowQuantity = inventoryAvailableToSell <= variant.lowInventoryWarningThreshold; } } From c5f0226aed266d6bc27d257f2295643fc15f501b Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 11:44:37 -0500 Subject: [PATCH 13/55] refactor: no-meteor simple-inventory register Signed-off-by: Eric Dobbertin --- .../included/simple-inventory/register.js | 18 ++------------- .../server/no-meteor/register.js | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/register.js diff --git a/imports/plugins/included/simple-inventory/register.js b/imports/plugins/included/simple-inventory/register.js index 022e56a8cff..e943d6f4d6e 100644 --- a/imports/plugins/included/simple-inventory/register.js +++ b/imports/plugins/included/simple-inventory/register.js @@ -1,18 +1,4 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; -import inventoryForProductConfigurations from "./server/no-meteor/utils/inventoryForProductConfigurations"; -import startup from "./server/no-meteor/startup"; +import register from "./server/no-meteor/register"; -/** - * Simple Inventory plugin - * Isolates the get/set of inventory data to this plugin. - */ - -Reaction.registerPackage({ - label: "Simple Inventory", - name: "reaction-simple-inventory", - functionsByType: { - inventoryForProductConfigurations: [inventoryForProductConfigurations], - startup: [startup] - }, - graphQL: {} -}); +Reaction.whenAppInstanceReady(register); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/register.js b/imports/plugins/included/simple-inventory/server/no-meteor/register.js new file mode 100644 index 00000000000..80adae275b9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/register.js @@ -0,0 +1,23 @@ +import startup from "./startup"; +import inventoryForProductConfigurations from "./utils/inventoryForProductConfigurations"; + +/** + * @summary Import and call this function to add this plugin to your API. + * @param {ReactionNodeApp} app The ReactionNodeApp instance + * @return {undefined} + */ +export default async function register(app) { + /** + * Simple Inventory plugin + * Isolates the get/set of inventory data to this plugin. + */ + await app.registerPlugin({ + label: "Simple Inventory", + name: "reaction-simple-inventory", + functionsByType: { + inventoryForProductConfigurations: [inventoryForProductConfigurations], + startup: [startup] + }, + graphQL: {} + }); +} From 2f6c62d135699344da82a70a1c1925621ea9aba5 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 12:40:15 -0500 Subject: [PATCH 14/55] refactor: move inventory GraphQL defs Signed-off-by: Eric Dobbertin --- .../server/no-meteor/schemas/schema.graphql | 78 ------------------- .../server/no-meteor/schemas/schema.graphql | 49 ++++++++++++ 2 files changed, 49 insertions(+), 78 deletions(-) diff --git a/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql index e5c8db1cb96..013aa6843ed 100644 --- a/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql @@ -52,18 +52,9 @@ interface CatalogProductOrVariant { "The height of the product, if it has physical dimensions" height: Float - "True if at least one of the variants has inventoryManagement enabled and has an available quantity less than its lowInventoryWarningThreshold" - isLowQuantity: Boolean! - - "True if every product variant has inventoryManagement enabled and has 0 inventory" - isSoldOut: Boolean! - "The length of the product, if it has physical dimensions" length: Float - "The quantity value below which `isLowQuantity` should be true" - lowInventoryWarningThreshold: Int - "Arbitrary additional metadata about this product" metafields: [Metafield] @@ -114,45 +105,15 @@ type CatalogProduct implements CatalogProductOrVariant & Node { "The height of the product, if it has physical dimensions" height: Float - """ - The quantity of this item currently available to sell. - This number is updated when an order is placed by the customer. - This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). - This number is calculated by summing all child variant inventory numbers. - This is most likely the quantity to display in the storefront UI. - """ - inventoryAvailableToSell: Int - - """ - The quantity of this item currently in stock. - This number is updated when an order is processed by the operator. - This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). - This number is calculated by summing all child variant inventory numbers. - This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI. - """ - inventoryInStock: Int - - "True if isSoldOut is true AND none of the variants have an inventoryPolicy set AND available inventory is 0" - isBackorder: Boolean! - "True if this product has been deleted. Typically, deleted products are not returned in queries." isDeleted: Boolean! - "True if at least one of the variants has inventoryManagement enabled and has an available quantity less than its lowInventoryWarningThreshold" - isLowQuantity: Boolean! - - "True if every product variant has inventoryManagement enabled and has 0 inventory" - isSoldOut: Boolean! - "True if this product should be shown to shoppers. Typically, non-visible products are not returned in queries." isVisible: Boolean! "The length of the product, if it has physical dimensions" length: Float - "The quantity value below which `isLowQuantity` should be true" - lowInventoryWarningThreshold: Int - "All media for this product and its variants" media: [ImageInfo] @@ -231,9 +192,6 @@ type CatalogProductVariant implements CatalogProductOrVariant & Node { "The product variant barcode value, if it has one" barcode: String - "Indicates when the seller has allowed the sale of product which is not in stock. True if inventoryManagement is true AND none of the variants have an inventoryPolicy set." - canBackorder: Boolean! - "The date and time at which this CatalogProductVariant was created, which is when the related product was first published" createdAt: DateTime @@ -243,45 +201,9 @@ type CatalogProductVariant implements CatalogProductOrVariant & Node { "The position of this variant among other variants at the same level of the product-variant-option hierarchy" index: Int! - """ - The quantity of this item currently available to sell. - This number is updated when an order is placed by the customer. - This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). - If this is a variant, this number is created by summing all child option inventory numbers. - This is most likely the quantity to display in the storefront UI. - """ - inventoryAvailableToSell: Int - - """ - The quantity of this item currently in stock. - This number is updated when an order is processed by the operator. - This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). - If this is a variant, this number is created by summing all child option inventory numbers. - This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI. - """ - inventoryInStock: Int - - "True if inventory management is enabled for this variant" - inventoryManagement: Boolean! - - "True if inventory policy is enabled for this variant" - inventoryPolicy: Boolean! - - "True if isSoldOut is true AND none of the variants have an inventoryPolicy set AND available inventory is 0" - isBackorder: Boolean! - - "True if inventoryManagement is enabled and this variant has an available quantity less than its lowInventoryWarningThreshold" - isLowQuantity: Boolean! - - "True if inventoryManagement is enabled and this variant has 0 inventory" - isSoldOut: Boolean! - "The length of the product, if it has physical dimensions" length: Float - "The quantity value below which `isLowQuantity` should be true" - lowInventoryWarningThreshold: Int - "All media for this variant / option" media: [ImageInfo] diff --git a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql index bd34c5d7490..b265b6bea4c 100644 --- a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql @@ -8,3 +8,52 @@ extend enum CatalogBooleanFilterName { "isBackorder" isBackorder } + +extend type CatalogProduct { + "True if isSoldOut is true AND none of the variants have an inventoryPolicy set AND available inventory is 0" + isBackorder: Boolean! + + "True if at least one of the variants has inventoryManagement enabled and has an available quantity less than its lowInventoryWarningThreshold" + isLowQuantity: Boolean! + + "True if every product variant has inventoryManagement enabled and has 0 inventory" + isSoldOut: Boolean! +} + +extend type CatalogProductVariant { + "Indicates when the seller has allowed the sale of product which is not in stock. True if inventoryManagement is true AND none of the variants have an inventoryPolicy set." + canBackorder: Boolean! + + """ + The quantity of this item currently available to sell. + This number is updated when an order is placed by the customer. + This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). + If this is a variant, this number is created by summing all child option inventory numbers. + This is most likely the quantity to display in the storefront UI. + """ + inventoryAvailableToSell: Int + + """ + The quantity of this item currently in stock. + This number is updated when an order is processed by the operator. + This number includes all inventory, including reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). + If this is a variant, this number is created by summing all child option inventory numbers. + This is most likely just used as a reference in the operator UI, and not displayed in the storefront UI. + """ + inventoryInStock: Int + + "True if inventory management is enabled for this variant" + inventoryManagement: Boolean! + + "True if inventory policy is enabled for this variant" + inventoryPolicy: Boolean! + + "True if isSoldOut is true AND none of the variants have an inventoryPolicy set AND available inventory is 0" + isBackorder: Boolean! + + "True if inventoryManagement is enabled and this variant has an available quantity less than its lowInventoryWarningThreshold" + isLowQuantity: Boolean! + + "True if inventoryManagement is enabled and this variant has 0 inventory" + isSoldOut: Boolean! +} From 853f744d0a3f17c87c4b6fb18e09850003dfff1a Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 12:40:37 -0500 Subject: [PATCH 15/55] feat: add xformCatalogProductVariants Signed-off-by: Eric Dobbertin --- .../resolvers/CatalogProduct/index.js | 10 +++- .../resolvers/CatalogProductVariant/index.js | 6 +- .../utils/xformCatalogProductVariants.js | 18 ++++++ imports/plugins/core/inventory/register.js | 4 +- .../utils/xformCatalogProductVariants.js | 59 +++++++++++++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js create mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js index 638d8fc9cbc..5d54641268f 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js @@ -1,15 +1,21 @@ +import graphqlFields from "graphql-fields"; import { encodeCatalogProductOpaqueId, xformCatalogProductMedia } from "@reactioncommerce/reaction-graphql-xforms/catalogProduct"; import { encodeProductOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/product"; import { resolveShopFromShopId } from "@reactioncommerce/reaction-graphql-utils"; +import xformCatalogProductVariants from "../../utils/xformCatalogProductVariants"; import tagIds from "./tagIds"; import tags from "./tags"; export default { _id: (node) => encodeCatalogProductOpaqueId(node._id), + media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), + primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context), productId: (node) => encodeProductOpaqueId(node.productId), shop: resolveShopFromShopId, tagIds, tags, - media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), - primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context) + variants: (node, args, context, info) => node.variants && xformCatalogProductVariants(context, node.variants, { + catalogProduct: node, + fields: graphqlFields(info) + }) }; diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js index 65c60c62025..11473424fd5 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js @@ -5,8 +5,8 @@ import { xformCatalogProductMedia } from "@reactioncommerce/reaction-graphql-xfo export default { _id: (node) => encodeCatalogProductVariantOpaqueId(node._id), - variantId: (node) => encodeProductOpaqueId(node.variantId), - shop: resolveShopFromShopId, media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), - primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context) + primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context), + shop: resolveShopFromShopId, + variantId: (node) => encodeProductOpaqueId(node.variantId) }; diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js b/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js new file mode 100644 index 00000000000..6416f0be019 --- /dev/null +++ b/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js @@ -0,0 +1,18 @@ +/** + * @summary Calls through to registered "xformCatalogProductVariants" functions + * that may mutate the CatalogProductVariant list. + * @param {Object} context App context + * @param {Object[]} catalogProductVariants An array of CatalogProductVariant objects + * @param {Object} [info] Additional info + * @return {Object[]} Returns potentially mutated catalogProductVariants so that this + * can be used as a resolver. + */ +export default async function xformCatalogProductVariants(context, catalogProductVariants, info = {}) { + const { getFunctionsOfType } = context; + + for (const mutateVariant of getFunctionsOfType("xformCatalogProductVariants")) { + await mutateVariant(context, catalogProductVariants, info); // eslint-disable-line no-await-in-loop + } + + return catalogProductVariants; +} diff --git a/imports/plugins/core/inventory/register.js b/imports/plugins/core/inventory/register.js index b049360e621..c7ec7e1da58 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -3,6 +3,7 @@ import queries from "./server/no-meteor/queries"; import schemas from "./server/no-meteor/schemas"; import publishProductToCatalog from "./server/no-meteor/utils/publishProductToCatalog"; import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; +import xformCatalogProductVariants from "./server/no-meteor/utils/xformCatalogProductVariants"; Reaction.registerPackage({ label: "Inventory", @@ -10,7 +11,8 @@ Reaction.registerPackage({ autoEnable: true, functionsByType: { publishProductToCatalog: [publishProductToCatalog], - xformCatalogBooleanFilters: [xformCatalogBooleanFilters] + xformCatalogBooleanFilters: [xformCatalogBooleanFilters], + xformCatalogProductVariants: [xformCatalogProductVariants] }, queries, graphQL: { diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js new file mode 100644 index 00000000000..f1165eeae09 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js @@ -0,0 +1,59 @@ +import has from "lodash/has"; + +const inventoryVariantFields = [ + "inventoryAvailableToSell", + "inventoryInStock", + "inventoryReserved", + "isBackorder", + "isLowQuantity", + "isSoldOut" +]; + +/** + * @summary Mutates an array of CatalogProductVariant to add inventory fields at read time + * @param {Object} context App context + * @param {Object[]} catalogProductVariants An array of CatalogProductVariant objects + * @param {Object} info Additional info + * @return {undefined} Returns nothing. Potentially mutates `catalogProductVariants` + */ +export default async function xformCatalogProductVariants(context, catalogProductVariants, info) { + const { catalogProduct, fields } = info; + + const anyInventoryFieldWasRequested = inventoryVariantFields.some((field) => has(fields, field)); + if (!anyInventoryFieldWasRequested) return; + + const productConfigurations = []; + for (const catalogProductVariant of catalogProductVariants) { + productConfigurations.push({ + productId: catalogProduct.productId, + variantId: catalogProductVariant.variantId + }); + + for (const option of (catalogProductVariant.options || [])) { + productConfigurations.push({ + productId: catalogProduct.productId, + variantId: option.variantId + }); + } + } + + const variantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { + productConfigurations + }); + + for (const catalogProductVariant of catalogProductVariants) { + const { inventoryInfo: variantInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => + productConfiguration.variantId === catalogProductVariant.variantId); + Object.getOwnPropertyNames(variantInventoryInfo).forEach((key) => { + catalogProductVariant[key] = variantInventoryInfo[key]; + }); + + for (const option of (catalogProductVariant.options || [])) { + const { inventoryInfo: optionInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => + productConfiguration.variantId === option.variantId); + Object.getOwnPropertyNames(optionInventoryInfo).forEach((key) => { + catalogProductVariant[key] = optionInventoryInfo[key]; + }); + } + } +} From 0a072836f1f780f3ea75a2e1c11497fb086eca03 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 1 May 2019 15:58:01 -0500 Subject: [PATCH 16/55] refactor: more work on inventoryForProductConfigurations Signed-off-by: Eric Dobbertin --- .../server/no-meteor/schemas/cart.graphql | 22 -- .../utils/xformCatalogProductVariants.js | 4 +- .../graphql/server/no-meteor/xforms/cart.js | 13 +- .../inventoryForProductConfigurations.js | 116 ++++++- .../server/no-meteor/schemas/schema.graphql | 19 + .../utils/publishProductToCatalog.js | 1 + .../server/no-meteor/utils/xformCartItems.js | 29 ++ .../utils/xformCatalogProductVariants.js | 2 + .../server/no-meteor/startup.js | 193 +++++------ ...ProductInventoryAvailableToSellQuantity.js | 21 -- ...ctInventoryAvailabletoSellQuantity.test.js | 238 ------------- .../getProductInventoryInStockQuantity.js | 20 -- ...getProductInventoryInStockQuantity.test.js | 238 ------------- .../no-meteor/utils/getTopLevelVariant.js | 30 -- ...VariantInventoryAvailableToSellQuantity.js | 28 -- ...ntInventoryAvailableToSellQuantity.test.js | 244 ------------- .../getVariantInventoryInStockQuantity.js | 28 -- ...getVariantInventoryInStockQuantity.test.js | 328 ------------------ ...iantInventoryNotAvailableToSellQuantity.js | 34 -- ...nventoryNotAvailableToSellQuantity.test.js | 205 ----------- .../inventoryForProductConfigurations.js | 100 ++---- .../utils/updateParentInventoryFields.js | 67 ---- 22 files changed, 272 insertions(+), 1708 deletions(-) create mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getTopLevelVariant.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js delete mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/updateParentInventoryFields.js diff --git a/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql b/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql index 6f3e8761237..2fd3a09bb57 100644 --- a/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql +++ b/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql @@ -131,31 +131,9 @@ type CartItem implements Node { """ createdAt: DateTime! - """ - The quantity of this item currently available to order. - """ - currentQuantity: Int @deprecated(reason: "Use `inventoryAvailableToSell`.") - "The URLs for a picture of the item in various sizes" imageURLs: ImageSizes - """ - The quantity of this item currently available to sell. - This number is updated when an order is placed by the customer. - This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). - This is most likely the quantity to display in the storefront UI. - """ - inventoryAvailableToSell: Int - - "Is this item currently backordered?" - isBackorder: Boolean! - - "Is this item quantity available currently below it's low quantity threshold?" - isLowQuantity: Boolean! - - "Is this item currently sold out?" - isSoldOut: Boolean! - "Arbitrary additional metadata about this cart item." metafields: [Metafield] diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js b/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js index 6416f0be019..a640397cdfa 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/xformCatalogProductVariants.js @@ -10,8 +10,8 @@ export default async function xformCatalogProductVariants(context, catalogProductVariants, info = {}) { const { getFunctionsOfType } = context; - for (const mutateVariant of getFunctionsOfType("xformCatalogProductVariants")) { - await mutateVariant(context, catalogProductVariants, info); // eslint-disable-line no-await-in-loop + for (const mutateVariants of getFunctionsOfType("xformCatalogProductVariants")) { + await mutateVariants(context, catalogProductVariants, info); // eslint-disable-line no-await-in-loop } return catalogProductVariants; diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js index 85a86318a79..4d26a7ac386 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js @@ -74,9 +74,6 @@ async function xformCartItem(context, catalogItems, products, cartItem) { return { ...cartItem, imageURLs: media && media.URLs, - isBackorder: variant.isBackorder || false, - isLowQuantity: variant.isLowQuantity || false, - isSoldOut: variant.isSoldOut || false, productConfiguration: { productId: cartItem.productId, productVariantId: cartItem.variantId @@ -90,7 +87,7 @@ async function xformCartItem(context, catalogItems, products, cartItem) { * @return {Object[]} Same array with GraphQL-only props added */ export async function xformCartItems(context, items) { - const { collections } = context; + const { collections, getFunctionsOfType } = context; const { Catalog, Products } = collections; const productIds = items.map((item) => item.productId); @@ -110,7 +107,13 @@ export async function xformCartItems(context, items) { } }).toArray(); - return items.map((item) => xformCartItem(context, catalogItems, products, item)); + const xformedItems = await Promise.all(items.map((item) => xformCartItem(context, catalogItems, products, item))); + + for (const mutateItems of getFunctionsOfType("xformCartItems")) { + await mutateItems(context, xformedItems); // eslint-disable-line no-await-in-loop + } + + return xformedItems; } /** diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js index 40a60722a8e..50b393f1e34 100644 --- a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -1,3 +1,5 @@ +import SimpleSchema from "simpl-schema"; + const ALL_FIELDS = [ "inventoryAvailableToSell", "inventoryInStock", @@ -16,6 +18,59 @@ const DEFAULT_INFO = { isSoldOut: false }; +const productConfigurationSchema = new SimpleSchema({ + isSellable: Boolean, + productId: String, + variantId: String +}); + +const inputSchema = new SimpleSchema({ + "fields": { + type: Array, + optional: true + }, + "fields.$": { + type: String, + allowedValues: ALL_FIELDS + }, + "productConfigurations": Array, + "productConfigurations.$": productConfigurationSchema, + "variants": { + type: Array, + optional: true + }, + "variants.$": { + type: Object, + blackbox: true + } +}); + +const inventoryInfoSchema = new SimpleSchema({ + inventoryAvailableToSell: { + type: SimpleSchema.Integer, + min: 0 + }, + inventoryInStock: { + type: SimpleSchema.Integer, + min: 0 + }, + inventoryReserved: { + type: SimpleSchema.Integer, + min: 0 + }, + isBackorder: Boolean, + isLowQuantity: Boolean, + isSoldOut: Boolean +}); + +const pluginResultSchema = new SimpleSchema({ + inventoryInfo: { + type: inventoryInfoSchema, + optional: true + }, + productConfigurations: productConfigurationSchema +}); + /** * @summary Returns an object with inventory information for one or more * product configurations. For performance, it is better to call this @@ -32,9 +87,26 @@ const DEFAULT_INFO = { * as `input.productConfigurations` array. */ export default async function inventoryForProductConfigurations(context, input) { - const { getFunctionsOfType } = context; - const { fields = ALL_FIELDS } = input; - let { productConfigurations } = input; + const { collections, getFunctionsOfType } = context; + const { Products } = collections; + + inputSchema.validate(input); + + const { fields = ALL_FIELDS, productConfigurations } = input; + let { variants } = input; + + // Inventory plugins are expected to provide inventory info only for sellable variants. + // If there are any non-sellable parent variants in the list, we remove them now. + // We'll aggregate their child option values after we get them. + let sellableProductConfigurations = []; + const parentVariantProductConfigurations = []; + for (const productConfiguration of productConfigurations) { + if (productConfiguration.isSellable) { + sellableProductConfigurations.push(productConfiguration); + } else { + parentVariantProductConfigurations.push(productConfiguration); + } + } // If there are multiple plugins providing inventory, we use the first one that has a response // for each product configuration. @@ -44,29 +116,57 @@ export default async function inventoryForProductConfigurations(context, input) // eslint-disable-next-line no-await-in-loop const pluginResults = await inventoryFn(context, { ...input, fields, productConfigurations }); + pluginResultSchema.validate(pluginResults); + // Add only those with inventory info to final results. - // Otherwise add to productConfigurations for next run - productConfigurations = []; + // Otherwise add to sellableProductConfigurations for next run + sellableProductConfigurations = []; for (const pluginResult of pluginResults) { if (pluginResult.inventoryInfo) { results.push(pluginResult); } else { - productConfigurations.push(pluginResult.productConfiguration); + sellableProductConfigurations.push(pluginResult.productConfiguration); } } - if (productConfigurations.length === 0) break; // found inventory info for every product config + if (sellableProductConfigurations.length === 0) break; // found inventory info for every product config } // If no inventory info was found for some of the product configs, such as // if there are no plugins providing inventory info, then use default info // that allows the product to be purchased always. - for (const productConfiguration of productConfigurations) { + for (const productConfiguration of sellableProductConfigurations) { results.push({ productConfiguration, inventoryInfo: DEFAULT_INFO }); } + // Now it's time to calculate top-level variant aggregated inventory and add those to the results + if (!variants) { + const variantIds = parentVariantProductConfigurations.map(({ variantId }) => variantId); + variants = await Products.find({ ancestors: { $in: variantIds } }).toArray(); + } + + for (const productConfiguration of parentVariantProductConfigurations) { + const childOptions = variants.filter((option) => option.ancestors.includes(productConfiguration.variantId)); + const childOptionsInventory = childOptions.reduce((list, option) => { + const optionResult = results.find((result) => result.productConfiguration.variantId === option._id); + if (optionResult) list.push(optionResult.inventoryInfo); + return list; + }, []); + results.push({ + productConfiguration, + inventoryInfo: { + inventoryAvailableToSell: childOptionsInventory.reduce((sum, option) => sum + option.inventoryAvailableToSell, 0), + inventoryInStock: childOptionsInventory.reduce((sum, option) => sum + option.inventoryInStock, 0), + inventoryReserved: childOptionsInventory.reduce((sum, option) => sum + option.inventoryReserved, 0), + isBackorder: childOptionsInventory.every((option) => option.isBackorder), + isLowQuantity: childOptionsInventory.some((option) => option.isLowQuantity), + isSoldOut: childOptionsInventory.every((option) => option.isSoldOut) + } + }); + } + return results; } diff --git a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql index b265b6bea4c..d466698cc6f 100644 --- a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql @@ -57,3 +57,22 @@ extend type CatalogProductVariant { "True if inventoryManagement is enabled and this variant has 0 inventory" isSoldOut: Boolean! } + +extend type CartItem { + """ + The quantity of this item currently available to sell. + This number is updated when an order is placed by the customer. + This number does not include reserved inventory (i.e. inventory that has been ordered, but not yet processed by the operator). + This is most likely the quantity to display in the storefront UI. + """ + inventoryAvailableToSell: Int + + "Is this item currently backordered?" + isBackorder: Boolean! + + "Is this item quantity available currently below it's low quantity threshold?" + isLowQuantity: Boolean! + + "Is this item currently sold out?" + isSoldOut: Boolean! +} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js index 7d270a918e4..9425c94bcf9 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js @@ -13,6 +13,7 @@ export default async function publishProductToCatalog(catalogProduct, { context, const topVariantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { productConfigurations: topVariants.map((option) => ({ + isSellable: !variants.some((variant) => variant.ancestors.includes(option._id)), productId: option.ancestors[0], variantId: option._id })), diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js b/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js new file mode 100644 index 00000000000..b0034605583 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js @@ -0,0 +1,29 @@ +/** + * @summary Mutates an array of CartItem to add inventory fields at read time + * @param {Object} context App context + * @param {Object[]} items An array of CartItem objects + * @param {Object} info Additional info + * @return {undefined} Returns nothing. Potentially mutates `items` + */ +export default async function xformCartItems(context, items) { + const productConfigurations = []; + for (const item of items) { + productConfigurations.push({ + isSellable: true, + productId: item.productConfiguration.productId, + variantId: item.productConfiguration.productVariantId + }); + } + + const variantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { + productConfigurations + }); + + for (const item of items) { + const { inventoryInfo: variantInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => + productConfiguration.variantId === item.productConfiguration.productVariantId); + Object.getOwnPropertyNames(variantInventoryInfo).forEach((key) => { + item[key] = variantInventoryInfo[key]; + }); + } +} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js index f1165eeae09..301ad99008f 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js @@ -25,12 +25,14 @@ export default async function xformCatalogProductVariants(context, catalogProduc const productConfigurations = []; for (const catalogProductVariant of catalogProductVariants) { productConfigurations.push({ + isSellable: (catalogProductVariant.options || []).length === 0, productId: catalogProduct.productId, variantId: catalogProductVariant.variantId }); for (const option of (catalogProductVariant.options || [])) { productConfigurations.push({ + isSellable: true, productId: catalogProduct.productId, variantId: option.variantId }); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js index 4c55dc7bf84..a3b2b922a48 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -1,3 +1,14 @@ +import Logger from "@reactioncommerce/logger"; + +/** + * @summary Get all order items + * @param {Object} order The order + * @return {Object[]} Order items from all fulfillment groups in a single array + */ +function getAllOrderItems(order) { + return order.shipping.reduce((list, group) => [...list, ...group.items], []); +} + /** * @summary Called on startup * @param {Object} context Startup context @@ -5,7 +16,10 @@ * @returns {undefined} */ export default function startup(context) { - const { appEvents, collections } = context; + const { app, appEvents, collections } = context; + + const SimpleInventory = app.db.collection("SimpleInventory"); + collections.SimpleInventory = SimpleInventory; appEvents.on("afterOrderCancel", async ({ order, returnToStock }) => { // Inventory is removed from stock only once an order has been approved @@ -14,108 +28,72 @@ export default function startup(context) { const orderIsApproved = !Array.isArray(order.payments) || order.payments.length === 0 || !!order.payments.find((payment) => payment.status !== "created"); - // If order is approved, the inventory has been taken away from both `inventoryInStock` and `inventoryAvailableToSell` - if (returnToStock && orderIsApproved) { - // Run this Product update inline instead of using ordersInventoryAdjust because the collection hooks fail - // in some instances which causes the order not to cancel - const orderItems = order.shipping.reduce((list, group) => [...list, ...group.items], []); - orderItems.forEach(async (item) => { - const { value: updatedItem } = await collections.Products.findOneAndUpdate( - { - _id: item.variantId - }, - { - $inc: { - inventoryAvailableToSell: +item.quantity, - inventoryInStock: +item.quantity - } - }, { - returnOriginal: false - } - ); + const bulkWriteOperations = []; - // Update parents of supplied item - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } - }, - { - $inc: { - inventoryAvailableToSell: +item.quantity, - inventoryInStock: +item.quantity + // If order is approved, the inventory has been taken away from `inventoryInStock` + if (returnToStock && orderIsApproved) { + getAllOrderItems(order).forEach((item) => { + bulkWriteOperations.push({ + updateOne: { + filter: { + productConfiguration: { + productId: item.productId, + variantId: item.variantId + } + }, + update: { + $inc: { + inventoryInStock: item.quantity + } } } - ); + }); }); - } - - // If order is not approved, the inventory hasn't been taken away from `inventoryInStock`, but has been taken away from `inventoryAvailableToSell` - if (!orderIsApproved) { - // Run this Product update inline instead of using ordersInventoryAdjust because the collection hooks fail - // in some instances which causes the order not to cancel - const orderItems = order.shipping.reduce((list, group) => [...list, ...group.items], []); - orderItems.forEach(async (item) => { - const { value: updatedItem } = await collections.Products.findOneAndUpdate( - { - _id: item.variantId - }, - { - $inc: { - inventoryAvailableToSell: +item.quantity - } - }, { - returnOriginal: false - } - ); - - // Update parents of supplied item - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } - }, - { - $inc: { - inventoryAvailableToSell: +item.quantity + } else if (!orderIsApproved) { + // If order is not approved, the inventory hasn't been taken away from `inventoryInStock` yet but is in `inventoryReserved` + getAllOrderItems(order).forEach((item) => { + bulkWriteOperations.push({ + updateOne: { + filter: { + productConfiguration: { + productId: item.productId, + variantId: item.variantId + } + }, + update: { + $inc: { + inventoryReserved: -item.quantity + } } } - ); + }); }); } + + SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }).catch((error) => { + Logger.error(error, "Bulk write error in simple-inventory afterOrderCancel listener"); + }); }); appEvents.on("afterOrderCreate", async ({ order }) => { - const orderItems = order.shipping.reduce((list, group) => [...list, ...group.items], []); - - // Create a new set of unique productIds - // We do this because variants might have the same productId - // and we don't want to update the product each time a variant is it's child - // we can map over the unique productIds at the end, and update each one once - orderItems.forEach(async (item) => { - // Update supplied item inventory - const { value: updatedItem } = await collections.Products.findOneAndUpdate( - { - _id: item.variantId - }, - { - $inc: { - inventoryAvailableToSell: -item.quantity + const bulkWriteOperations = getAllOrderItems(order).map((item) => ({ + updateOne: { + filter: { + productConfiguration: { + productId: item.productId, + variantId: item.variantId } - }, { - returnOriginal: false - } - ); - - // Update supplied item inventory - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } }, - { + update: { $inc: { - inventoryAvailableToSell: -item.quantity + inventoryReserved: item.quantity } } - ); + } + })); + + SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }).catch((error) => { + Logger.error(error, "Bulk write error in simple-inventory afterOrderCreate listener"); }); }); @@ -124,38 +102,25 @@ export default function startup(context) { const nonApprovedPayment = (order.payments || []).find((payment) => payment.status === "created"); if (nonApprovedPayment) return; - const orderItems = order.shipping.reduce((list, group) => [...list, ...group.items], []); - - // Create a new set of unique productIds - // We do this because variants might have the same productId - // and we don't want to update the product each time a variant is it's child - // we can map over the unique productIds at the end, and update each one once - orderItems.forEach(async (item) => { - // Update supplied item inventory - const { value: updatedItem } = await collections.Products.findOneAndUpdate( - { - _id: item.variantId - }, - { - $inc: { - inventoryInStock: -item.quantity + const bulkWriteOperations = getAllOrderItems(order).map((item) => ({ + updateOne: { + filter: { + productConfiguration: { + productId: item.productId, + variantId: item.variantId } - }, { - returnOriginal: false - } - ); - - // Update supplied item inventory - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } }, - { + update: { $inc: { - inventoryInStock: -item.quantity + inventoryInStock: -item.quantity, + inventoryReserved: -item.quantity } } - ); + } + })); + + SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }).catch((error) => { + Logger.error(error, "Bulk write error in simple-inventory afterOrderApprovePayment listener"); }); }); } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js deleted file mode 100644 index 84de8fbe34b..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js +++ /dev/null @@ -1,21 +0,0 @@ -import getVariants from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; - -/** - * - * @method getProductInventoryAvailableToSellQuantity - * @summary This function can take only a top product ID and a mongo collection as params to return the product - * `inventoryAvailableToSell` quantity, which is a calculation of the sum of all variant - * `inventoryAvailableToSell` quantities. - * @param {Object} productId - A top level product variant object. - * @param {Object} collections - Raw mongo collections. - * @param {Object[]} variants - Array of product variant option objects. - * @return {Promise} Variant quantity. - */ -export default async function getProductInventoryAvailableToSellQuantity(productId, collections) { - const variants = await getVariants(productId, collections, true); - - if (variants && variants.length) { - return variants.reduce((sum, variant) => sum + (variant.inventoryAvailableToSell || 0), 0); - } - return 0; -} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js deleted file mode 100644 index 3d4dee8a33e..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js +++ /dev/null @@ -1,238 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$getVariants, restore as restore$getVariants } from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; -import getProductInventoryAvailableToSellQuantity from "./getProductInventoryAvailableToSellQuantity"; - -const mockCollections = { ...mockContext.collections }; -const mockGetVariants = jest.fn().mockName("getVariants"); - -const internalShopId = "123"; -const internalCatalogProductId = "999"; -const internalVariantIds = ["875", "874", "879"]; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryAvailableToSell: 6, - inventoryManagement: true, - inventoryPolicy: false, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryAvailableToSell: 3, - inventoryManagement: true, - inventoryPolicy: true, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockVariantsWithUndefinedInventory = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryAvailableToSell: 6, - inventoryManagement: true, - inventoryPolicy: false, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryAvailableToSell: 3, - inventoryManagement: true, - inventoryPolicy: true, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - }, { - _id: internalVariantIds[2], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -beforeAll(() => { - rewire$getVariants(mockGetVariants); -}); - -afterAll(restore$getVariants); - -// expect product variant quantity number when passing a single variant -test("expect product variant quantity number", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariants)); - const spec = await getProductInventoryAvailableToSellQuantity(mockVariants, mockCollections); - expect(spec).toEqual(9); -}); - -// expect 0 if all variants have an inventory quantity of 0 -test("expect 0 if all variants have an inventory quantity of 0", async () => { - mockVariants[0].inventoryAvailableToSell = 0; - mockVariants[1].inventoryAvailableToSell = 0; - const spec = await getProductInventoryAvailableToSellQuantity(mockVariants[0], mockCollections, mockVariants); - expect(spec).toEqual(0); -}); - -// Expect product variant with quantity even if one has undefined inventory -test("expect product variant quantity number even when some have undefined inventory", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariantsWithUndefinedInventory)); - const spec = await getProductInventoryAvailableToSellQuantity(mockVariantsWithUndefinedInventory, mockCollections); - expect(spec).toEqual(9); -}); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js deleted file mode 100644 index d13475aca84..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js +++ /dev/null @@ -1,20 +0,0 @@ -import getVariants from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; - -/** - * - * @method getProductInventoryInStockQuantity - * @summary This function can take only a top product ID and a mongo collection as params to return the product - * `inventoryInStock` quantity, which is a calculation of the sum of all variant `inventoryInStock` quantities. - * @param {Object} productId - A top level product variant object. - * @param {Object} collections - Raw mongo collections. - * @param {Object[]} variants - Array of product variant option objects. - * @return {Promise} Variant quantity. - */ -export default async function getProductInventoryInStockQuantity(productId, collections) { - const variants = await getVariants(productId, collections, true); - - if (variants && variants.length) { - return variants.reduce((sum, variant) => sum + (variant.inventoryInStock || 0), 0); - } - return 0; -} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js deleted file mode 100644 index 9aa0620dd46..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js +++ /dev/null @@ -1,238 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$getVariants, restore as restore$getVariants } from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; -import getProductInventoryInStockQuantity from "./getProductInventoryInStockQuantity"; - -const mockCollections = { ...mockContext.collections }; -const mockGetVariants = jest.fn().mockName("getVariants"); - -const internalShopId = "123"; -const internalCatalogProductId = "999"; -const internalVariantIds = ["875", "874", "879"]; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryAvailableToSell: 5, - inventoryManagement: true, - inventoryPolicy: false, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryAvailableToSell: 5, - inventoryManagement: true, - inventoryPolicy: true, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockVariantsWithUndefinedInventory = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryAvailableToSell: 6, - inventoryManagement: true, - inventoryPolicy: false, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryAvailableToSell: 3, - inventoryManagement: true, - inventoryPolicy: true, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - }, { - _id: internalVariantIds[2], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -beforeAll(() => { - rewire$getVariants(mockGetVariants); -}); - -afterAll(restore$getVariants); - -// expect product variant quantity number when passing a single variant -test("expect product variant quantity number", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariants)); - const spec = await getProductInventoryInStockQuantity(mockVariants, mockCollections); - expect(spec).toEqual(10); -}); - -// expect 0 if all variants have an inventory quantity of 0 -test("expect 0 if all variants have an inventory quantity of 0", async () => { - mockVariants[0].inventoryAvailableToSell = 0; - mockVariants[1].inventoryAvailableToSell = 0; - const spec = await getProductInventoryInStockQuantity(mockVariants[0], mockCollections, mockVariants); - expect(spec).toEqual(0); -}); - -// Expect product variant with quantity even if one has undefined inventory -test("expect product variant quantity number", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariantsWithUndefinedInventory)); - const spec = await getProductInventoryInStockQuantity(mockVariantsWithUndefinedInventory, mockCollections); - expect(spec).toEqual(10); -}); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getTopLevelVariant.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getTopLevelVariant.js deleted file mode 100644 index a1cacf2a7b5..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getTopLevelVariant.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * - * @method getTopLevelVariant - * @summary Get a top level variant based on provided ID - * @param {String} productOrVariantId - A variant or top level Product Variant ID. - * @param {Object} collections - Raw mongo collections. - * @return {Promise} Top level product object. - */ -export default async function getTopLevelVariant(productOrVariantId, collections) { - const { Products } = collections; - - // Find a product or variant - let product = await Products.findOne({ - _id: productOrVariantId - }, { - projection: { - ancestors: 1 - } - }); - - // If the found product has two ancestors, this means it's an option, and we get it's parent variant - // otherwise we have the top level variant, and we return it. - if (product && Array.isArray(product.ancestors) && product.ancestors.length && product.ancestors.length === 2) { - product = await Products.findOne({ - _id: product.ancestors[1] - }); - } - - return product; -} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js deleted file mode 100644 index ec62f8f521c..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js +++ /dev/null @@ -1,28 +0,0 @@ -import getVariants from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; - -/** - * - * @method getVariantInventoryAvailableToSellQuantity - * @summary Get the number of product variants still av to sell. This calculates based off of `inventoryAvailableToSell`. - * This function can take only a top level variant object and a mongo collection as params to return the product - * variant quantity. This method can also take a top level variant, mongo collection and an array of - * product variant options as params to skip the db lookup and return the variant quantity - * based on the provided options. - * @param {Object} variant - A top level product variant object. - * @param {Object} collections - Raw mongo collections. - * @param {Object[]} variants - Array of product variant option objects. - * @return {Promise} Variant quantity. - */ -export default async function getVariantInventoryAvailableToSellQuantity(variant, collections, variants) { - let options; - if (variants) { - options = variants.filter((option) => option.ancestors[1] === variant._id); - } else { - options = await getVariants(variant._id, collections); - } - - if (options && options.length) { - return options.reduce((sum, option) => sum + (option.inventoryAvailableToSell || 0), 0); - } - return variant.inventoryAvailableToSell || 0; -} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js deleted file mode 100644 index 4baaba244e1..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js +++ /dev/null @@ -1,244 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$getVariants, restore as restore$getVariants } from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; -import getVariantInventoryAvailableToSellQuantity from "./getVariantInventoryAvailableToSellQuantity"; - -const mockCollections = { ...mockContext.collections }; -const mockGetVariants = jest.fn().mockName("getVariants"); - -const internalShopId = "123"; -const internalCatalogProductId = "999"; -const internalVariantIds = ["875", "874", "879"]; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 5, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - inventoryAvailableToSell: 5, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockVariantsWithUndefinedInventory = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 5, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - inventoryAvailableToSell: 5, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - }, { - _id: internalVariantIds[2], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -beforeAll(() => { - rewire$getVariants(mockGetVariants); -}); - -afterAll(restore$getVariants); - -// expect product variant quantity number when passing a single variant -test("expect product variant quantity number when pasing a single variant", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([mockVariants[1]])); - const spec = await getVariantInventoryAvailableToSellQuantity(mockVariants[0], mockCollections); - expect(spec).toEqual(5); -}); - -// expect product variant quantity number when passing a array of product variant objects -test("expect product variant quantity number when passing a array of product variant objects", async () => { - const spec = await getVariantInventoryAvailableToSellQuantity(mockVariants[0], mockCollections, mockVariants); - expect(spec).toEqual(5); -}); - -// expect 0 if all variants have an inventory quantity of 0 -test("expect 0 if all variants have an inventory quantity of 0", async () => { - mockVariants[0].inventoryAvailableToSell = 0; - mockVariants[1].inventoryAvailableToSell = 0; - const spec = await getVariantInventoryAvailableToSellQuantity(mockVariants[0], mockCollections, mockVariants); - expect(spec).toEqual(0); -}); - -// Expect product variant with quantity even if one has undefined inventory -test("expect product variant quantity number when passing a array of product variant objects", async () => { - const spec = await getVariantInventoryAvailableToSellQuantity(mockVariants[0], mockCollections, mockVariantsWithUndefinedInventory); - expect(spec).toEqual(5); -}); - diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js deleted file mode 100644 index 21e1acab7c0..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js +++ /dev/null @@ -1,28 +0,0 @@ -import getVariants from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; - -/** - * - * @method getVariantInventoryInStockQuantity - * @summary Get the number of product variants in stock. This calculates based off of `inventoryInStock`. - * This function can take only a top level variant object and a mongo collection as params to return the product - * variant quantity. This method can also take a top level variant, mongo collection and an array of - * product variant options as params to skip the db lookup and return the variant quantity - * based on the provided options. - * @param {Object} variant - A top level product variant object. - * @param {Object} collections - Raw mongo collections. - * @param {Object[]} variants - Array of product variant option objects. - * @return {Promise} Variant quantity. - */ -export default async function getVariantInventoryInStockQuantity(variant, collections, variants) { - let options; - if (variants) { - options = variants.filter((option) => option.ancestors[1] === variant._id); - } else { - options = await getVariants(variant._id, collections); - } - - if (options && options.length) { - return options.reduce((sum, option) => sum + (option.inventoryInStock || 0), 0); - } - return variant.inventoryInStock || 0; -} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js deleted file mode 100644 index 14abf2d8cdb..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js +++ /dev/null @@ -1,328 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$getVariants, restore as restore$getVariants } from "/imports/plugins/core/catalog/server/no-meteor/utils/getVariants"; -import getVariantInventoryInStockQuantity from "./getVariantInventoryInStockQuantity"; - -const mockCollections = { ...mockContext.collections }; -const mockGetVariants = jest.fn().mockName("getVariants"); - -const internalShopId = "123"; -const internalCatalogProductId = "999"; -const internalCatalogProductIdNoInventory = "888"; -const internalVariantIds = ["875", "874", "879"]; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 5, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - inventoryAvailableToSell: 10, - inventoryInStock: 10, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockVariantsNoInventory = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductIdNoInventory], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 0, - inventoryInStock: 0, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductIdNoInventory], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - inventoryAvailableToSell: 0, - inventoryInStock: 0, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockVariantsWithUndefinedInventory = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 5, - inventoryInStock: 5, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - inventoryAvailableToSell: 10, - inventoryInStock: 10, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - }, { - _id: internalVariantIds[2], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -beforeAll(() => { - rewire$getVariants(mockGetVariants); -}); - -afterAll(restore$getVariants); - -// expect product variant quantity number when passing a single variant -test("expect product variant quantity number when passing a single variant", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([mockVariants[0]])); - const spec = await getVariantInventoryInStockQuantity(mockVariants[0], mockCollections); - expect(spec).toEqual(5); -}); - -// expect product variant quantity number when passing a array of product variant objects -test("expect product variant quantity number when passing a array of product variant objects", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([mockVariants[0]])); - const spec = await getVariantInventoryInStockQuantity(mockVariants[0], mockCollections, mockVariants); - expect(spec).toEqual(10); -}); - -// expect 0 if all variants have an inventory quantity of 0 -test("expect 0 if all variants have an inventory quantity of 0", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([mockVariantsNoInventory[0]])); - const spec = await getVariantInventoryInStockQuantity(mockVariantsNoInventory[0], mockCollections, mockVariantsNoInventory); - expect(spec).toEqual(0); -}); - -// Expect product variant with quantity even if one has undefined inventory -test("expect product variant quantity number when passing a array of product variant objects", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([mockVariants[0]])); - const spec = await getVariantInventoryInStockQuantity(mockVariants[0], mockCollections, mockVariantsWithUndefinedInventory); - expect(spec).toEqual(10); -}); - - diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js deleted file mode 100644 index 010d6c63127..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * - * @method getVariantInventoryNotAvailableToSellQuantity - * @summary Get the number of product variants that are currently reserved in an order. - * This function can take any variant object. - * @param {Object} variant - A product variant object. - * @param {Object} collections - Raw mongo collections. - * @return {Promise} Reserved variant quantity. - */ -export default async function getVariantInventoryNotAvailableToSellQuantity(variant, collections) { - // Find orders that are new or processing - const orders = await collections.Orders.find({ - "workflow.status": { $in: ["new", "coreOrderWorkflow/processing"] }, - "shipping.items.variantId": variant._id - }).toArray(); - - const reservedQuantity = orders.reduce((sum, order) => { - // Reduce through each fulfillment (shipping) object - const shippingGroupsItems = order.shipping.reduce((acc, shippingGroup) => { - // Get all items in order that match the item being adjusted - const matchingItems = shippingGroup.items.filter((item) => item.variantId === variant._id && item.workflow.workflow.includes("coreItemWorkflow/removedFromInventoryAvailableToSell")); - - // Reduce `quantity` fields of matched items into single number - const reservedQuantityOfItem = matchingItems.reduce((quantity, matchingItem) => quantity + matchingItem.quantity, 0); - - return acc + reservedQuantityOfItem; - }, 0); - - // Sum up numbers from all fulfillment (shipping) groups - return sum + shippingGroupsItems; - }, 0); - - return reservedQuantity; -} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js deleted file mode 100644 index 8c72021786b..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js +++ /dev/null @@ -1,205 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import getVariantInventoryNotAvailableToSellQuantity from "./getVariantInventoryNotAvailableToSellQuantity"; - -const mockCollections = { ...mockContext.collections }; - -const mockOrdersArray = [ - { - _id: "123", - shipping: [ - { - _id: "XikuDrWa3JX5dZhhn", - items: [ - { - _id: "123a", - addedAt: "2018-12-19T01:07:46.401Z", - createdAt: "2018-12-19T01:08:22.904Z", - isTaxable: false, - optionTitle: "Red", - parcel: { - weight: 25, - height: 3, - width: 10, - length: 10 - }, - price: { - amount: 19.99, - currencyCode: "USD" - }, - productId: "123", - productSlug: "basic-reaction-product", - productType: "product-simple", - productTagIds: [ - "rpjCvTBGjhBi2xdro", - "cseCBSSrJ3t8HQSNP" - ], - productVendor: "Example Manufacturer", - quantity: 1, - shopId: "J8Bhq3uTtdgwZx3rz", - subtotal: 19.99, - title: "Basic Reaction Product", - updatedAt: "2018-12-19T01:08:22.904Z", - variantId: "456", - variantTitle: "Option 1 - Red Dwarf", - workflow: { - status: "new", - workflow: [ - "coreOrderWorkflow/created", - "coreItemWorkflow/removedFromInventoryAvailableToSell" - ] - }, - tax: 0, - taxableAmount: 0, - taxes: [] - } - ] - } - ], - totalItemQuantity: 1, - workflow: { - status: "coreOrderWorkflow/processing", - workflow: [ - "coreOrderWorkflow/processing", - "coreOrderWorkflow/created" - ] - } - }, { - _id: "456", - shipping: [ - { - _id: "XikuDrWa3JX5dZhhn", - items: [ - { - _id: "123a", - addedAt: "2018-12-19T01:07:46.401Z", - createdAt: "2018-12-19T01:08:22.904Z", - isTaxable: false, - optionTitle: "Red", - parcel: { - weight: 25, - height: 3, - width: 10, - length: 10 - }, - price: { - amount: 19.99, - currencyCode: "USD" - }, - productId: "123", - productSlug: "basic-reaction-product", - productType: "product-simple", - productTagIds: [ - "rpjCvTBGjhBi2xdro", - "cseCBSSrJ3t8HQSNP" - ], - productVendor: "Example Manufacturer", - quantity: 1, - shopId: "J8Bhq3uTtdgwZx3rz", - subtotal: 19.99, - title: "Basic Reaction Product", - updatedAt: "2018-12-19T01:08:22.904Z", - variantId: "456", - variantTitle: "Option 1 - Red Dwarf", - workflow: { - status: "new", - workflow: [ - "coreOrderWorkflow/created", - "coreItemWorkflow/removedFromInventoryAvailableToSell" - ] - }, - tax: 0, - taxableAmount: 0, - taxes: [] - } - ] - } - ], - totalItemQuantity: 1, - workflow: { - status: "coreOrderWorkflow/processing", - workflow: [ - "coreOrderWorkflow/processing", - "coreOrderWorkflow/created" - ] - } - }, { - _id: "789", - shipping: [ - { - _id: "XikuDrWa3JX5dZhhn", - items: [ - { - _id: "123a", - addedAt: "2018-12-19T01:07:46.401Z", - createdAt: "2018-12-19T01:08:22.904Z", - isTaxable: false, - optionTitle: "Red", - parcel: { - weight: 25, - height: 3, - width: 10, - length: 10 - }, - price: { - amount: 19.99, - currencyCode: "USD" - }, - productId: "123", - productSlug: "basic-reaction-product", - productType: "product-simple", - productTagIds: [ - "rpjCvTBGjhBi2xdro", - "cseCBSSrJ3t8HQSNP" - ], - productVendor: "Example Manufacturer", - quantity: 1, - shopId: "J8Bhq3uTtdgwZx3rz", - subtotal: 19.99, - title: "Basic Reaction Product", - updatedAt: "2018-12-19T01:08:22.904Z", - variantId: "456", - variantTitle: "Option 1 - Red Dwarf", - workflow: { - status: "new", - workflow: [ - "coreOrderWorkflow/created", - "coreItemWorkflow/removedFromInventoryAvailableToSell" - ] - }, - tax: 0, - taxableAmount: 0, - taxes: [] - } - ] - } - ], - totalItemQuantity: 1, - workflow: { - status: "coreOrderWorkflow/processing", - workflow: [ - "coreOrderWorkflow/processing", - "coreOrderWorkflow/created" - ] - } - } -]; - -const mockVariants = [ - { - _id: "456", - inventoryInStock: 5 - } -]; - - -test("expect single order with 1 item quantity reserved to return 1", async () => { - mockCollections.Orders.toArray.mockReturnValueOnce(Promise.resolve([mockOrdersArray[0]])); - const spec = await getVariantInventoryNotAvailableToSellQuantity(mockVariants[0], mockCollections); - expect(spec).toEqual(1); -}); - -test("expect multiple orders with 3 item quantity reserved between orders to return 3", async () => { - mockCollections.Orders.toArray.mockReturnValueOnce(Promise.resolve(mockOrdersArray)); - const spec = await getVariantInventoryNotAvailableToSellQuantity(mockVariants[0], mockCollections); - expect(spec).toEqual(3); -}); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js index eeb9e5de244..1ed7c6a7c65 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js @@ -1,15 +1,4 @@ -import getVariantInventoryAvailableToSellQuantity from "./getVariantInventoryAvailableToSellQuantity"; -import getVariantInventoryInStockQuantity from "./getVariantInventoryInStockQuantity"; -import getVariantInventoryNotAvailableToSellQuantity from "./getVariantInventoryNotAvailableToSellQuantity"; - -const DEFAULT_INFO = { - inventoryAvailableToSell: 0, - inventoryInStock: 0, - inventoryReserved: 0, - isBackorder: true, - isLowQuantity: true, - isSoldOut: true -}; +import isEqual from "lodash/isEqual"; /** * @summary Returns an object with inventory information for one or more @@ -27,74 +16,33 @@ const DEFAULT_INFO = { */ export default async function inventoryForProductConfigurations(context, input) { const { collections } = context; - const { Products } = collections; - const { - fields, - productConfigurations - } = input; - let { variants } = input; - - const variantIds = productConfigurations.map(({ variantId }) => variantId); - - if (!variants) { - variants = await Products.find({ - $or: [ - { _id: { $in: variantIds } }, - { ancestors: { $in: variantIds } } - ] - }).toArray(); - } - - return Promise.all(productConfigurations.map(async (productConfiguration) => { - const { variantId } = productConfiguration; - - const variant = variants.find((listVariant) => listVariant._id === variantId); - if (!variant) { + const { SimpleInventory } = collections; + const { productConfigurations } = input; + + const inventoryDocs = await SimpleInventory + .find({ + productConfiguration: { $in: productConfigurations } + }) + .limit(productConfigurations.length) // optimize query speed + .toArray(); + + return productConfigurations.map((productConfiguration) => { + const inventoryDoc = inventoryDocs.find((doc) => isEqual(productConfiguration, doc.productConfiguration)); + if (!inventoryDoc || !inventoryDoc.isEnabled) { return { - inventoryInfo: DEFAULT_INFO, + inventoryInfo: null, productConfiguration }; } - let inventoryAvailableToSell = null; - let inventoryInStock = null; - let inventoryReserved = null; - let isBackorder = null; - let isLowQuantity = null; - let isSoldOut = null; - - if (fields.includes("inventoryAvailableToSell") || fields.includes("isBackorder") || fields.includes("isLowQuantity") || fields.includes("isSoldOut")) { - inventoryAvailableToSell = await getVariantInventoryAvailableToSellQuantity(variant, collections, variants); - } - - if (fields.includes("inventoryInStock")) { - inventoryInStock = await getVariantInventoryInStockQuantity(variant, collections, variants); - } - - if (fields.includes("inventoryReserved")) { - inventoryReserved = await getVariantInventoryNotAvailableToSellQuantity(variant, collections); - } - - if (fields.includes("isSoldOut")) { - isSoldOut = inventoryAvailableToSell <= 0; - } - - if (fields.includes("isBackorder")) { - isBackorder = inventoryAvailableToSell <= 0; - } - - if (fields.includes("isLowQuantity")) { - const variantOptions = variants.filter((listVariant) => listVariant.ancestors.includes(variantId)); - if (variantOptions.length) { - const optionInfo = await Promise.all(variantOptions.map(async (option) => ({ - inventoryAvailableToSell: await getVariantInventoryAvailableToSellQuantity(variant, collections, variants), - lowInventoryWarningThreshold: option.lowInventoryWarningThreshold - }))); - isLowQuantity = optionInfo.some((option) => option.inventoryAvailableToSell <= variant.lowInventoryWarningThreshold); - } else { - isLowQuantity = inventoryAvailableToSell <= variant.lowInventoryWarningThreshold; - } - } + const { lowInventoryWarningThreshold } = inventoryDoc; + let { inventoryInStock, inventoryReserved } = inventoryDoc; + inventoryInStock = Math.max(0, inventoryInStock); + inventoryReserved = Math.max(0, inventoryReserved); + const inventoryAvailableToSell = Math.max(0, inventoryInStock - inventoryReserved); + const isSoldOut = inventoryAvailableToSell === 0; + const isBackorder = inventoryAvailableToSell === 0; + const isLowQuantity = inventoryAvailableToSell <= lowInventoryWarningThreshold; return { inventoryInfo: { @@ -107,5 +55,5 @@ export default async function inventoryForProductConfigurations(context, input) }, productConfiguration }; - })); + }); } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/updateParentInventoryFields.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/updateParentInventoryFields.js deleted file mode 100644 index 10f59ec88ef..00000000000 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/updateParentInventoryFields.js +++ /dev/null @@ -1,67 +0,0 @@ -import getProductInventoryAvailableToSellQuantity from "./getProductInventoryAvailableToSellQuantity"; -import getProductInventoryInStockQuantity from "./getProductInventoryInStockQuantity"; -import getTopLevelVariant from "./getTopLevelVariant"; -import getVariantInventoryAvailableToSellQuantity from "./getVariantInventoryAvailableToSellQuantity"; -import getVariantInventoryInStockQuantity from "./getVariantInventoryInStockQuantity"; - -/** - * - * @method updateParentInventoryFields - * @summary Get the number of product variants that are currently reserved in an order. - * This function can take any variant object. - * @param {Object} item - A product item object, either from the cart or the products catalog - * @param {Object} collections - Raw mongo collections. - * @return {undefined} - */ -export default async function updateParentInventoryFields(item, collections) { - // Since either a cart item or a product catalog item can be provided, we need to determine - // the parent based on different data - // If this is a cart item, `productId` and `variantId` are fields on the object - // If this is a product object, _id is the equivalent of `variantId`, and `ancestors[0]` is the productId - let updateProductId; - let updateVariantId; - if (item.variantId && item.productId) { - updateProductId = item.productId; - updateVariantId = item.variantId; - } else { - updateProductId = item.ancestors[0]; // eslint-disable-line - updateVariantId = item._id; - } - - // Check to see if this item is the top level variant, or an option - const topLevelVariant = await getTopLevelVariant(updateVariantId, collections); - - // If item is an option, update the quantity on its parent variant too - if (topLevelVariant._id !== updateVariantId) { - const variantInventoryAvailableToSellQuantity = await getVariantInventoryAvailableToSellQuantity(topLevelVariant, collections); - const variantInventoryInStockQuantity = await getVariantInventoryInStockQuantity(topLevelVariant, collections); - - await collections.Products.updateOne( - { - _id: topLevelVariant._id - }, - { - $set: { - inventoryAvailableToSell: variantInventoryAvailableToSellQuantity, - inventoryInStock: variantInventoryInStockQuantity - } - } - ); - } - - // Update the top level product to be the sum of all variant inventory numbers - const productInventoryAvailableToSellQuantity = await getProductInventoryAvailableToSellQuantity(updateProductId, collections); - const productInventoryInStockQuantity = await getProductInventoryInStockQuantity(updateProductId, collections); - - await collections.Products.updateOne( - { - _id: updateProductId - }, - { - $set: { - inventoryAvailableToSell: productInventoryAvailableToSellQuantity, - inventoryInStock: productInventoryInStockQuantity - } - } - ); -} From 9f1072885f856db34e3b30043861ba84bba72a91 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 07:04:32 -0500 Subject: [PATCH 17/55] docs: improve inventory gql schema docs Signed-off-by: Eric Dobbertin --- .../server/no-meteor/schemas/schema.graphql | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql index d466698cc6f..c2249b80fbc 100644 --- a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql @@ -10,18 +10,32 @@ extend enum CatalogBooleanFilterName { } extend type CatalogProduct { - "True if isSoldOut is true AND none of the variants have an inventoryPolicy set AND available inventory is 0" + """ + True if every purchasable variant of this product is sold out but allows backorders. A storefront UI may use this + to decide to show a "Backordered" indicator. + """ isBackorder: Boolean! - "True if at least one of the variants has inventoryManagement enabled and has an available quantity less than its lowInventoryWarningThreshold" + """ + True if at least one purchasable variant of this product has a low quantity in stock. A storefront UI may use this + to decide to show a "Low Quantity" indicator. + """ isLowQuantity: Boolean! - "True if every product variant has inventoryManagement enabled and has 0 inventory" + """ + True if every purchasable variant of this product is sold out. A storefront UI may use this + to decide to show a "Sold Out" indicator when `isBackorder` is not also true. + """ isSoldOut: Boolean! } extend type CatalogProductVariant { - "Indicates when the seller has allowed the sale of product which is not in stock. True if inventoryManagement is true AND none of the variants have an inventoryPolicy set." + """ + True for a purchasable variant if an order containing this variant will be accepted even where there is insufficient + available inventory to fulfill it immediately. For non-purchasable variants, this is true if at least one purchasable + child variant can be backordered. A storefront UI may use this in combination with `inventoryAvailableToSell` to + decide whether to show or enable an "Add to Cart" button. + """ canBackorder: Boolean! """ @@ -42,19 +56,25 @@ extend type CatalogProductVariant { """ inventoryInStock: Int - "True if inventory management is enabled for this variant" - inventoryManagement: Boolean! - - "True if inventory policy is enabled for this variant" - inventoryPolicy: Boolean! - - "True if isSoldOut is true AND none of the variants have an inventoryPolicy set AND available inventory is 0" + """ + True for a purchasable variant if it is sold out but allows backorders. For non-purchasable variants, this is + true if every purchasable child variant is sold out but allows backorders. A storefront UI may use this + to decide to show a "Backordered" indicator. + """ isBackorder: Boolean! - "True if inventoryManagement is enabled and this variant has an available quantity less than its lowInventoryWarningThreshold" + """ + True for a purchasable variant if it has a low quantity in stock. For non-purchasable variants, this is + true if at least one purchasable child variant has a low quantity in stock. A storefront UI may use this + to decide to show a "Low Quantity" indicator. + """ isLowQuantity: Boolean! - "True if inventoryManagement is enabled and this variant has 0 inventory" + """ + True for a purchasable variant if it is sold out. For non-purchasable variants, this is + true if every purchasable child variant is sold out. A storefront UI may use this + to decide to show a "Sold Out" indicator when `isBackorder` is not also true. + """ isSoldOut: Boolean! } @@ -67,12 +87,21 @@ extend type CartItem { """ inventoryAvailableToSell: Int - "Is this item currently backordered?" + """ + True if this item is currently sold out but allows backorders. A storefront UI may use this + to decide to show a "Backordered" indicator. + """ isBackorder: Boolean! - "Is this item quantity available currently below it's low quantity threshold?" + """ + True it this item has a low quantity in stock. A storefront UI may use this + to decide to show a "Low Quantity" indicator. + """ isLowQuantity: Boolean! - "Is this item currently sold out?" + """ + True if this item is currently sold out. A storefront UI may use this + to decide to show a "Sold Out" indicator when `isBackorder` is not also true. + """ isSoldOut: Boolean! } From 75827c0c0f9e80a5fd32eaf9408f3f34590985f6 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 07:25:19 -0500 Subject: [PATCH 18/55] refactor: inventory field cleanup Signed-off-by: Eric Dobbertin --- imports/collections/schemas/catalog.js | 31 ---------- .../server/no-meteor/utils/canBackorder.js | 10 --- .../no-meteor/utils/canBackorder.test.js | 38 ------------ .../no-meteor/utils/createCatalogProduct.js | 1 - .../utils/createCatalogProduct.test.js | 18 ------ .../utils/publishProductToCatalog.test.js | 10 --- .../core/core/server/fixtures/products.js | 15 ----- .../inventoryForProductConfigurations.js | 11 +++- .../server/no-meteor/utils/isBackorder.js | 10 --- .../no-meteor/utils/isBackorder.test.js | 61 ------------------- .../server/no-meteor/utils/isLowQuantity.js | 17 ------ .../no-meteor/utils/isLowQuantity.test.js | 53 ---------------- .../server/no-meteor/utils/isSoldOut.js | 10 --- .../server/no-meteor/utils/isSoldOut.test.js | 52 ---------------- .../utils/xformCatalogProductVariants.js | 1 + .../util/filterShippingMethods.test.js | 6 -- .../inventoryForProductConfigurations.js | 9 +-- .../no-meteor/util/surchargeCheck.test.js | 6 -- tests/meteor/product-publications.app-test.js | 30 ++------- tests/mocks/mockCatalogProducts.js | 34 ----------- 20 files changed, 18 insertions(+), 405 deletions(-) delete mode 100644 imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.js delete mode 100644 imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.test.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.test.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.test.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.js delete mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.test.js diff --git a/imports/collections/schemas/catalog.js b/imports/collections/schemas/catalog.js index 34bd2626bef..55c53dd323f 100644 --- a/imports/collections/schemas/catalog.js +++ b/imports/collections/schemas/catalog.js @@ -99,14 +99,10 @@ export const SocialMetadata = new SimpleSchema({ * @type {SimpleSchema} * @property {String} _id required * @property {String} barcode optional - * @property {Boolean} canBackorder required, Indicates when the seller has allowed the sale of product which is not in stock * @property {Date} createdAt required * @property {Number} height optional, default value: `0` * @property {Number} index required - * @property {Boolean} inventoryManagement required, True if inventory management is enabled for this variant - * @property {Boolean} inventoryPolicy required, True if inventory policy is enabled for this variant * @property {Number} length optional, default value: `0` - * @property {Number} lowInventoryWarningThreshold optional, default value: `0` * @property {ImageInfo[]} media optional * @property {Metafield[]} metafields optional * @property {Number} minOrderQuantity optional, default value: `1` @@ -131,10 +127,6 @@ export const VariantBaseSchema = new SimpleSchema({ label: "Barcode", optional: true }, - "canBackorder": { - type: Boolean, - label: "Can backorder" - }, "createdAt": { type: Date, label: "Date/time this variant was created at" @@ -150,14 +142,6 @@ export const VariantBaseSchema = new SimpleSchema({ type: SimpleSchema.Integer, label: "The position of this variant among other variants at the same level of the product-variant-option hierarchy" }, - "inventoryManagement": { - type: Boolean, - label: "Inventory management" - }, - "inventoryPolicy": { - type: Boolean, - label: "Inventory policy" - }, "length": { type: Number, label: "Length", @@ -165,13 +149,6 @@ export const VariantBaseSchema = new SimpleSchema({ optional: true, defaultValue: 0 }, - "lowInventoryWarningThreshold": { - type: SimpleSchema.Integer, - label: "Warn at", - min: 0, - optional: true, - defaultValue: 0 - }, "media": { type: Array, label: "Media", @@ -277,7 +254,6 @@ export const CatalogVariantSchema = VariantBaseSchema.clone().extend({ * @property {Number} height optional, default value: `0` * @property {Boolean} isVisible required, default value: `false` * @property {Number} length optional, default value: `0` - * @property {Number} lowInventoryWarningThreshold optional, default value: `0` * @property {ImageInfo[]} media optional * @property {Metafield[]} metafields optional * @property {String} metaDescription optional @@ -345,13 +321,6 @@ export const CatalogProduct = new SimpleSchema({ optional: true, defaultValue: 0 }, - "lowInventoryWarningThreshold": { - type: SimpleSchema.Integer, - label: "Warn at", - min: 0, - optional: true, - defaultValue: 0 - }, "media": { type: Array, label: "Media", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.js b/imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.js deleted file mode 100644 index 4f9a1d464ea..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @method canBackorder - * @summary If all the products variants have inventory policy disabled and inventory management enabled - * @memberof Catalog - * @param {Object[]} variants - Array with product variant objects - * @return {boolean} is backorder allowed or not for a product - */ -export default function canBackorder(variants) { - return variants.every((variant) => !variant.inventoryPolicy && variant.inventoryManagement); -} diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.test.js deleted file mode 100644 index c7131542d8a..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/utils/canBackorder.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import canBackorder from "./canBackorder"; - -// mock variant -const mockVariantWithBackorder = { - inventoryManagement: true, - inventoryPolicy: false -}; - -const mockVariantWithOutBackorder = { - inventoryManagement: true, - inventoryPolicy: true -}; - -const mockVariantWithOutInventory = { - inventoryManagement: false, - inventoryPolicy: false -}; - - -test("expect true when a single product variant is sold out and has inventory policy disabled", () => { - const spec = canBackorder([mockVariantWithBackorder]); - expect(spec).toBe(true); -}); - -test("expect true when an array of product variants are sold out and have inventory policy disabled", () => { - const spec = canBackorder([mockVariantWithBackorder, mockVariantWithBackorder]); - expect(spec).toBe(true); -}); - -test("expect false when a single product variant is sold out and has inventory policy enabled", () => { - const spec = canBackorder([mockVariantWithOutBackorder]); - expect(spec).toBe(false); -}); - -test("expect false when a single product variant is sold out and has inventory controls disabled", () => { - const spec = canBackorder([mockVariantWithOutInventory]); - expect(spec).toBe(false); -}); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index ba109b75a14..4a770582609 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -14,7 +14,6 @@ export function xformVariant(variant, variantMedia) { return { _id: variant._id, barcode: variant.barcode, - canBackorder: !variant.inventoryPolicy, createdAt: variant.createdAt || new Date(), height: variant.height, index: variant.index || 0, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js index 9bb8cc995e0..69a9d511589 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js @@ -23,17 +23,13 @@ const mockVariants = [ _id: internalVariantIds[0], ancestors: [internalCatalogProductId], barcode: "barcode", - canBackorder: true, createdAt, compareAtPrice: 1100, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -59,16 +55,12 @@ const mockVariants = [ _id: internalVariantIds[1], ancestors: [internalCatalogProductId, internalVariantIds[0]], barcode: "barcode", - canBackorder: false, createdAt, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -103,7 +95,6 @@ const mockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -180,7 +171,6 @@ const mockCatalogProduct = { isDeleted: false, isVisible: false, length: 5.67, - lowInventoryWarningThreshold: 2, media: [{ URLs: { large: "large/path/to/image.jpg", @@ -251,14 +241,10 @@ const mockCatalogProduct = { variants: [{ _id: "875", barcode: "barcode", - canBackorder: false, createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, length: 0, - lowInventoryWarningThreshold: 0, media: [], metafields: [{ description: "description", @@ -273,14 +259,10 @@ const mockCatalogProduct = { options: [{ _id: "874", barcode: "barcode", - canBackorder: false, createdAt, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, length: 2, - lowInventoryWarningThreshold: 0, media: [{ URLs: { large: "large/path/to/image.jpg", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js index 8dde4c28e29..cf63d126dd6 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js @@ -22,14 +22,10 @@ const mockVariants = [ { _id: internalVariantIds[0], barcode: "barcode", - canBackorder: true, createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -54,14 +50,10 @@ const mockVariants = [ { _id: internalVariantIds[0], barcode: "barcode", - canBackorder: true, createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, length: 5, - lowInventoryWarningThreshold: 8, metafields: [ { value: "value", @@ -94,7 +86,6 @@ const mockProduct = { isDeleted: false, isVisible: true, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -143,7 +134,6 @@ const updatedMockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", diff --git a/imports/plugins/core/core/server/fixtures/products.js b/imports/plugins/core/core/server/fixtures/products.js index f1ec60c74f6..5eec45a9e9b 100755 --- a/imports/plugins/core/core/server/fixtures/products.js +++ b/imports/plugins/core/core/server/fixtures/products.js @@ -31,9 +31,6 @@ export function metaField(options = {}) { * @param {String} [options.parentId] - variant's parent's ID. Sets variant as child. * @param {String} [options.compareAtPrice] - MSRP Price / Compare At Price * @param {String} [options.weight] - productVariant weight - * @param {String} [options.inventoryManagement] - Track inventory for this product? - * @param {String} [options.inventoryPolicy] - Allow overselling of this product? - * @param {String} [options.lowInventoryWarningThreshold] - Qty left of inventory that sets off warning * @param {String} [options.price] - productVariant price * @param {String} [options.title] - productVariant title * @param {String} [options.optionTitle] - productVariant option title @@ -47,9 +44,6 @@ export function productVariant(options = {}) { ancestors: [], compareAtPrice: _.random(0, 1000), weight: _.random(0, 10), - inventoryManagement: faker.random.boolean(), - inventoryPolicy: faker.random.boolean(), - lowInventoryWarningThreshold: _.random(1, 5), isTaxable: faker.random.boolean(), isVisible: true, price: _.random(10, 1000), @@ -82,9 +76,6 @@ export function productVariant(options = {}) { * @param {String} [options.parentId] - variant's parent's ID. Sets variant as child. * @param {String} [options.compareAtPrice] - MSRP Price / Compare At Price * @param {String} [options.weight] - productVariant weight - * @param {String} [options.inventoryManagement] - Track inventory for this product? - * @param {String} [options.inventoryPolicy] - Allow overselling of this product? - * @param {String} [options.lowInventoryWarningThreshold] - Qty left of inventory that sets off warning * @param {String} [options.price] - productVariant price * @param {String} [options.title] - productVariant title * @param {String} [options.optionTitle] - productVariant option title @@ -188,9 +179,6 @@ export default function () { * @property {String} price.range `"1.00 - 12.99"` * @property {Number} price.min `1.00` * @property {Number} price.max `12.9` - * @property {Boolean} isLowQuantity `false` - * @property {Boolean} isSoldOut `false` - * @property {Boolean} isBackorder `false` * @property {Array} metafields `[]` * @property {String[]} supportedFulfillmentTypes - ["shipping"] * @property {Array} hashtags `[]` @@ -220,9 +208,6 @@ export default function () { type: "simple", vendor: faker.company.companyName(), price: priceRange, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false, metafields: [], supportedFulfillmentTypes: ["shipping"], hashtags: [], diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js index 50b393f1e34..8b101fd7a65 100644 --- a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -1,6 +1,7 @@ import SimpleSchema from "simpl-schema"; const ALL_FIELDS = [ + "canBackorder", "inventoryAvailableToSell", "inventoryInStock", "inventoryReserved", @@ -10,6 +11,7 @@ const ALL_FIELDS = [ ]; const DEFAULT_INFO = { + canBackorder: true, inventoryAvailableToSell: 0, inventoryInStock: 0, inventoryReserved: 0, @@ -46,6 +48,7 @@ const inputSchema = new SimpleSchema({ }); const inventoryInfoSchema = new SimpleSchema({ + canBackorder: Boolean, inventoryAvailableToSell: { type: SimpleSchema.Integer, min: 0 @@ -58,9 +61,7 @@ const inventoryInfoSchema = new SimpleSchema({ type: SimpleSchema.Integer, min: 0 }, - isBackorder: Boolean, - isLowQuantity: Boolean, - isSoldOut: Boolean + isLowQuantity: Boolean }); const pluginResultSchema = new SimpleSchema({ @@ -123,6 +124,9 @@ export default async function inventoryForProductConfigurations(context, input) sellableProductConfigurations = []; for (const pluginResult of pluginResults) { if (pluginResult.inventoryInfo) { + // Add fields that we calculate here so that each plugin doesn't have to + pluginResult.inventoryInfo.isSoldOut = pluginResult.inventoryInfo.inventoryAvailableToSell === 0; + pluginResult.inventoryInfo.isBackorder = pluginResult.inventoryInfo.isSoldOut && pluginResult.inventoryInfo.canBackorder; results.push(pluginResult); } else { sellableProductConfigurations.push(pluginResult.productConfiguration); @@ -158,6 +162,7 @@ export default async function inventoryForProductConfigurations(context, input) results.push({ productConfiguration, inventoryInfo: { + canBackorder: childOptionsInventory.some((option) => option.canBackorder), inventoryAvailableToSell: childOptionsInventory.reduce((sum, option) => sum + option.inventoryAvailableToSell, 0), inventoryInStock: childOptionsInventory.reduce((sum, option) => sum + option.inventoryInStock, 0), inventoryReserved: childOptionsInventory.reduce((sum, option) => sum + option.inventoryReserved, 0), diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.js b/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.js deleted file mode 100644 index ea8fbbba15a..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @method isBackorder - * @summary If all the products variants have inventory policy disabled, inventory management enabled and a quantity of zero return `true` - * @memberof Catalog - * @param {Object[]} variants - Array with product variant objects - * @return {boolean} is backorder currently active or not for a product - */ -export default function isBackorder(variants) { - return variants.every((variant) => !variant.inventoryPolicy && variant.inventoryManagement && variant.inventoryAvailableToSell === 0); -} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.test.js deleted file mode 100644 index 0399737520e..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import isBackorder from "./isBackorder"; - -// mock variant -const mockVariantWithBackorder = { - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 0 -}; - -const mockVariantWithBackorderNotSoldOut = { - inventoryManagement: true, - inventoryPolicy: false, - inventoryAvailableToSell: 10 -}; - -const mockVariantWithOutBackorder = { - inventoryManagement: true, - inventoryPolicy: true, - inventoryAvailableToSell: 0 -}; - -const mockVariantWithOutInventory = { - inventoryManagement: false, - inventoryPolicy: false, - inventoryAvailableToSell: 0 -}; - -test("expect true when a single product variant is sold out and has inventory policy disabled", () => { - const spec = isBackorder([mockVariantWithBackorder]); - expect(spec).toBe(true); -}); - -test("expect true when an array of product variants are sold out and have inventory policy disabled", () => { - const spec = isBackorder([mockVariantWithBackorder, mockVariantWithBackorder]); - expect(spec).toBe(true); -}); - -test("expect false when an array of product variants has one sold out and another not sold out and both have inventory policy disabled", () => { - const spec = isBackorder([mockVariantWithBackorder, mockVariantWithBackorderNotSoldOut]); - expect(spec).toBe(false); -}); - -test("expect false when a single product variant is not sold out and has inventory policy disabled", () => { - const spec = isBackorder([mockVariantWithBackorderNotSoldOut]); - expect(spec).toBe(false); -}); - -test("expect false when an array of product variants are not sold out and have inventory policy disabled", () => { - const spec = isBackorder([mockVariantWithBackorderNotSoldOut, mockVariantWithBackorderNotSoldOut]); - expect(spec).toBe(false); -}); - -test("expect false when a single product variant is sold out and has inventory policy enabled", () => { - const spec = isBackorder([mockVariantWithOutBackorder]); - expect(spec).toBe(false); -}); - -test("expect false when a single product variant is sold out and has inventory controls disabled", () => { - const spec = isBackorder([mockVariantWithOutInventory]); - expect(spec).toBe(false); -}); diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.js b/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.js deleted file mode 100644 index 294dd584ebe..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @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 - */ -export default function isLowQuantity(variants) { - const threshold = variants && variants.length && variants[0].lowInventoryWarningThreshold; - const results = variants.map((variant) => { - if (variant.inventoryManagement && variant.inventoryAvailableToSell) { - return variant.inventoryAvailableToSell <= threshold; - } - return false; - }); - return results.some((result) => result); -} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.test.js deleted file mode 100644 index 85ec2fc8057..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import isLowQuantity from "./isLowQuantity"; - -// mock variant -const mockVariantWithInventoryManagmentWithoutLowQuantity = { - inventoryManagement: true, - inventoryPolicy: true, - lowInventoryWarningThreshold: 5, - inventoryAvailableToSell: 10 -}; - -const mockVariantWithInventoryManagmentWithLowQuantity = { - inventoryManagement: true, - inventoryPolicy: true, - lowInventoryWarningThreshold: 5, - inventoryAvailableToSell: 4 -}; - -const mockVariantWithOutInventoryManagment = { - inventoryManagement: false, - inventoryPolicy: false, - lowInventoryWarningThreshold: 10, - inventoryAvailableToSell: 5 -}; - -test("expect true when a single product variant has a low quantity and inventory controls are enabled", () => { - const spec = isLowQuantity([mockVariantWithInventoryManagmentWithLowQuantity]); - expect(spec).toBe(true); -}); - -test("expect true when an array of product variants each have a low quantity and inventory controls are enabled", () => { - const spec = isLowQuantity([mockVariantWithInventoryManagmentWithLowQuantity, mockVariantWithInventoryManagmentWithLowQuantity]); - expect(spec).toBe(true); -}); - -test("expect false when a single product variant does not have a low quantity and inventory controls are enabled", () => { - const spec = isLowQuantity([mockVariantWithInventoryManagmentWithoutLowQuantity]); - expect(spec).toBe(false); -}); - -test("expect false when an array of product variants each do not have a low quantity and inventory controls are enabled", () => { - const spec = isLowQuantity([mockVariantWithInventoryManagmentWithoutLowQuantity, mockVariantWithInventoryManagmentWithoutLowQuantity]); - expect(spec).toBe(false); -}); - -test("expect false when a single product variant has a low quantity and inventory controls are disabled", () => { - const spec = isLowQuantity([mockVariantWithOutInventoryManagment]); - expect(spec).toBe(false); -}); - -test("expect false when an array of product variants each have a low quantity and inventory controls are disabled", () => { - const spec = isLowQuantity([mockVariantWithOutInventoryManagment, mockVariantWithOutInventoryManagment]); - expect(spec).toBe(false); -}); diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.js b/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.js deleted file mode 100644 index fc978dd8a37..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.js +++ /dev/null @@ -1,10 +0,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. - */ -export default function isSoldOut(variants) { - return variants.every((variant) => variant.inventoryManagement && variant.inventoryAvailableToSell <= 0); -} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.test.js deleted file mode 100644 index 6f4a876915c..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut.test.js +++ /dev/null @@ -1,52 +0,0 @@ -import isSoldOut from "./isSoldOut"; - -// mock variant -const mockVariantWithInventoryManagmentAndInventroy = { - inventoryManagement: true, - inventoryAvailableToSell: 1 -}; - -const mockVariantWithInventoryManagmentAndNoInventroy = { - inventoryManagement: true, - inventoryAvailableToSell: 0 -}; - -const mockVariantWithOutInventoryManagmentAndNoInventroy = { - inventoryManagement: false, - inventoryAvailableToSell: 0 -}; - -test("expect true when a single product variant is sold out and inventory management is enabled", () => { - const spec = isSoldOut([mockVariantWithInventoryManagmentAndNoInventroy]); - expect(spec).toBe(true); -}); - -test("expect false when a single product variant is not sold out and inventory management is enabled", () => { - const spec = isSoldOut([mockVariantWithInventoryManagmentAndInventroy]); - expect(spec).toBe(false); -}); - -test("expect true when an array of product variants are sold out and inventory management is enabled", () => { - const spec = isSoldOut([mockVariantWithInventoryManagmentAndNoInventroy, mockVariantWithInventoryManagmentAndNoInventroy]); - expect(spec).toBe(true); -}); - -test("expect false when an array of product variants are not sold out and inventory management is enabled", () => { - const spec = isSoldOut([mockVariantWithInventoryManagmentAndInventroy, mockVariantWithInventoryManagmentAndInventroy]); - expect(spec).toBe(false); -}); - -test("expect false when an array of product variants has one sold out and one not sold out and inventory management is enabled", () => { - const spec = isSoldOut([mockVariantWithInventoryManagmentAndInventroy, mockVariantWithInventoryManagmentAndNoInventroy]); - expect(spec).toBe(false); -}); - -test("expect false when a single product variant is sold out and inventory management is disabled", () => { - const spec = isSoldOut([mockVariantWithOutInventoryManagmentAndNoInventroy]); - expect(spec).toBe(false); -}); - -test("expect false when one product variant has inventory management is disabled", () => { - const spec = isSoldOut([mockVariantWithOutInventoryManagmentAndNoInventroy, mockVariantWithInventoryManagmentAndInventroy]); - expect(spec).toBe(false); -}); diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js index 301ad99008f..4b3c8eb3e46 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js @@ -1,6 +1,7 @@ import has from "lodash/has"; const inventoryVariantFields = [ + "canBackorder", "inventoryAvailableToSell", "inventoryInStock", "inventoryReserved", diff --git a/imports/plugins/included/shipping-rates/server/no-meteor/util/filterShippingMethods.test.js b/imports/plugins/included/shipping-rates/server/no-meteor/util/filterShippingMethods.test.js index e19d00e68e6..f3405c65a23 100644 --- a/imports/plugins/included/shipping-rates/server/no-meteor/util/filterShippingMethods.test.js +++ b/imports/plugins/included/shipping-rates/server/no-meteor/util/filterShippingMethods.test.js @@ -27,10 +27,7 @@ const mockHydratedOrderItems = { _id: "tMkp5QwZog5ihYTfG", createdAt: "2018-11-01T16:42:03.448Z", description: "Represent the city that never sleeps with this classic T.", - isBackorder: false, isDeleted: false, - isLowQuantity: false, - isSoldOut: false, isTaxable: true, isVisible: true, pageTitle: "212. 646. 917.", @@ -47,10 +44,7 @@ const mockHydratedOrderItems = { vendor: "Restricted Vendor", height: 10, index: 0, - inventoryManagement: true, - inventoryPolicy: false, length: 10, - lowInventoryWarningThreshold: 0, optionTitle: "Small", originCountry: "US", taxCode: "0000", diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js index 1ed7c6a7c65..9c7aea20d75 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js @@ -35,23 +35,20 @@ export default async function inventoryForProductConfigurations(context, input) }; } - const { lowInventoryWarningThreshold } = inventoryDoc; + const { canBackorder, lowInventoryWarningThreshold } = inventoryDoc; let { inventoryInStock, inventoryReserved } = inventoryDoc; inventoryInStock = Math.max(0, inventoryInStock); inventoryReserved = Math.max(0, inventoryReserved); const inventoryAvailableToSell = Math.max(0, inventoryInStock - inventoryReserved); - const isSoldOut = inventoryAvailableToSell === 0; - const isBackorder = inventoryAvailableToSell === 0; const isLowQuantity = inventoryAvailableToSell <= lowInventoryWarningThreshold; return { inventoryInfo: { + canBackorder, inventoryAvailableToSell, inventoryInStock, inventoryReserved, - isBackorder, - isLowQuantity, - isSoldOut + isLowQuantity }, productConfiguration }; diff --git a/imports/plugins/included/surcharges/server/no-meteor/util/surchargeCheck.test.js b/imports/plugins/included/surcharges/server/no-meteor/util/surchargeCheck.test.js index 16cf65543c0..9c5a2e3b242 100644 --- a/imports/plugins/included/surcharges/server/no-meteor/util/surchargeCheck.test.js +++ b/imports/plugins/included/surcharges/server/no-meteor/util/surchargeCheck.test.js @@ -102,10 +102,7 @@ const mockHydratedOrderItems = { _id: "tMkp5QwZog5ihYTfG", createdAt: "2018-11-01T16:42:03.448Z", description: "Represent the city that never sleeps with this classic T.", - isBackorder: false, isDeleted: false, - isLowQuantity: false, - isSoldOut: false, isTaxable: true, isVisible: true, pageTitle: "212. 646. 917.", @@ -122,10 +119,7 @@ const mockHydratedOrderItems = { vendor: "Restricted Vendor", height: 10, index: 0, - inventoryManagement: true, - inventoryPolicy: false, length: 10, - lowInventoryWarningThreshold: 0, optionTitle: "Small", originCountry: "US", taxCode: "0000", diff --git a/tests/meteor/product-publications.app-test.js b/tests/meteor/product-publications.app-test.js index 89d5d040ffd..769f9d357a7 100644 --- a/tests/meteor/product-publications.app-test.js +++ b/tests/meteor/product-publications.app-test.js @@ -84,10 +84,7 @@ describe("Publication", function () { shopId, type: "simple", price: priceRangeA, - isVisible: false, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false + isVisible: false }); // a product with price range B, and visible const productId2 = Collections.Products.insert({ @@ -97,10 +94,7 @@ describe("Publication", function () { shopId, price: priceRangeB, type: "simple", - isVisible: true, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false + isVisible: true }); // a product with price range A, and visible const productId3 = Collections.Products.insert({ @@ -110,10 +104,7 @@ describe("Publication", function () { shopId, price: priceRangeA, type: "simple", - isVisible: true, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false + isVisible: true }); // a product for an unrelated marketplace shop const productId4 = Collections.Products.insert({ @@ -123,10 +114,7 @@ describe("Publication", function () { shopId: merchantShopId, type: "simple", price: priceRangeA, - isVisible: true, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false + isVisible: true }); // a product for the Primary Shop const productId5 = Collections.Products.insert({ @@ -136,10 +124,7 @@ describe("Publication", function () { shopId: primaryShopId, type: "simple", price: priceRangeA, - isVisible: true, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false + isVisible: true }); // a product for an inactive Merchant Shop // this product is here to guard against false-positive test results @@ -150,10 +135,7 @@ describe("Publication", function () { shopId: inactiveMerchantShopId, type: "simple", price: priceRangeA, - isVisible: true, - isLowQuantity: false, - isSoldOut: false, - isBackorder: false + isVisible: true }); // helper arrays for writing expectations in tests diff --git a/tests/mocks/mockCatalogProducts.js b/tests/mocks/mockCatalogProducts.js index f71a674e94a..5bac6569d9e 100644 --- a/tests/mocks/mockCatalogProducts.js +++ b/tests/mocks/mockCatalogProducts.js @@ -41,13 +41,8 @@ export const mockInternalCatalogOptions = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isLowQuantity: false, - isSoldOut: false, isTaxable: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -86,13 +81,8 @@ export const mockInternalCatalogOptions = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isLowQuantity: true, - isSoldOut: false, isTaxable: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -137,13 +127,8 @@ export const mockExternalCatalogOptions = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isLowQuantity: false, - isSoldOut: false, isTaxable: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -188,13 +173,8 @@ export const mockExternalCatalogOptions = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isLowQuantity: true, - isSoldOut: false, isTaxable: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -245,13 +225,8 @@ export const mockInternalCatalogVariants = [ createdAt: createdAt.toISOString(), height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, - isLowQuantity: true, - isSoldOut: false, isTaxable: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -297,13 +272,8 @@ export const mockExternalCatalogVariants = [ createdAt: createdAt.toISOString(), height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, - isLowQuantity: true, - isSoldOut: false, isTaxable: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -356,7 +326,6 @@ export const mockInternalCatalogProducts = [ height: 11.23, isVisible: true, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -444,7 +413,6 @@ export const mockInternalCatalogProducts = [ height: 11.23, isVisible: true, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -547,7 +515,6 @@ export const mockExternalCatalogProducts = [ isLowQuantity: false, isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -653,7 +620,6 @@ export const mockExternalCatalogProducts = [ isLowQuantity: false, isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", From a3d1b8912d7cf72fb2434fd5917433debe6e5516 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 11:17:05 -0500 Subject: [PATCH 19/55] feat: use inventoryForProductConfiguration for check when ordering Signed-off-by: Eric Dobbertin --- .../queries/inventoryForProductConfigurations.js | 9 +++++---- .../orders/server/no-meteor/util/buildOrderItem.js | 13 ++++++++----- .../utils/inventoryForProductConfigurations.js | 4 +++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js index 8b101fd7a65..e589d6b8f31 100644 --- a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -23,7 +23,7 @@ const DEFAULT_INFO = { const productConfigurationSchema = new SimpleSchema({ isSellable: Boolean, productId: String, - variantId: String + productVariantId: String }); const inputSchema = new SimpleSchema({ @@ -102,10 +102,11 @@ export default async function inventoryForProductConfigurations(context, input) let sellableProductConfigurations = []; const parentVariantProductConfigurations = []; for (const productConfiguration of productConfigurations) { - if (productConfiguration.isSellable) { - sellableProductConfigurations.push(productConfiguration); + const { isSellable, ...coreProductConfiguration } = productConfiguration; + if (isSellable) { + sellableProductConfigurations.push(coreProductConfiguration); } else { - parentVariantProductConfigurations.push(productConfiguration); + parentVariantProductConfigurations.push(coreProductConfiguration); } } diff --git a/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js index 63e4fa52359..8c619ee6c37 100644 --- a/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js +++ b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js @@ -14,12 +14,10 @@ export default async function buildOrderItem(context, { currencyCode, inputItem const { addedAt, price, - productConfiguration: { - productId, - productVariantId - }, + productConfiguration, quantity } = inputItem; + const { productId, productVariantId } = productConfiguration; const { catalogProduct: chosenProduct, @@ -38,7 +36,12 @@ export default async function buildOrderItem(context, { currencyCode, inputItem throw new ReactionError("invalid", `Provided price for the "${chosenVariant.title}" item does not match current published price`); } - if (!chosenVariant.canBackorder && (quantity > chosenVariant.inventoryAvailableToSell)) { + const inventoryInfo = await context.queries.inventoryForProductConfiguration(context, { + fields: ["canBackorder", "inventoryAvailableToSell"], + productConfiguration + }); + + if (!inventoryInfo.canBackorder && (quantity > inventoryInfo.inventoryAvailableToSell)) { throw new ReactionError("invalid-order-quantity", `Quantity ordered is more than available inventory for "${chosenVariant.title}"`); } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js index 9c7aea20d75..40a6907df95 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js @@ -19,9 +19,11 @@ export default async function inventoryForProductConfigurations(context, input) const { SimpleInventory } = collections; const { productConfigurations } = input; + const productVariantIds = productConfigurations.map(({ productVariantId }) => productVariantId); + const inventoryDocs = await SimpleInventory .find({ - productConfiguration: { $in: productConfigurations } + "productConfiguration.productVariantId": { $in: productVariantIds } }) .limit(productConfigurations.length) // optimize query speed .toArray(); From 52e6a0eb304feea08a9eb1638b90a66178bc07a9 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 12:58:09 -0500 Subject: [PATCH 20/55] refactor: more inventory refactoring Signed-off-by: Eric Dobbertin --- imports/collections/schemas/products.js | 38 ---- .../mutations/publishProducts.test.js | 12 -- .../no-meteor/utils/createCatalogProduct.js | 3 - .../utils/getTopLevelProduct.test.js | 6 - .../no-meteor/utils/getVariants.test.js | 10 -- .../no-meteor/utils/hasChildVariant.test.js | 10 -- .../utils/publishProductToCatalogById.test.js | 10 -- .../utils/publishProductsToCatalog.test.js | 10 -- imports/plugins/core/inventory/register.js | 2 + .../inventoryForProductConfigurations.js | 170 +++++++++++------- .../server/no-meteor/schemas/schema.graphql | 5 + .../utils/publishProductToCatalog.js | 2 +- .../server/no-meteor/utils/xformCartItems.js | 11 +- .../utils/xformCatalogProductVariants.js | 19 +- .../server/no-meteor/mutations/index.js | 5 + .../mutations/updateSimpleInventory.js | 119 ++++++++++++ .../server/no-meteor/queries/index.js | 5 + .../no-meteor/queries/simpleInventory.js | 35 ++++ .../server/no-meteor/register.js | 11 +- .../no-meteor/resolvers/Mutation/index.js | 5 + .../Mutation/updateSimpleInventory.js | 35 ++++ .../server/no-meteor/resolvers/Query/index.js | 5 + .../resolvers/Query/simpleInventory.js | 28 +++ .../server/no-meteor/resolvers/index.js | 7 + .../server/no-meteor/schemas/index.js | 3 + .../server/no-meteor/schemas/schema.graphql | 81 +++++++++ .../server/no-meteor/simpleSchemas.js | 26 +++ .../server/no-meteor/startup.js | 11 +- .../util/getProductPriceRange.test.js | 10 -- private/data/Products.json | 6 - .../CatalogItemProductFullQuery.graphql | 6 - .../CatalogProductItemsFullQuery.graphql | 6 - 32 files changed, 502 insertions(+), 210 deletions(-) create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/queries/index.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/updateSimpleInventory.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/index.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/simpleInventory.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/resolvers/index.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/schemas/index.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js diff --git a/imports/collections/schemas/products.js b/imports/collections/schemas/products.js index 31ffb45d904..2e4f36623f7 100644 --- a/imports/collections/schemas/products.js +++ b/imports/collections/schemas/products.js @@ -57,12 +57,9 @@ registerSchema("VariantMedia", VariantMedia); * @property {Event[]} eventLog optional, Variant Event Log * @property {Number} height optional, default value: `0` * @property {Number} index optional, Variant position number in list. Keep array index for moving variants in a list. - * @property {Boolean} inventoryManagement, default value: `true` - * @property {Boolean} inventoryPolicy, default value: `false`, If disabled, item can be sold even if it not in stock. * @property {Boolean} isDeleted, default value: `false` * @property {Boolean} isVisible, default value: `false` * @property {Number} length optional, default value: `0` - * @property {Number} lowInventoryWarningThreshold, default value: `0`, Warn of low inventory at this number * @property {Metafield[]} metafields optional * @property {Number} minOrderQuantity optional * @property {String} optionTitle, Option internal name, default value: `"Untitled option"` @@ -128,34 +125,6 @@ export const ProductVariant = new SimpleSchema({ type: SimpleSchema.Integer, optional: true }, - "inventoryManagement": { - type: Boolean, - label: "Inventory Tracking", - optional: true, - defaultValue: true, - custom() { - if (Meteor.isClient) { - if (!(this.siblingField("type").value === "inventory" || this.value || - this.value === false)) { - return SimpleSchema.ErrorTypes.REQUIRED; - } - } - } - }, - "inventoryPolicy": { - type: Boolean, - label: "Deny when out of stock", - optional: true, - defaultValue: false, - custom() { - if (Meteor.isClient) { - if (!(this.siblingField("type").value === "inventory" || this.value || - this.value === false)) { - return SimpleSchema.ErrorTypes.REQUIRED; - } - } - } - }, "isDeleted": { type: Boolean, defaultValue: false @@ -171,13 +140,6 @@ export const ProductVariant = new SimpleSchema({ optional: true, defaultValue: 0 }, - "lowInventoryWarningThreshold": { - type: SimpleSchema.Integer, - label: "Warn at", - min: 0, - optional: true, - defaultValue: 0 - }, "metafields": { type: Array, optional: true diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js index 31d8549dcf4..41334f44853 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js @@ -38,12 +38,9 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -73,12 +70,9 @@ const mockVariants = [ compareAtPrice: 15, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -179,10 +173,7 @@ const expectedOptionsResponse = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -217,10 +208,7 @@ const expectedVariantsResponse = [ createdAt: createdAt.toISOString(), height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index 4a770582609..93e59750698 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -17,10 +17,7 @@ export function xformVariant(variant, variantMedia) { createdAt: variant.createdAt || new Date(), height: variant.height, index: variant.index || 0, - inventoryManagement: !!variant.inventoryManagement, - inventoryPolicy: !!variant.inventoryPolicy, length: variant.length, - lowInventoryWarningThreshold: variant.lowInventoryWarningThreshold, media: variantMedia, metafields: variant.metafields, minOrderQuantity: variant.minOrderQuantity, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js index 49f974d42cd..facbffeee0f 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js @@ -25,14 +25,11 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, isLowQuantity: true, isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -63,14 +60,11 @@ const mockVariants = [ createdAt, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, isLowQuantity: true, isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js index db47c85450e..43441c8a002 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js @@ -19,14 +19,9 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -56,14 +51,9 @@ const mockVariants = [ barcode: "barcode", height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js index 38659b99318..f158450c211 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js @@ -19,14 +19,9 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -56,14 +51,9 @@ const mockVariants = [ barcode: "barcode", height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js index 41258f64bcb..362456c9039 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js @@ -28,14 +28,9 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -65,14 +60,9 @@ const mockVariants = [ barcode: "barcode", height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js index ee2b23ab387..c4cf8273e6e 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js @@ -28,14 +28,9 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -65,14 +60,9 @@ const mockVariants = [ barcode: "barcode", height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/imports/plugins/core/inventory/register.js b/imports/plugins/core/inventory/register.js index c7ec7e1da58..e4a937ad8cb 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -2,6 +2,7 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; import queries from "./server/no-meteor/queries"; import schemas from "./server/no-meteor/schemas"; import publishProductToCatalog from "./server/no-meteor/utils/publishProductToCatalog"; +import xformCartItems from "./server/no-meteor/utils/xformCartItems"; import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; import xformCatalogProductVariants from "./server/no-meteor/utils/xformCatalogProductVariants"; @@ -11,6 +12,7 @@ Reaction.registerPackage({ autoEnable: true, functionsByType: { publishProductToCatalog: [publishProductToCatalog], + xformCartItems: [xformCartItems], xformCatalogBooleanFilters: [xformCatalogBooleanFilters], xformCatalogProductVariants: [xformCatalogProductVariants] }, diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js index e589d6b8f31..e423e95ab93 100644 --- a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -64,65 +64,44 @@ const inventoryInfoSchema = new SimpleSchema({ isLowQuantity: Boolean }); +const responseProductConfigurationSchema = new SimpleSchema({ + productId: String, + productVariantId: String +}); + const pluginResultSchema = new SimpleSchema({ inventoryInfo: { type: inventoryInfoSchema, optional: true }, - productConfigurations: productConfigurationSchema + productConfiguration: responseProductConfigurationSchema }); /** - * @summary Returns an object with inventory information for one or more - * product configurations. For performance, it is better to call this - * function once rather than calling `inventoryForProductConfiguration` - * (singular) in a loop. + * @summary Gets inventory results for multiple product configs + * @private * @param {Object} context App context - * @param {Object} input Additional input arguments - * @param {Object[]} input.productConfigurations An array of ProductConfiguration objects - * @param {String[]} [input.fields] Optional array of fields you need. If you don't need all, - * you can pass this to skip some calculations and database lookups, improving speed. - * @param {Object[]} [input.variants] Optionally pass an array of the relevant variants if - * you have already looked them up. This will save a database query. - * @return {Promise} Array of responses. Order is not guaranteed to be the same - * as `input.productConfigurations` array. + * @param {Object} input Input + * @return {Object[]} Array of result objects */ -export default async function inventoryForProductConfigurations(context, input) { - const { collections, getFunctionsOfType } = context; - const { Products } = collections; - - inputSchema.validate(input); - - const { fields = ALL_FIELDS, productConfigurations } = input; - let { variants } = input; - - // Inventory plugins are expected to provide inventory info only for sellable variants. - // If there are any non-sellable parent variants in the list, we remove them now. - // We'll aggregate their child option values after we get them. - let sellableProductConfigurations = []; - const parentVariantProductConfigurations = []; - for (const productConfiguration of productConfigurations) { - const { isSellable, ...coreProductConfiguration } = productConfiguration; - if (isSellable) { - sellableProductConfigurations.push(coreProductConfiguration); - } else { - parentVariantProductConfigurations.push(coreProductConfiguration); - } - } - +async function getInventoryResults(context, input) { // If there are multiple plugins providing inventory, we use the first one that has a response // for each product configuration. const results = []; - for (const inventoryFn of getFunctionsOfType("inventoryForProductConfigurations")) { - // Functions of type "publishProductToCatalog" are expected to mutate the provided catalogProduct. + let remainingProductConfigurations = input.productConfigurations; + for (const inventoryFn of context.getFunctionsOfType("inventoryForProductConfigurations")) { // eslint-disable-next-line no-await-in-loop - const pluginResults = await inventoryFn(context, { ...input, fields, productConfigurations }); + const pluginResults = await inventoryFn(context, input); - pluginResultSchema.validate(pluginResults); + try { + pluginResultSchema.validate(pluginResults); + } catch (error) { + throw new Error(`Response from "inventoryForProductConfigurations" type function was invalid: ${error.message}`); + } // Add only those with inventory info to final results. // Otherwise add to sellableProductConfigurations for next run - sellableProductConfigurations = []; + remainingProductConfigurations = []; for (const pluginResult of pluginResults) { if (pluginResult.inventoryInfo) { // Add fields that we calculate here so that each plugin doesn't have to @@ -130,48 +109,109 @@ export default async function inventoryForProductConfigurations(context, input) pluginResult.inventoryInfo.isBackorder = pluginResult.inventoryInfo.isSoldOut && pluginResult.inventoryInfo.canBackorder; results.push(pluginResult); } else { - sellableProductConfigurations.push(pluginResult.productConfiguration); + remainingProductConfigurations.push(pluginResult.productConfiguration); } } - if (sellableProductConfigurations.length === 0) break; // found inventory info for every product config + if (remainingProductConfigurations.length === 0) break; // found inventory info for every product config } // If no inventory info was found for some of the product configs, such as // if there are no plugins providing inventory info, then use default info // that allows the product to be purchased always. - for (const productConfiguration of sellableProductConfigurations) { + for (const productConfiguration of remainingProductConfigurations) { results.push({ productConfiguration, inventoryInfo: DEFAULT_INFO }); } - // Now it's time to calculate top-level variant aggregated inventory and add those to the results - if (!variants) { - const variantIds = parentVariantProductConfigurations.map(({ variantId }) => variantId); - variants = await Products.find({ ancestors: { $in: variantIds } }).toArray(); + return results; +} + +/** + * @summary Returns an object with inventory information for one or more + * product configurations. For performance, it is better to call this + * function once rather than calling `inventoryForProductConfiguration` + * (singular) in a loop. + * @param {Object} context App context + * @param {Object} input Additional input arguments + * @param {Object[]} input.productConfigurations An array of ProductConfiguration objects + * @param {String[]} [input.fields] Optional array of fields you need. If you don't need all, + * you can pass this to skip some calculations and database lookups, improving speed. + * @return {Promise} Array of responses. Order is not guaranteed to be the same + * as `input.productConfigurations` array. + */ +export default async function inventoryForProductConfigurations(context, input) { + const { collections } = context; + const { Products } = collections; + + inputSchema.validate(input); + + const { fields = ALL_FIELDS, productConfigurations } = input; + + // Inventory plugins are expected to provide inventory info only for sellable variants. + // If there are any non-sellable parent variants in the list, we remove them now. + // We'll aggregate their child option values after we get them. + const parentVariantProductConfigurations = []; + const sellableProductConfigurations = []; + for (const productConfiguration of productConfigurations) { + const { isSellable, ...coreProductConfiguration } = productConfiguration; + if (isSellable) { + sellableProductConfigurations.push(coreProductConfiguration); + } else { + parentVariantProductConfigurations.push(coreProductConfiguration); + } } - for (const productConfiguration of parentVariantProductConfigurations) { - const childOptions = variants.filter((option) => option.ancestors.includes(productConfiguration.variantId)); - const childOptionsInventory = childOptions.reduce((list, option) => { - const optionResult = results.find((result) => result.productConfiguration.variantId === option._id); - if (optionResult) list.push(optionResult.inventoryInfo); - return list; - }, []); - results.push({ - productConfiguration, - inventoryInfo: { - canBackorder: childOptionsInventory.some((option) => option.canBackorder), - inventoryAvailableToSell: childOptionsInventory.reduce((sum, option) => sum + option.inventoryAvailableToSell, 0), - inventoryInStock: childOptionsInventory.reduce((sum, option) => sum + option.inventoryInStock, 0), - inventoryReserved: childOptionsInventory.reduce((sum, option) => sum + option.inventoryReserved, 0), - isBackorder: childOptionsInventory.every((option) => option.isBackorder), - isLowQuantity: childOptionsInventory.some((option) => option.isLowQuantity), - isSoldOut: childOptionsInventory.every((option) => option.isSoldOut) + // Get results for sellable product configs + const results = await getInventoryResults(context, { + fields, + productConfigurations: sellableProductConfigurations + }); + + // Now it's time to calculate top-level variant aggregated inventory and add those to the results. + // For non-sellable (parent) variants, we need to get inventory for all of their options + // and calculate aggregated values from them. + let childOptionResults = []; + if (parentVariantProductConfigurations.length) { + const variantIds = parentVariantProductConfigurations.map(({ variantId }) => variantId); + const allOptions = await Products.find({ + ancestors: { $in: variantIds } + }, { + projection: { + ancestors: 1 } + }).toArray(); + + childOptionResults = await getInventoryResults(context, { + fields, + productConfigurations: allOptions.map((option) => ({ + productId: option.productId, + productVariantId: option._id + })) }); + + for (const productConfiguration of parentVariantProductConfigurations) { + const childOptions = allOptions.filter((option) => option.ancestors.includes(productConfiguration.productVariantId)); + const childOptionsInventory = childOptions.reduce((list, option) => { + const optionResult = childOptionResults.find((result) => result.productConfiguration.productVariantId === option._id); + if (optionResult) list.push(optionResult.inventoryInfo); + return list; + }, []); + results.push({ + productConfiguration, + inventoryInfo: { + canBackorder: childOptionsInventory.some((option) => option.canBackorder), + inventoryAvailableToSell: childOptionsInventory.reduce((sum, option) => sum + option.inventoryAvailableToSell, 0), + inventoryInStock: childOptionsInventory.reduce((sum, option) => sum + option.inventoryInStock, 0), + inventoryReserved: childOptionsInventory.reduce((sum, option) => sum + option.inventoryReserved, 0), + isBackorder: childOptionsInventory.every((option) => option.isBackorder), + isLowQuantity: childOptionsInventory.some((option) => option.isLowQuantity), + isSoldOut: childOptionsInventory.every((option) => option.isSoldOut) + } + }); + } } return results; diff --git a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql index c2249b80fbc..a7b8fb886ed 100644 --- a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql @@ -79,6 +79,11 @@ extend type CatalogProductVariant { } extend type CartItem { + """ + The quantity of this item currently available to order. + """ + currentQuantity: Int @deprecated(reason: "Use `inventoryAvailableToSell`.") + """ The quantity of this item currently available to sell. This number is updated when an order is placed by the customer. diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js index 9425c94bcf9..89d7a8ba386 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js @@ -15,7 +15,7 @@ export default async function publishProductToCatalog(catalogProduct, { context, productConfigurations: topVariants.map((option) => ({ isSellable: !variants.some((variant) => variant.ancestors.includes(option._id)), productId: option.ancestors[0], - variantId: option._id + productVariantId: option._id })), fields: ["isBackorder", "isLowQuantity", "isSoldOut"], variants diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js b/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js index b0034605583..d7c3964a59a 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js @@ -8,11 +8,7 @@ export default async function xformCartItems(context, items) { const productConfigurations = []; for (const item of items) { - productConfigurations.push({ - isSellable: true, - productId: item.productConfiguration.productId, - variantId: item.productConfiguration.productVariantId - }); + productConfigurations.push({ ...item.productConfiguration, isSellable: true }); } const variantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { @@ -21,9 +17,12 @@ export default async function xformCartItems(context, items) { for (const item of items) { const { inventoryInfo: variantInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => - productConfiguration.variantId === item.productConfiguration.productVariantId); + productConfiguration.productVariantId === item.productConfiguration.productVariantId); Object.getOwnPropertyNames(variantInventoryInfo).forEach((key) => { item[key] = variantInventoryInfo[key]; }); + + // Set deprecated `currentQuantity` for now + item.currentQuantity = item.inventoryAvailableToSell; } } diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js index 4b3c8eb3e46..2a88323c095 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js @@ -7,7 +7,14 @@ const inventoryVariantFields = [ "inventoryReserved", "isBackorder", "isLowQuantity", - "isSoldOut" + "isSoldOut", + "options.canBackorder", + "options.inventoryAvailableToSell", + "options.inventoryInStock", + "options.inventoryReserved", + "options.isBackorder", + "options.isLowQuantity", + "options.isSoldOut" ]; /** @@ -28,14 +35,14 @@ export default async function xformCatalogProductVariants(context, catalogProduc productConfigurations.push({ isSellable: (catalogProductVariant.options || []).length === 0, productId: catalogProduct.productId, - variantId: catalogProductVariant.variantId + productVariantId: catalogProductVariant.variantId }); for (const option of (catalogProductVariant.options || [])) { productConfigurations.push({ isSellable: true, productId: catalogProduct.productId, - variantId: option.variantId + productVariantId: option.variantId }); } } @@ -46,16 +53,16 @@ export default async function xformCatalogProductVariants(context, catalogProduc for (const catalogProductVariant of catalogProductVariants) { const { inventoryInfo: variantInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => - productConfiguration.variantId === catalogProductVariant.variantId); + productConfiguration.productVariantId === catalogProductVariant.variantId); Object.getOwnPropertyNames(variantInventoryInfo).forEach((key) => { catalogProductVariant[key] = variantInventoryInfo[key]; }); for (const option of (catalogProductVariant.options || [])) { const { inventoryInfo: optionInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => - productConfiguration.variantId === option.variantId); + productConfiguration.productVariantId === option.variantId); Object.getOwnPropertyNames(optionInventoryInfo).forEach((key) => { - catalogProductVariant[key] = optionInventoryInfo[key]; + option[key] = optionInventoryInfo[key]; }); } } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js new file mode 100644 index 00000000000..8129b65eaf9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js @@ -0,0 +1,5 @@ +import updateSimpleInventory from "./updateSimpleInventory"; + +export default { + updateSimpleInventory +}; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js new file mode 100644 index 00000000000..034051211db --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -0,0 +1,119 @@ +import SimpleSchema from "simpl-schema"; +import ReactionError from "@reactioncommerce/reaction-error"; +import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; + +const inputSchema = new SimpleSchema({ + productConfiguration: ProductConfigurationSchema, + canBackorder: { + type: Boolean, + optional: true + }, + inventoryInStock: { + type: SimpleSchema.Integer, + min: 0, + optional: true + }, + isEnabled: { + type: Boolean, + optional: true + }, + lowInventoryWarningThreshold: { + type: SimpleSchema.Integer, + min: 0, + optional: true + }, + shopId: String +}); + +const updateFields = [ + "canBackorder", + "inventoryInStock", + "isEnabled", + "lowInventoryWarningThreshold" +]; + +const defaultValues = { + canBackorder: false, + inventoryInStock: 0, + isEnabled: false, + lowInventoryWarningThreshold: 0 +}; + +/** + * @summary Updates SimpleInventory data for a product configuration. Pass only + * those arguments you want to update. + * @param {Object} context App context + * @param {Object} input Input + * @param {Object} input.productConfiguration Product configuration object + * @param {String} input.shopId ID of shop that owns the product + * @param {Boolean} input.canBackorder Whether to allow ordering this product configuration when there is insufficient quantity available + * @param {Number} input.inventoryInStock Current quantity of this product configuration in stock + * @param {Boolean} input.isEnabled Whether the SimpleInventory plugin should manage inventory for this product configuration + * @param {Number} input.lowInventoryWarningThreshold The "low quantity" flag will be applied to this product configuration + * when the available quantity is at or below this threshold. + * @return {Object} Updated inventory values + */ +export default async function updateSimpleInventory(context, input) { + inputSchema.validate(input); + + const { collections, userHasPermission } = context; + const { Products, SimpleInventory } = collections; + const { productConfiguration, shopId } = input; + + // Verify that the product exists + const foundProduct = await Products.findOne({ + _id: productConfiguration.productId, + shopId + }, { + projection: { + shopId: 1 + } + }); + if (!foundProduct) throw new ReactionError("not-found", "Product not found"); + + if (!userHasPermission(["admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } + + const $set = { updatedAt: new Date() }; + const $setOnInsert = { + "createdAt": new Date(), + // inventoryReserved is calculated by this plugin rather than being set by + // users, but we need to init it to 0 on inserts since it's required. + "inventoryReserved": 0, + // The upsert query below has only `productVariantId` so we need to ensure both are inserted + "productConfiguration.productId": productConfiguration.productId + }; + updateFields.forEach((field) => { + const value = input[field]; + if (value !== undefined && value !== null) { + $set[field] = value; + } else { + // If we are not setting the value here, then we add it to the setOnInsert. + // This is necessary because all fields are required by the collection schema. + $setOnInsert[field] = defaultValues[field]; + } + }); + + if ($set.getOwnPropertyNames().length === 1) { + throw new ReactionError("invalid-param", "You must provide at least one field to update."); + } + + const modifier = { $set, $setOnInsert }; + + SimpleInventoryCollectionSchema.validate(modifier, { modifier: true, upsert: true }); + + const { value: updatedDoc } = await SimpleInventory.findOneAndUpdate( + { + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + }, + modifier, + { + returnOriginal: false, + upsert: true + } + ); + + return updatedDoc; +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/queries/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/queries/index.js new file mode 100644 index 00000000000..8223eb46464 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/queries/index.js @@ -0,0 +1,5 @@ +import simpleInventory from "./simpleInventory"; + +export default { + simpleInventory +}; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js new file mode 100644 index 00000000000..e0dd93e5125 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js @@ -0,0 +1,35 @@ +import SimpleSchema from "simpl-schema"; +import ReactionError from "@reactioncommerce/reaction-error"; +import { ProductConfigurationSchema } from "../simpleSchemas"; + +const inputSchema = new SimpleSchema({ + productConfiguration: ProductConfigurationSchema, + shopId: String +}); + +/** + * @name simpleInventory + * @summary Gets SimpleInventory data for a product configuration + * @param {Object} context App context + * @param {Object} input Input + * @param {Object} input.productConfiguration Product configuration object + * @param {String} input.shopId Shop ID + * @return {Object|null} SimpleInventory info + */ +export default async function simpleInventory(context, input) { + inputSchema.validate(input); + + const { productConfiguration, shopId } = input; + const { collections, userHasPermission } = context; + const { SimpleInventory } = collections; + + if (!userHasPermission(["admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } + + return SimpleInventory.findOne({ + "productConfiguration.productVariantId": productConfiguration.productVariantId, + // Must include shopId here or the security check above is worthless + shopId + }); +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/register.js b/imports/plugins/included/simple-inventory/server/no-meteor/register.js index 80adae275b9..cb67d22451e 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/register.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/register.js @@ -1,3 +1,7 @@ +import mutations from "./mutations"; +import queries from "./queries"; +import resolvers from "./resolvers"; +import schemas from "./schemas"; import startup from "./startup"; import inventoryForProductConfigurations from "./utils/inventoryForProductConfigurations"; @@ -18,6 +22,11 @@ export default async function register(app) { inventoryForProductConfigurations: [inventoryForProductConfigurations], startup: [startup] }, - graphQL: {} + graphQL: { + resolvers, + schemas + }, + mutations, + queries }); } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js new file mode 100644 index 00000000000..8129b65eaf9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js @@ -0,0 +1,5 @@ +import updateSimpleInventory from "./updateSimpleInventory"; + +export default { + updateSimpleInventory +}; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/updateSimpleInventory.js new file mode 100644 index 00000000000..20b80b16ac7 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/updateSimpleInventory.js @@ -0,0 +1,35 @@ +import { decodeProductOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/product"; +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Mutation/updateSimpleInventory + * @summary Updates SimpleInventory data for a product configuration. Pass only + * those arguments you want to update. + * @param {Object} _ unused + * @param {Object} args Args passed by the client + * @param {Object} args.input Input + * @param {Object} args.input.productConfiguration Product configuration object + * @param {Object} context App context + * @return {Object} Updated inventory values + */ +export default async function updateSimpleInventory(_, { input }, context) { + const { clientMutationId = null, productConfiguration, shopId: opaqueShopId, ...passThroughInput } = input; + + const productId = decodeProductOpaqueId(productConfiguration.productId); + const productVariantId = decodeProductOpaqueId(productConfiguration.productVariantId); + const shopId = decodeShopOpaqueId(opaqueShopId); + + const inventoryInfo = await context.mutations.updateSimpleInventory(context, { + ...passThroughInput, + productConfiguration: { + productId, + productVariantId + }, + shopId + }); + + return { + clientMutationId, + inventoryInfo + }; +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/index.js new file mode 100644 index 00000000000..8223eb46464 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/index.js @@ -0,0 +1,5 @@ +import simpleInventory from "./simpleInventory"; + +export default { + simpleInventory +}; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/simpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/simpleInventory.js new file mode 100644 index 00000000000..e3ea2f91ab9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Query/simpleInventory.js @@ -0,0 +1,28 @@ +import { decodeProductOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/product"; +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Query/simpleInventory + * @summary Gets SimpleInventory data for a product configuration + * @param {Object} _ unused + * @param {Object} args Args passed by the client + * @param {String} args.shopId Shop ID + * @param {Object} args.productConfiguration Product configuration object + * @param {Object} context App context + * @return {Object|null} SimpleInventory info + */ +export default async function simpleInventory(_, args, context) { + const { productConfiguration, shopId: opaqueShopId } = args; + + const productId = decodeProductOpaqueId(productConfiguration.productId); + const productVariantId = decodeProductOpaqueId(productConfiguration.productVariantId); + const shopId = decodeShopOpaqueId(opaqueShopId); + + return context.queries.simpleInventory(context, { + productConfiguration: { + productId, + productVariantId + }, + shopId + }); +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/index.js new file mode 100644 index 00000000000..df177b00426 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/index.js @@ -0,0 +1,7 @@ +import Mutation from "./Mutation"; +import Query from "./Query"; + +export default { + Mutation, + Query +}; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/schemas/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/index.js new file mode 100644 index 00000000000..cc293a21b1e --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/index.js @@ -0,0 +1,3 @@ +import schema from "./schema.graphql"; + +export default [schema]; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql new file mode 100644 index 00000000000..bafc9372c28 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql @@ -0,0 +1,81 @@ +type SimpleInventoryInfo { + """ + Whether to allow ordering this product configuration when there is insufficient quantity available + """ + canBackorder: Boolean + + """ + Current quantity of this product configuration in stock + """ + inventoryInStock: Int + + """ + Whether the SimpleInventory plugin should manage inventory for this product configuration + """ + isEnabled: Boolean + + """ + The "low quantity" flag will be applied to this product configuration when the available quantity + is at or below this threshold + """ + lowInventoryWarningThreshold: Int + + "The product and chosen options this info applies to" + productConfiguration: ProductConfiguration! +} + +"Input for the `updateSimpleInventory` mutation. In addition to `shopId`, at least one field to update is required." +input UpdateSimpleInventoryInput { + "An optional string identifying the mutation call, which will be returned in the response payload" + clientMutationId: String + + """ + Whether to allow ordering this product configuration when there is insufficient quantity available. + Set this to `true` or `false` if you want to update it. + """ + canBackorder: Boolean + + """ + Current quantity of this product configuration in stock. Set this to an integer if you want to update it. + """ + inventoryInStock: Int + + """ + Whether the SimpleInventory plugin should manage inventory for this product configuration. + Set this to `true` or `false` if you want to update it. + """ + isEnabled: Boolean + + """ + The "low quantity" flag will be applied to this product configuration when the available quantity + is at or below this threshold. Set this to an integer if you want to update it. + """ + lowInventoryWarningThreshold: Int + + "The product and chosen options this info applies to" + productConfiguration: ProductConfigurationInput! + + "Shop that owns the product" + shopId: ID! +} + +type UpdateSimpleInventoryPayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "The updated inventory info" + inventoryInfo: SimpleInventoryInfo! +} + +extend type Query { + """ + Get the SimpleInventory info for a product configuration. Returns `null` if `updateSimpleInventory` + has never been called for this product configuration. + """ + simpleInventory(shopId: ID!, productConfiguration: ProductConfigurationInput!): SimpleInventoryInfo +} + +extend type Mutation { + "Update the SimpleInventory info for a product configuration" + updateSimpleInventory(input: UpdateSimpleInventoryInput!): UpdateSimpleInventoryPayload! +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js new file mode 100644 index 00000000000..95c2eaeb383 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js @@ -0,0 +1,26 @@ +import SimpleSchema from "simpl-schema"; + +export const ProductConfigurationSchema = new SimpleSchema({ + productId: String, + productVariantId: String +}); + +export const SimpleInventoryCollectionSchema = new SimpleSchema({ + productConfiguration: ProductConfigurationSchema, + canBackorder: Boolean, + createdAt: Date, + inventoryInStock: { + type: SimpleSchema.Integer, + min: 0 + }, + isEnabled: Boolean, + lowInventoryWarningThreshold: { + type: SimpleSchema.Integer, + min: 0 + }, + inventoryReserved: { + type: SimpleSchema.Integer, + min: 0 + }, + updatedAt: Date +}); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js index a3b2b922a48..b4d53be301a 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -1,4 +1,5 @@ import Logger from "@reactioncommerce/logger"; +import collectionIndex from "/imports/utils/collectionIndex"; /** * @summary Get all order items @@ -21,6 +22,8 @@ export default function startup(context) { const SimpleInventory = app.db.collection("SimpleInventory"); collections.SimpleInventory = SimpleInventory; + collectionIndex(SimpleInventory, { "productConfiguration.productVariantId": 1, "shopId": 1 }, { unique: true }); + appEvents.on("afterOrderCancel", async ({ order, returnToStock }) => { // Inventory is removed from stock only once an order has been approved // This is indicated by payment.status being anything other than `created` @@ -38,7 +41,7 @@ export default function startup(context) { filter: { productConfiguration: { productId: item.productId, - variantId: item.variantId + productVariantId: item.variantId } }, update: { @@ -57,7 +60,7 @@ export default function startup(context) { filter: { productConfiguration: { productId: item.productId, - variantId: item.variantId + productVariantId: item.variantId } }, update: { @@ -81,7 +84,7 @@ export default function startup(context) { filter: { productConfiguration: { productId: item.productId, - variantId: item.variantId + productVariantId: item.variantId } }, update: { @@ -107,7 +110,7 @@ export default function startup(context) { filter: { productConfiguration: { productId: item.productId, - variantId: item.variantId + productVariantId: item.variantId } }, update: { diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js index d99c19f3cc8..5002089c7b1 100644 --- a/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js @@ -18,14 +18,9 @@ const mockVariants = [ createdAt, height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -56,14 +51,9 @@ const mockVariants = [ barcode: "barcode", height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", diff --git a/private/data/Products.json b/private/data/Products.json index a8154baa721..7c093c68b9e 100644 --- a/private/data/Products.json +++ b/private/data/Products.json @@ -36,8 +36,6 @@ ], "title": "Basic Example Variant", "price": 19.99, - "inventoryManagement": false, - "inventoryPolicy": false, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" @@ -62,8 +60,6 @@ "title": "Option 1 - Red Dwarf", "optionTitle": "Red", "price": 19.99, - "inventoryManagement": false, - "inventoryPolicy": false, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" @@ -91,8 +87,6 @@ "title": "Option 2 - Green Tomato", "optionTitle": "Green", "price": 12.99, - "inventoryManagement": false, - "inventoryPolicy": false, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" diff --git a/tests/catalog/CatalogItemProductFullQuery.graphql b/tests/catalog/CatalogItemProductFullQuery.graphql index 4c7efe0868d..a7d32bc14bc 100644 --- a/tests/catalog/CatalogItemProductFullQuery.graphql +++ b/tests/catalog/CatalogItemProductFullQuery.graphql @@ -101,13 +101,10 @@ query ($slugOrId: String!) { createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace @@ -123,13 +120,10 @@ query ($slugOrId: String!) { createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace diff --git a/tests/catalog/CatalogProductItemsFullQuery.graphql b/tests/catalog/CatalogProductItemsFullQuery.graphql index 754bad446d9..fc09d585d82 100644 --- a/tests/catalog/CatalogProductItemsFullQuery.graphql +++ b/tests/catalog/CatalogProductItemsFullQuery.graphql @@ -103,13 +103,10 @@ query ($shopIds: [ID]!, $first: ConnectionLimitInt, $sortBy: CatalogItemSortByFi createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace @@ -125,13 +122,10 @@ query ($shopIds: [ID]!, $first: ConnectionLimitInt, $sortBy: CatalogItemSortByFi createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace From 8887a72df483ea67ef61383256c5b7af0f71875c Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 19:04:32 -0500 Subject: [PATCH 21/55] feat: client side simple-inventory and related server changes Signed-off-by: Eric Dobbertin --- .../plugins/core/core/server/Reaction/core.js | 220 +++++++++--------- .../server/no-meteor/util/buildOrderItem.js | 5 +- .../client/blocks/ProductDetailForm.js | 1 - .../client/blocks/ProductSocialForm.js | 1 - .../client/blocks/VariantInventoryForm.js | 180 -------------- .../product-admin/client/blocks/index.js | 9 - .../client/components/VariantTable.js | 2 - .../product-admin/client/hocs/withProduct.js | 4 - .../client/hocs/withProductForm.js | 1 - .../client/hocs/withVariantForm.js | 1 - .../product-variant/components/productGrid.js | 1 - .../components/productGridItems.js | 28 --- .../client/VariantInventoryForm.js | 168 +++++++++++++ .../client/getInventoryInfo.js | 12 + .../included/simple-inventory/client/index.js | 12 + .../client/withUpdateVariantInventoryInfo.js | 54 +++++ .../client/withVariantInventoryInfo.js | 89 +++++++ .../simple-inventory/server/i18n/en.json | 18 ++ .../simple-inventory/server/i18n/index.js | 10 + .../included/simple-inventory/server/index.js | 1 + .../mutations/updateSimpleInventory.js | 11 +- .../server/no-meteor/simpleSchemas.js | 3 +- private/data/i18n/en.json | 7 - 23 files changed, 490 insertions(+), 348 deletions(-) delete mode 100644 imports/plugins/included/product-admin/client/blocks/VariantInventoryForm.js create mode 100644 imports/plugins/included/simple-inventory/client/VariantInventoryForm.js create mode 100644 imports/plugins/included/simple-inventory/client/getInventoryInfo.js create mode 100644 imports/plugins/included/simple-inventory/client/index.js create mode 100644 imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js create mode 100644 imports/plugins/included/simple-inventory/client/withVariantInventoryInfo.js create mode 100644 imports/plugins/included/simple-inventory/server/i18n/en.json create mode 100644 imports/plugins/included/simple-inventory/server/i18n/index.js create mode 100644 imports/plugins/included/simple-inventory/server/index.js diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index a9bc1016014..4fd5df2449f 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -96,10 +96,6 @@ export default { */ registerPackage(packageInfo) { this.whenAppInstanceReady((app) => app.registerPlugin(packageInfo)); - - // Save the package info - this.Packages[packageInfo.name] = packageInfo; - return this.Packages[packageInfo.name]; }, defaultCustomerRoles: ["guest", "account/profile", "product", "tag", "index", "cart/completed"], @@ -774,44 +770,47 @@ export default { } } - const packages = this.Packages; - // for each shop, we're loading packages in a unique registry - // Object.keys(pkgConfigs).forEach((pkgName) => { - for (const packageName in packages) { - // Guard to prevent unexpected `for in` behavior - if ({}.hasOwnProperty.call(packages, packageName)) { - const config = packages[packageName]; - this.assignOwnerRoles(shopId, packageName, config.registry); - - const pkg = Object.assign({}, config, { - shopId - }); - - // populate array of layouts that don't already exist (?!) - if (pkg.layout) { - // filter out layout templates - for (const template of pkg.layout) { - if (template && template.layout) { - layouts.push(template); + this.whenAppInstanceReady((app) => { + const { registeredPlugins } = app; + + // for each shop, we're loading packages in a unique registry + // Object.keys(pkgConfigs).forEach((pkgName) => { + for (const packageName in registeredPlugins) { + // Guard to prevent unexpected `for in` behavior + if ({}.hasOwnProperty.call(registeredPlugins, packageName)) { + const config = registeredPlugins[packageName]; + this.assignOwnerRoles(shopId, packageName, config.registry); + + const pkg = Object.assign({}, config, { + shopId + }); + + // populate array of layouts that don't already exist (?!) + if (pkg.layout) { + // filter out layout templates + for (const template of pkg.layout) { + if (template && template.layout) { + layouts.push(template); + } } } - } - if (enabledPackages && Array.isArray(enabledPackages)) { - if (enabledPackages.indexOf(pkg.name) === -1) { - pkg.enabled = false; - } else if (pkg.settings && pkg.settings[packageName]) { // Enable "soft switch" for package. - pkg.settings[packageName].enabled = true; + if (enabledPackages && Array.isArray(enabledPackages)) { + if (enabledPackages.indexOf(pkg.name) === -1) { + pkg.enabled = false; + } else if (pkg.settings && pkg.settings[packageName]) { // Enable "soft switch" for package. + pkg.settings[packageName].enabled = true; + } } + Packages.insert(pkg); + Logger.debug(`Initializing ${shopId} ${packageName}`); } - Packages.insert(pkg); - Logger.debug(`Initializing ${shopId} ${packageName}`); } - } - // helper for removing layout duplicates - const uniqLayouts = uniqWith(layouts, _.isEqual); - Shops.update({ _id: shopId }, { $set: { layout: uniqLayouts } }); + // helper for removing layout duplicates + const uniqLayouts = uniqWith(layouts, _.isEqual); + Shops.update({ _id: shopId }, { $set: { layout: uniqLayouts } }); + }); }, /** @@ -865,89 +864,92 @@ export default { } } - const layouts = []; - const totalPackages = Object.keys(this.Packages).length; - let loadedIndex = 1; - // for each shop, we're loading packages in a unique registry - _.each(this.Packages, (config, pkgName) => - Shops.find().forEach((shop) => { - const shopId = shop._id; - if (!shopId) return; - - // existing registry will be upserted with changes, perhaps we should add: - this.assignOwnerRoles(shopId, pkgName, config.registry); - - // Settings from the package registry.js - const settingsFromPackage = { - name: pkgName, - version: config.version, - icon: config.icon, - enabled: !!config.autoEnable, - settings: config.settings, - registry: config.registry, - layout: config.layout - }; - - // Setting from a fixture file, most likely reaction.json - let settingsFromFixture; - if (registryFixtureData) { - settingsFromFixture = registryFixtureData[0].find((packageSetting) => config.name === packageSetting.name); - } + this.whenAppInstanceReady((app) => { + const layouts = []; + const { registeredPlugins } = app; + const totalPackages = Object.keys(registeredPlugins).length; + let loadedIndex = 1; + // for each shop, we're loading packages in a unique registry + _.each(registeredPlugins, (config, pkgName) => + Shops.find().forEach((shop) => { + const shopId = shop._id; + if (!shopId) return; + + // existing registry will be upserted with changes, perhaps we should add: + this.assignOwnerRoles(shopId, pkgName, config.registry); + + // Settings from the package registry.js + const settingsFromPackage = { + name: pkgName, + version: config.version, + icon: config.icon, + enabled: !!config.autoEnable, + settings: config.settings, + registry: config.registry, + layout: config.layout + }; + + // Setting from a fixture file, most likely reaction.json + let settingsFromFixture; + if (registryFixtureData) { + settingsFromFixture = registryFixtureData[0].find((packageSetting) => config.name === packageSetting.name); + } - // Setting already imported into the packages collection - const settingsFromDB = packages.find((ps) => (config.name === ps.name && shopId === ps.shopId)); + // Setting already imported into the packages collection + const settingsFromDB = packages.find((ps) => (config.name === ps.name && shopId === ps.shopId)); - const combinedSettings = merge({}, settingsFromPackage, settingsFromFixture || {}, settingsFromDB || {}); + const combinedSettings = merge({}, settingsFromPackage, settingsFromFixture || {}, settingsFromDB || {}); - // always use version from package - if (combinedSettings.version) { - combinedSettings.version = settingsFromPackage.version || settingsFromDB.version; - } - if (combinedSettings.registry) { - combinedSettings.registry = combinedSettings.registry.map((entry) => { - if (entry.provides && !Array.isArray(entry.provides)) { - entry.provides = [entry.provides]; - Logger.warn(`Plugin ${combinedSettings.name} is using a deprecated version of the provides property for` + - ` the ${entry.name || entry.route} registry entry. Since v1.5.0 registry provides accepts` + - " an array of strings."); - } - return entry; - }); - } + // always use version from package + if (combinedSettings.version) { + combinedSettings.version = settingsFromPackage.version || settingsFromDB.version; + } + if (combinedSettings.registry) { + combinedSettings.registry = combinedSettings.registry.map((entry) => { + if (entry.provides && !Array.isArray(entry.provides)) { + entry.provides = [entry.provides]; + Logger.warn(`Plugin ${combinedSettings.name} is using a deprecated version of the provides property for` + + ` the ${entry.name || entry.route} registry entry. Since v1.5.0 registry provides accepts` + + " an array of strings."); + } + return entry; + }); + } - // populate array of layouts that don't already exist in Shops - if (combinedSettings.layout) { - // filter out layout Templates - for (const pkg of combinedSettings.layout) { - if (pkg.layout) { - layouts.push(pkg); + // populate array of layouts that don't already exist in Shops + if (combinedSettings.layout) { + // filter out layout Templates + for (const pkg of combinedSettings.layout) { + if (pkg.layout) { + layouts.push(pkg); + } } } + // Import package data + this.Importer.package(combinedSettings, shopId); + Logger.info(`Successfully initialized package: ${pkgName}... ${loadedIndex}/${totalPackages}`); + loadedIndex += 1; + })); + + // helper for removing layout duplicates + const uniqLayouts = uniqWith(layouts, _.isEqual); + // import layouts into Shops + Shops.find().forEach((shop) => { + this.Importer.layout(uniqLayouts, shop._id); + }); + + // + // package cleanup + // + Shops.find().forEach((shop) => Packages.find().forEach((pkg) => { + // delete registry entries for packages that have been removed + if (!_.has(registeredPlugins, pkg.name)) { + Logger.debug(`Removing ${pkg.name}`); + return Packages.remove({ shopId: shop._id, name: pkg.name }); } - // Import package data - this.Importer.package(combinedSettings, shopId); - Logger.info(`Successfully initialized package: ${pkgName}... ${loadedIndex}/${totalPackages}`); - loadedIndex += 1; + return false; })); - - // helper for removing layout duplicates - const uniqLayouts = uniqWith(layouts, _.isEqual); - // import layouts into Shops - Shops.find().forEach((shop) => { - this.Importer.layout(uniqLayouts, shop._id); }); - - // - // package cleanup - // - Shops.find().forEach((shop) => Packages.find().forEach((pkg) => { - // delete registry entries for packages that have been removed - if (!_.has(this.Packages, pkg.name)) { - Logger.debug(`Removing ${pkg.name}`); - return Packages.remove({ shopId: shop._id, name: pkg.name }); - } - return false; - })); }, /** diff --git a/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js index 8c619ee6c37..048bc2b131b 100644 --- a/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js +++ b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js @@ -38,7 +38,10 @@ export default async function buildOrderItem(context, { currencyCode, inputItem const inventoryInfo = await context.queries.inventoryForProductConfiguration(context, { fields: ["canBackorder", "inventoryAvailableToSell"], - productConfiguration + productConfiguration: { + ...productConfiguration, + isSellable: true + } }); if (!inventoryInfo.canBackorder && (quantity > inventoryInfo.inventoryAvailableToSell)) { diff --git a/imports/plugins/included/product-admin/client/blocks/ProductDetailForm.js b/imports/plugins/included/product-admin/client/blocks/ProductDetailForm.js index 94dd5895517..25ef33a20de 100644 --- a/imports/plugins/included/product-admin/client/blocks/ProductDetailForm.js +++ b/imports/plugins/included/product-admin/client/blocks/ProductDetailForm.js @@ -320,7 +320,6 @@ DetailForm.propTypes = { onProductFieldSave: PropTypes.func, onRestoreProduct: PropTypes.func, product: PropTypes.object, - revisonDocumentIds: PropTypes.arrayOf(PropTypes.string), templates: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, value: PropTypes.any diff --git a/imports/plugins/included/product-admin/client/blocks/ProductSocialForm.js b/imports/plugins/included/product-admin/client/blocks/ProductSocialForm.js index b4094e64d18..7de1553ddaf 100644 --- a/imports/plugins/included/product-admin/client/blocks/ProductSocialForm.js +++ b/imports/plugins/included/product-admin/client/blocks/ProductSocialForm.js @@ -318,7 +318,6 @@ ProductAdmin.propTypes = { onProductFieldSave: PropTypes.func, onRestoreProduct: PropTypes.func, product: PropTypes.object, - revisonDocumentIds: PropTypes.arrayOf(PropTypes.string), templates: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, value: PropTypes.any diff --git a/imports/plugins/included/product-admin/client/blocks/VariantInventoryForm.js b/imports/plugins/included/product-admin/client/blocks/VariantInventoryForm.js deleted file mode 100644 index 2d844ab1385..00000000000 --- a/imports/plugins/included/product-admin/client/blocks/VariantInventoryForm.js +++ /dev/null @@ -1,180 +0,0 @@ -import React, { Fragment } from "react"; -import PropTypes from "prop-types"; -import { Components } from "@reactioncommerce/reaction-components"; -import { i18next } from "/client/api"; -import Card from "@material-ui/core/Card"; -import CardContent from "@material-ui/core/CardContent"; -import CardHeader from "@material-ui/core/CardHeader"; -import FormControlLabel from "@material-ui/core/FormControlLabel"; -import Grid from "@material-ui/core/Grid"; -import Switch from "@material-ui/core/Switch"; -import { FormHelperText } from "@material-ui/core"; - -/** - * Variant inventory form block component - * @param {Object} props Component props - * @return {Node} React node - */ -function VariantInventoryForm(props) { - const { - onVariantCheckboxChange, - onVariantFieldBlur, - onVariantFieldChange, - onVariantInventoryPolicyChange, - hasChildVariants: hasChildVariantsProp, - validation, - variant - } = props; - - const hasChildVariants = hasChildVariantsProp(variant); - let quantityFields; - - if (hasChildVariants) { - quantityFields = ( - - - - - - - - - ); - } else { - quantityFields = ( - - - - - - - - - ); - } - - return ( - - - - - { - onVariantCheckboxChange(event, checked, "inventoryManagement"); - }} - /> - } - label="Manage inventory" - /> - - {quantityFields} - - - - - - - { - onVariantInventoryPolicyChange(event, checked, "inventoryPolicy"); - }} - /> - } - label="Allow backorder" - /> - {hasChildVariants && - - {i18next.t("admin.helpText.variantBackorderToggle")} - - } - - - - - ); -} - -VariantInventoryForm.propTypes = { - hasChildVariants: PropTypes.func, - onVariantCheckboxChange: PropTypes.func, - onVariantFieldBlur: PropTypes.func, - onVariantFieldChange: PropTypes.func, - onVariantInventoryPolicyChange: PropTypes.func, - validation: PropTypes.object, - variant: PropTypes.object -}; - -export default VariantInventoryForm; diff --git a/imports/plugins/included/product-admin/client/blocks/index.js b/imports/plugins/included/product-admin/client/blocks/index.js index 09f73a61d5e..c7d360fbccf 100644 --- a/imports/plugins/included/product-admin/client/blocks/index.js +++ b/imports/plugins/included/product-admin/client/blocks/index.js @@ -14,7 +14,6 @@ import VariantList from "./VariantList"; import VariantDetailForm from "./VariantDetailForm"; import VariantTaxForm from "./VariantTaxForm"; import VariantMediaForm from "./VariantMediaForm"; -import VariantInventoryForm from "./VariantInventoryForm"; import OptionTable from "./OptionTable"; // Register blocks @@ -122,14 +121,6 @@ registerBlock({ priority: 30 }); -registerBlock({ - region: "VariantDetailMain", - name: "VariantInventoryForm", - component: VariantInventoryForm, - hocs: [withVariantForm], - priority: 30 -}); - registerBlock({ region: "VariantDetailMain", name: "OptionTable", diff --git a/imports/plugins/included/product-admin/client/components/VariantTable.js b/imports/plugins/included/product-admin/client/components/VariantTable.js index d5fa8b921af..4ee9d7cf4d7 100644 --- a/imports/plugins/included/product-admin/client/components/VariantTable.js +++ b/imports/plugins/included/product-admin/client/components/VariantTable.js @@ -81,7 +81,6 @@ function VariantTable(props) { {i18next.t("admin.productTable.header.title")} {i18next.t("admin.productTable.header.price")} - {i18next.t("admin.productTable.header.qty")} {i18next.t("admin.productTable.header.visible")} @@ -121,7 +120,6 @@ function VariantTable(props) { {item.displayPrice} - {item.inventoryInStock} {item.isVisible ? "Visible" : "Hidden"} diff --git a/imports/plugins/included/product-admin/client/hocs/withProduct.js b/imports/plugins/included/product-admin/client/hocs/withProduct.js index f3158df662e..9c133829344 100644 --- a/imports/plugins/included/product-admin/client/hocs/withProduct.js +++ b/imports/plugins/included/product-admin/client/hocs/withProduct.js @@ -225,7 +225,6 @@ function composer(props, onData) { let tags; let media; - let revisonDocumentIds; if (product) { if (_.isArray(product.hashtags)) { @@ -241,8 +240,6 @@ function composer(props, onData) { }); } - revisonDocumentIds = [product._id]; - const templates = Templates.find({ parser: "react", provides: "template", @@ -285,7 +282,6 @@ function composer(props, onData) { product, media, tags, - revisonDocumentIds, templates, countries, editable, diff --git a/imports/plugins/included/product-admin/client/hocs/withProductForm.js b/imports/plugins/included/product-admin/client/hocs/withProductForm.js index 9dd76cfdcd6..e2ea79f7f63 100644 --- a/imports/plugins/included/product-admin/client/hocs/withProductForm.js +++ b/imports/plugins/included/product-admin/client/hocs/withProductForm.js @@ -173,7 +173,6 @@ const wrapComponent = (Comp) => { onProductFieldSave: PropTypes.func, onRestoreProduct: PropTypes.func, product: PropTypes.object, - revisonDocumentIds: PropTypes.arrayOf(PropTypes.string), templates: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, value: PropTypes.any diff --git a/imports/plugins/included/product-admin/client/hocs/withVariantForm.js b/imports/plugins/included/product-admin/client/hocs/withVariantForm.js index 5f2fa09e384..d5aa6f6b36e 100644 --- a/imports/plugins/included/product-admin/client/hocs/withVariantForm.js +++ b/imports/plugins/included/product-admin/client/hocs/withVariantForm.js @@ -297,7 +297,6 @@ const wrapComponent = (Comp) => ( onVariantFieldChange={this.handleFieldChange} onVariantFieldBlur={this.handleFieldBlur} onVariantCheckboxChange={this.handleCheckboxChange} - onVariantInventoryPolicyChange={this.handleInventoryPolicyChange} onVariantSelectChange={this.handleSelectChange} variant={this.variant} /> diff --git a/imports/plugins/included/product-variant/components/productGrid.js b/imports/plugins/included/product-variant/components/productGrid.js index 5adb06f9e29..d70697f0a79 100644 --- a/imports/plugins/included/product-variant/components/productGrid.js +++ b/imports/plugins/included/product-variant/components/productGrid.js @@ -244,7 +244,6 @@ class ProductGrid extends Component { Title Price Published - Status Visible diff --git a/imports/plugins/included/product-variant/components/productGridItems.js b/imports/plugins/included/product-variant/components/productGridItems.js index 0ffbdbc2f22..e878d2caca1 100644 --- a/imports/plugins/included/product-variant/components/productGridItems.js +++ b/imports/plugins/included/product-variant/components/productGridItems.js @@ -1,7 +1,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { Link } from "react-router-dom"; -import { Components } from "@reactioncommerce/reaction-components"; import { formatPriceString, i18next } from "/client/api"; import TableCell from "@material-ui/core/TableCell"; import TableRow from "@material-ui/core/TableRow"; @@ -70,30 +69,6 @@ class ProductGridItems extends Component { ); } - renderGridContent() { - return ( - - ); - } - handleSelect = (event) => { this.props.onSelect(event.target.checked, this.props.product); } @@ -121,9 +96,6 @@ class ProductGridItems extends Component { {this.renderPublishStatus()} - - - {i18next.t(product.isVisible ? "admin.tags.visible" : "admin.tags.hidden")} diff --git a/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js new file mode 100644 index 00000000000..3bc9a86419d --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js @@ -0,0 +1,168 @@ +import React, { Fragment } from "react"; +import PropTypes from "prop-types"; +import { Components } from "@reactioncommerce/reaction-components"; +import { i18next } from "/client/api"; +import { ReactionProduct } from "/lib/api"; +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import CardHeader from "@material-ui/core/CardHeader"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Grid from "@material-ui/core/Grid"; +import Switch from "@material-ui/core/Switch"; + +/** + * Variant inventory form block component + * @param {Object} props Component props + * @return {Node} React node + */ +function VariantInventoryForm(props) { + const { + inventoryInfo, + isLoadingInventoryInfo, + updateSimpleInventory, + variables, + variant + } = props; + + if (!variant) return null; + + if (isLoadingInventoryInfo) return ; + + const { + canBackorder, + inventoryInStock, + isEnabled, + lowInventoryWarningThreshold + } = inventoryInfo || {}; + + const hasChildVariants = ReactionProduct.checkChildVariants(variant._id) > 0; + + let content; + if (hasChildVariants) { + content = ( + +

{i18next.t("productVariant.noInventoryTracking")}

+
+ ); + } else { + content = ( + +

{i18next.t("productVariant.inventoryMessage")}

+ { + updateSimpleInventory({ + variables: { + input: { + ...variables, + isEnabled: checked + } + } + }); + }} + /> + } + label={i18next.t("productVariant.isInventoryManagementEnabled")} + /> + + + { + if (value < 0) return; + updateSimpleInventory({ + variables: { + input: { + ...variables, + inventoryInStock: value + } + } + }); + }} + placeholder="0" + type="number" + value={isEnabled ? inventoryInStock : 0} + /> + + + { + if (value < 0) return; + updateSimpleInventory({ + variables: { + input: { + ...variables, + lowInventoryWarningThreshold: value + } + } + }); + }} + placeholder="0" + type="number" + value={isEnabled ? lowInventoryWarningThreshold : 0} + /> + + + + + { + updateSimpleInventory({ + variables: { + input: { + ...variables, + canBackorder: checked + } + } + }); + }} + /> + } + label={i18next.t("productVariant.allowBackorder")} + /> + + +
+ ); + } + + return ( + + + {content} + + ); +} + +VariantInventoryForm.propTypes = { + inventoryInfo: PropTypes.shape({ + canBackorder: PropTypes.bool, + inventoryInStock: PropTypes.number, + isEnabled: PropTypes.bool, + lowInventoryWarningThreshold: PropTypes.number + }), + isLoadingInventoryInfo: PropTypes.bool, + updateSimpleInventory: PropTypes.func, + variables: PropTypes.object, + variant: PropTypes.object +}; + +export default VariantInventoryForm; diff --git a/imports/plugins/included/simple-inventory/client/getInventoryInfo.js b/imports/plugins/included/simple-inventory/client/getInventoryInfo.js new file mode 100644 index 00000000000..fdc0febea8e --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/getInventoryInfo.js @@ -0,0 +1,12 @@ +import gql from "graphql-tag"; + +export default gql` + query getInventoryInfo($shopId: ID!, $productConfiguration: ProductConfigurationInput!) { + simpleInventory(shopId: $shopId, productConfiguration: $productConfiguration) { + canBackorder + inventoryInStock + isEnabled + lowInventoryWarningThreshold + } + } +`; diff --git a/imports/plugins/included/simple-inventory/client/index.js b/imports/plugins/included/simple-inventory/client/index.js new file mode 100644 index 00000000000..be4aa555fcc --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/index.js @@ -0,0 +1,12 @@ +import { registerBlock } from "/imports/plugins/core/components/lib"; +import VariantInventoryForm from "./VariantInventoryForm"; +import withUpdateVariantInventoryInfo from "./withUpdateVariantInventoryInfo"; +import withVariantInventoryInfo from "./withVariantInventoryInfo"; + +registerBlock({ + region: "VariantDetailMain", + name: "VariantInventoryForm", + component: VariantInventoryForm, + hocs: [withVariantInventoryInfo, withUpdateVariantInventoryInfo], + priority: 30 +}); diff --git a/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js b/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js new file mode 100644 index 00000000000..66c36a0cce9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js @@ -0,0 +1,54 @@ +import React from "react"; +import PropTypes from "prop-types"; +import gql from "graphql-tag"; +import { Mutation } from "react-apollo"; +import getInventoryInfo from "./getInventoryInfo"; + +const updateSimpleInventoryMutation = gql` + mutation updateSimpleInventoryMutation($input: UpdateSimpleInventoryInput!) { + updateSimpleInventory(input: $input) { + inventoryInfo { + canBackorder + inventoryInStock + isEnabled + lowInventoryWarningThreshold + } + } + } +`; + +export default (Component) => ( + class WithUpdateSimpleInventory extends React.Component { + static propTypes = { + variables: PropTypes.object + } + + render() { + const { variables } = this.props; + + return ( + { + if (updateSimpleInventory && updateSimpleInventory.inventoryInfo) { + cache.writeQuery({ + query: getInventoryInfo, + variables, + data: { + simpleInventory: { ...updateSimpleInventory.inventoryInfo } + } + }); + } + }} + > + {(updateSimpleInventory) => ( + + )} + + ); + } + } +); diff --git a/imports/plugins/included/simple-inventory/client/withVariantInventoryInfo.js b/imports/plugins/included/simple-inventory/client/withVariantInventoryInfo.js new file mode 100644 index 00000000000..8cce13755b9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/withVariantInventoryInfo.js @@ -0,0 +1,89 @@ +import React from "react"; +import { Query } from "react-apollo"; +import PropTypes from "prop-types"; +import getOpaqueIds from "/imports/plugins/core/core/client/util/getOpaqueIds"; +import getInventoryInfo from "./getInventoryInfo"; + +export default (Component) => ( + class InventoryInfoQuery extends React.Component { + static propTypes = { + variant: PropTypes.object + } + + state = { + variantId: null, + variables: null + } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + getOpaqueIds(variant) { + this.isGettingIds = true; + getOpaqueIds([ + { namespace: "Product", id: variant.ancestors[0] }, + { namespace: "Product", id: variant._id }, + { namespace: "Shop", id: variant.shopId } + ]) + .then(([productId, productVariantId, shopId]) => { + if (this._isMounted) { + this.setState({ + variantId: variant._id, + variables: { + productConfiguration: { + productId, + productVariantId + }, + shopId + } + }); + } + this.isGettingIds = false; + return null; + }) + .catch((error) => { + throw error; + }); + } + + render() { + const { variant } = this.props; + + if (!variant) return null; + + if (variant._id !== this.state.variantId && !this.isGettingIds) { + this.getOpaqueIds(variant); + return null; + } + + const { variables } = this.state; + + if (!variables) return null; // still getting them + + return ( + + {({ loading, data }) => { + const props = { + ...this.props, + isLoadingInventoryInfo: loading, + variables + }; + + if (!loading && data) { + props.inventoryInfo = data.simpleInventory; + } + + return ( + + ); + }} + + ); + } + } +); diff --git a/imports/plugins/included/simple-inventory/server/i18n/en.json b/imports/plugins/included/simple-inventory/server/i18n/en.json new file mode 100644 index 00000000000..8a83481d9ee --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/i18n/en.json @@ -0,0 +1,18 @@ +[{ + "i18n": "en", + "ns": "reaction-simple-inventory", + "translation": { + "reaction-simple-inventory": { + "productVariant": { + "allowBackorder": "Allow backorder", + "inventoryHeading": "Inventory", + "inventoryInStock": "Quantity", + "inventoryMessage": "If inventory management is disabled for a variant, shoppers can always order any quantity of it. If inventory management is enabled, shoppers can only order up to the available quantity, unless you enable back-ordering. All changes on this card take effect immediately. Publishing is not necessary.", + "isInventoryManagementEnabled": "Manage inventory", + "lowInventoryWarningThreshold": "Warn at", + "lowInventoryWarningThresholdLabel": "Warn customers that the product will be close to sold out when the quantity reaches this threshold", + "noInventoryTracking": "Inventory is tracked only for sellable variants. Select an option to manage inventory for it." + } + } + } +}] diff --git a/imports/plugins/included/simple-inventory/server/i18n/index.js b/imports/plugins/included/simple-inventory/server/i18n/index.js new file mode 100644 index 00000000000..bce646aa335 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/i18n/index.js @@ -0,0 +1,10 @@ +import { loadTranslations } from "/imports/plugins/core/core/server/startup/i18n"; + +import en from "./en.json"; + +// +// we want all the files in individual +// imports for easier handling by +// automated translation software +// +loadTranslations([en]); diff --git a/imports/plugins/included/simple-inventory/server/index.js b/imports/plugins/included/simple-inventory/server/index.js new file mode 100644 index 00000000000..3979f964b5a --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/index.js @@ -0,0 +1 @@ +import "./i18n"; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index 034051211db..214de602733 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -95,13 +95,20 @@ export default async function updateSimpleInventory(context, input) { } }); - if ($set.getOwnPropertyNames().length === 1) { + if (Object.getOwnPropertyNames($set).length === 1) { throw new ReactionError("invalid-param", "You must provide at least one field to update."); } const modifier = { $set, $setOnInsert }; - SimpleInventoryCollectionSchema.validate(modifier, { modifier: true, upsert: true }); + SimpleInventoryCollectionSchema.validate({ + $set, + $setOnInsert: { + ...$setOnInsert, + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + } + }, { modifier: true, upsert: true }); const { value: updatedDoc } = await SimpleInventory.findOneAndUpdate( { diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js index 95c2eaeb383..2383d311d21 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js @@ -6,7 +6,6 @@ export const ProductConfigurationSchema = new SimpleSchema({ }); export const SimpleInventoryCollectionSchema = new SimpleSchema({ - productConfiguration: ProductConfigurationSchema, canBackorder: Boolean, createdAt: Date, inventoryInStock: { @@ -22,5 +21,7 @@ export const SimpleInventoryCollectionSchema = new SimpleSchema({ type: SimpleSchema.Integer, min: 0 }, + productConfiguration: ProductConfigurationSchema, + shopId: String, updatedAt: Date }); diff --git a/private/data/i18n/en.json b/private/data/i18n/en.json index 7a1cca8c75e..1b2af876615 100644 --- a/private/data/i18n/en.json +++ b/private/data/i18n/en.json @@ -257,7 +257,6 @@ }, "productVariant": { "addVariantOptions": "Add options to enable 'Add to Cart' button", - "inventoryInStock": "Quantity", "title": "Title", "price": "Price", "optionTitle": "Option title", @@ -271,12 +270,6 @@ "taxable": "Taxable", "taxCode": "Tax code", "taxDescription": "Tax description", - "inventoryManagement": "Inventory tracking", - "inventoryManagementLabel": "Register product in inventory system and track its status", - "inventoryPolicy": "Allow backorder", - "inventoryPolicyLabel": "Do not sell the product variant when it is not in stock", - "lowInventoryWarningThreshold": "Warn at", - "lowInventoryWarningThresholdLabel": "Warn customers that the product will be close to sold out when the quantity reaches the next threshold", "originCountry": "Origin country", "selectTaxCode": "Select tax code" }, From 15b178c42ee034ca6879e39296dd134730eae8ae Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 19:12:10 -0500 Subject: [PATCH 22/55] feat: migrate inventory data to SimpleInventory Signed-off-by: Eric Dobbertin --- .../migrations/63_inventory_to_collection.js | 118 ++++++++++++++++++ .../core/versions/server/migrations/index.js | 1 + .../util/findAndConvertInBatchesNoMeteor.js | 54 ++++++++ 3 files changed, 173 insertions(+) create mode 100644 imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js create mode 100644 imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js new file mode 100644 index 00000000000..fbbc30cbd20 --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -0,0 +1,118 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { MongoInternals } from "meteor/mongo"; +import rawCollections from "/imports/collections/rawCollections"; +import findAndConvertInBatches from "../util/findAndConvertInBatchesNoMeteor"; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +const SimpleInventory = db.collection("SimpleInventory"); + +const { + Catalog, + Products +} = rawCollections; + +Migrations.add({ + version: 63, + up() { + // Clear most inventory fields from Catalog. We'll use values from Products to populate the SimpleInventory collection + Promise.await(Catalog.updateMany({}, { + $unset: { + "product.inventoryInStock": "", + "product.inventoryAvailableToSell": "", + "product.variants.$[].canBackorder": "", + "product.variants.$[].inventoryInStock": "", + "product.variants.$[].inventoryAvailableToSell": "", + "product.variants.$[].inventoryManagement": "", + "product.variants.$[].inventoryPolicy": "", + "product.variants.$[].lowInventoryWarningThreshold": "", + "product.variants.$[].isBackorder": "", + "product.variants.$[].isLowQuantity": "", + "product.variants.$[].isSoldOut": "" + } + })); + + Promise.await(Catalog.updateMany({ + "product.variants.options": { $exists: true } + }, { + $unset: { + "product.variants.$[].options.$[].canBackorder": "", + "product.variants.$[].options.$[].inventoryInStock": "", + "product.variants.$[].options.$[].inventoryAvailableToSell": "", + "product.variants.$[].options.$[].inventoryManagement": "", + "product.variants.$[].options.$[].inventoryPolicy": "", + "product.variants.$[].options.$[].lowInventoryWarningThreshold": "", + "product.variants.$[].options.$[].isBackorder": "", + "product.variants.$[].options.$[].isLowQuantity": "", + "product.variants.$[].options.$[].isSoldOut": "" + } + })); + + Promise.await(findAndConvertInBatches({ + collection: Products, + query: { type: "variant" }, + converter: async (variant) => { + // If `inventoryManagement` prop is undefined, assume we already converted it and skip. + if (variant.inventoryManagement === undefined) return null; + + const childOption = await Products.findOne({ ancestors: variant._id }, { projection: { _id: 1 } }); + if (!childOption) { + // Create SimpleInventory record + await SimpleInventory.updateOne( + { + productConfiguration: { + productId: variant.ancestors[0], + productVariantId: variant._id + } + }, + { + $set: { + canBackorder: !variant.inventoryPolicy, + inventoryInStock: variant.inventoryInStock || 0, + inventoryReserved: (variant.inventoryInStock || 0) - (variant.inventoryAvailableToSell || 0), + isEnabled: variant.inventoryManagement || false, + lowInventoryWarningThreshold: variant.lowInventoryWarningThreshold || 0, + shopId: variant.shopId, + updatedAt: new Date() + }, + $setOnInsert: { + createdAt: new Date() + } + }, + { + upsert: true + } + ); + } + + delete variant.inventoryAvailableToSell; + delete variant.inventoryInStock; + delete variant.inventoryPolicy; + delete variant.inventoryManagement; + delete variant.lowInventoryWarningThreshold; + delete variant.isBackorder; + delete variant.isLowQuantity; + delete variant.isSoldOut; + + return variant; + } + })); + + Promise.await(findAndConvertInBatches({ + collection: Products, + query: { type: "simple" }, + converter: (product) => { + delete product.inventoryAvailableToSell; + delete product.inventoryInStock; + delete product.inventoryPolicy; + delete product.inventoryManagement; + delete product.lowInventoryWarningThreshold; + delete product.isBackorder; + delete product.isLowQuantity; + delete product.isSoldOut; + + return product; + } + })); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/index.js b/imports/plugins/core/versions/server/migrations/index.js index 7654a769f5b..719fbcc344f 100644 --- a/imports/plugins/core/versions/server/migrations/index.js +++ b/imports/plugins/core/versions/server/migrations/index.js @@ -61,3 +61,4 @@ import "./59_drop_indexes"; import "./60_remove_template_assets"; import "./61_drop_indexes"; import "./62_drop_indexes"; +import "./63_inventory_to_collection"; diff --git a/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js b/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js new file mode 100644 index 00000000000..c2867d4066e --- /dev/null +++ b/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js @@ -0,0 +1,54 @@ +import Logger from "@reactioncommerce/logger"; + +// Do this migration in batches of 200 to avoid memory issues +const LIMIT = 200; + +/** + * @summary Find documents in a collection that need to be converted, and convert them + * in small batches. + * @param {Object} options Options + * @param {Object} options.collection The Mongo collection instance + * @param {Function} options.converter Conversion function for a single doc + * @param {Object} [options.query] Optional MongoDB query that will only find not-yet-converted docs + * @returns {undefined} + */ +export default async function findAndConvertInBatchesNoMeteor({ collection, converter, query = {} }) { + let docs; + let skip = 0; + + /* eslint-disable no-await-in-loop */ + do { + docs = await collection.find(query, { + limit: LIMIT, + skip, + sort: { + _id: 1 + } + }).toArray(); + skip += LIMIT; + + if (docs.length) { + Logger.debug( + { name: "migrations" }, + `Migrating batch of ${docs.length} ${collection.collectionName} documents matching query ${JSON.stringify(query)}` + ); + let operations = await Promise.all(docs.map(async (doc) => { + const replacement = await converter(doc); + if (replacement) { + return { + replaceOne: { + filter: { _id: doc._id }, + replacement + } + }; + } + return null; + })); + + // Remove nulls + operations = operations.filter((op) => !!op); + + await collection.bulkWrite(operations, { ordered: false }); + } + } while (docs.length); +} From 4bc11b7952d67ffac45b1009ba8b512f167aee45 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 2 May 2019 22:08:37 -0500 Subject: [PATCH 23/55] fix: various fixes to simple-inventory Signed-off-by: Eric Dobbertin --- imports/node-app/devserver/extendSchemas.js | 1 + imports/node-app/devserver/mutations.js | 4 +++- imports/node-app/devserver/queries.js | 4 ++++ imports/node-app/devserver/registerPlugins.js | 2 ++ imports/node-app/devserver/resolvers.js | 2 ++ imports/node-app/devserver/schemas.js | 4 ++++ .../server/no-meteor/mutations/hashProduct.test.js | 1 - .../server/no-meteor/mutations/publishProducts.test.js | 2 -- .../server/no-meteor/utils/createCatalogProduct.js | 6 +++--- .../server/no-meteor/utils/getTopLevelProduct.test.js | 1 - .../no-meteor/utils/publishProductToCatalogById.test.js | 1 - .../no-meteor/utils/publishProductsToCatalog.test.js | 1 - .../orders/server/no-meteor/mutations/placeOrder.test.js | 6 ++++++ .../server/no-meteor/schemas/schema.graphql | 8 +++++--- imports/test-utils/helpers/mockContext.js | 1 + tests/catalog/CatalogItemProductFullQuery.graphql | 1 - tests/catalog/CatalogProductItemsFullQuery.graphql | 1 - tests/meteor/orders.app-test.js | 2 +- 18 files changed, 32 insertions(+), 16 deletions(-) diff --git a/imports/node-app/devserver/extendSchemas.js b/imports/node-app/devserver/extendSchemas.js index 0e6969d9e3d..6e98106d06d 100644 --- a/imports/node-app/devserver/extendSchemas.js +++ b/imports/node-app/devserver/extendSchemas.js @@ -1,2 +1,3 @@ +import "/imports/plugins/core/inventory/lib/extendCoreSchemas"; import "/imports/plugins/core/taxes/lib/extendCoreSchemas"; import "/imports/plugins/included/simple-pricing/server/extendCoreSchemas"; diff --git a/imports/node-app/devserver/mutations.js b/imports/node-app/devserver/mutations.js index d77a3703213..fd72730ef6e 100644 --- a/imports/node-app/devserver/mutations.js +++ b/imports/node-app/devserver/mutations.js @@ -10,6 +10,7 @@ import shipping from "/imports/plugins/core/shipping/server/no-meteor/mutations" import taxes from "/imports/plugins/core/taxes/server/no-meteor/mutations"; // INCLUDED import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/mutations"; +import simpleInventory from "/imports/plugins/included/simple-inventory/server/no-meteor/mutations"; export default merge( {}, @@ -21,5 +22,6 @@ export default merge( payments, shipping, taxes, - shippingRates + shippingRates, + simpleInventory ); diff --git a/imports/node-app/devserver/queries.js b/imports/node-app/devserver/queries.js index 6caa2582bb2..f69bb0008e9 100644 --- a/imports/node-app/devserver/queries.js +++ b/imports/node-app/devserver/queries.js @@ -5,6 +5,7 @@ import address from "/imports/plugins/core/address/server/no-meteor/queries"; import cart from "/imports/plugins/core/cart/server/no-meteor/queries"; import catalog from "/imports/plugins/core/catalog/server/no-meteor/queries"; import core from "/imports/plugins/core/core/server/no-meteor/queries"; +import inventory from "/imports/plugins/core/inventory/server/no-meteor/queries"; import navigation from "/imports/plugins/core/navigation/server/no-meteor/queries"; import shipping from "/imports/plugins/core/shipping/server/no-meteor/queries"; import orders from "/imports/plugins/core/orders/server/no-meteor/queries"; @@ -12,6 +13,7 @@ import taxes from "/imports/plugins/core/taxes/server/no-meteor/queries"; import tags from "/imports/plugins/core/tags/server/no-meteor/queries"; // INCLUDED import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/queries"; +import simpleInventory from "/imports/plugins/included/simple-inventory/server/no-meteor/queries"; import simplePricing from "/imports/plugins/included/simple-pricing/server/no-meteor/queries"; export default merge( @@ -21,11 +23,13 @@ export default merge( cart, catalog, core, + inventory, navigation, shipping, orders, taxes, tags, shippingRates, + simpleInventory, simplePricing ); diff --git a/imports/node-app/devserver/registerPlugins.js b/imports/node-app/devserver/registerPlugins.js index c7e66d39def..94d909e8d4c 100644 --- a/imports/node-app/devserver/registerPlugins.js +++ b/imports/node-app/devserver/registerPlugins.js @@ -1,3 +1,4 @@ +import registerSimpleInventoryPlugin from "/imports/plugins/included/simple-inventory/server/no-meteor/register"; import registerSimplePricingPlugin from "/imports/plugins/included/simple-pricing/server/no-meteor/register"; /** @@ -7,5 +8,6 @@ import registerSimplePricingPlugin from "/imports/plugins/included/simple-pricin * @return {Promise} Null */ export default async function registerPlugins(app) { + await registerSimpleInventoryPlugin(app); await registerSimplePricingPlugin(app); } diff --git a/imports/node-app/devserver/resolvers.js b/imports/node-app/devserver/resolvers.js index 6e778021e4d..7e52b10202e 100644 --- a/imports/node-app/devserver/resolvers.js +++ b/imports/node-app/devserver/resolvers.js @@ -14,6 +14,7 @@ import tags from "/imports/plugins/core/tags/server/no-meteor/resolvers"; import taxes from "/imports/plugins/core/taxes/server/no-meteor/resolvers"; // INCLUDED import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/resolvers"; +import simpleInventory from "/imports/plugins/included/simple-inventory/server/no-meteor/resolvers"; import simplePricing from "/imports/plugins/included/simple-pricing/server/no-meteor/resolvers"; export default merge( @@ -31,5 +32,6 @@ export default merge( tags, taxes, shippingRates, + simpleInventory, simplePricing ); diff --git a/imports/node-app/devserver/schemas.js b/imports/node-app/devserver/schemas.js index fd4d71b822e..8535af58d53 100644 --- a/imports/node-app/devserver/schemas.js +++ b/imports/node-app/devserver/schemas.js @@ -4,6 +4,7 @@ import address from "/imports/plugins/core/address/server/no-meteor/schemas"; import cart from "/imports/plugins/core/cart/server/no-meteor/schemas"; import catalog from "/imports/plugins/core/catalog/server/no-meteor/schemas"; import core from "/imports/plugins/core/core/server/no-meteor/schemas"; +import inventory from "/imports/plugins/core/inventory/server/no-meteor/schemas"; import navigation from "/imports/plugins/core/navigation/server/no-meteor/schemas"; import orders from "/imports/plugins/core/orders/server/no-meteor/schemas"; import payments from "/imports/plugins/core/payments/server/no-meteor/schemas"; @@ -16,6 +17,7 @@ import marketplace from "/imports/plugins/included/marketplace/server/no-meteor/ import paymentsExample from "/imports/plugins/included/payments-example/server/no-meteor/schemas"; import paymentsStripe from "/imports/plugins/included/payments-stripe/server/no-meteor/schemas"; import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/schemas"; +import simpleInventory from "/imports/plugins/included/simple-inventory/server/no-meteor/schemas"; import simplePricing from "/imports/plugins/included/simple-pricing/server/no-meteor/schemas"; export default [ @@ -24,6 +26,7 @@ export default [ ...cart, ...catalog, ...core, + ...inventory, ...navigation, ...orders, ...payments, @@ -35,5 +38,6 @@ export default [ ...paymentsExample, ...paymentsStripe, ...shippingRates, + ...simpleInventory, ...simplePricing ]; diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js index f2bd60230fd..4bf65700ee0 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js @@ -31,7 +31,6 @@ const mockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js index 41334f44853..e38856c88e3 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js @@ -109,7 +109,6 @@ const mockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -253,7 +252,6 @@ const expectedItemsResponse = { description: "description", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index 93e59750698..2be880f3262 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -70,8 +70,9 @@ export async function xformProduct({ context, product, variants }) { const variantMedia = catalogProductMedia.filter((media) => media.variantId === variant._id); const newVariant = xformVariant(variant, variantMedia); - if (newVariant.options) { - newVariant.options = newVariant.options.map((option) => { + const variantOptions = options.get(variant._id); + if (variantOptions) { + newVariant.options = variantOptions.map((option) => { const optionMedia = catalogProductMedia.filter((media) => media.variantId === option._id); return xformVariant(option, optionMedia); }); @@ -90,7 +91,6 @@ export async function xformProduct({ context, product, variants }) { isDeleted: !!product.isDeleted, isVisible: !!product.isVisible, length: product.length, - lowInventoryWarningThreshold: product.lowInventoryWarningThreshold, media: catalogProductMedia, metafields: product.metafields, metaDescription: product.metaDescription, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js index facbffeee0f..e50ea8a4f1d 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js @@ -101,7 +101,6 @@ const mockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js index 362456c9039..7ebb177c619 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js @@ -98,7 +98,6 @@ const mockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js index c4cf8273e6e..7064f1417e1 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js @@ -98,7 +98,6 @@ const mockProduct = { googleplusMsg: "googlePlusMessage", height: 11.23, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", diff --git a/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js b/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js index 5b4581584e3..ae596a6ebe4 100644 --- a/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js +++ b/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js @@ -35,6 +35,12 @@ test("places an anonymous $0 order with no cartId and no payments", async () => price: 0 }); + mockContext.queries.inventoryForProductConfiguration = jest.fn().mockName("inventoryForProductConfiguration"); + mockContext.queries.inventoryForProductConfiguration.mockReturnValueOnce({ + canBackOrder: true, + inventoryAvailableToSell: 10 + }); + mockContext.queries.getFulfillmentMethodsWithQuotes = jest.fn().mockName("getFulfillmentMethodsWithQuotes"); mockContext.queries.getFulfillmentMethodsWithQuotes.mockReturnValueOnce([{ method: selectedFulfillmentMethod, diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql index bafc9372c28..c5812745c8e 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql @@ -1,3 +1,4 @@ +"Inventory info for a specific product configuration. For inventory managed by the SimpleInventory plugin." type SimpleInventoryInfo { """ Whether to allow ordering this product configuration when there is insufficient quantity available @@ -26,15 +27,15 @@ type SimpleInventoryInfo { "Input for the `updateSimpleInventory` mutation. In addition to `shopId`, at least one field to update is required." input UpdateSimpleInventoryInput { - "An optional string identifying the mutation call, which will be returned in the response payload" - clientMutationId: String - """ Whether to allow ordering this product configuration when there is insufficient quantity available. Set this to `true` or `false` if you want to update it. """ canBackorder: Boolean + "An optional string identifying the mutation call, which will be returned in the response payload" + clientMutationId: String + """ Current quantity of this product configuration in stock. Set this to an integer if you want to update it. """ @@ -59,6 +60,7 @@ input UpdateSimpleInventoryInput { shopId: ID! } +"Response payload for the `updateSimpleInventory` mutation" type UpdateSimpleInventoryPayload { "The same string you sent with the mutation params, for matching mutation calls with their responses" clientMutationId: String diff --git a/imports/test-utils/helpers/mockContext.js b/imports/test-utils/helpers/mockContext.js index 702e63da8f0..93cfcfff760 100644 --- a/imports/test-utils/helpers/mockContext.js +++ b/imports/test-utils/helpers/mockContext.js @@ -74,6 +74,7 @@ export function mockCollection(collectionName) { "SellerShops", "Shipping", "Shops", + "SimpleInventory", "Tags", "Templates", "Themes", diff --git a/tests/catalog/CatalogItemProductFullQuery.graphql b/tests/catalog/CatalogItemProductFullQuery.graphql index a7d32bc14bc..d0fbeb9e829 100644 --- a/tests/catalog/CatalogItemProductFullQuery.graphql +++ b/tests/catalog/CatalogItemProductFullQuery.graphql @@ -16,7 +16,6 @@ query ($slugOrId: String!) { isLowQuantity isSoldOut length - lowInventoryWarningThreshold metafields { value namespace diff --git a/tests/catalog/CatalogProductItemsFullQuery.graphql b/tests/catalog/CatalogProductItemsFullQuery.graphql index fc09d585d82..d4f92f30bd7 100644 --- a/tests/catalog/CatalogProductItemsFullQuery.graphql +++ b/tests/catalog/CatalogProductItemsFullQuery.graphql @@ -18,7 +18,6 @@ query ($shopIds: [ID]!, $first: ConnectionLimitInt, $sortBy: CatalogItemSortByFi isLowQuantity isSoldOut length - lowInventoryWarningThreshold metafields { value namespace diff --git a/tests/meteor/orders.app-test.js b/tests/meteor/orders.app-test.js index f4ca5b8e606..6048ad4cb64 100644 --- a/tests/meteor/orders.app-test.js +++ b/tests/meteor/orders.app-test.js @@ -9,7 +9,7 @@ import ReactionError from "@reactioncommerce/reaction-error"; import Fixtures from "/imports/plugins/core/core/server/fixtures"; import Reaction from "/imports/plugins/core/core/server/Reaction"; import { getShop } from "/imports/plugins/core/core/server/fixtures/shops"; -import { Orders, Notifications, Products, Shops } from "/lib/collections"; +import { Orders, Notifications, Shops } from "/lib/collections"; import { Media } from "/imports/plugins/core/files/server"; Fixtures(); From 3f9b77800fb86c5057125566cd7201553073762d Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 3 May 2019 09:11:53 -0500 Subject: [PATCH 24/55] feat: allow internal calls to updateSimpleInventory Signed-off-by: Eric Dobbertin --- .../server/no-meteor/mutations/updateSimpleInventory.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index 214de602733..298150c1448 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -56,7 +56,7 @@ const defaultValues = { export default async function updateSimpleInventory(context, input) { inputSchema.validate(input); - const { collections, userHasPermission } = context; + const { collections, isInternalCall, userHasPermission } = context; const { Products, SimpleInventory } = collections; const { productConfiguration, shopId } = input; @@ -71,7 +71,9 @@ export default async function updateSimpleInventory(context, input) { }); if (!foundProduct) throw new ReactionError("not-found", "Product not found"); - if (!userHasPermission(["admin"], shopId)) { + // Allow update if the account has "admin" permission. When called internally by another + // plugin, context.isInternalCall can be set to `true` to disable this check. + if (!isInternalCall && !userHasPermission(["admin"], shopId)) { throw new ReactionError("access-denied", "Access denied"); } From 47cbcaeefced61af94ac7ff6c02110f5b3bf7b75 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 3 May 2019 09:12:20 -0500 Subject: [PATCH 25/55] refactor: deMeteorize core inventory register Signed-off-by: Eric Dobbertin --- imports/node-app/devserver/registerPlugins.js | 2 ++ imports/plugins/core/inventory/register.js | 23 ++------------- .../inventory/server/no-meteor/register.js | 28 +++++++++++++++++++ 3 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 imports/plugins/core/inventory/server/no-meteor/register.js diff --git a/imports/node-app/devserver/registerPlugins.js b/imports/node-app/devserver/registerPlugins.js index 94d909e8d4c..3550af6f8cd 100644 --- a/imports/node-app/devserver/registerPlugins.js +++ b/imports/node-app/devserver/registerPlugins.js @@ -1,3 +1,4 @@ +import registerInventoryPlugin from "/imports/plugins/core/inventory/server/no-meteor/register"; import registerSimpleInventoryPlugin from "/imports/plugins/included/simple-inventory/server/no-meteor/register"; import registerSimplePricingPlugin from "/imports/plugins/included/simple-pricing/server/no-meteor/register"; @@ -8,6 +9,7 @@ import registerSimplePricingPlugin from "/imports/plugins/included/simple-pricin * @return {Promise} Null */ export default async function registerPlugins(app) { + await registerInventoryPlugin(app); await registerSimpleInventoryPlugin(app); await registerSimplePricingPlugin(app); } diff --git a/imports/plugins/core/inventory/register.js b/imports/plugins/core/inventory/register.js index e4a937ad8cb..e943d6f4d6e 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -1,23 +1,4 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; -import queries from "./server/no-meteor/queries"; -import schemas from "./server/no-meteor/schemas"; -import publishProductToCatalog from "./server/no-meteor/utils/publishProductToCatalog"; -import xformCartItems from "./server/no-meteor/utils/xformCartItems"; -import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; -import xformCatalogProductVariants from "./server/no-meteor/utils/xformCatalogProductVariants"; +import register from "./server/no-meteor/register"; -Reaction.registerPackage({ - label: "Inventory", - name: "reaction-inventory", - autoEnable: true, - functionsByType: { - publishProductToCatalog: [publishProductToCatalog], - xformCartItems: [xformCartItems], - xformCatalogBooleanFilters: [xformCatalogBooleanFilters], - xformCatalogProductVariants: [xformCatalogProductVariants] - }, - queries, - graphQL: { - schemas - } -}); +Reaction.whenAppInstanceReady(register); diff --git a/imports/plugins/core/inventory/server/no-meteor/register.js b/imports/plugins/core/inventory/server/no-meteor/register.js new file mode 100644 index 00000000000..536f392513f --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/register.js @@ -0,0 +1,28 @@ +import queries from "./queries"; +import schemas from "./schemas"; +import publishProductToCatalog from "./utils/publishProductToCatalog"; +import xformCartItems from "./utils/xformCartItems"; +import xformCatalogBooleanFilters from "./utils/xformCatalogBooleanFilters"; +import xformCatalogProductVariants from "./utils/xformCatalogProductVariants"; + +/** + * @summary Import and call this function to add this plugin to your API. + * @param {ReactionNodeApp} app The ReactionNodeApp instance + * @return {undefined} + */ +export default async function register(app) { + await app.registerPlugin({ + label: "Inventory", + name: "reaction-inventory", + functionsByType: { + publishProductToCatalog: [publishProductToCatalog], + xformCartItems: [xformCartItems], + xformCatalogBooleanFilters: [xformCatalogBooleanFilters], + xformCatalogProductVariants: [xformCatalogProductVariants] + }, + queries, + graphQL: { + schemas + } + }); +} From 267f02af76c704ecc2dcec5e4bd6b9b65f214b25 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 3 May 2019 09:12:43 -0500 Subject: [PATCH 26/55] test: fix test mock data Signed-off-by: Eric Dobbertin --- tests/mocks/mockCatalogProducts.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/mocks/mockCatalogProducts.js b/tests/mocks/mockCatalogProducts.js index 5bac6569d9e..3644f79b11e 100644 --- a/tests/mocks/mockCatalogProducts.js +++ b/tests/mocks/mockCatalogProducts.js @@ -324,6 +324,9 @@ export const mockInternalCatalogProducts = [ createdAt: createdAt.toISOString(), description: "description", height: 11.23, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false, isVisible: true, length: 5.67, metafields: [ @@ -411,6 +414,9 @@ export const mockInternalCatalogProducts = [ createdAt: createdAt.toISOString(), description: "description", height: 11.23, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false, isVisible: true, length: 5.67, metafields: [ From 2f3052f9b049980c3c5010512e7eaadc16335f55 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 3 May 2019 09:28:54 -0500 Subject: [PATCH 27/55] test: fix inventory-related integration tests Signed-off-by: Eric Dobbertin --- tests/TestApp.js | 36 +++++++++++++++++-- tests/catalog/catalogItemProduct.test.js | 1 + tests/catalog/catalogItems.test.js | 1 + .../catalog/publishProductsToCatalog.test.js | 3 ++ tests/mocks/mockCatalogProducts.js | 6 ++++ tests/order/addOrderFulfillmentGroup.test.js | 2 ++ 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/TestApp.js b/tests/TestApp.js index 49003c97c9b..7237c4dc037 100644 --- a/tests/TestApp.js +++ b/tests/TestApp.js @@ -1,7 +1,7 @@ import { merge } from "lodash"; import mongodb, { MongoClient } from "mongodb"; import MongoDBMemoryServer from "mongodb-memory-server"; -import { gql } from "apollo-server"; +import { gql, PubSub } from "apollo-server"; import { createTestClient } from "apollo-server-testing"; import Random from "@reactioncommerce/random"; import appEvents from "../imports/node-app/core/util/appEvents"; @@ -20,10 +20,13 @@ import "../imports/node-app/devserver/extendSchemas"; class TestApp { constructor(options = {}) { - const { extraSchemas = [] } = options; + const { additionalCollections = [], extraSchemas = [] } = options; - this.collections = {}; + this.options = { ...options }; + this.collections = { ...additionalCollections }; this.context = { + ...(options.context || {}), + app: this, appEvents, collections: this.collections, getFunctionsOfType: (type) => { @@ -37,6 +40,7 @@ class TestApp { } return funcs; }, + pubSub: new PubSub(), mutations: { ...mutations }, queries: { ...queries } }; @@ -135,6 +139,7 @@ class TestApp { currency: "USD", name: "Primary Shop", ...shopData, + shopType: "primary", domains: [domain] }); @@ -182,6 +187,31 @@ class TestApp { } } + async runServiceStartup() { + // Call `functionsByType.registerPluginHandler` functions for every plugin that + // has supplied one, passing in all other plugins. Allows one plugin to check + // for the presence of another plugin and read its config. + // + // These are not async but they run before plugin `startup` functions, so a plugin + // can save off relevant config and handle it later in `startup`. + const registerPluginHandlerFuncs = this.functionsByType.registerPluginHandler || []; + const packageInfoArray = Object.values(this.registeredPlugins); + registerPluginHandlerFuncs.forEach((registerPluginHandlerFunc) => { + if (typeof registerPluginHandlerFunc !== "function") { + throw new Error('A plugin registered a function of type "registerPluginHandler" which is not actually a function'); + } + packageInfoArray.forEach(registerPluginHandlerFunc); + }); + + const startupFunctionsRegisteredByPlugins = this.functionsByType.startup; + if (Array.isArray(startupFunctionsRegisteredByPlugins)) { + // We are intentionally running these in series, in the order in which they were registered + for (const startupFunction of startupFunctionsRegisteredByPlugins) { + await startupFunction(this.context); // eslint-disable-line no-await-in-loop + } + } + } + initServer() { const { resolvers, schemas } = this.graphQL; diff --git a/tests/catalog/catalogItemProduct.test.js b/tests/catalog/catalogItemProduct.test.js index a2a7299d630..b9636015b73 100644 --- a/tests/catalog/catalogItemProduct.test.js +++ b/tests/catalog/catalogItemProduct.test.js @@ -20,6 +20,7 @@ beforeAll(async () => { await testApp.start(); query = testApp.query(CatalogItemProductFullQuery); await testApp.insertPrimaryShop({ _id: internalShopId, name: shopName }); + await testApp.runServiceStartup(); await Promise.all(internalTagIds.map((_id) => testApp.collections.Tags.insert({ _id, shopId: internalShopId }))); await testApp.collections.Catalog.insert(mockCatalogItem); }); diff --git a/tests/catalog/catalogItems.test.js b/tests/catalog/catalogItems.test.js index 8d8a3ce3cb0..273d446d8f6 100644 --- a/tests/catalog/catalogItems.test.js +++ b/tests/catalog/catalogItems.test.js @@ -21,6 +21,7 @@ beforeAll(async () => { await testApp.start(); query = testApp.query(CatalogProductItemsFullQuery); await testApp.insertPrimaryShop({ _id: internalShopId, name: shopName }); + await testApp.runServiceStartup(); await Promise.all(internalTagIds.map((_id) => testApp.collections.Tags.insert({ _id, shopId: internalShopId }))); await Promise.all(mockCatalogItems.map((mockCatalogItem) => testApp.collections.Catalog.insert(mockCatalogItem))); }); diff --git a/tests/catalog/publishProductsToCatalog.test.js b/tests/catalog/publishProductsToCatalog.test.js index 6c012d0291e..a7149f494d9 100644 --- a/tests/catalog/publishProductsToCatalog.test.js +++ b/tests/catalog/publishProductsToCatalog.test.js @@ -91,6 +91,9 @@ beforeAll(async () => { await testApp.collections.Products.insert(mockVariant); await testApp.collections.Products.insert(mockOptionOne); await testApp.collections.Products.insert(mockOptionTwo); + + await testApp.runServiceStartup(); + await testApp.setLoggedInUser({ _id: "123", roles: { [internalShopId]: ["createProduct"] } diff --git a/tests/mocks/mockCatalogProducts.js b/tests/mocks/mockCatalogProducts.js index 3644f79b11e..dd07e0a47a9 100644 --- a/tests/mocks/mockCatalogProducts.js +++ b/tests/mocks/mockCatalogProducts.js @@ -127,6 +127,8 @@ export const mockExternalCatalogOptions = [ createdAt: null, height: 2, index: 0, + isLowQuantity: false, + isSoldOut: false, isTaxable: true, length: 2, metafields: [ @@ -173,6 +175,8 @@ export const mockExternalCatalogOptions = [ createdAt: null, height: 2, index: 0, + isLowQuantity: false, + isSoldOut: false, isTaxable: true, length: 2, metafields: [ @@ -272,6 +276,8 @@ export const mockExternalCatalogVariants = [ createdAt: createdAt.toISOString(), height: 0, index: 0, + isLowQuantity: false, + isSoldOut: true, isTaxable: true, length: 0, metafields: [ diff --git a/tests/order/addOrderFulfillmentGroup.test.js b/tests/order/addOrderFulfillmentGroup.test.js index 797badb16f7..52444cfa85f 100644 --- a/tests/order/addOrderFulfillmentGroup.test.js +++ b/tests/order/addOrderFulfillmentGroup.test.js @@ -106,6 +106,8 @@ beforeAll(async () => { }); await testApp.collections.Catalog.insertOne(catalogItem2); + await testApp.runServiceStartup(); + addOrderFulfillmentGroup = testApp.mutate(AddOrderFulfillmentGroupMutation); }); From 89473f81840c235efbd2abdeead3ed49413e73a2 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 8 May 2019 10:27:44 -0500 Subject: [PATCH 28/55] fix: update hashProduct func used for migrations Signed-off-by: Eric Dobbertin --- .../migrations/28_add_hash_to_products.js | 23 ++-- .../core/versions/server/util/hashProduct.js | 122 +++++++++++++++--- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js b/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js index 68ef54f91c2..e3efacab938 100644 --- a/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js +++ b/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js @@ -1,20 +1,19 @@ -import Logger from "@reactioncommerce/logger"; import { Migrations } from "meteor/percolate:migrations"; -import { Catalog } from "/lib/collections"; -import collections from "/imports/collections/rawCollections"; +import rawCollections from "/imports/collections/rawCollections"; import hashProduct from "../util/hashProduct"; +import findAndConvertInBatches from "../util/findAndConvertInBatchesNoMeteor"; + +const { Catalog } = rawCollections; Migrations.add({ version: 28, up() { - const catalogItems = Catalog.find({ - "product.type": "product-simple" - }).fetch(); - - try { - catalogItems.forEach((catalogItem) => Promise.await(hashProduct(catalogItem.product._id, collections))); - } catch (error) { - Logger.error("Error in migration 28, hashProduct", error); - } + Promise.await(findAndConvertInBatches({ + collection: Catalog, + query: { + "product.type": "product-simple" + }, + converter: async (catalogItem) => hashProduct(catalogItem.product._id, rawCollections) + })); } }); diff --git a/imports/plugins/core/versions/server/util/hashProduct.js b/imports/plugins/core/versions/server/util/hashProduct.js index 26e11f1041a..9dcba244417 100644 --- a/imports/plugins/core/versions/server/util/hashProduct.js +++ b/imports/plugins/core/versions/server/util/hashProduct.js @@ -1,5 +1,6 @@ import hash from "object-hash"; import Logger from "@reactioncommerce/logger"; +import { customPublishedProductFields, customPublishedProductVariantFields } from "/imports/plugins/core/catalog/server/no-meteor/registration"; const productFieldsThatNeedPublishing = [ "_id", @@ -32,6 +33,73 @@ const productFieldsThatNeedPublishing = [ "vendor" ]; +const variantFieldsThatNeedPublishing = [ + "_id", + "barcode", + "compareAtPrice", + "height", + "index", + "isDeleted", + "isVisible", + "length", + "metafields", + "minOrderQuantity", + "optionTitle", + "originCountry", + "shopId", + "sku", + "title", + "type", + "weight", + "width" +]; + +/** + * + * @method getCatalogProductMedia + * @summary Get an array of ImageInfo objects by Product ID + * @param {String} productId - A product ID. Must be a top-level product. + * @param {Object} collections - Raw mongo collections + * @return {Promise} Array of ImageInfo objects sorted by priority + */ +async function getCatalogProductMedia(productId, collections) { + const { Media } = collections; + const mediaArray = await Media.find( + { + "metadata.productId": productId, + "metadata.toGrid": 1, + "metadata.workflow": { $nin: ["archived", "unpublished"] } + }, + { + sort: { "metadata.priority": 1, "uploadedAt": 1 } + } + ); + + // Denormalize media + const catalogProductMedia = mediaArray + .map((media) => { + const { metadata } = media; + const { toGrid, priority, productId: prodId, variantId } = metadata || {}; + + return { + priority, + toGrid, + productId: prodId, + variantId, + URLs: { + large: `${media.url({ store: "large" })}`, + medium: `${media.url({ store: "medium" })}`, + original: `${media.url({ store: "image" })}`, + small: `${media.url({ store: "small" })}`, + thumbnail: `${media.url({ store: "thumbnail" })}` + } + }; + }) + .sort((itemA, itemB) => itemA.priority - itemB.priority); + + return catalogProductMedia; +} + /** * * @method getTopLevelProduct @@ -63,14 +131,39 @@ async function getTopLevelProduct(productOrVariantId, collections) { * @method createProductHash * @summary Create a hash of a product to compare for updates * @memberof Catalog - * @param {String} product - The Product document to hash + * @param {String} product - The Product document to hash. Expected to be a top-level product, not a variant + * @param {Object} collections - Raw mongo collections * @return {String} product hash */ -function createProductHash(product) { +async function createProductHash(product, collections) { + const variants = await collections.Products.find({ ancestors: product._id, type: "variant" }).toArray(); + const productForHashing = {}; productFieldsThatNeedPublishing.forEach((field) => { productForHashing[field] = product[field]; }); + if (Array.isArray(customPublishedProductFields)) { + customPublishedProductFields.forEach((field) => { + productForHashing[field] = product[field]; + }); + } + + // Track changes to all related media, too + productForHashing.media = await getCatalogProductMedia(product._id, collections); + + // Track changes to all variants, too + productForHashing.variants = variants.map((variant) => { + const variantForHashing = {}; + variantFieldsThatNeedPublishing.forEach((field) => { + variantForHashing[field] = variant[field]; + }); + if (Array.isArray(customPublishedProductVariantFields)) { + customPublishedProductVariantFields.forEach((field) => { + variantForHashing[field] = variant[field]; + }); + } + return variantForHashing; + }); return hash(productForHashing); } @@ -87,9 +180,12 @@ function createProductHash(product) { export default async function hashProduct(productId, collections, isPublished = true) { const { Products } = collections; - const product = await getTopLevelProduct(productId, collections); + const topLevelProduct = await getTopLevelProduct(productId, collections); + if (!topLevelProduct) { + throw new Error(`No top level product found for product with ID ${productId}`); + } - const productHash = createProductHash(product); + const productHash = await createProductHash(topLevelProduct, collections); // Insert/update product document with hash field const hashFields = { @@ -100,21 +196,15 @@ export default async function hashProduct(productId, collections, isPublished = hashFields.publishedProductHash = productHash; } - const result = await Products.updateOne( - { - _id: product._id - }, - { - $set: { - ...hashFields, - updatedAt: new Date() - } - } - ); + const productUpdates = { + ...hashFields, + updatedAt: new Date() + }; + const result = await Products.updateOne({ _id: topLevelProduct._id }, { $set: productUpdates }); if (!result || !result.result || result.result.ok !== 1) { Logger.error(result && result.result); - throw new Error(`Failed to update product hashes for product with ID ${product._id}`); + throw new Error(`Failed to update product hashes for product with ID ${topLevelProduct._id}`); } return null; From 317e50179fc8e49d05ff1e963f7da3f725245b9d Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 8 May 2019 11:22:24 -0500 Subject: [PATCH 29/55] fix: update catalog inventory bools after inventory updates Signed-off-by: Eric Dobbertin --- .../inventory/server/no-meteor/register.js | 2 + .../server/no-meteor/utils/startup.js | 43 +++++++++++ .../mutations/updateSimpleInventory.js | 4 +- .../server/no-meteor/startup.js | 74 +++++++++++++++---- 4 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 imports/plugins/core/inventory/server/no-meteor/utils/startup.js diff --git a/imports/plugins/core/inventory/server/no-meteor/register.js b/imports/plugins/core/inventory/server/no-meteor/register.js index 536f392513f..e5a79a84015 100644 --- a/imports/plugins/core/inventory/server/no-meteor/register.js +++ b/imports/plugins/core/inventory/server/no-meteor/register.js @@ -1,6 +1,7 @@ import queries from "./queries"; import schemas from "./schemas"; import publishProductToCatalog from "./utils/publishProductToCatalog"; +import startup from "./utils/startup"; import xformCartItems from "./utils/xformCartItems"; import xformCatalogBooleanFilters from "./utils/xformCatalogBooleanFilters"; import xformCatalogProductVariants from "./utils/xformCatalogProductVariants"; @@ -16,6 +17,7 @@ export default async function register(app) { name: "reaction-inventory", functionsByType: { publishProductToCatalog: [publishProductToCatalog], + startup: [startup], xformCartItems: [xformCartItems], xformCatalogBooleanFilters: [xformCatalogBooleanFilters], xformCatalogProductVariants: [xformCatalogProductVariants] diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/startup.js b/imports/plugins/core/inventory/server/no-meteor/utils/startup.js new file mode 100644 index 00000000000..ec83548bee4 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/startup.js @@ -0,0 +1,43 @@ +/** + * @summary Called on startup + * @param {Object} context Startup context + * @param {Object} context.collections Map of MongoDB collections + * @returns {undefined} + */ +export default function startup(context) { + const { appEvents, collections } = context; + const { Catalog, Products } = collections; + + // Whenever inventory is updated for any sellable variant, the plugin that did the update is + // expected to emit `afterInventoryUpdate`. We listen for this and keep the boolean fields + // on the CatalogProduct correct. + appEvents.on("afterInventoryUpdate", async ({ productConfiguration }) => { + const { productId } = productConfiguration; + + const variants = await Products.find({ + ancestors: productId, + isDeleted: { $ne: true }, + isVisible: true + }).toArray(); + + const topVariants = variants.filter((variant) => variant.ancestors.length === 1); + + const topVariantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { + productConfigurations: topVariants.map((option) => ({ + isSellable: !variants.some((variant) => variant.ancestors.includes(option._id)), + productId: option.ancestors[0], + productVariantId: option._id + })), + fields: ["isBackorder", "isLowQuantity", "isSoldOut"], + variants + }); + + await Catalog.updateOne({ "product.productId": productId }, { + $set: { + "product.isBackorder": topVariantsInventoryInfo.every(({ inventoryInfo }) => inventoryInfo.isBackorder), + "product.isLowQuantity": topVariantsInventoryInfo.some(({ inventoryInfo }) => inventoryInfo.isLowQuantity), + "product.isSoldOut": topVariantsInventoryInfo.every(({ inventoryInfo }) => inventoryInfo.isSoldOut) + } + }); + }); +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index 298150c1448..f3f118280ea 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -56,7 +56,7 @@ const defaultValues = { export default async function updateSimpleInventory(context, input) { inputSchema.validate(input); - const { collections, isInternalCall, userHasPermission } = context; + const { appEvents, collections, isInternalCall, userHasPermission, userId } = context; const { Products, SimpleInventory } = collections; const { productConfiguration, shopId } = input; @@ -124,5 +124,7 @@ export default async function updateSimpleInventory(context, input) { } ); + appEvents.emit("afterInventoryUpdate", { productConfiguration, updatedBy: userId }); + return updatedDoc; } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js index b4d53be301a..4e529b76bbf 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -31,11 +31,13 @@ export default function startup(context) { const orderIsApproved = !Array.isArray(order.payments) || order.payments.length === 0 || !!order.payments.find((payment) => payment.status !== "created"); + const allOrderItems = getAllOrderItems(order); + const bulkWriteOperations = []; // If order is approved, the inventory has been taken away from `inventoryInStock` if (returnToStock && orderIsApproved) { - getAllOrderItems(order).forEach((item) => { + allOrderItems.forEach((item) => { bulkWriteOperations.push({ updateOne: { filter: { @@ -54,7 +56,7 @@ export default function startup(context) { }); } else if (!orderIsApproved) { // If order is not approved, the inventory hasn't been taken away from `inventoryInStock` yet but is in `inventoryReserved` - getAllOrderItems(order).forEach((item) => { + allOrderItems.forEach((item) => { bulkWriteOperations.push({ updateOne: { filter: { @@ -73,13 +75,29 @@ export default function startup(context) { }); } - SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }).catch((error) => { - Logger.error(error, "Bulk write error in simple-inventory afterOrderCancel listener"); - }); + if (bulkWriteOperations.length === 0) return; + + SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) + .then(() => ( + Promise.all(allOrderItems.map((item) => ( + appEvents.emit("afterInventoryUpdate", { + productConfiguration: { + productId: item.productId, + productVariantId: item.variantId + }, + updatedBy: null + }) + ))) + )) + .catch((error) => { + Logger.error(error, "Bulk write error in simple-inventory afterOrderCancel listener"); + }); }); appEvents.on("afterOrderCreate", async ({ order }) => { - const bulkWriteOperations = getAllOrderItems(order).map((item) => ({ + const allOrderItems = getAllOrderItems(order); + + const bulkWriteOperations = allOrderItems.map((item) => ({ updateOne: { filter: { productConfiguration: { @@ -95,9 +113,23 @@ export default function startup(context) { } })); - SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }).catch((error) => { - Logger.error(error, "Bulk write error in simple-inventory afterOrderCreate listener"); - }); + if (bulkWriteOperations.length === 0) return; + + SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) + .then(() => ( + Promise.all(allOrderItems.map((item) => ( + appEvents.emit("afterInventoryUpdate", { + productConfiguration: { + productId: item.productId, + productVariantId: item.variantId + }, + updatedBy: null + }) + ))) + )) + .catch((error) => { + Logger.error(error, "Bulk write error in simple-inventory afterOrderCreate listener"); + }); }); appEvents.on("afterOrderApprovePayment", async ({ order }) => { @@ -105,7 +137,9 @@ export default function startup(context) { const nonApprovedPayment = (order.payments || []).find((payment) => payment.status === "created"); if (nonApprovedPayment) return; - const bulkWriteOperations = getAllOrderItems(order).map((item) => ({ + const allOrderItems = getAllOrderItems(order); + + const bulkWriteOperations = allOrderItems.map((item) => ({ updateOne: { filter: { productConfiguration: { @@ -122,8 +156,22 @@ export default function startup(context) { } })); - SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }).catch((error) => { - Logger.error(error, "Bulk write error in simple-inventory afterOrderApprovePayment listener"); - }); + if (bulkWriteOperations.length === 0) return; + + SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) + .then(() => ( + Promise.all(allOrderItems.map((item) => ( + appEvents.emit("afterInventoryUpdate", { + productConfiguration: { + productId: item.productId, + productVariantId: item.variantId + }, + updatedBy: null + }) + ))) + )) + .catch((error) => { + Logger.error(error, "Bulk write error in simple-inventory afterOrderApprovePayment listener"); + }); }); } From 592580035e8cc38ae4284bdb521688688b230ab9 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 8 May 2019 11:57:45 -0500 Subject: [PATCH 30/55] feat: show reserved/available inventory on form Signed-off-by: Eric Dobbertin --- .../simple-inventory/client/VariantInventoryForm.js | 8 ++++++-- .../included/simple-inventory/client/getInventoryInfo.js | 1 + .../plugins/included/simple-inventory/server/i18n/en.json | 4 +++- .../server/no-meteor/schemas/schema.graphql | 6 ++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js index 3bc9a86419d..b0058c8e7e4 100644 --- a/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js +++ b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js @@ -31,6 +31,7 @@ function VariantInventoryForm(props) { const { canBackorder, inventoryInStock, + inventoryReserved, isEnabled, lowInventoryWarningThreshold } = inventoryInfo || {}; @@ -70,8 +71,11 @@ function VariantInventoryForm(props) { Date: Wed, 8 May 2019 12:36:11 -0500 Subject: [PATCH 31/55] feat: add admin button to recalc reserved inventory Signed-off-by: Eric Dobbertin --- .../client/VariantInventoryForm.js | 21 ++++ .../included/simple-inventory/client/index.js | 9 +- .../withRecalculateReservedSimpleInventory.js | 55 +++++++++ .../client/withUpdateVariantInventoryInfo.js | 1 + .../simple-inventory/server/i18n/en.json | 3 +- .../server/no-meteor/mutations/index.js | 2 + .../recalculateReservedSimpleInventory.js | 111 ++++++++++++++++++ .../no-meteor/resolvers/Mutation/index.js | 2 + .../recalculateReservedSimpleInventory.js | 35 ++++++ .../server/no-meteor/schemas/schema.graphql | 21 ++++ 10 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 imports/plugins/included/simple-inventory/client/withRecalculateReservedSimpleInventory.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/recalculateReservedSimpleInventory.js diff --git a/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js index b0058c8e7e4..90a69b63181 100644 --- a/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js +++ b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js @@ -17,8 +17,12 @@ import Switch from "@material-ui/core/Switch"; */ function VariantInventoryForm(props) { const { + components: { + Button + }, inventoryInfo, isLoadingInventoryInfo, + recalculateReservedSimpleInventory, updateSimpleInventory, variables, variant @@ -95,6 +99,22 @@ function VariantInventoryForm(props) { type="number" value={isEnabled ? inventoryInStock : 0} /> + ( + class WithUpdateSimpleInventory extends React.Component { + static propTypes = { + variables: PropTypes.object + } + + render() { + const { variables } = this.props; + + return ( + { + if (recalculateReservedSimpleInventory && recalculateReservedSimpleInventory.inventoryInfo) { + cache.writeQuery({ + query: getInventoryInfo, + variables, + data: { + simpleInventory: { ...recalculateReservedSimpleInventory.inventoryInfo } + } + }); + } + }} + > + {(recalculateReservedSimpleInventory) => ( + + )} + + ); + } + } +); diff --git a/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js b/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js index 66c36a0cce9..980d1c0baa9 100644 --- a/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js +++ b/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js @@ -10,6 +10,7 @@ const updateSimpleInventoryMutation = gql` inventoryInfo { canBackorder inventoryInStock + inventoryReserved isEnabled lowInventoryWarningThreshold } diff --git a/imports/plugins/included/simple-inventory/server/i18n/en.json b/imports/plugins/included/simple-inventory/server/i18n/en.json index 847a5234b63..8f156c2417e 100644 --- a/imports/plugins/included/simple-inventory/server/i18n/en.json +++ b/imports/plugins/included/simple-inventory/server/i18n/en.json @@ -13,7 +13,8 @@ "isInventoryManagementEnabled": "Manage inventory", "lowInventoryWarningThreshold": "Warn at", "lowInventoryWarningThresholdLabel": "Warn customers that the product will be close to sold out when the available quantity reaches this threshold", - "noInventoryTracking": "Inventory is tracked only for sellable variants. Select an option to manage inventory for it." + "noInventoryTracking": "Inventory is tracked only for sellable variants. Select an option to manage inventory for it.", + "recalculateReservedInventory": "Recalculate reserved quantity" } } } diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js index 8129b65eaf9..033d9119140 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js @@ -1,5 +1,7 @@ +import recalculateReservedSimpleInventory from "./recalculateReservedSimpleInventory"; import updateSimpleInventory from "./updateSimpleInventory"; export default { + recalculateReservedSimpleInventory, updateSimpleInventory }; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js new file mode 100644 index 00000000000..b732dc45f79 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js @@ -0,0 +1,111 @@ +import SimpleSchema from "simpl-schema"; +import ReactionError from "@reactioncommerce/reaction-error"; +import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; + +const inputSchema = new SimpleSchema({ + productConfiguration: ProductConfigurationSchema, + shopId: String +}); + +/** + * + * @method getReservedQuantity + * @summary Get the number of product variants that are currently reserved in an order. + * This function can take any variant object. + * @param {Object} context App context + * @param {Object} productConfiguration Product configuration + * @return {Promise} Reserved variant quantity + */ +async function getReservedQuantity(context, productConfiguration) { + const { productVariantId } = productConfiguration; + + // Find orders that are new or processing + const orders = await context.collections.Orders.find({ + "workflow.status": { $in: ["new", "coreOrderWorkflow/processing"] }, + "shipping.items.variantId": productVariantId + }).toArray(); + + const reservedQuantity = orders.reduce((sum, order) => { + // Reduce through each fulfillment (shipping) object + const shippingGroupsItems = order.shipping.reduce((acc, shippingGroup) => { + // Get all items in order that match the item being adjusted + const matchingItems = shippingGroup.items.filter((item) => item.variantId === productVariantId); + + // Reduce `quantity` fields of matched items into single number + const reservedQuantityOfItem = matchingItems.reduce((quantity, matchingItem) => quantity + matchingItem.quantity, 0); + + return acc + reservedQuantityOfItem; + }, 0); + + // Sum up numbers from all fulfillment (shipping) groups + return sum + shippingGroupsItems; + }, 0); + + return reservedQuantity; +} + +/** + * @summary Force recalculation of the system-managed `inventoryReserved` field based on current order statuses. + * @param {Object} context App context + * @param {Object} input Input + * @param {Object} input.productConfiguration Product configuration object + * @param {String} input.shopId ID of shop that owns the product + * @param {Boolean} input.canBackorder Whether to allow ordering this product configuration when there is insufficient quantity available + * @param {Number} input.inventoryInStock Current quantity of this product configuration in stock + * @param {Boolean} input.isEnabled Whether the SimpleInventory plugin should manage inventory for this product configuration + * @param {Number} input.lowInventoryWarningThreshold The "low quantity" flag will be applied to this product configuration + * when the available quantity is at or below this threshold. + * @return {Object} Updated inventory values + */ +export default async function recalculateReservedSimpleInventory(context, input) { + inputSchema.validate(input); + + const { appEvents, collections, isInternalCall, userHasPermission, userId } = context; + const { Products, SimpleInventory } = collections; + const { productConfiguration, shopId } = input; + + // Verify that the product exists + const foundProduct = await Products.findOne({ + _id: productConfiguration.productId, + shopId + }, { + projection: { + shopId: 1 + } + }); + if (!foundProduct) throw new ReactionError("not-found", "Product not found"); + + // Allow update if the account has "admin" permission. When called internally by another + // plugin, context.isInternalCall can be set to `true` to disable this check. + if (!isInternalCall && !userHasPermission(["admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } + + const inventoryReserved = await getReservedQuantity(context, productConfiguration); + + const modifier = { + $set: { + inventoryReserved, + updatedAt: new Date() + } + }; + + SimpleInventoryCollectionSchema.validate(modifier, { modifier: true }); + + const { value: updatedDoc } = await SimpleInventory.findOneAndUpdate( + { + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + }, + modifier, + { + returnOriginal: false + } + ); + + if (!updatedDoc) throw new ReactionError("not-tracked", "Inventory not tracked for this product"); + + appEvents.emit("afterInventoryUpdate", { productConfiguration, updatedBy: userId }); + + return updatedDoc; +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js index 8129b65eaf9..033d9119140 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js @@ -1,5 +1,7 @@ +import recalculateReservedSimpleInventory from "./recalculateReservedSimpleInventory"; import updateSimpleInventory from "./updateSimpleInventory"; export default { + recalculateReservedSimpleInventory, updateSimpleInventory }; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/recalculateReservedSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/recalculateReservedSimpleInventory.js new file mode 100644 index 00000000000..ee51d92d4de --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/recalculateReservedSimpleInventory.js @@ -0,0 +1,35 @@ +import { decodeProductOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/product"; +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Mutation/recalculateReservedSimpleInventory + * @summary Force recalculation of the system-managed `inventoryReserved` field based on current order statuses. + * @param {Object} _ unused + * @param {Object} args Args passed by the client + * @param {Object} args.input Input + * @param {Object} args.input.productConfiguration Product configuration object + * @param {String} args.input.shopId ID of shop that owns the product + * @param {Object} context App context + * @return {Object} Updated inventory values + */ +export default async function recalculateReservedSimpleInventory(_, { input }, context) { + const { clientMutationId = null, productConfiguration, shopId: opaqueShopId, ...passThroughInput } = input; + + const productId = decodeProductOpaqueId(productConfiguration.productId); + const productVariantId = decodeProductOpaqueId(productConfiguration.productVariantId); + const shopId = decodeShopOpaqueId(opaqueShopId); + + const inventoryInfo = await context.mutations.recalculateReservedSimpleInventory(context, { + ...passThroughInput, + productConfiguration: { + productId, + productVariantId + }, + shopId + }); + + return { + clientMutationId, + inventoryInfo + }; +} diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql index 5b190c63fcd..c92ffadd4cf 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql @@ -31,6 +31,24 @@ type SimpleInventoryInfo { productConfiguration: ProductConfiguration! } +"Input for the `recalculateReservedSimpleInventory` mutation" +input RecalculateReservedSimpleInventoryInput { + "The product and chosen options this info applies to" + productConfiguration: ProductConfigurationInput! + + "Shop that owns the product" + shopId: ID! +} + +"Response payload for the `updateSimpleInventory` mutation" +type RecalculateReservedSimpleInventoryPayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "The updated inventory info" + inventoryInfo: SimpleInventoryInfo! +} + "Input for the `updateSimpleInventory` mutation. In addition to `shopId`, at least one field to update is required." input UpdateSimpleInventoryInput { """ @@ -84,6 +102,9 @@ extend type Query { } extend type Mutation { + "Force recalculation of the system-managed `inventoryReserved` field based on current order statuses" + recalculateReservedSimpleInventory(input: RecalculateReservedSimpleInventoryInput!): RecalculateReservedSimpleInventoryPayload! + "Update the SimpleInventory info for a product configuration" updateSimpleInventory(input: UpdateSimpleInventoryInput!): UpdateSimpleInventoryPayload! } From aba4b894383cb6460c2937190eddcb82ffba1721 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 8 May 2019 16:49:57 -0500 Subject: [PATCH 32/55] feat: skip product check for internal inventory update calls Signed-off-by: Eric Dobbertin --- .../mutations/updateSimpleInventory.js | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index f3f118280ea..50b1fc5d22d 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -60,21 +60,25 @@ export default async function updateSimpleInventory(context, input) { const { Products, SimpleInventory } = collections; const { productConfiguration, shopId } = input; - // Verify that the product exists - const foundProduct = await Products.findOne({ - _id: productConfiguration.productId, - shopId - }, { - projection: { - shopId: 1 - } - }); - if (!foundProduct) throw new ReactionError("not-found", "Product not found"); + if (!isInternalCall) { + // Verify that the product exists. For internal calls, we assume we can skip this + // verification because it saves a database command and maybe we are storing inventories + // before product is created due to some syncing process. + const foundProduct = await Products.findOne({ + _id: productConfiguration.productId, + shopId + }, { + projection: { + shopId: 1 + } + }); + if (!foundProduct) throw new ReactionError("not-found", "Product not found"); - // Allow update if the account has "admin" permission. When called internally by another - // plugin, context.isInternalCall can be set to `true` to disable this check. - if (!isInternalCall && !userHasPermission(["admin"], shopId)) { - throw new ReactionError("access-denied", "Access denied"); + // Allow update if the account has "admin" permission. When called internally by another + // plugin, context.isInternalCall can be set to `true` to disable this check. + if (!userHasPermission(["admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } } const $set = { updatedAt: new Date() }; From f0e76a49398d25d976f7672b71d24c088b7a10f3 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 9 May 2019 12:10:59 -0500 Subject: [PATCH 33/55] fix: fix simple inventory recalc error Signed-off-by: Eric Dobbertin --- .../mutations/recalculateReservedSimpleInventory.js | 3 +++ .../simple-inventory/server/no-meteor/startup.js | 12 +++++------- .../server/no-meteor/utils/orderIsApproved.js | 11 +++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/orderIsApproved.js diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js index b732dc45f79..78951736ec9 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js @@ -1,6 +1,7 @@ import SimpleSchema from "simpl-schema"; import ReactionError from "@reactioncommerce/reaction-error"; import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; +import orderIsApproved from "../utils/orderIsApproved"; const inputSchema = new SimpleSchema({ productConfiguration: ProductConfigurationSchema, @@ -26,6 +27,8 @@ async function getReservedQuantity(context, productConfiguration) { }).toArray(); const reservedQuantity = orders.reduce((sum, order) => { + if (orderIsApproved(order)) return sum; + // Reduce through each fulfillment (shipping) object const shippingGroupsItems = order.shipping.reduce((acc, shippingGroup) => { // Get all items in order that match the item being adjusted diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js index 4e529b76bbf..6e5972ea872 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -1,5 +1,6 @@ import Logger from "@reactioncommerce/logger"; import collectionIndex from "/imports/utils/collectionIndex"; +import orderIsApproved from "./utils/orderIsApproved"; /** * @summary Get all order items @@ -28,15 +29,13 @@ export default function startup(context) { // Inventory is removed from stock only once an order has been approved // This is indicated by payment.status being anything other than `created` // We need to check to make sure the inventory has been removed before we return it to stock - const orderIsApproved = !Array.isArray(order.payments) || order.payments.length === 0 || - !!order.payments.find((payment) => payment.status !== "created"); - + const isOrderApproved = orderIsApproved(order); const allOrderItems = getAllOrderItems(order); const bulkWriteOperations = []; // If order is approved, the inventory has been taken away from `inventoryInStock` - if (returnToStock && orderIsApproved) { + if (returnToStock && isOrderApproved) { allOrderItems.forEach((item) => { bulkWriteOperations.push({ updateOne: { @@ -54,7 +53,7 @@ export default function startup(context) { } }); }); - } else if (!orderIsApproved) { + } else if (!isOrderApproved) { // If order is not approved, the inventory hasn't been taken away from `inventoryInStock` yet but is in `inventoryReserved` allOrderItems.forEach((item) => { bulkWriteOperations.push({ @@ -134,8 +133,7 @@ export default function startup(context) { appEvents.on("afterOrderApprovePayment", async ({ order }) => { // We only decrease the inventory quantity after the final payment is approved - const nonApprovedPayment = (order.payments || []).find((payment) => payment.status === "created"); - if (nonApprovedPayment) return; + if (!orderIsApproved(order)) return; const allOrderItems = getAllOrderItems(order); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/orderIsApproved.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/orderIsApproved.js new file mode 100644 index 00000000000..488b3861d72 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/orderIsApproved.js @@ -0,0 +1,11 @@ +/** + * @summary Checks whether the order is approved, i.e., all payments on it + * are approved. + * @param {Object} order The order + * @return {Boolean} True if approved + */ +export default function orderIsApproved(order) { + return !Array.isArray(order.payments) || + order.payments.length === 0 || + !order.payments.find((payment) => payment.status === "created"); +} From 398ac18603311db3ee90b3a5700fd1009301c762 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 9 May 2019 16:56:15 -0500 Subject: [PATCH 34/55] fix: set inventoryReserved correctly on SimpleInventory insert Signed-off-by: Eric Dobbertin --- .../recalculateReservedSimpleInventory.js | 41 +------------------ .../mutations/updateSimpleInventory.js | 5 ++- .../no-meteor/utils/getReservedQuantity.js | 40 ++++++++++++++++++ 3 files changed, 44 insertions(+), 42 deletions(-) create mode 100644 imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js index 78951736ec9..f4554d930aa 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js @@ -1,52 +1,13 @@ import SimpleSchema from "simpl-schema"; import ReactionError from "@reactioncommerce/reaction-error"; import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; -import orderIsApproved from "../utils/orderIsApproved"; +import getReservedQuantity from "../utils/getReservedQuantity"; const inputSchema = new SimpleSchema({ productConfiguration: ProductConfigurationSchema, shopId: String }); -/** - * - * @method getReservedQuantity - * @summary Get the number of product variants that are currently reserved in an order. - * This function can take any variant object. - * @param {Object} context App context - * @param {Object} productConfiguration Product configuration - * @return {Promise} Reserved variant quantity - */ -async function getReservedQuantity(context, productConfiguration) { - const { productVariantId } = productConfiguration; - - // Find orders that are new or processing - const orders = await context.collections.Orders.find({ - "workflow.status": { $in: ["new", "coreOrderWorkflow/processing"] }, - "shipping.items.variantId": productVariantId - }).toArray(); - - const reservedQuantity = orders.reduce((sum, order) => { - if (orderIsApproved(order)) return sum; - - // Reduce through each fulfillment (shipping) object - const shippingGroupsItems = order.shipping.reduce((acc, shippingGroup) => { - // Get all items in order that match the item being adjusted - const matchingItems = shippingGroup.items.filter((item) => item.variantId === productVariantId); - - // Reduce `quantity` fields of matched items into single number - const reservedQuantityOfItem = matchingItems.reduce((quantity, matchingItem) => quantity + matchingItem.quantity, 0); - - return acc + reservedQuantityOfItem; - }, 0); - - // Sum up numbers from all fulfillment (shipping) groups - return sum + shippingGroupsItems; - }, 0); - - return reservedQuantity; -} - /** * @summary Force recalculation of the system-managed `inventoryReserved` field based on current order statuses. * @param {Object} context App context diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index 50b1fc5d22d..86e80e0475f 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -1,6 +1,7 @@ import SimpleSchema from "simpl-schema"; import ReactionError from "@reactioncommerce/reaction-error"; import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; +import getReservedQuantity from "../utils/getReservedQuantity"; const inputSchema = new SimpleSchema({ productConfiguration: ProductConfigurationSchema, @@ -85,8 +86,8 @@ export default async function updateSimpleInventory(context, input) { const $setOnInsert = { "createdAt": new Date(), // inventoryReserved is calculated by this plugin rather than being set by - // users, but we need to init it to 0 on inserts since it's required. - "inventoryReserved": 0, + // users, but we need to init it to the correct value on inserts. + "inventoryReserved": await getReservedQuantity(context, productConfiguration), // The upsert query below has only `productVariantId` so we need to ensure both are inserted "productConfiguration.productId": productConfiguration.productId }; diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js new file mode 100644 index 00000000000..6617214f546 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js @@ -0,0 +1,40 @@ +import orderIsApproved from "./orderIsApproved"; + +/** + * + * @method getReservedQuantity + * @summary Get the number of product variants that are currently reserved in an order. + * This function can take any variant object. + * @param {Object} context App context + * @param {Object} productConfiguration Product configuration + * @return {Promise} Reserved variant quantity + */ +export default async function getReservedQuantity(context, productConfiguration) { + const { productVariantId } = productConfiguration; + + // Find orders that are new or processing + const orders = await context.collections.Orders.find({ + "workflow.status": { $in: ["new", "coreOrderWorkflow/processing"] }, + "shipping.items.variantId": productVariantId + }).toArray(); + + const reservedQuantity = orders.reduce((sum, order) => { + if (orderIsApproved(order)) return sum; + + // Reduce through each fulfillment (shipping) object + const shippingGroupsItems = order.shipping.reduce((acc, shippingGroup) => { + // Get all items in order that match the item being adjusted + const matchingItems = shippingGroup.items.filter((item) => item.variantId === productVariantId); + + // Reduce `quantity` fields of matched items into single number + const reservedQuantityOfItem = matchingItems.reduce((quantity, matchingItem) => quantity + matchingItem.quantity, 0); + + return acc + reservedQuantityOfItem; + }, 0); + + // Sum up numbers from all fulfillment (shipping) groups + return sum + shippingGroupsItems; + }, 0); + + return reservedQuantity; +} From 0e33e97b34d1a4ef28ce2a4224f20c3a284831c6 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 9 May 2019 17:09:58 -0500 Subject: [PATCH 35/55] refactor: improve perf of setting initial inventoryReserved Signed-off-by: Eric Dobbertin --- .../recalculateReservedSimpleInventory.js | 32 +++++++++++-------- .../mutations/updateSimpleInventory.js | 27 ++++++++++++---- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js index f4554d930aa..016f197d3c1 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js @@ -28,21 +28,25 @@ export default async function recalculateReservedSimpleInventory(context, input) const { Products, SimpleInventory } = collections; const { productConfiguration, shopId } = input; - // Verify that the product exists - const foundProduct = await Products.findOne({ - _id: productConfiguration.productId, - shopId - }, { - projection: { - shopId: 1 - } - }); - if (!foundProduct) throw new ReactionError("not-found", "Product not found"); + if (!isInternalCall) { + // Verify that the product exists. For internal calls, we assume we can skip this + // verification because it saves a database command and maybe we are storing inventories + // before product is created due to some syncing process. + const foundProduct = await Products.findOne({ + _id: productConfiguration.productId, + shopId + }, { + projection: { + shopId: 1 + } + }); + if (!foundProduct) throw new ReactionError("not-found", "Product not found"); - // Allow update if the account has "admin" permission. When called internally by another - // plugin, context.isInternalCall can be set to `true` to disable this check. - if (!isInternalCall && !userHasPermission(["admin"], shopId)) { - throw new ReactionError("access-denied", "Access denied"); + // Allow update if the account has "admin" permission. When called internally by another + // plugin, context.isInternalCall can be set to `true` to disable this check. + if (!userHasPermission(["admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } } const inventoryReserved = await getReservedQuantity(context, productConfiguration); diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index 86e80e0475f..e69f945b6bd 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -1,7 +1,6 @@ import SimpleSchema from "simpl-schema"; import ReactionError from "@reactioncommerce/reaction-error"; import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; -import getReservedQuantity from "../utils/getReservedQuantity"; const inputSchema = new SimpleSchema({ productConfiguration: ProductConfigurationSchema, @@ -86,8 +85,9 @@ export default async function updateSimpleInventory(context, input) { const $setOnInsert = { "createdAt": new Date(), // inventoryReserved is calculated by this plugin rather than being set by - // users, but we need to init it to the correct value on inserts. - "inventoryReserved": await getReservedQuantity(context, productConfiguration), + // users, but we need to init it to some number since this is required. + // Below we update this to the correct number if we inserted. + "inventoryReserved": 0, // The upsert query below has only `productVariantId` so we need to ensure both are inserted "productConfiguration.productId": productConfiguration.productId }; @@ -117,19 +117,32 @@ export default async function updateSimpleInventory(context, input) { } }, { modifier: true, upsert: true }); - const { value: updatedDoc } = await SimpleInventory.findOneAndUpdate( + const { upsertedCount } = await SimpleInventory.updateOne( { "productConfiguration.productVariantId": productConfiguration.productVariantId, shopId }, modifier, { - returnOriginal: false, upsert: true } ); - appEvents.emit("afterInventoryUpdate", { productConfiguration, updatedBy: userId }); + // If we inserted, set the "reserved" quantity to what it should be. We could have + // put this in the $setOnInsert but then we'd have to do the Orders lookup for + // calculating reserved every time, even when only an update happens. It's better + // to wait until here when we know whether we inserted. + if (upsertedCount === 1) { + await context.mutations.recalculateReservedSimpleInventory(context, { + productConfiguration, + shopId + }); + } - return updatedDoc; + await appEvents.emit("afterInventoryUpdate", { productConfiguration, updatedBy: userId }); + + return SimpleInventory.findOne({ + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + }); } From 6708089126d3f484efc5af86648d17da0b7932a5 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 9 May 2019 17:16:22 -0500 Subject: [PATCH 36/55] perf: optimization for updateSimpleInventory mutation Signed-off-by: Eric Dobbertin --- .../mutations/updateSimpleInventory.js | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index e69f945b6bd..654e4fb886f 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -51,14 +51,18 @@ const defaultValues = { * @param {Boolean} input.isEnabled Whether the SimpleInventory plugin should manage inventory for this product configuration * @param {Number} input.lowInventoryWarningThreshold The "low quantity" flag will be applied to this product configuration * when the available quantity is at or below this threshold. - * @return {Object} Updated inventory values + * @param {Object} [options] Other options + * @param {Boolean} [options.returnUpdatedDoc=true] Set to `false` as a performance optimization + * if you don't need the updated document returned. + * @return {Object|null} Updated inventory values, or `null` if `returnUpdatedDoc` is `false` */ -export default async function updateSimpleInventory(context, input) { +export default async function updateSimpleInventory(context, input, options) { inputSchema.validate(input); const { appEvents, collections, isInternalCall, userHasPermission, userId } = context; const { Products, SimpleInventory } = collections; const { productConfiguration, shopId } = input; + const { returnUpdatedDoc = true } = options; if (!isInternalCall) { // Verify that the product exists. For internal calls, we assume we can skip this @@ -141,8 +145,13 @@ export default async function updateSimpleInventory(context, input) { await appEvents.emit("afterInventoryUpdate", { productConfiguration, updatedBy: userId }); - return SimpleInventory.findOne({ - "productConfiguration.productVariantId": productConfiguration.productVariantId, - shopId - }); + let updatedDoc = null; + if (returnUpdatedDoc) { + updatedDoc = await SimpleInventory.findOne({ + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + }); + } + + return updatedDoc; } From cca97aa9ec84d5755ad82f382f51d7b9a092b647 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 9 May 2019 18:04:18 -0500 Subject: [PATCH 37/55] feat: add isInternalCall check to simpleInventory query Signed-off-by: Eric Dobbertin --- .../server/no-meteor/queries/simpleInventory.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js index e0dd93e5125..61a5f9bf9e7 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/queries/simpleInventory.js @@ -20,10 +20,10 @@ export default async function simpleInventory(context, input) { inputSchema.validate(input); const { productConfiguration, shopId } = input; - const { collections, userHasPermission } = context; + const { collections, isInternalCall, userHasPermission } = context; const { SimpleInventory } = collections; - if (!userHasPermission(["admin"], shopId)) { + if (!isInternalCall && !userHasPermission(["admin"], shopId)) { throw new ReactionError("access-denied", "Access denied"); } From c395fba99ffb68ca9fb6a27d42a5513aeda9c156 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 9 May 2019 20:12:07 -0500 Subject: [PATCH 38/55] fix: proper array filtering for migration 63 Signed-off-by: Eric Dobbertin --- .../migrations/63_inventory_to_collection.js | 29 ++++++++----------- .../util/findAndConvertInBatchesNoMeteor.js | 4 ++- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js index fbbc30cbd20..1a086b338c7 100644 --- a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -16,7 +16,7 @@ Migrations.add({ version: 63, up() { // Clear most inventory fields from Catalog. We'll use values from Products to populate the SimpleInventory collection - Promise.await(Catalog.updateMany({}, { + Promise.await(Catalog.updateMany({ "product.variants": { $exists: true } }, { $unset: { "product.inventoryInStock": "", "product.inventoryAvailableToSell": "", @@ -28,24 +28,19 @@ Migrations.add({ "product.variants.$[].lowInventoryWarningThreshold": "", "product.variants.$[].isBackorder": "", "product.variants.$[].isLowQuantity": "", - "product.variants.$[].isSoldOut": "" + "product.variants.$[].isSoldOut": "", + "product.variants.$[variantWithOptions].options.$[].canBackorder": "", + "product.variants.$[variantWithOptions].options.$[].inventoryInStock": "", + "product.variants.$[variantWithOptions].options.$[].inventoryAvailableToSell": "", + "product.variants.$[variantWithOptions].options.$[].inventoryManagement": "", + "product.variants.$[variantWithOptions].options.$[].inventoryPolicy": "", + "product.variants.$[variantWithOptions].options.$[].lowInventoryWarningThreshold": "", + "product.variants.$[variantWithOptions].options.$[].isBackorder": "", + "product.variants.$[variantWithOptions].options.$[].isLowQuantity": "", + "product.variants.$[variantWithOptions].options.$[].isSoldOut": "" } - })); - - Promise.await(Catalog.updateMany({ - "product.variants.options": { $exists: true } }, { - $unset: { - "product.variants.$[].options.$[].canBackorder": "", - "product.variants.$[].options.$[].inventoryInStock": "", - "product.variants.$[].options.$[].inventoryAvailableToSell": "", - "product.variants.$[].options.$[].inventoryManagement": "", - "product.variants.$[].options.$[].inventoryPolicy": "", - "product.variants.$[].options.$[].lowInventoryWarningThreshold": "", - "product.variants.$[].options.$[].isBackorder": "", - "product.variants.$[].options.$[].isLowQuantity": "", - "product.variants.$[].options.$[].isSoldOut": "" - } + arrayFilters: [{ "variantWithOptions.options": { $exists: true } }] })); Promise.await(findAndConvertInBatches({ diff --git a/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js b/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js index c2867d4066e..134dcc1e681 100644 --- a/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js +++ b/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js @@ -48,7 +48,9 @@ export default async function findAndConvertInBatchesNoMeteor({ collection, conv // Remove nulls operations = operations.filter((op) => !!op); - await collection.bulkWrite(operations, { ordered: false }); + if (operations.length) { + await collection.bulkWrite(operations, { ordered: false }); + } } } while (docs.length); } From 2e7221f910a67340c36147e0ca4c2df863aa2387 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 07:50:46 -0500 Subject: [PATCH 39/55] fix: default value for options Signed-off-by: Eric Dobbertin --- .../server/no-meteor/mutations/updateSimpleInventory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index 654e4fb886f..f502b784105 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -56,7 +56,7 @@ const defaultValues = { * if you don't need the updated document returned. * @return {Object|null} Updated inventory values, or `null` if `returnUpdatedDoc` is `false` */ -export default async function updateSimpleInventory(context, input, options) { +export default async function updateSimpleInventory(context, input, options = {}) { inputSchema.validate(input); const { appEvents, collections, isInternalCall, userHasPermission, userId } = context; From fa14473f6326e976a76b6421fc9e5067e85eb23e Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 07:53:24 -0500 Subject: [PATCH 40/55] fix: use string _id for SimpleInventory Signed-off-by: Eric Dobbertin --- .../versions/server/migrations/63_inventory_to_collection.js | 2 ++ .../server/no-meteor/mutations/updateSimpleInventory.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js index 1a086b338c7..db878a5c693 100644 --- a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -1,5 +1,6 @@ import { Migrations } from "meteor/percolate:migrations"; import { MongoInternals } from "meteor/mongo"; +import Random from "@reactioncommerce/random"; import rawCollections from "/imports/collections/rawCollections"; import findAndConvertInBatches from "../util/findAndConvertInBatchesNoMeteor"; @@ -71,6 +72,7 @@ Migrations.add({ updatedAt: new Date() }, $setOnInsert: { + _id: Random.id(), createdAt: new Date() } }, diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index f502b784105..37dca936a80 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -1,4 +1,5 @@ import SimpleSchema from "simpl-schema"; +import Random from "@reactioncommerce/random"; import ReactionError from "@reactioncommerce/reaction-error"; import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; @@ -87,6 +88,7 @@ export default async function updateSimpleInventory(context, input, options = {} const $set = { updatedAt: new Date() }; const $setOnInsert = { + "_id": Random.id(), "createdAt": new Date(), // inventoryReserved is calculated by this plugin rather than being set by // users, but we need to init it to some number since this is required. From 5806137fdf106a495bbe5ccf3b00eda9cd713573 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 07:53:24 -0500 Subject: [PATCH 41/55] fix: use string _id for SimpleInventory Signed-off-by: Eric Dobbertin --- .../versions/server/migrations/63_inventory_to_collection.js | 2 ++ .../server/no-meteor/mutations/updateSimpleInventory.js | 2 ++ .../included/simple-inventory/server/no-meteor/simpleSchemas.js | 1 + 3 files changed, 5 insertions(+) diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js index 1a086b338c7..db878a5c693 100644 --- a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -1,5 +1,6 @@ import { Migrations } from "meteor/percolate:migrations"; import { MongoInternals } from "meteor/mongo"; +import Random from "@reactioncommerce/random"; import rawCollections from "/imports/collections/rawCollections"; import findAndConvertInBatches from "../util/findAndConvertInBatchesNoMeteor"; @@ -71,6 +72,7 @@ Migrations.add({ updatedAt: new Date() }, $setOnInsert: { + _id: Random.id(), createdAt: new Date() } }, diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js index f502b784105..37dca936a80 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -1,4 +1,5 @@ import SimpleSchema from "simpl-schema"; +import Random from "@reactioncommerce/random"; import ReactionError from "@reactioncommerce/reaction-error"; import { ProductConfigurationSchema, SimpleInventoryCollectionSchema } from "../simpleSchemas"; @@ -87,6 +88,7 @@ export default async function updateSimpleInventory(context, input, options = {} const $set = { updatedAt: new Date() }; const $setOnInsert = { + "_id": Random.id(), "createdAt": new Date(), // inventoryReserved is calculated by this plugin rather than being set by // users, but we need to init it to some number since this is required. diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js index 2383d311d21..8e3fce9d740 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js @@ -6,6 +6,7 @@ export const ProductConfigurationSchema = new SimpleSchema({ }); export const SimpleInventoryCollectionSchema = new SimpleSchema({ + _id: String, canBackorder: Boolean, createdAt: Date, inventoryInStock: { From c292d893d6b93057f5f6a577c75085cb5e8d5fc7 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 08:40:48 -0500 Subject: [PATCH 42/55] docs: documentation improvements Signed-off-by: Eric Dobbertin --- .../plugins/core/core/server/Reaction/core.js | 4 ++-- .../server/no-meteor/schemas/schema.graphql | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index 4fd5df2449f..f8a19186e19 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -889,13 +889,13 @@ export default { layout: config.layout }; - // Setting from a fixture file, most likely reaction.json + // Settings from a fixture file, most likely reaction.json let settingsFromFixture; if (registryFixtureData) { settingsFromFixture = registryFixtureData[0].find((packageSetting) => config.name === packageSetting.name); } - // Setting already imported into the packages collection + // Settings already imported into the packages collection const settingsFromDB = packages.find((ps) => (config.name === ps.name && shopId === ps.shopId)); const combinedSettings = merge({}, settingsFromPackage, settingsFromFixture || {}, settingsFromDB || {}); diff --git a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql index a7b8fb886ed..2b69a262e03 100644 --- a/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/inventory/server/no-meteor/schemas/schema.graphql @@ -31,8 +31,8 @@ extend type CatalogProduct { extend type CatalogProductVariant { """ - True for a purchasable variant if an order containing this variant will be accepted even where there is insufficient - available inventory to fulfill it immediately. For non-purchasable variants, this is true if at least one purchasable + True for a purchasable variant if an order containing this variant will be accepted even when there is insufficient + available inventory (`inventoryAvailableToSell`) to fulfill it immediately. For non-purchasable variants, this is true if at least one purchasable child variant can be backordered. A storefront UI may use this in combination with `inventoryAvailableToSell` to decide whether to show or enable an "Add to Cart" button. """ @@ -64,15 +64,15 @@ extend type CatalogProductVariant { isBackorder: Boolean! """ - True for a purchasable variant if it has a low quantity in stock. For non-purchasable variants, this is - true if at least one purchasable child variant has a low quantity in stock. A storefront UI may use this - to decide to show a "Low Quantity" indicator. + True for a purchasable variant if it has a low available quantity (`inventoryAvailableToSell`) in stock. + For non-purchasable variants, this is true if at least one purchasable child variant has a low available + quantity in stock. A storefront UI may use this to decide to show a "Low Quantity" indicator. """ isLowQuantity: Boolean! """ - True for a purchasable variant if it is sold out. For non-purchasable variants, this is - true if every purchasable child variant is sold out. A storefront UI may use this + True for a purchasable variant if it is sold out (`inventoryAvailableToSell` is 0). For non-purchasable + variants, this is true if every purchasable child variant is sold out. A storefront UI may use this to decide to show a "Sold Out" indicator when `isBackorder` is not also true. """ isSoldOut: Boolean! @@ -99,14 +99,14 @@ extend type CartItem { isBackorder: Boolean! """ - True it this item has a low quantity in stock. A storefront UI may use this - to decide to show a "Low Quantity" indicator. + True if this item has a low available quantity (`inventoryAvailableToSell`) in stock. + A storefront UI may use this to decide to show a "Low Quantity" indicator. """ isLowQuantity: Boolean! """ - True if this item is currently sold out. A storefront UI may use this - to decide to show a "Sold Out" indicator when `isBackorder` is not also true. + True if this item is currently sold out (`inventoryAvailableToSell` is 0). A storefront + UI may use this to decide to show a "Sold Out" indicator when `isBackorder` is not also true. """ isSoldOut: Boolean! } From 8908a2f44172ddb17401881894cb232b716b973e Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 08:45:32 -0500 Subject: [PATCH 43/55] refactor: move migration util to no-meteor Signed-off-by: Eric Dobbertin --- .../core/versions/server/migrations/28_add_hash_to_products.js | 2 +- .../versions/server/migrations/63_inventory_to_collection.js | 2 +- .../util/findAndConvertInBatches.js} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename imports/plugins/core/versions/server/{util/findAndConvertInBatchesNoMeteor.js => no-meteor/util/findAndConvertInBatches.js} (93%) diff --git a/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js b/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js index e3efacab938..e3708919187 100644 --- a/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js +++ b/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js @@ -1,7 +1,7 @@ import { Migrations } from "meteor/percolate:migrations"; import rawCollections from "/imports/collections/rawCollections"; import hashProduct from "../util/hashProduct"; -import findAndConvertInBatches from "../util/findAndConvertInBatchesNoMeteor"; +import findAndConvertInBatches from "../no-meteor/util/findAndConvertInBatches"; const { Catalog } = rawCollections; diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js index db878a5c693..79cb392ac7e 100644 --- a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -2,7 +2,7 @@ import { Migrations } from "meteor/percolate:migrations"; import { MongoInternals } from "meteor/mongo"; import Random from "@reactioncommerce/random"; import rawCollections from "/imports/collections/rawCollections"; -import findAndConvertInBatches from "../util/findAndConvertInBatchesNoMeteor"; +import findAndConvertInBatches from "../no-meteor/util/findAndConvertInBatches"; const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; diff --git a/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js b/imports/plugins/core/versions/server/no-meteor/util/findAndConvertInBatches.js similarity index 93% rename from imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js rename to imports/plugins/core/versions/server/no-meteor/util/findAndConvertInBatches.js index 134dcc1e681..153e98df2d6 100644 --- a/imports/plugins/core/versions/server/util/findAndConvertInBatchesNoMeteor.js +++ b/imports/plugins/core/versions/server/no-meteor/util/findAndConvertInBatches.js @@ -12,7 +12,7 @@ const LIMIT = 200; * @param {Object} [options.query] Optional MongoDB query that will only find not-yet-converted docs * @returns {undefined} */ -export default async function findAndConvertInBatchesNoMeteor({ collection, converter, query = {} }) { +export default async function findAndConvertInBatches({ collection, converter, query = {} }) { let docs; let skip = 0; From d85149869e16ca94826daf8911defe880f6669f3 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 11:27:16 -0500 Subject: [PATCH 44/55] refactor: speed up migration 63 Signed-off-by: Eric Dobbertin --- .../migrations/63_inventory_to_collection.js | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js index 79cb392ac7e..2a95edb4bb9 100644 --- a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -51,7 +51,16 @@ Migrations.add({ // If `inventoryManagement` prop is undefined, assume we already converted it and skip. if (variant.inventoryManagement === undefined) return null; - const childOption = await Products.findOne({ ancestors: variant._id }, { projection: { _id: 1 } }); + // Figure out if this variant has at least one child option (which means it isn't a sellable variant) + let childOption; + + if (variant.ancestors.length === 2) { + childOption = null; + } else { + // For first-level variants, we need another query to know whether there are any options + childOption = await Products.findOne({ ancestors: variant._id }, { projection: { _id: 1 } }); + } + if (!childOption) { // Create SimpleInventory record await SimpleInventory.updateOne( @@ -82,33 +91,24 @@ Migrations.add({ ); } - delete variant.inventoryAvailableToSell; - delete variant.inventoryInStock; - delete variant.inventoryPolicy; - delete variant.inventoryManagement; - delete variant.lowInventoryWarningThreshold; - delete variant.isBackorder; - delete variant.isLowQuantity; - delete variant.isSoldOut; - - return variant; + // We won't update yet. We'll do it below with `Products.updateMany` because it should + // be much faster. + return null; } })); - Promise.await(findAndConvertInBatches({ - collection: Products, - query: { type: "simple" }, - converter: (product) => { - delete product.inventoryAvailableToSell; - delete product.inventoryInStock; - delete product.inventoryPolicy; - delete product.inventoryManagement; - delete product.lowInventoryWarningThreshold; - delete product.isBackorder; - delete product.isLowQuantity; - delete product.isSoldOut; - - return product; + // Now that we've moved all to SimpleInventory collection, we can delete + // inventory related fields from Products. + Promise.await(Products.updateMany({}, { + $unset: { + inventoryAvailableToSell: "", + inventoryInStock: "", + inventoryPolicy: "", + inventoryManagement: "", + lowInventoryWarningThreshold: "", + isBackorder: "", + isLowQuantity: "", + isSoldOut: "" } })); } From 7790b1b086119d4c521494833c3c5de3c9c0e064 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 12:48:45 -0500 Subject: [PATCH 45/55] refactor: ensure migration 63 update can use index Signed-off-by: Eric Dobbertin --- .../server/migrations/63_inventory_to_collection.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js index 2a95edb4bb9..1d2f0360cbc 100644 --- a/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -65,10 +65,8 @@ Migrations.add({ // Create SimpleInventory record await SimpleInventory.updateOne( { - productConfiguration: { - productId: variant.ancestors[0], - productVariantId: variant._id - } + "productConfiguration.productVariantId": variant._id, + "shopId": variant.shopId }, { $set: { @@ -81,8 +79,10 @@ Migrations.add({ updatedAt: new Date() }, $setOnInsert: { - _id: Random.id(), - createdAt: new Date() + "_id": Random.id(), + "createdAt": new Date(), + // The upsert query has only `productVariantId` so we need to ensure both are inserted + "productConfiguration.productId": variant.ancestors[0] } }, { From 1189fb56ab90cf632f9086768683c81c5af09da2 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Fri, 10 May 2019 12:01:59 -0700 Subject: [PATCH 46/55] add `readyForMigrations` appEvent Signed-off-by: Erik Kieckhafer --- imports/plugins/core/core/server/Reaction/core.js | 1 + imports/plugins/core/versions/server/startup.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index f8a19186e19..e4d819ed378 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -72,6 +72,7 @@ export default { } this.whenAppInstanceReadyCallbacks = []; } + appEvents.emit("readyForMigrations"); }, /** diff --git a/imports/plugins/core/versions/server/startup.js b/imports/plugins/core/versions/server/startup.js index 825d3d58ac4..5c8ec29da45 100644 --- a/imports/plugins/core/versions/server/startup.js +++ b/imports/plugins/core/versions/server/startup.js @@ -18,7 +18,7 @@ Migrations.config({ collectionName: "Migrations" }); -appEvents.on("afterCoreInit", () => { +appEvents.on("readyForMigrations", () => { const currentMigrationVersion = Migrations._getControl().version; const highestAvailableVersion = Migrations._list[Migrations._list.length - 1].version; From 065fa2e8dc0209f504383ef6c63fe858c50df3f8 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 16:35:31 -0500 Subject: [PATCH 47/55] fix: fix inventory xform Signed-off-by: Eric Dobbertin --- .../catalog/server/no-meteor/utils/createCatalogProduct.js | 2 +- .../no-meteor/queries/inventoryForProductConfigurations.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index 2be880f3262..ef8a3db2811 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -147,7 +147,7 @@ export default async function createCatalogProduct(product, context) { const shop = await Shops.findOne( { _id: product.shopId }, { - fields: { + projection: { currencies: 1, currency: 1 } diff --git a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js index e423e95ab93..e1db274b7f4 100644 --- a/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -175,7 +175,7 @@ export default async function inventoryForProductConfigurations(context, input) // and calculate aggregated values from them. let childOptionResults = []; if (parentVariantProductConfigurations.length) { - const variantIds = parentVariantProductConfigurations.map(({ variantId }) => variantId); + const variantIds = parentVariantProductConfigurations.map(({ productVariantId }) => productVariantId); const allOptions = await Products.find({ ancestors: { $in: variantIds } }, { @@ -187,7 +187,7 @@ export default async function inventoryForProductConfigurations(context, input) childOptionResults = await getInventoryResults(context, { fields, productConfigurations: allOptions.map((option) => ({ - productId: option.productId, + productId: option.ancestors[0], productVariantId: option._id })) }); From 8d2577076945a4f4a7e30219c11f20d362973c0a Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 17:03:48 -0500 Subject: [PATCH 48/55] refactor: minor adjustments to simple-inventory startup Signed-off-by: Eric Dobbertin --- .../server/no-meteor/startup.js | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js index 6e5972ea872..9386682f6ca 100644 --- a/imports/plugins/included/simple-inventory/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -40,10 +40,7 @@ export default function startup(context) { bulkWriteOperations.push({ updateOne: { filter: { - productConfiguration: { - productId: item.productId, - productVariantId: item.variantId - } + "productConfiguration.productVariantId": item.variantId }, update: { $inc: { @@ -59,10 +56,7 @@ export default function startup(context) { bulkWriteOperations.push({ updateOne: { filter: { - productConfiguration: { - productId: item.productId, - productVariantId: item.variantId - } + "productConfiguration.productVariantId": item.variantId }, update: { $inc: { @@ -76,7 +70,7 @@ export default function startup(context) { if (bulkWriteOperations.length === 0) return; - SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) + await SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) .then(() => ( Promise.all(allOrderItems.map((item) => ( appEvents.emit("afterInventoryUpdate", { @@ -99,10 +93,7 @@ export default function startup(context) { const bulkWriteOperations = allOrderItems.map((item) => ({ updateOne: { filter: { - productConfiguration: { - productId: item.productId, - productVariantId: item.variantId - } + "productConfiguration.productVariantId": item.variantId }, update: { $inc: { @@ -114,7 +105,7 @@ export default function startup(context) { if (bulkWriteOperations.length === 0) return; - SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) + await SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) .then(() => ( Promise.all(allOrderItems.map((item) => ( appEvents.emit("afterInventoryUpdate", { @@ -140,10 +131,7 @@ export default function startup(context) { const bulkWriteOperations = allOrderItems.map((item) => ({ updateOne: { filter: { - productConfiguration: { - productId: item.productId, - productVariantId: item.variantId - } + "productConfiguration.productVariantId": item.variantId }, update: { $inc: { @@ -156,7 +144,7 @@ export default function startup(context) { if (bulkWriteOperations.length === 0) return; - SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) + await SimpleInventory.bulkWrite(bulkWriteOperations, { ordered: false }) .then(() => ( Promise.all(allOrderItems.map((item) => ( appEvents.emit("afterInventoryUpdate", { From f5b05c5ef1ea7729e3cf4fbb9cdc0c5e32fd18c0 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 17:04:08 -0500 Subject: [PATCH 49/55] test: add testApp.publishProducts func Signed-off-by: Eric Dobbertin --- tests/TestApp.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/TestApp.js b/tests/TestApp.js index 7237c4dc037..e3f93fa465c 100644 --- a/tests/TestApp.js +++ b/tests/TestApp.js @@ -5,6 +5,7 @@ import { gql, PubSub } from "apollo-server"; import { createTestClient } from "apollo-server-testing"; import Random from "@reactioncommerce/random"; import appEvents from "../imports/node-app/core/util/appEvents"; +import buildContext from "../imports/node-app/core/util/buildContext"; import createApolloServer from "../imports/node-app/core/createApolloServer"; import defineCollections from "../imports/node-app/core/util/defineCollections"; import Factory from "../imports/test-utils/helpers/factory"; @@ -123,6 +124,13 @@ class TestApp { this.context.user = null; } + async publishProducts(productIds) { + const requestContext = { ...this.context }; + await buildContext(requestContext); + requestContext.userHasPermission = () => true; + return this.context.mutations.publishProducts(requestContext, productIds); + } + async insertPrimaryShop(shopData) { // Need shop domains and ROOT_URL set in order for `shopId` to be correctly set on GraphQL context const domain = "shop.fake.site"; From 476c9045ede1239141b8e38d099174e0b19241a8 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 17:04:25 -0500 Subject: [PATCH 50/55] test: inventory integration tests Signed-off-by: Eric Dobbertin --- .../__snapshots__/inventory.test.js.snap | 5 + tests/inventory/catalogItemQuery.graphql | 25 + tests/inventory/inventory.test.js | 844 ++++++++++++++++++ tests/inventory/simpleInventoryQuery.graphql | 9 + .../updateSimpleInventoryMutation.graphql | 11 + 5 files changed, 894 insertions(+) create mode 100644 tests/inventory/__snapshots__/inventory.test.js.snap create mode 100644 tests/inventory/catalogItemQuery.graphql create mode 100644 tests/inventory/inventory.test.js create mode 100644 tests/inventory/simpleInventoryQuery.graphql create mode 100644 tests/inventory/updateSimpleInventoryMutation.graphql diff --git a/tests/inventory/__snapshots__/inventory.test.js.snap b/tests/inventory/__snapshots__/inventory.test.js.snap new file mode 100644 index 00000000000..acfa85bb4ef --- /dev/null +++ b/tests/inventory/__snapshots__/inventory.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws access-denied when getting simpleInventory if not an admin 1`] = `[GraphQLError: Access denied]`; + +exports[`throws access-denied when updating simpleInventory if not an admin 1`] = `[GraphQLError: Access denied]`; diff --git a/tests/inventory/catalogItemQuery.graphql b/tests/inventory/catalogItemQuery.graphql new file mode 100644 index 00000000000..80b484674b9 --- /dev/null +++ b/tests/inventory/catalogItemQuery.graphql @@ -0,0 +1,25 @@ +query catalogItemQuery($slugOrId: String!) { + catalogItemProduct(slugOrId: $slugOrId) { + product { + isBackorder + isLowQuantity + isSoldOut + variants { + canBackorder + inventoryAvailableToSell + inventoryInStock + isBackorder + isLowQuantity + isSoldOut + options { + canBackorder + inventoryAvailableToSell + inventoryInStock + isBackorder + isLowQuantity + isSoldOut + } + } + } + } +} diff --git a/tests/inventory/inventory.test.js b/tests/inventory/inventory.test.js new file mode 100644 index 00000000000..4bb4acb4e44 --- /dev/null +++ b/tests/inventory/inventory.test.js @@ -0,0 +1,844 @@ +import TestApp from "../TestApp"; +import Factory from "/imports/test-utils/helpers/factory"; +import catalogItemQuery from "./catalogItemQuery.graphql"; +import simpleInventoryQuery from "./simpleInventoryQuery.graphql"; +import updateSimpleInventoryMutation from "./updateSimpleInventoryMutation.graphql"; + +jest.setTimeout(300000); + +const internalShopId = "123"; +const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 +const internalProductId = "product1"; +const opaqueProductId = "cmVhY3Rpb24vcHJvZHVjdDpwcm9kdWN0MQ=="; +const internalVariantId = "variant1"; +const opaqueVariantId = "cmVhY3Rpb24vcHJvZHVjdDp2YXJpYW50MQ=="; +const internalOptionId1 = "option1"; +const opaqueOptionId1 = "cmVhY3Rpb24vcHJvZHVjdDpvcHRpb24x"; +const internalOptionId2 = "option2"; +const opaqueOptionId2 = "cmVhY3Rpb24vcHJvZHVjdDpvcHRpb24y"; +const shopName = "Test Shop"; + +const product = Factory.Product.makeOne({ + _id: internalProductId, + ancestors: [], + handle: "test-product", + isDeleted: false, + isVisible: true, + shopId: internalShopId, + type: "simple" +}); + +const variant = Factory.Product.makeOne({ + _id: internalVariantId, + ancestors: [internalProductId], + isDeleted: false, + isVisible: true, + shopId: internalShopId, + type: "variant" +}); + +const option1 = Factory.Product.makeOne({ + _id: internalOptionId1, + ancestors: [internalProductId, internalVariantId], + isDeleted: false, + isVisible: true, + shopId: internalShopId, + type: "variant" +}); + +const option2 = Factory.Product.makeOne({ + _id: internalOptionId2, + ancestors: [internalProductId, internalVariantId], + isDeleted: false, + isVisible: true, + shopId: internalShopId, + type: "variant" +}); + +const mockCustomerAccount = Factory.Accounts.makeOne({ + roles: { + [internalShopId]: [] + }, + shopId: internalShopId +}); + +const mockAdminAccount = Factory.Accounts.makeOne({ + roles: { + [internalShopId]: ["admin"] + }, + shopId: internalShopId +}); + +let testApp; +let getCatalogItem; +let simpleInventory; +let updateSimpleInventory; +beforeAll(async () => { + testApp = new TestApp(); + await testApp.start(); + + await testApp.insertPrimaryShop({ _id: internalShopId, name: shopName }); + + await testApp.runServiceStartup(); + + await testApp.collections.Products.insertOne(product); + await testApp.collections.Products.insertOne(variant); + await testApp.collections.Products.insertOne(option1); + await testApp.collections.Products.insertOne(option2); + + await testApp.publishProducts([internalProductId]); + + await testApp.createUserAndAccount(mockCustomerAccount); + await testApp.createUserAndAccount(mockAdminAccount); + + getCatalogItem = testApp.query(catalogItemQuery); + simpleInventory = testApp.query(simpleInventoryQuery); + updateSimpleInventory = testApp.mutate(updateSimpleInventoryMutation); +}); + +afterAll(async () => { + await testApp.collections.Products.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); + testApp.stop(); +}); + +test("throws access-denied when getting simpleInventory if not an admin", async () => { + await testApp.setLoggedInUser(mockCustomerAccount); + + try { + await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + } catch (errors) { + expect(errors[0]).toMatchSnapshot(); + } +}); + +test("throws access-denied when updating simpleInventory if not an admin", async () => { + await testApp.setLoggedInUser(mockCustomerAccount); + + try { + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true + } + }); + } catch (errors) { + expect(errors[0]).toMatchSnapshot(); + } +}); + +test("returns null if no SimpleInventory record", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + const result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: null + }); +}); + +test("returns SimpleInventory record", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + const mutationResult = await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true + } + }); + expect(mutationResult).toEqual({ + updateSimpleInventory: { + inventoryInfo: { + canBackorder: false, + inventoryInStock: 0, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 0 + } + } + }); + + const mutationResult2 = await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + canBackorder: true, + inventoryInStock: 20, + lowInventoryWarningThreshold: 2 + } + }); + expect(mutationResult2).toEqual({ + updateSimpleInventory: { + inventoryInfo: { + canBackorder: true, + inventoryInStock: 20, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + } + }); + + const queryResult = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(queryResult).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 20, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); +}); + +test("when all options are sold out and canBackorder, isBackorder is true in Catalog", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 0 + } + }); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId2 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 0 + } + }); + + const queryResult = await getCatalogItem({ + slugOrId: product.handle + }); + expect(queryResult).toEqual({ + catalogItemProduct: { + product: { + isBackorder: true, + isLowQuantity: true, + isSoldOut: true, + variants: [{ + canBackorder: true, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: true, + isLowQuantity: true, + isSoldOut: true, + options: [ + { + canBackorder: true, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: true, + isLowQuantity: true, + isSoldOut: true + }, + { + canBackorder: true, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: true, + isLowQuantity: true, + isSoldOut: true + } + ] + }] + } + } + }); +}); + +test("when all options are sold out and canBackorder is false, isBackorder is false in Catalog", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: false, + inventoryInStock: 0 + } + }); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId2 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: false, + inventoryInStock: 0 + } + }); + + const queryResult = await getCatalogItem({ + slugOrId: product.handle + }); + expect(queryResult).toEqual({ + catalogItemProduct: { + product: { + isBackorder: false, + isLowQuantity: true, + isSoldOut: true, + variants: [{ + canBackorder: false, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: false, + isLowQuantity: true, + isSoldOut: true, + options: [ + { + canBackorder: false, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: false, + isLowQuantity: true, + isSoldOut: true + }, + { + canBackorder: false, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: false, + isLowQuantity: true, + isSoldOut: true + } + ] + }] + } + } + }); +}); + +test("when one option is backordered, isBackorder is true for product in Catalog", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: false, + inventoryInStock: 10 + } + }); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId2 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 0 + } + }); + + const queryResult = await getCatalogItem({ + slugOrId: product.handle + }); + expect(queryResult).toEqual({ + catalogItemProduct: { + product: { + isBackorder: false, + isLowQuantity: true, + isSoldOut: false, + variants: [{ + canBackorder: true, + inventoryAvailableToSell: 10, + inventoryInStock: 10, + isBackorder: false, + isLowQuantity: true, + isSoldOut: false, + options: [ + { + canBackorder: false, + inventoryAvailableToSell: 10, + inventoryInStock: 10, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false + }, + { + canBackorder: true, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + isBackorder: true, + isLowQuantity: true, + isSoldOut: true + } + ] + }] + } + } + }); +}); + +test("all options available", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 10 + } + }); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId2 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 20 + } + }); + + const queryResult = await getCatalogItem({ + slugOrId: product.handle + }); + expect(queryResult).toEqual({ + catalogItemProduct: { + product: { + isBackorder: false, + isLowQuantity: false, + isSoldOut: false, + variants: [{ + canBackorder: true, + inventoryAvailableToSell: 30, + inventoryInStock: 30, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false, + options: [ + { + canBackorder: true, + inventoryAvailableToSell: 10, + inventoryInStock: 10, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false + }, + { + canBackorder: true, + inventoryAvailableToSell: 20, + inventoryInStock: 20, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false + } + ] + }] + } + } + }); +}); + +test("simple-inventory updates during standard order flow", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 10 + } + }); + + const order = { + payments: [], + shipping: [ + { + items: [ + { + productId: internalProductId, + quantity: 2, + variantId: internalOptionId1 + } + ] + } + ], + workflow: { + status: "new", + workflow: ["new"] + } + }; + + await testApp.context.appEvents.emit("afterOrderCreate", { order }); + + let result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 10, + inventoryReserved: 2, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); + + await testApp.context.appEvents.emit("afterOrderApprovePayment", { order }); + + result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 8, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); +}); + +test("simple-inventory updates when canceling before approve", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 10 + } + }); + + const order = { + payments: [ + { + status: "created" + } + ], + shipping: [ + { + items: [ + { + productId: internalProductId, + quantity: 2, + variantId: internalOptionId1 + } + ] + } + ], + workflow: { + status: "new", + workflow: ["new"] + } + }; + + await testApp.context.appEvents.emit("afterOrderCreate", { order }); + + let result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 10, + inventoryReserved: 2, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); + + await testApp.context.appEvents.emit("afterOrderCancel", { order }); + + result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 10, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); +}); + +test("simple-inventory updates when canceling after approve, do not return to stock", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 10 + } + }); + + const order = { + payments: [ + { + status: "created" + } + ], + shipping: [ + { + items: [ + { + productId: internalProductId, + quantity: 2, + variantId: internalOptionId1 + } + ] + } + ], + workflow: { + status: "new", + workflow: ["new"] + } + }; + + await testApp.context.appEvents.emit("afterOrderCreate", { order }); + + let result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 10, + inventoryReserved: 2, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); + + order.payments[0].status = "approved"; + await testApp.context.appEvents.emit("afterOrderApprovePayment", { order }); + + result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 8, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); + + await testApp.context.appEvents.emit("afterOrderCancel", { order, returnToStock: false }); + + result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 8, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); +}); + +test("simple-inventory updates when canceling after approve, do return to stock", async () => { + await testApp.setLoggedInUser(mockAdminAccount); + + await updateSimpleInventory({ + input: { + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId, + isEnabled: true, + canBackorder: true, + inventoryInStock: 10 + } + }); + + const order = { + payments: [ + { + status: "created" + } + ], + shipping: [ + { + items: [ + { + productId: internalProductId, + quantity: 2, + variantId: internalOptionId1 + } + ] + } + ], + workflow: { + status: "new", + workflow: ["new"] + } + }; + + await testApp.context.appEvents.emit("afterOrderCreate", { order }); + + let result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 10, + inventoryReserved: 2, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); + + order.payments[0].status = "approved"; + await testApp.context.appEvents.emit("afterOrderApprovePayment", { order }); + + result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 8, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); + + await testApp.context.appEvents.emit("afterOrderCancel", { order, returnToStock: true }); + + result = await simpleInventory({ + productConfiguration: { + productId: opaqueProductId, + productVariantId: opaqueOptionId1 + }, + shopId: opaqueShopId + }); + expect(result).toEqual({ + simpleInventory: { + canBackorder: true, + inventoryInStock: 10, + inventoryReserved: 0, + isEnabled: true, + lowInventoryWarningThreshold: 2 + } + }); +}); diff --git a/tests/inventory/simpleInventoryQuery.graphql b/tests/inventory/simpleInventoryQuery.graphql new file mode 100644 index 00000000000..4b0a9c4f8e0 --- /dev/null +++ b/tests/inventory/simpleInventoryQuery.graphql @@ -0,0 +1,9 @@ +query simpleInventoryQuery($shopId: ID!, $productConfiguration: ProductConfigurationInput!) { + simpleInventory(shopId: $shopId, productConfiguration: $productConfiguration) { + canBackorder + inventoryInStock + inventoryReserved + isEnabled + lowInventoryWarningThreshold + } +} diff --git a/tests/inventory/updateSimpleInventoryMutation.graphql b/tests/inventory/updateSimpleInventoryMutation.graphql new file mode 100644 index 00000000000..17ebdb44fe2 --- /dev/null +++ b/tests/inventory/updateSimpleInventoryMutation.graphql @@ -0,0 +1,11 @@ +mutation updateSimpleInventoryMutation($input: UpdateSimpleInventoryInput!) { + updateSimpleInventory(input: $input) { + inventoryInfo { + canBackorder + inventoryInStock + inventoryReserved + isEnabled + lowInventoryWarningThreshold + } + } +} From 28c64bc71389a243b15237bffc76f8b9e5ddef62 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Fri, 10 May 2019 16:11:02 -0700 Subject: [PATCH 51/55] move/remove Importer actions Signed-off-by: Erik Kieckhafer --- imports/plugins/core/core/server/Reaction/core.js | 7 +++---- .../versions/server/migrations/3_reset_package_registry.js | 5 +---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index e4d819ed378..9e2df0d63c7 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -43,8 +43,6 @@ export default { } this.loadPackages(); - // process imports from packages and any hooked imports - this.Importer.flush(); createGroups(); this.setAppVersion(); @@ -836,8 +834,6 @@ export default { * @return {String} returns insert result */ loadPackages() { - const packages = Packages.find().fetch(); - let registryFixtureData; if (process.env.REACTION_REGISTRY) { @@ -867,6 +863,7 @@ export default { this.whenAppInstanceReady((app) => { const layouts = []; + const packages = Packages.find().fetch(); const { registeredPlugins } = app; const totalPackages = Object.keys(registeredPlugins).length; let loadedIndex = 1; @@ -939,6 +936,8 @@ export default { this.Importer.layout(uniqLayouts, shop._id); }); + this.Importer.flush(); + // // package cleanup // diff --git a/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js index fe9a6de0807..c0393f5fdfe 100644 --- a/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js +++ b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js @@ -1,7 +1,6 @@ import { Migrations } from "meteor/percolate:migrations"; import { Packages } from "/lib/collections"; -import Reaction from "/imports/plugins/core/core/server/Reaction"; -// Add keys to search so that stock search is enabled by default + Migrations.add({ version: 3, up() { @@ -13,7 +12,5 @@ Migrations.add({ }, { bypassCollection2: true, multi: true } ); - Reaction.loadPackages(); - Reaction.Importer.flush(); } }); From 22115bae6b655a0cbb0830808e801449d55b4ca3 Mon Sep 17 00:00:00 2001 From: Erik Kieckhafer Date: Fri, 10 May 2019 16:11:15 -0700 Subject: [PATCH 52/55] lint fix Signed-off-by: Erik Kieckhafer --- tests/inventory/inventory.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/inventory/inventory.test.js b/tests/inventory/inventory.test.js index 4bb4acb4e44..083ed38ee1c 100644 --- a/tests/inventory/inventory.test.js +++ b/tests/inventory/inventory.test.js @@ -11,7 +11,6 @@ const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 const internalProductId = "product1"; const opaqueProductId = "cmVhY3Rpb24vcHJvZHVjdDpwcm9kdWN0MQ=="; const internalVariantId = "variant1"; -const opaqueVariantId = "cmVhY3Rpb24vcHJvZHVjdDp2YXJpYW50MQ=="; const internalOptionId1 = "option1"; const opaqueOptionId1 = "cmVhY3Rpb24vcHJvZHVjdDpvcHRpb24x"; const internalOptionId2 = "option2"; From 205b07fead1838fbea432106ece26db5ab4d913f Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 10 May 2019 18:48:14 -0500 Subject: [PATCH 53/55] fix: fix error logging of index drop migrations Signed-off-by: Eric Dobbertin --- .../server/migrations/59_drop_indexes.js | 189 +++++++++--------- .../server/migrations/61_drop_indexes.js | 21 +- .../server/migrations/62_drop_indexes.js | 21 +- 3 files changed, 126 insertions(+), 105 deletions(-) diff --git a/imports/plugins/core/versions/server/migrations/59_drop_indexes.js b/imports/plugins/core/versions/server/migrations/59_drop_indexes.js index 21f41811eba..225f7ddd9bb 100644 --- a/imports/plugins/core/versions/server/migrations/59_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/59_drop_indexes.js @@ -18,6 +18,18 @@ const { Translations } = rawCollections; +/** + * @private + * @param {Error} error Error or null + * @return {undefined} + */ +function handleError(error) { + // This may fail if the index doesn't exist, which is what we want anyway + if (error && (typeof error.message !== "string" || !error.message.includes("index not found"))) { + Logger.warn(error, "Caught error from dropIndex calls in migration 59"); + } +} + /** * Drop all indexes that support queries that are no longer expected * to be made by any plugins, or that are already supported by other @@ -26,104 +38,99 @@ const { Migrations.add({ version: 59, up() { - try { - Accounts.dropIndex("c2_sessions"); + Accounts.dropIndex("c2_sessions", handleError); - Cart.dropIndex("c2_billing.$.paymentMethod.items.$.productId"); - Cart.dropIndex("c2_billing.$.paymentMethod.items.$.shopId"); - Cart.dropIndex("c2_billing.$.paymentMethod.workflow.status"); - Cart.dropIndex("c2_email"); - Cart.dropIndex("c2_items.$.product.ancestors"); - Cart.dropIndex("c2_items.$.product.createdAt"); - Cart.dropIndex("c2_items.$.product.handle"); - Cart.dropIndex("c2_items.$.product.hashtags"); - Cart.dropIndex("c2_items.$.product.isDeleted"); - Cart.dropIndex("c2_items.$.product.isVisible"); - Cart.dropIndex("c2_items.$.product.shopId"); - Cart.dropIndex("c2_items.$.product.workflow.status"); - Cart.dropIndex("c2_items.$.shopId"); - Cart.dropIndex("c2_items.$.variants.isDeleted"); - Cart.dropIndex("c2_items.$.variants.isVisible"); - Cart.dropIndex("c2_items.$.variants.shopId"); - Cart.dropIndex("c2_items.$.variants.workflow.status"); - Cart.dropIndex("c2_sessionId"); - Cart.dropIndex("c2_shipping.$.items.$.productId"); - Cart.dropIndex("c2_shipping.$.items.$.shopId"); - Cart.dropIndex("c2_shipping.$.workflow.status"); - Cart.dropIndex("c2_workflow.status"); + Cart.dropIndex("c2_billing.$.paymentMethod.items.$.productId", handleError); + Cart.dropIndex("c2_billing.$.paymentMethod.items.$.shopId", handleError); + Cart.dropIndex("c2_billing.$.paymentMethod.workflow.status", handleError); + Cart.dropIndex("c2_email", handleError); + Cart.dropIndex("c2_items.$.product.ancestors", handleError); + Cart.dropIndex("c2_items.$.product.createdAt", handleError); + Cart.dropIndex("c2_items.$.product.handle", handleError); + Cart.dropIndex("c2_items.$.product.hashtags", handleError); + Cart.dropIndex("c2_items.$.product.isDeleted", handleError); + Cart.dropIndex("c2_items.$.product.isVisible", handleError); + Cart.dropIndex("c2_items.$.product.shopId", handleError); + Cart.dropIndex("c2_items.$.product.workflow.status", handleError); + Cart.dropIndex("c2_items.$.shopId", handleError); + Cart.dropIndex("c2_items.$.variants.isDeleted", handleError); + Cart.dropIndex("c2_items.$.variants.isVisible", handleError); + Cart.dropIndex("c2_items.$.variants.shopId", handleError); + Cart.dropIndex("c2_items.$.variants.workflow.status", handleError); + Cart.dropIndex("c2_sessionId", handleError); + Cart.dropIndex("c2_shipping.$.items.$.productId", handleError); + Cart.dropIndex("c2_shipping.$.items.$.shopId", handleError); + Cart.dropIndex("c2_shipping.$.workflow.status", handleError); + Cart.dropIndex("c2_workflow.status", handleError); - Discounts.dropIndex("c2_calculation.method"); - Discounts.dropIndex("c2_discountMethod"); - Discounts.dropIndex("c2_transactions.$.cartId"); - Discounts.dropIndex("c2_transactions.$.userId"); + Discounts.dropIndex("c2_calculation.method", handleError); + Discounts.dropIndex("c2_discountMethod", handleError); + Discounts.dropIndex("c2_transactions.$.cartId", handleError); + Discounts.dropIndex("c2_transactions.$.userId", handleError); - Inventory.dropIndex("c2_orderItemId"); - Inventory.dropIndex("c2_productId"); - Inventory.dropIndex("c2_shopId"); - Inventory.dropIndex("c2_variantId"); - Inventory.dropIndex("c2_workflow.status"); + Inventory.dropIndex("c2_orderItemId", handleError); + Inventory.dropIndex("c2_productId", handleError); + Inventory.dropIndex("c2_shopId", handleError); + Inventory.dropIndex("c2_variantId", handleError); + Inventory.dropIndex("c2_workflow.status", handleError); - Orders.dropIndex("c2_accountId"); - Orders.dropIndex("c2_anonymousAccessToken"); - Orders.dropIndex("c2_billing.$.paymentMethod.items.$.productId"); - Orders.dropIndex("c2_billing.$.paymentMethod.items.$.shopId"); - Orders.dropIndex("c2_billing.$.paymentMethod.workflow.status"); - Orders.dropIndex("c2_items.$.product.ancestors"); - Orders.dropIndex("c2_items.$.product.createdAt"); - Orders.dropIndex("c2_items.$.product.handle"); - Orders.dropIndex("c2_items.$.product.hashtags"); - Orders.dropIndex("c2_items.$.product.isDeleted"); - Orders.dropIndex("c2_items.$.product.isVisible"); - Orders.dropIndex("c2_items.$.product.shopId"); - Orders.dropIndex("c2_items.$.product.workflow.status"); - Orders.dropIndex("c2_items.$.shopId"); - Orders.dropIndex("c2_items.$.variants.isDeleted"); - Orders.dropIndex("c2_items.$.variants.isVisible"); - Orders.dropIndex("c2_items.$.variants.shopId"); - Orders.dropIndex("c2_items.$.variants.workflow.status"); - Orders.dropIndex("c2_items.$.workflow.status"); - Orders.dropIndex("c2_sessionId"); - Orders.dropIndex("c2_shipping.$.items.$.productId"); - Orders.dropIndex("c2_shipping.$.items.$.shopId"); - Orders.dropIndex("c2_shipping.$.items.$.variantId"); - Orders.dropIndex("c2_shipping.$.items.$.workflow.status"); - Orders.dropIndex("c2_shipping.$.workflow.status"); + Orders.dropIndex("c2_accountId", handleError); + Orders.dropIndex("c2_anonymousAccessToken", handleError); + Orders.dropIndex("c2_billing.$.paymentMethod.items.$.productId", handleError); + Orders.dropIndex("c2_billing.$.paymentMethod.items.$.shopId", handleError); + Orders.dropIndex("c2_billing.$.paymentMethod.workflow.status", handleError); + Orders.dropIndex("c2_items.$.product.ancestors", handleError); + Orders.dropIndex("c2_items.$.product.createdAt", handleError); + Orders.dropIndex("c2_items.$.product.handle", handleError); + Orders.dropIndex("c2_items.$.product.hashtags", handleError); + Orders.dropIndex("c2_items.$.product.isDeleted", handleError); + Orders.dropIndex("c2_items.$.product.isVisible", handleError); + Orders.dropIndex("c2_items.$.product.shopId", handleError); + Orders.dropIndex("c2_items.$.product.workflow.status", handleError); + Orders.dropIndex("c2_items.$.shopId", handleError); + Orders.dropIndex("c2_items.$.variants.isDeleted", handleError); + Orders.dropIndex("c2_items.$.variants.isVisible", handleError); + Orders.dropIndex("c2_items.$.variants.shopId", handleError); + Orders.dropIndex("c2_items.$.variants.workflow.status", handleError); + Orders.dropIndex("c2_items.$.workflow.status", handleError); + Orders.dropIndex("c2_sessionId", handleError); + Orders.dropIndex("c2_shipping.$.items.$.productId", handleError); + Orders.dropIndex("c2_shipping.$.items.$.shopId", handleError); + Orders.dropIndex("c2_shipping.$.items.$.variantId", handleError); + Orders.dropIndex("c2_shipping.$.items.$.workflow.status", handleError); + Orders.dropIndex("c2_shipping.$.workflow.status", handleError); - Packages.dropIndex("c2_layout.$.layout"); - Packages.dropIndex("c2_layout.$.structure.adminControlsFooter"); - Packages.dropIndex("c2_layout.$.structure.dashboardControls"); - Packages.dropIndex("c2_layout.$.structure.dashboardHeader"); - Packages.dropIndex("c2_layout.$.structure.dashboardHeaderControls"); - Packages.dropIndex("c2_layout.$.structure.layoutFooter"); - Packages.dropIndex("c2_layout.$.structure.layoutHeader"); - Packages.dropIndex("c2_layout.$.structure.notFound"); - Packages.dropIndex("c2_layout.$.structure.template"); - Packages.dropIndex("c2_name"); - Packages.dropIndex("c2_registry.$.name"); - Packages.dropIndex("c2_registry.$.route"); - Packages.dropIndex("c2_shopId"); + Packages.dropIndex("c2_layout.$.layout", handleError); + Packages.dropIndex("c2_layout.$.structure.adminControlsFooter", handleError); + Packages.dropIndex("c2_layout.$.structure.dashboardControls", handleError); + Packages.dropIndex("c2_layout.$.structure.dashboardHeader", handleError); + Packages.dropIndex("c2_layout.$.structure.dashboardHeaderControls", handleError); + Packages.dropIndex("c2_layout.$.structure.layoutFooter", handleError); + Packages.dropIndex("c2_layout.$.structure.layoutHeader", handleError); + Packages.dropIndex("c2_layout.$.structure.notFound", handleError); + Packages.dropIndex("c2_layout.$.structure.template", handleError); + Packages.dropIndex("c2_name", handleError); + Packages.dropIndex("c2_registry.$.name", handleError); + Packages.dropIndex("c2_registry.$.route", handleError); + Packages.dropIndex("c2_shopId", handleError); - Products.dropIndex("c2_isDeleted"); - Products.dropIndex("c2_isVisible"); + Products.dropIndex("c2_isDeleted", handleError); + Products.dropIndex("c2_isVisible", handleError); - Shops.dropIndex("c2_active"); - Shops.dropIndex("c2_layout.$.layout"); - Shops.dropIndex("c2_layout.$.structure.adminControlsFooter"); - Shops.dropIndex("c2_layout.$.structure.dashboardControls"); - Shops.dropIndex("c2_layout.$.structure.dashboardHeader"); - Shops.dropIndex("c2_layout.$.structure.dashboardHeaderControls"); - Shops.dropIndex("c2_layout.$.structure.layoutFooter"); - Shops.dropIndex("c2_layout.$.structure.layoutHeader"); - Shops.dropIndex("c2_layout.$.structure.notFound"); - Shops.dropIndex("c2_layout.$.structure.template"); - Shops.dropIndex("c2_shopType"); - Shops.dropIndex("c2_workflow.status"); + Shops.dropIndex("c2_active", handleError); + Shops.dropIndex("c2_layout.$.layout", handleError); + Shops.dropIndex("c2_layout.$.structure.adminControlsFooter", handleError); + Shops.dropIndex("c2_layout.$.structure.dashboardControls", handleError); + Shops.dropIndex("c2_layout.$.structure.dashboardHeader", handleError); + Shops.dropIndex("c2_layout.$.structure.dashboardHeaderControls", handleError); + Shops.dropIndex("c2_layout.$.structure.layoutFooter", handleError); + Shops.dropIndex("c2_layout.$.structure.layoutHeader", handleError); + Shops.dropIndex("c2_layout.$.structure.notFound", handleError); + Shops.dropIndex("c2_layout.$.structure.template", handleError); + Shops.dropIndex("c2_shopType", handleError); + Shops.dropIndex("c2_workflow.status", handleError); - Translations.dropIndex("c2_i18n"); - Translations.dropIndex("c2_shopId"); - } catch (error) { - // This may fail if the index doesn't exist, which is what we want anyway - Logger.warn(error, "Caught error from dropIndex calls in migration 59"); - } + Translations.dropIndex("c2_i18n", handleError); + Translations.dropIndex("c2_shopId", handleError); } }); diff --git a/imports/plugins/core/versions/server/migrations/61_drop_indexes.js b/imports/plugins/core/versions/server/migrations/61_drop_indexes.js index 13219eca025..16aef803cb8 100644 --- a/imports/plugins/core/versions/server/migrations/61_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/61_drop_indexes.js @@ -6,6 +6,18 @@ const { Orders } = rawCollections; +/** + * @private + * @param {Error} error Error or null + * @return {undefined} + */ +function handleError(error) { + // This may fail if the index doesn't exist, which is what we want anyway + if (error && (typeof error.message !== "string" || !error.message.includes("index not found"))) { + Logger.warn(error, "Caught error from dropIndex calls in migration 59"); + } +} + /** * Drop all indexes that support queries that are no longer expected * to be made by any plugins, or that are already supported by other @@ -14,12 +26,7 @@ const { Migrations.add({ version: 61, up() { - try { - Orders.dropIndex("c2_items.$.productId"); - Orders.dropIndex("c2_items.$.variantId"); - } catch (error) { - // This may fail if the index doesn't exist, which is what we want anyway - Logger.warn(error, "Caught error from dropIndex calls in migration 61"); - } + Orders.dropIndex("c2_items.$.productId", handleError); + Orders.dropIndex("c2_items.$.variantId", handleError); } }); diff --git a/imports/plugins/core/versions/server/migrations/62_drop_indexes.js b/imports/plugins/core/versions/server/migrations/62_drop_indexes.js index 02dac8e5e8a..abaf5140375 100644 --- a/imports/plugins/core/versions/server/migrations/62_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/62_drop_indexes.js @@ -6,6 +6,18 @@ const { Catalog } = rawCollections; +/** + * @private + * @param {Error} error Error or null + * @return {undefined} + */ +function handleError(error) { + // This may fail if the index doesn't exist, which is what we want anyway + if (error && (typeof error.message !== "string" || !error.message.includes("index not found"))) { + Logger.warn(error, "Caught error from dropIndex calls in migration 59"); + } +} + /** * Drop all indexes that support queries that are no longer expected * to be made by any plugins, or that are already supported by other @@ -14,12 +26,7 @@ const { Migrations.add({ version: 62, up() { - try { - Catalog.dropIndex("createdAt_1"); - Catalog.dropIndex("updatedAt_1"); - } catch (error) { - // This may fail if the index doesn't exist, which is what we want anyway - Logger.warn(error, "Caught error from dropIndex calls in migration 62"); - } + Catalog.dropIndex("createdAt_1", handleError); + Catalog.dropIndex("updatedAt_1", handleError); } }); From eaa05dfec6e07e54fcd65c78396bdda054667e54 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Sun, 12 May 2019 23:30:06 -0700 Subject: [PATCH 54/55] fix: startup fixes Signed-off-by: Eric Dobbertin --- imports/plugins/core/core/server/Reaction/core.js | 8 +++++--- .../core/versions/server/migrations/59_drop_indexes.js | 10 ++++++++-- .../core/versions/server/migrations/61_drop_indexes.js | 10 ++++++++-- .../core/versions/server/migrations/62_drop_indexes.js | 10 ++++++++-- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index 9e2df0d63c7..4a263cd4b29 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -864,12 +864,13 @@ export default { this.whenAppInstanceReady((app) => { const layouts = []; const packages = Packages.find().fetch(); + const shops = Shops.find().fetch(); const { registeredPlugins } = app; const totalPackages = Object.keys(registeredPlugins).length; let loadedIndex = 1; // for each shop, we're loading packages in a unique registry - _.each(registeredPlugins, (config, pkgName) => - Shops.find().forEach((shop) => { + _.each(registeredPlugins, (config, pkgName) => { + shops.forEach((shop) => { const shopId = shop._id; if (!shopId) return; @@ -927,7 +928,8 @@ export default { this.Importer.package(combinedSettings, shopId); Logger.info(`Successfully initialized package: ${pkgName}... ${loadedIndex}/${totalPackages}`); loadedIndex += 1; - })); + }); + }); // helper for removing layout duplicates const uniqLayouts = uniqWith(layouts, _.isEqual); diff --git a/imports/plugins/core/versions/server/migrations/59_drop_indexes.js b/imports/plugins/core/versions/server/migrations/59_drop_indexes.js index 225f7ddd9bb..2da57c9a3bc 100644 --- a/imports/plugins/core/versions/server/migrations/59_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/59_drop_indexes.js @@ -24,8 +24,14 @@ const { * @return {undefined} */ function handleError(error) { - // This may fail if the index doesn't exist, which is what we want anyway - if (error && (typeof error.message !== "string" || !error.message.includes("index not found"))) { + // This may fail if the index or the collection doesn't exist, which is what we want anyway + if ( + error && + ( + typeof error.message !== "string" || + (!error.message.includes("index not found") && !error.message.includes("ns not found")) + ) + ) { Logger.warn(error, "Caught error from dropIndex calls in migration 59"); } } diff --git a/imports/plugins/core/versions/server/migrations/61_drop_indexes.js b/imports/plugins/core/versions/server/migrations/61_drop_indexes.js index 16aef803cb8..2738182a679 100644 --- a/imports/plugins/core/versions/server/migrations/61_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/61_drop_indexes.js @@ -12,8 +12,14 @@ const { * @return {undefined} */ function handleError(error) { - // This may fail if the index doesn't exist, which is what we want anyway - if (error && (typeof error.message !== "string" || !error.message.includes("index not found"))) { + // This may fail if the index or the collection doesn't exist, which is what we want anyway + if ( + error && + ( + typeof error.message !== "string" || + (!error.message.includes("index not found") && !error.message.includes("ns not found")) + ) + ) { Logger.warn(error, "Caught error from dropIndex calls in migration 59"); } } diff --git a/imports/plugins/core/versions/server/migrations/62_drop_indexes.js b/imports/plugins/core/versions/server/migrations/62_drop_indexes.js index abaf5140375..331d02381c9 100644 --- a/imports/plugins/core/versions/server/migrations/62_drop_indexes.js +++ b/imports/plugins/core/versions/server/migrations/62_drop_indexes.js @@ -12,8 +12,14 @@ const { * @return {undefined} */ function handleError(error) { - // This may fail if the index doesn't exist, which is what we want anyway - if (error && (typeof error.message !== "string" || !error.message.includes("index not found"))) { + // This may fail if the index or the collection doesn't exist, which is what we want anyway + if ( + error && + ( + typeof error.message !== "string" || + (!error.message.includes("index not found") && !error.message.includes("ns not found")) + ) + ) { Logger.warn(error, "Caught error from dropIndex calls in migration 59"); } } From 86b089834c9a8e02399752a28380775b54ad3c17 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Mon, 13 May 2019 06:55:46 -0700 Subject: [PATCH 55/55] fix: disable migration 3 Signed-off-by: Eric Dobbertin --- .../migrations/3_reset_package_registry.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js index c0393f5fdfe..33da47bcf24 100644 --- a/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js +++ b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js @@ -1,16 +1,20 @@ import { Migrations } from "meteor/percolate:migrations"; -import { Packages } from "/lib/collections"; +// import { Packages } from "/lib/collections"; Migrations.add({ version: 3, up() { - Packages.update( - {}, { - $set: { - registry: [] - } - }, - { bypassCollection2: true, multi: true } - ); + // ED 5-13-2019 This migration is now running after packages load and + // wiping out registry on a fresh installation. This may have been + // correct at one point but I don't think it is anymore. Commenting out. + + // Packages.update( + // {}, { + // $set: { + // registry: [] + // } + // }, + // { bypassCollection2: true, multi: true } + // ); } });