diff --git a/imports/collections/schemas/catalog.js b/imports/collections/schemas/catalog.js index c43c39d2b98..55c53dd323f 100644 --- a/imports/collections/schemas/catalog.js +++ b/imports/collections/schemas/catalog.js @@ -99,19 +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} 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 * @property {Metafield[]} metafields optional * @property {Number} minOrderQuantity optional, default value: `1` @@ -136,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" @@ -155,35 +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" }, - "inventoryAvailableToSell": { - type: SimpleSchema.Integer, - label: "Inventory available to sell" - }, - "inventoryInStock": { - type: SimpleSchema.Integer, - label: "Inventory in stock" - }, - "inventoryManagement": { - type: Boolean, - label: "Inventory management" - }, - "inventoryPolicy": { - 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", @@ -191,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", @@ -301,14 +252,8 @@ 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` * @property {ImageInfo[]} media optional * @property {Metafield[]} metafields optional * @property {String} metaDescription optional @@ -359,31 +304,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", @@ -396,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/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/collections/schemas/products.js b/imports/collections/schemas/products.js index cbc7a50b479..2e4f36623f7 100644 --- a/imports/collections/schemas/products.js +++ b/imports/collections/schemas/products.js @@ -57,18 +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} 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 * @property {Metafield[]} metafields optional * @property {Number} minOrderQuantity optional * @property {String} optionTitle, Option internal name, default value: `"Untitled option"` @@ -134,73 +125,10 @@ 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; - } - } - } - }, - "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 @@ -212,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 @@ -309,12 +230,7 @@ registerSchema("ProductVariant", ProductVariant); * @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 @@ -381,45 +297,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/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/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..3550af6f8cd 100644 --- a/imports/node-app/devserver/registerPlugins.js +++ b/imports/node-app/devserver/registerPlugins.js @@ -1,3 +1,5 @@ +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"; /** @@ -7,5 +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/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/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/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js index 0fe43fc0571..baa8270bbf4 100644 --- a/imports/plugins/core/catalog/server/methods/catalog.js +++ b/imports/plugins/core/catalog/server/methods/catalog.js @@ -13,11 +13,6 @@ import appEvents from "/imports/node-app/core/util/appEvents"; import rawCollections from "/imports/collections/rawCollections"; import getGraphQLContextInMeteorMethod from "/imports/plugins/core/graphql/server/getGraphQLContextInMeteorMethod"; import hashProduct from "../no-meteor/mutations/hashProduct"; -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 */ @@ -30,19 +25,6 @@ import isBackorder from "/imports/plugins/core/inventory/server/no-meteor/utils/ * @namespace Methods/Products */ -/** - * @array toDenormalize - * @private - * @summary contains a list of fields, which should be denormalized - * @type {string[]} - */ -const toDenormalize = [ - "inventoryInStock", - "lowInventoryWarningThreshold", - "inventoryPolicy", - "inventoryManagement" -]; - /** * @function createTitle * @private @@ -192,104 +174,6 @@ 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 - * quantity, etc. 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: - * "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)); - } - 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: - return; - } - - Products.update( - id, - { - $set: update - }, - { - selector: { - type: "simple" - } - } - ); -} - -/** - * 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 @@ -452,8 +336,6 @@ Meteor.methods({ } delete clone.updatedAt; delete clone.createdAt; - delete clone.inventoryInStock; - delete clone.lowInventoryWarningThreshold; // Apply custom transformations from plugins. for (const customFunc of context.getFunctionsOfType("mutateNewVariantBeforeCreate")) { @@ -530,17 +412,11 @@ Meteor.methods({ const isOption = ancestors.length > 1; if (isOption) { Object.assign(newVariant, { - title: `${parent.title} - Untitled option` + optionTitle: "Untitled", + 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}`); @@ -617,11 +493,6 @@ Meteor.methods({ }); }); - // After variant was removed from product, we need to recalculate all - // denormalized fields - const productId = variantsToDelete[0].ancestors[0]; - toDenormalize.forEach((field) => denormalize(productId, field)); - Logger.debug(`Flagged variant and all its children as deleted.`); return true; @@ -941,12 +812,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 @@ -990,13 +855,9 @@ Meteor.methods({ throw new ReactionError("server-error", err.message); } - // If we get a result from the product update, - // denormalize and attach results to top-level product + // If we get a result from the product update, emit update events if (result === 1) { if (type === "variant") { - if (toDenormalize.indexOf(field) >= 0) { - denormalize(doc.ancestors[0], field); - } appEvents.emit("afterVariantUpdate", { _id, field, value }); } else { appEvents.emit("afterProductUpdate", { _id, field, value }); 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 c38c9d6d07b..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 @@ -30,11 +30,7 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, 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 1ab540b013f..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 @@ -38,14 +38,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", @@ -75,14 +70,9 @@ const mockVariants = [ compareAtPrice: 15, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -118,11 +108,7 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -186,12 +172,7 @@ const expectedOptionsResponse = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isLowQuantity: true, - isSoldOut: false, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -226,12 +207,7 @@ const expectedVariantsResponse = [ createdAt: createdAt.toISOString(), height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, - isLowQuantity: true, - isSoldOut: false, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -275,11 +251,7 @@ const expectedItemsResponse = { createdAt: createdAt.toISOString(), description: "description", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", 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/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/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 a71084768de..ef8a3db2811 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -1,38 +1,23 @@ import Logger from "@reactioncommerce/logger"; -import canBackorder from "./canBackorder"; import getCatalogProductMedia from "./getCatalogProductMedia"; -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 * @summary Converts a variant Product document into the catalog schema for variants * @param {Object} variant The variant from Products collection * @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, variantMedia, variantInventory) { +export function xformVariant(variant, variantMedia) { const primaryImage = variantMedia.find(({ toGrid }) => toGrid === 1) || null; return { _id: variant._id, barcode: variant.barcode, - canBackorder: variantInventory.canBackorder, 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, metafields: variant.metafields, minOrderQuantity: variant.minOrderQuantity, @@ -53,12 +38,13 @@ export function xformVariant(variant, variantMedia, variantInventory) { /** * @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.variants The Product documents for all variants of this product * @returns {Object} The CatalogProduct document */ -export async function xformProduct({ collections, product, variants }) { +export async function xformProduct({ context, product, variants }) { + const { collections } = context; const catalogProductMedia = await getCatalogProductMedia(product._id, collections); const primaryImage = catalogProductMedia.find(({ toGrid }) => toGrid === 1) || null; @@ -81,46 +67,17 @@ export async function xformProduct({ collections, product, 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 variantInventory; - if (variantOptions) { - variantInventory = { - canBackorder: canBackorder(variantOptions), - inventoryAvailableToSell: variant.inventoryAvailableToSell || 0, - inventoryInStock: variant.inventoryInStock || 0, - isBackorder: isBackorder(variantOptions), - isLowQuantity: isLowQuantity(variantOptions), - isSoldOut: isSoldOut(variantOptions) - }; - } else { - variantInventory = { - canBackorder: canBackorder([variant]), - inventoryAvailableToSell: variant.inventoryAvailableToSell || 0, - inventoryInStock: variant.inventoryInStock || 0, - isBackorder: isBackorder([variant]), - isLowQuantity: isLowQuantity([variant]), - isSoldOut: isSoldOut([variant]) - }; - } - const variantMedia = catalogProductMedia.filter((media) => media.variantId === variant._id); + const newVariant = xformVariant(variant, variantMedia); - const newVariant = xformVariant(variant, variantMedia, variantInventory); - + const variantOptions = options.get(variant._id); 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, optionMedia, optionInventory); + return xformVariant(option, optionMedia); }); } + return newVariant; }); @@ -131,15 +88,9 @@ export async function xformProduct({ collections, product, variants }) { createdAt: product.createdAt || new Date(), description: product.description, 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, media: catalogProductMedia, metafields: product.metafields, metaDescription: product.metaDescription, @@ -196,7 +147,7 @@ export default async function createCatalogProduct(product, context) { const shop = await Shops.findOne( { _id: product.shopId }, { - fields: { + projection: { currencies: 1, currency: 1 } @@ -214,7 +165,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 c7fba523800..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 @@ -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"; @@ -26,22 +23,13 @@ const mockVariants = [ _id: internalVariantIds[0], ancestors: [internalCatalogProductId], barcode: "barcode", - canBackorder: true, createdAt, compareAtPrice: 1100, height: 0, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - inventoryManagement: true, - inventoryPolicy: false, - isBackorder: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -67,21 +55,12 @@ const mockVariants = [ _id: internalVariantIds[1], ancestors: [internalCatalogProductId, internalVariantIds[0]], barcode: "barcode", - canBackorder: false, createdAt, height: 2, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - inventoryManagement: true, - inventoryPolicy: true, - isBackorder: false, isDeleted: false, - isLowQuantity: true, - isSoldOut: false, isVisible: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -115,13 +94,7 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -195,15 +168,9 @@ const mockCatalogProduct = { createdAt, description: "description", height: 11.23, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - isBackorder: false, isDeleted: false, - isLowQuantity: false, - isSoldOut: false, isVisible: false, length: 5.67, - lowInventoryWarningThreshold: 2, media: [{ URLs: { large: "large/path/to/image.jpg", @@ -274,19 +241,10 @@ const mockCatalogProduct = { variants: [{ _id: "875", barcode: "barcode", - canBackorder: false, createdAt, height: 0, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - isBackorder: false, - inventoryManagement: true, - inventoryPolicy: false, - isLowQuantity: false, - isSoldOut: false, length: 0, - lowInventoryWarningThreshold: 0, media: [], metafields: [{ description: "description", @@ -301,19 +259,10 @@ const mockCatalogProduct = { options: [{ _id: "874", barcode: "barcode", - canBackorder: false, createdAt, height: 2, index: 0, - inventoryAvailableToSell: 10, - inventoryInStock: 0, - inventoryManagement: true, - inventoryPolicy: true, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 2, - lowInventoryWarningThreshold: 0, media: [{ URLs: { large: "large/path/to/image.jpg", @@ -394,30 +343,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/catalog/server/no-meteor/utils/getTopLevelProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js index 3f65c82e1ac..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 @@ -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", @@ -106,11 +100,7 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, 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/publishProductToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js index 3ba83628d08..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 @@ -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"; @@ -25,19 +22,10 @@ const mockVariants = [ { _id: internalVariantIds[0], barcode: "barcode", - canBackorder: true, createdAt, height: 0, index: 0, - inventoryAvailableToSell: 0, - inventoryInStock: 0, - inventoryManagement: true, - inventoryPolicy: false, - isBackorder: false, - isLowQuantity: true, - isSoldOut: false, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -62,19 +50,10 @@ const mockVariants = [ { _id: internalVariantIds[0], barcode: "barcode", - canBackorder: true, createdAt, height: 0, index: 0, - inventoryAvailableToSell: 0, - inventoryInStock: 0, - inventoryManagement: true, - inventoryPolicy: false, - isBackorder: false, - isLowQuantity: true, - isSoldOut: false, length: 5, - lowInventoryWarningThreshold: 8, metafields: [ { value: "value", @@ -104,15 +83,9 @@ const mockProduct = { createdAt, description: "Mock product description", height: 11.23, - inventoryAvailableToSell: 0, - inventoryInStock: 0, - isBackorder: false, isDeleted: false, - isLowQuantity: false, - isSoldOut: false, isVisible: true, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -160,13 +133,7 @@ const updatedMockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - inventoryAvailableToSell: 0, - inventoryInStock: 0, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -256,18 +223,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") @@ -275,16 +230,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 3ad712f1abb..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 @@ -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", @@ -107,11 +97,7 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, 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 6c8a656ec08..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 @@ -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", @@ -107,11 +97,7 @@ const mockProduct = { fulfillmentService: "fulfillmentService", googleplusMsg: "googlePlusMessage", height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", 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..a640397cdfa --- /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 mutateVariants of getFunctionsOfType("xformCatalogProductVariants")) { + await mutateVariants(context, catalogProductVariants, info); // eslint-disable-line no-await-in-loop + } + + return catalogProductVariants; +} diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index a9bc1016014..4a263cd4b29 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(); @@ -72,6 +70,7 @@ export default { } this.whenAppInstanceReadyCallbacks = []; } + appEvents.emit("readyForMigrations"); }, /** @@ -96,10 +95,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 +769,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 - }); + 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); + // 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 } }); + }); }, /** @@ -836,8 +834,6 @@ export default { * @return {String} returns insert result */ loadPackages() { - const packages = Packages.find().fetch(); - let registryFixtureData; if (process.env.REACTION_REGISTRY) { @@ -865,89 +861,97 @@ 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 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.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 + }; + + // 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 - const settingsFromDB = packages.find((ps) => (config.name === ps.name && shopId === ps.shopId)); + // 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 || {}); + 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); + }); + + this.Importer.flush(); + + // + // 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/core/server/fixtures/cart.js b/imports/plugins/core/core/server/fixtures/cart.js index fb2a7af569e..1cbbb72066e 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 price = _.random(1, 100); const defaults = { _id: Random.id(), @@ -99,7 +99,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..5eec45a9e9b 100755 --- a/imports/plugins/core/core/server/fixtures/products.js +++ b/imports/plugins/core/core/server/fixtures/products.js @@ -31,10 +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.inventoryInStock] - Inventory Quantity * @param {String} [options.price] - productVariant price * @param {String} [options.title] - productVariant title * @param {String} [options.optionTitle] - productVariant option title @@ -48,10 +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), - inventoryInStock: _.random(0, 100), isTaxable: faker.random.boolean(), isVisible: true, price: _.random(10, 1000), @@ -84,10 +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.inventoryInStock] - Inventory Quantity * @param {String} [options.price] - productVariant price * @param {String} [options.title] - productVariant title * @param {String} [options.optionTitle] - productVariant option title @@ -191,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 `[]` @@ -223,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/graphql/server/no-meteor/xforms/cart.js b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js index c899e010d2d..4d26a7ac386 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js @@ -71,17 +71,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, productConfiguration: { productId: cartItem.productId, productVariantId: cartItem.variantId @@ -95,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); @@ -115,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/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/register.js b/imports/plugins/core/inventory/register.js index 95c02df7283..e943d6f4d6e 100644 --- a/imports/plugins/core/inventory/register.js +++ b/imports/plugins/core/inventory/register.js @@ -1,51 +1,4 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; -import config from "./server/config"; -import startup from "./server/no-meteor/startup"; -import schemas from "./server/no-meteor/schemas"; -import xformCatalogBooleanFilters from "./server/no-meteor/utils/xformCatalogBooleanFilters"; +import register from "./server/no-meteor/register"; -const publishedProductFields = []; - -// These require manual publication always -const publishedProductVariantFields = [ - "inventoryManagement", - "inventoryPolicy" -]; - -// Additional 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", - "lowInventoryWarningThreshold" - ); -} - -Reaction.registerPackage({ - label: "Inventory", - name: "reaction-inventory", - autoEnable: true, - functionsByType: { - startup: [startup], - xformCatalogBooleanFilters: [xformCatalogBooleanFilters] - }, - graphQL: { - schemas - }, - catalog: { - publishedProductFields, - publishedProductVariantFields - } -}); +Reaction.whenAppInstanceReady(register); diff --git a/imports/plugins/core/inventory/server/config.js b/imports/plugins/core/inventory/server/config.js deleted file mode 100644 index cba43861dac..00000000000 --- a/imports/plugins/core/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/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/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..e1db274b7f4 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/queries/inventoryForProductConfigurations.js @@ -0,0 +1,218 @@ +import SimpleSchema from "simpl-schema"; + +const ALL_FIELDS = [ + "canBackorder", + "inventoryAvailableToSell", + "inventoryInStock", + "inventoryReserved", + "isBackorder", + "isLowQuantity", + "isSoldOut" +]; + +const DEFAULT_INFO = { + canBackorder: true, + inventoryAvailableToSell: 0, + inventoryInStock: 0, + inventoryReserved: 0, + isBackorder: false, + isLowQuantity: false, + isSoldOut: false +}; + +const productConfigurationSchema = new SimpleSchema({ + isSellable: Boolean, + productId: String, + productVariantId: 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({ + canBackorder: Boolean, + inventoryAvailableToSell: { + type: SimpleSchema.Integer, + min: 0 + }, + inventoryInStock: { + type: SimpleSchema.Integer, + min: 0 + }, + inventoryReserved: { + type: SimpleSchema.Integer, + min: 0 + }, + isLowQuantity: Boolean +}); + +const responseProductConfigurationSchema = new SimpleSchema({ + productId: String, + productVariantId: String +}); + +const pluginResultSchema = new SimpleSchema({ + inventoryInfo: { + type: inventoryInfoSchema, + optional: true + }, + productConfiguration: responseProductConfigurationSchema +}); + +/** + * @summary Gets inventory results for multiple product configs + * @private + * @param {Object} context App context + * @param {Object} input Input + * @return {Object[]} Array of result objects + */ +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 = []; + 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); + + 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 + remainingProductConfigurations = []; + 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 { + remainingProductConfigurations.push(pluginResult.productConfiguration); + } + } + + 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 remainingProductConfigurations) { + results.push({ + productConfiguration, + inventoryInfo: DEFAULT_INFO + }); + } + + 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); + } + } + + // 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(({ productVariantId }) => productVariantId); + const allOptions = await Products.find({ + ancestors: { $in: variantIds } + }, { + projection: { + ancestors: 1 + } + }).toArray(); + + childOptionResults = await getInventoryResults(context, { + fields, + productConfigurations: allOptions.map((option) => ({ + productId: option.ancestors[0], + 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/register.js b/imports/plugins/core/inventory/server/no-meteor/register.js new file mode 100644 index 00000000000..e5a79a84015 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/register.js @@ -0,0 +1,30 @@ +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"; + +/** + * @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], + startup: [startup], + xformCartItems: [xformCartItems], + xformCatalogBooleanFilters: [xformCatalogBooleanFilters], + xformCatalogProductVariants: [xformCatalogProductVariants] + }, + queries, + graphQL: { + schemas + } + }); +} 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..2b69a262e03 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,105 @@ extend enum CatalogBooleanFilterName { "isBackorder" isBackorder } + +extend type CatalogProduct { + """ + 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 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 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 { + """ + 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. + """ + 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 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 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 (`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! +} + +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. + 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 + + """ + 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! + + """ + 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 (`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! +} diff --git a/imports/plugins/core/inventory/server/no-meteor/startup.js b/imports/plugins/core/inventory/server/no-meteor/startup.js deleted file mode 100644 index a7d9ac8b67a..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/startup.js +++ /dev/null @@ -1,230 +0,0 @@ -import config from "../config"; -import getVariantInventoryNotAvailableToSellQuantity from "./utils/getVariantInventoryNotAvailableToSellQuantity"; -import updateCatalogProductInventoryStatus from "./utils/updateCatalogProductInventoryStatus"; -import updateParentInventoryFields from "./utils/updateParentInventoryFields"; - -/** - * @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; - - 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` - // 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"); - - // 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 - } - ); - - // Update parents of supplied item - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } - }, - { - $inc: { - inventoryAvailableToSell: +item.quantity, - inventoryInStock: +item.quantity - } - } - ); - }); - - // 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` - 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 - } - } - ); - }); - - // 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("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 - } - }, { - returnOriginal: false - } - ); - - // Update supplied item inventory - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } - }, - { - $inc: { - inventoryAvailableToSell: -item.quantity - } - } - ); - }); - - // 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 }) => { - // We only decrease the inventory quantity after the final payment is approved - 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 - } - }, { - returnOriginal: false - } - ); - - // Update supplied item inventory - await collections.Products.updateMany( - { - _id: { $in: updatedItem.ancestors } - }, - { - $inc: { - inventoryInStock: -item.quantity - } - } - ); - }); - - // 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 }) => { - // 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); - - // 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/core/inventory/server/no-meteor/utils/getProductInventoryAvailableToSellQuantity.js deleted file mode 100644 index 84de8fbe34b..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js deleted file mode 100644 index 3d4dee8a33e..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.js deleted file mode 100644 index d13475aca84..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js deleted file mode 100644 index 9aa0620dd46..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getTopLevelVariant.js b/imports/plugins/core/inventory/server/no-meteor/utils/getTopLevelVariant.js deleted file mode 100644 index a1cacf2a7b5..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.js deleted file mode 100644 index ec62f8f521c..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js deleted file mode 100644 index 4baaba244e1..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.js deleted file mode 100644 index 21e1acab7c0..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js deleted file mode 100644 index 14abf2d8cdb..00000000000 --- a/imports/plugins/core/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/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.test.js deleted file mode 100644 index 8c72021786b..00000000000 --- a/imports/plugins/core/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/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/publishProductToCatalog.js b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js new file mode 100644 index 00000000000..89d7a8ba386 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/publishProductToCatalog.js @@ -0,0 +1,28 @@ +/** + * @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) => ({ + isSellable: !variants.some((variant) => variant.ancestors.includes(option._id)), + productId: option.ancestors[0], + productVariantId: 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); +} 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/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 41fd3f8b5ca..00000000000 --- a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js +++ /dev/null @@ -1,219 +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", - 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", - 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", - 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/core/inventory/server/no-meteor/utils/updateParentInventoryFields.js b/imports/plugins/core/inventory/server/no-meteor/utils/updateParentInventoryFields.js deleted file mode 100644 index 10f59ec88ef..00000000000 --- a/imports/plugins/core/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 - } - } - ); -} 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..d7c3964a59a --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCartItems.js @@ -0,0 +1,28 @@ +/** + * @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({ ...item.productConfiguration, isSellable: true }); + } + + const variantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { + productConfigurations + }); + + for (const item of items) { + const { inventoryInfo: variantInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => + 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 new file mode 100644 index 00000000000..2a88323c095 --- /dev/null +++ b/imports/plugins/core/inventory/server/no-meteor/utils/xformCatalogProductVariants.js @@ -0,0 +1,69 @@ +import has from "lodash/has"; + +const inventoryVariantFields = [ + "canBackorder", + "inventoryAvailableToSell", + "inventoryInStock", + "inventoryReserved", + "isBackorder", + "isLowQuantity", + "isSoldOut", + "options.canBackorder", + "options.inventoryAvailableToSell", + "options.inventoryInStock", + "options.inventoryReserved", + "options.isBackorder", + "options.isLowQuantity", + "options.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({ + isSellable: (catalogProductVariant.options || []).length === 0, + productId: catalogProduct.productId, + productVariantId: catalogProductVariant.variantId + }); + + for (const option of (catalogProductVariant.options || [])) { + productConfigurations.push({ + isSellable: true, + productId: catalogProduct.productId, + productVariantId: option.variantId + }); + } + } + + const variantsInventoryInfo = await context.queries.inventoryForProductConfigurations(context, { + productConfigurations + }); + + for (const catalogProductVariant of catalogProductVariants) { + const { inventoryInfo: variantInventoryInfo } = variantsInventoryInfo.find(({ productConfiguration }) => + 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.productVariantId === option.variantId); + Object.getOwnPropertyNames(optionInventoryInfo).forEach((key) => { + option[key] = optionInventoryInfo[key]; + }); + } + } +} 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/core/orders/server/no-meteor/util/buildOrderItem.js b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js index 63e4fa52359..048bc2b131b 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,15 @@ 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: { + ...productConfiguration, + isSellable: true + } + }); + + 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/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/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..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,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 "../no-meteor/util/findAndConvertInBatches"; + +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/migrations/3_reset_package_registry.js b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js index fe9a6de0807..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,19 +1,20 @@ 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 +// import { Packages } from "/lib/collections"; + Migrations.add({ version: 3, up() { - Packages.update( - {}, { - $set: { - registry: [] - } - }, - { bypassCollection2: true, multi: true } - ); - Reaction.loadPackages(); - Reaction.Importer.flush(); + // 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 } + // ); } }); 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..2da57c9a3bc 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, @@ -14,6 +18,24 @@ const { Translations } = rawCollections; +/** + * @private + * @param {Error} error Error or null + * @return {undefined} + */ +function handleError(error) { + // 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"); + } +} + /** * Drop all indexes that support queries that are no longer expected * to be made by any plugins, or that are already supported by other @@ -22,104 +44,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..2738182a679 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,24 @@ const { Orders } = rawCollections; +/** + * @private + * @param {Error} error Error or null + * @return {undefined} + */ +function handleError(error) { + // 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"); + } +} + /** * 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 +32,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..331d02381c9 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,24 @@ const { Catalog } = rawCollections; +/** + * @private + * @param {Error} error Error or null + * @return {undefined} + */ +function handleError(error) { + // 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"); + } +} + /** * 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 +32,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); } }); 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..1d2f0360cbc --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/63_inventory_to_collection.js @@ -0,0 +1,115 @@ +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 "../no-meteor/util/findAndConvertInBatches"; + +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({ "product.variants": { $exists: true } }, { + $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": "", + "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": "" + } + }, { + arrayFilters: [{ "variantWithOptions.options": { $exists: true } }] + })); + + 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; + + // 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( + { + "productConfiguration.productVariantId": variant._id, + "shopId": variant.shopId + }, + { + $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: { + "_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] + } + }, + { + upsert: true + } + ); + } + + // We won't update yet. We'll do it below with `Products.updateMany` because it should + // be much faster. + return null; + } + })); + + // 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: "" + } + })); + } +}); 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/no-meteor/util/findAndConvertInBatches.js b/imports/plugins/core/versions/server/no-meteor/util/findAndConvertInBatches.js new file mode 100644 index 00000000000..153e98df2d6 --- /dev/null +++ b/imports/plugins/core/versions/server/no-meteor/util/findAndConvertInBatches.js @@ -0,0 +1,56 @@ +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 findAndConvertInBatches({ 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); + + if (operations.length) { + await collection.bulkWrite(operations, { ordered: false }); + } + } + } while (docs.length); +} 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; 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; 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) { 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-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/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 1acc3415135..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"; @@ -16,6 +15,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 +38,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 +55,6 @@ class ProductGridItems extends Component { ); } - renderNotices() { - const { product } = this.props; - - return ( -
- - -
- ); - } - renderPublishStatus() { const { product } = this.props; @@ -93,30 +69,6 @@ class ProductGridItems extends Component { ); } - renderGridContent() { - return ( - - ); - } - handleSelect = (event) => { this.props.onSelect(event.target.checked, this.props.product); } @@ -144,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/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 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/client/VariantInventoryForm.js b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js new file mode 100644 index 00000000000..90a69b63181 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/VariantInventoryForm.js @@ -0,0 +1,193 @@ +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 { + components: { + Button + }, + inventoryInfo, + isLoadingInventoryInfo, + recalculateReservedSimpleInventory, + updateSimpleInventory, + variables, + variant + } = props; + + if (!variant) return null; + + if (isLoadingInventoryInfo) return ; + + const { + canBackorder, + inventoryInStock, + inventoryReserved, + 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, + recalculateReservedSimpleInventory: PropTypes.func, + 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..1a235af7426 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/getInventoryInfo.js @@ -0,0 +1,13 @@ +import gql from "graphql-tag"; + +export default gql` + query getInventoryInfo($shopId: ID!, $productConfiguration: ProductConfigurationInput!) { + simpleInventory(shopId: $shopId, productConfiguration: $productConfiguration) { + canBackorder + inventoryInStock + inventoryReserved + 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..86ce8019660 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/index.js @@ -0,0 +1,19 @@ +import { withComponents } from "@reactioncommerce/components-context"; +import { registerBlock } from "/imports/plugins/core/components/lib"; +import VariantInventoryForm from "./VariantInventoryForm"; +import withRecalculateReservedSimpleInventory from "./withRecalculateReservedSimpleInventory"; +import withUpdateVariantInventoryInfo from "./withUpdateVariantInventoryInfo"; +import withVariantInventoryInfo from "./withVariantInventoryInfo"; + +registerBlock({ + region: "VariantDetailMain", + name: "VariantInventoryForm", + component: VariantInventoryForm, + hocs: [ + withVariantInventoryInfo, + withUpdateVariantInventoryInfo, + withRecalculateReservedSimpleInventory, + withComponents + ], + priority: 30 +}); diff --git a/imports/plugins/included/simple-inventory/client/withRecalculateReservedSimpleInventory.js b/imports/plugins/included/simple-inventory/client/withRecalculateReservedSimpleInventory.js new file mode 100644 index 00000000000..dae219ba752 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/withRecalculateReservedSimpleInventory.js @@ -0,0 +1,55 @@ +import React from "react"; +import PropTypes from "prop-types"; +import gql from "graphql-tag"; +import { Mutation } from "react-apollo"; +import getInventoryInfo from "./getInventoryInfo"; + +const recalculateReservedSimpleInventoryMutation = gql` + mutation recalculateReservedSimpleInventoryMutation($input: RecalculateReservedSimpleInventoryInput!) { + recalculateReservedSimpleInventory(input: $input) { + inventoryInfo { + canBackorder + inventoryInStock + inventoryReserved + isEnabled + lowInventoryWarningThreshold + } + } + } +`; + +export default (Component) => ( + 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 new file mode 100644 index 00000000000..980d1c0baa9 --- /dev/null +++ b/imports/plugins/included/simple-inventory/client/withUpdateVariantInventoryInfo.js @@ -0,0 +1,55 @@ +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 + inventoryReserved + 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/register.js b/imports/plugins/included/simple-inventory/register.js new file mode 100644 index 00000000000..e943d6f4d6e --- /dev/null +++ b/imports/plugins/included/simple-inventory/register.js @@ -0,0 +1,4 @@ +import Reaction from "/imports/plugins/core/core/server/Reaction"; +import register from "./server/no-meteor/register"; + +Reaction.whenAppInstanceReady(register); 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..8f156c2417e --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/i18n/en.json @@ -0,0 +1,21 @@ +[{ + "i18n": "en", + "ns": "reaction-simple-inventory", + "translation": { + "reaction-simple-inventory": { + "productVariant": { + "allowBackorder": "Allow backorder", + "inventoryDisabled": "Inventory tracking is disabled", + "inventoryHeading": "Inventory", + "inventoryInStock": "Quantity", + "inventoryInStockHelpText": "{{inventoryInStock}} in stock - {{inventoryReserved}} reserved = {{inventoryAvailableToSell}} available", + "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 available quantity reaches this threshold", + "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/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/index.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js new file mode 100644 index 00000000000..033d9119140 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/index.js @@ -0,0 +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..016f197d3c1 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/recalculateReservedSimpleInventory.js @@ -0,0 +1,79 @@ +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, + shopId: String +}); + +/** + * @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; + + 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 (!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/mutations/updateSimpleInventory.js b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js new file mode 100644 index 00000000000..37dca936a80 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/mutations/updateSimpleInventory.js @@ -0,0 +1,159 @@ +import SimpleSchema from "simpl-schema"; +import Random from "@reactioncommerce/random"; +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. + * @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, 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 + // 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 (!userHasPermission(["admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } + } + + 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. + // 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 + }; + 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 (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({ + $set, + $setOnInsert: { + ...$setOnInsert, + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + } + }, { modifier: true, upsert: true }); + + const { upsertedCount } = await SimpleInventory.updateOne( + { + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + }, + modifier, + { + upsert: true + } + ); + + // 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 + }); + } + + await appEvents.emit("afterInventoryUpdate", { productConfiguration, updatedBy: userId }); + + let updatedDoc = null; + if (returnUpdatedDoc) { + updatedDoc = await SimpleInventory.findOne({ + "productConfiguration.productVariantId": productConfiguration.productVariantId, + shopId + }); + } + + 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..61a5f9bf9e7 --- /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, isInternalCall, userHasPermission } = context; + const { SimpleInventory } = collections; + + if (!isInternalCall && !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 new file mode 100644 index 00000000000..cb67d22451e --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/register.js @@ -0,0 +1,32 @@ +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"; + +/** + * @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: { + 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..033d9119140 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/resolvers/Mutation/index.js @@ -0,0 +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/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..c92ffadd4cf --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/schemas/schema.graphql @@ -0,0 +1,110 @@ +"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 + """ + canBackorder: Boolean + + """ + Current quantity of this product configuration in stock + """ + inventoryInStock: Int + + """ + Current quantity of this product configuration unavailable for ordering. This value is calculated + by the system based on this product variant being in not-yet-approved orders. + """ + inventoryReserved: 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 `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 { + """ + 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. + """ + 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! +} + +"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 + + "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 { + "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! +} 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..8e3fce9d740 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/simpleSchemas.js @@ -0,0 +1,28 @@ +import SimpleSchema from "simpl-schema"; + +export const ProductConfigurationSchema = new SimpleSchema({ + productId: String, + productVariantId: String +}); + +export const SimpleInventoryCollectionSchema = new SimpleSchema({ + _id: String, + canBackorder: Boolean, + createdAt: Date, + inventoryInStock: { + type: SimpleSchema.Integer, + min: 0 + }, + isEnabled: Boolean, + lowInventoryWarningThreshold: { + type: SimpleSchema.Integer, + min: 0 + }, + inventoryReserved: { + type: SimpleSchema.Integer, + min: 0 + }, + productConfiguration: ProductConfigurationSchema, + shopId: String, + 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 new file mode 100644 index 00000000000..9386682f6ca --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/startup.js @@ -0,0 +1,163 @@ +import Logger from "@reactioncommerce/logger"; +import collectionIndex from "/imports/utils/collectionIndex"; +import orderIsApproved from "./utils/orderIsApproved"; + +/** + * @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 + * @param {Object} context.collections Map of MongoDB collections + * @returns {undefined} + */ +export default function startup(context) { + const { app, appEvents, collections } = 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` + // We need to check to make sure the inventory has been removed before we return it to stock + const isOrderApproved = orderIsApproved(order); + const allOrderItems = getAllOrderItems(order); + + const bulkWriteOperations = []; + + // If order is approved, the inventory has been taken away from `inventoryInStock` + if (returnToStock && isOrderApproved) { + allOrderItems.forEach((item) => { + bulkWriteOperations.push({ + updateOne: { + filter: { + "productConfiguration.productVariantId": item.variantId + }, + update: { + $inc: { + inventoryInStock: item.quantity + } + } + } + }); + }); + } 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({ + updateOne: { + filter: { + "productConfiguration.productVariantId": item.variantId + }, + update: { + $inc: { + inventoryReserved: -item.quantity + } + } + } + }); + }); + } + + if (bulkWriteOperations.length === 0) return; + + await 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 allOrderItems = getAllOrderItems(order); + + const bulkWriteOperations = allOrderItems.map((item) => ({ + updateOne: { + filter: { + "productConfiguration.productVariantId": item.variantId + }, + update: { + $inc: { + inventoryReserved: item.quantity + } + } + } + })); + + if (bulkWriteOperations.length === 0) return; + + await 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 }) => { + // We only decrease the inventory quantity after the final payment is approved + if (!orderIsApproved(order)) return; + + const allOrderItems = getAllOrderItems(order); + + const bulkWriteOperations = allOrderItems.map((item) => ({ + updateOne: { + filter: { + "productConfiguration.productVariantId": item.variantId + }, + update: { + $inc: { + inventoryInStock: -item.quantity, + inventoryReserved: -item.quantity + } + } + } + })); + + if (bulkWriteOperations.length === 0) return; + + await 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"); + }); + }); +} diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js similarity index 63% rename from imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js rename to imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js index 010d6c63127..6617214f546 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryNotAvailableToSellQuantity.js +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/getReservedQuantity.js @@ -1,24 +1,30 @@ +import orderIsApproved from "./orderIsApproved"; + /** * - * @method getVariantInventoryNotAvailableToSellQuantity + * @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} variant - A product variant object. - * @param {Object} collections - Raw mongo collections. - * @return {Promise} Reserved variant quantity. + * @param {Object} context App context + * @param {Object} productConfiguration Product configuration + * @return {Promise} Reserved variant quantity */ -export default async function getVariantInventoryNotAvailableToSellQuantity(variant, collections) { +export default async function getReservedQuantity(context, productConfiguration) { + const { productVariantId } = productConfiguration; + // Find orders that are new or processing - const orders = await collections.Orders.find({ + const orders = await context.collections.Orders.find({ "workflow.status": { $in: ["new", "coreOrderWorkflow/processing"] }, - "shipping.items.variantId": variant._id + "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 === variant._id && item.workflow.workflow.includes("coreItemWorkflow/removedFromInventoryAvailableToSell")); + 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); 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..40a6907df95 --- /dev/null +++ b/imports/plugins/included/simple-inventory/server/no-meteor/utils/inventoryForProductConfigurations.js @@ -0,0 +1,58 @@ +import isEqual from "lodash/isEqual"; + +/** + * @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 { SimpleInventory } = collections; + const { productConfigurations } = input; + + const productVariantIds = productConfigurations.map(({ productVariantId }) => productVariantId); + + const inventoryDocs = await SimpleInventory + .find({ + "productConfiguration.productVariantId": { $in: productVariantIds } + }) + .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: null, + productConfiguration + }; + } + + 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 isLowQuantity = inventoryAvailableToSell <= lowInventoryWarningThreshold; + + return { + inventoryInfo: { + canBackorder, + inventoryAvailableToSell, + inventoryInStock, + inventoryReserved, + isLowQuantity + }, + productConfiguration + }; + }); +} 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"); +} 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/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/imports/test-utils/helpers/mockContext.js b/imports/test-utils/helpers/mockContext.js index 374faf76080..93cfcfff760 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", @@ -75,6 +74,7 @@ export function mockCollection(collectionName) { "SellerShops", "Shipping", "Shops", + "SimpleInventory", "Tags", "Templates", "Themes", 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 diff --git a/private/data/Products.json b/private/data/Products.json index 6d33a259ba1..7c093c68b9e 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,6 @@ ], "title": "Basic Example Variant", "price": 19.99, - "inventoryManagement": true, - "inventoryPolicy": true, - "inventoryAvailableToSell": 35, - "inventoryInStock": 35, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" @@ -69,10 +60,6 @@ "title": "Option 1 - Red Dwarf", "optionTitle": "Red", "price": 19.99, - "inventoryManagement": true, - "inventoryPolicy": true, - "inventoryAvailableToSell": 20, - "inventoryInStock": 20, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" @@ -100,10 +87,6 @@ "title": "Option 2 - Green Tomato", "optionTitle": "Green", "price": 12.99, - "inventoryManagement": true, - "inventoryPolicy": true, - "inventoryAvailableToSell": 10, - "inventoryInStock": 10, "isVisible": true, "updatedAt": { "$date": "2014-04-03T13:46:52.411-0700" diff --git a/private/data/i18n/en.json b/private/data/i18n/en.json index a46d3ffa30b..b00bf091324 100644 --- a/private/data/i18n/en.json +++ b/private/data/i18n/en.json @@ -270,7 +270,6 @@ }, "productVariant": { "addVariantOptions": "Add options to enable 'Add to Cart' button", - "inventoryInStock": "Quantity", "title": "Title", "price": "Price", "optionTitle": "Option title", @@ -284,12 +283,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" }, diff --git a/tests/TestApp.js b/tests/TestApp.js index 49003c97c9b..e3f93fa465c 100644 --- a/tests/TestApp.js +++ b/tests/TestApp.js @@ -1,10 +1,11 @@ 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"; +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"; @@ -20,10 +21,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 +41,7 @@ class TestApp { } return funcs; }, + pubSub: new PubSub(), mutations: { ...mutations }, queries: { ...queries } }; @@ -119,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"; @@ -135,6 +147,7 @@ class TestApp { currency: "USD", name: "Primary Shop", ...shopData, + shopType: "primary", domains: [domain] }); @@ -182,6 +195,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/CatalogItemProductFullQuery.graphql b/tests/catalog/CatalogItemProductFullQuery.graphql index 4c7efe0868d..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 @@ -101,13 +100,10 @@ query ($slugOrId: String!) { createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace @@ -123,13 +119,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..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 @@ -103,13 +102,10 @@ query ($shopIds: [ID]!, $first: ConnectionLimitInt, $sortBy: CatalogItemSortByFi createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace @@ -125,13 +121,10 @@ query ($shopIds: [ID]!, $first: ConnectionLimitInt, $sortBy: CatalogItemSortByFi createdAt height index - inventoryManagement - inventoryPolicy isLowQuantity isSoldOut isTaxable length - lowInventoryWarningThreshold metafields { value namespace 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/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..083ed38ee1c --- /dev/null +++ b/tests/inventory/inventory.test.js @@ -0,0 +1,843 @@ +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 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 + } + } +} diff --git a/tests/meteor/orders.app-test.js b/tests/meteor/orders.app-test.js index 46987843b17..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(); @@ -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; 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 d5e4749b130..dd07e0a47a9 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,10 @@ 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 +175,10 @@ export const mockExternalCatalogOptions = [ createdAt: null, height: 2, index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isLowQuantity: true, + isLowQuantity: false, isSoldOut: false, isTaxable: true, length: 2, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -245,13 +229,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 +276,10 @@ export const mockExternalCatalogVariants = [ createdAt: createdAt.toISOString(), height: 0, index: 0, - inventoryManagement: true, - inventoryPolicy: false, - isLowQuantity: true, - isSoldOut: false, + isLowQuantity: false, + isSoldOut: true, isTaxable: true, length: 0, - lowInventoryWarningThreshold: 0, metafields: [ { value: "value", @@ -359,7 +335,6 @@ export const mockInternalCatalogProducts = [ isSoldOut: false, isVisible: true, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -450,7 +425,6 @@ export const mockInternalCatalogProducts = [ isSoldOut: false, isVisible: true, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -553,7 +527,6 @@ export const mockExternalCatalogProducts = [ isLowQuantity: false, isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", @@ -659,7 +632,6 @@ export const mockExternalCatalogProducts = [ isLowQuantity: false, isSoldOut: false, length: 5.67, - lowInventoryWarningThreshold: 2, metafields: [ { value: "value", 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); });