Skip to content

Commit

Permalink
Adjust inventory on shipment (#1240)
Browse files Browse the repository at this point in the history
* Trap order-completed change

* Add missing import

* Add cartItemId field to order so that we can modify inventory

* Add new "inventory/sold" method

* Explicitly set from and to statuses

* Handle order insert hook to move ordered inventory to "sold" status

* Handle moving inventory from "reserved" to "sold" status

* Fix email import

* Fix tests

* Not so much logging

* Call status change method with explicit values

* Add test for moving inventory from "reserved" to "sold"

* Add test for moving inventory from "sold" to "shipped"
  • Loading branch information
zenweasel authored and mikemurray committed Aug 4, 2016
1 parent dc58430 commit 0116262
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 12 deletions.
19 changes: 14 additions & 5 deletions imports/plugins/included/inventory/server/methods/inventory.js
Expand Up @@ -37,8 +37,10 @@ Meteor.methods({
* @summary sets status from one status to a new status. Defaults to "new" to "reserved"
* @param {Array} cartItems array of objects of type Schemas.CartItems
* @param {String} status optional - sets the inventory workflow status, defaults to "reserved"
* @param {String} currentStatus
* @param {String} notFoundStatus
* @todo move this to bulkOp
* @return {undefined} returns undefined
* @return {Number} returns reservationCount
*/
"inventory/setStatus": function (cartItems, status, currentStatus, notFoundStatus) {
check(cartItems, [Schemas.CartItem]);
Expand All @@ -56,7 +58,8 @@ Meteor.methods({
const reservationStatus = status || "reserved"; // change status to options object
const defaultStatus = currentStatus || "new"; // default to the "new" status
const backorderStatus = notFoundStatus || "backorder"; // change status to options object
let reservationCount = 0;
let reservationCount;
Logger.info(`Moving Inventory items from ${defaultStatus} to ${reservationStatus}`);

// update inventory status for cartItems
for (let item of cartItems) {
Expand Down Expand Up @@ -105,9 +108,15 @@ Meteor.methods({
// if we have inventory available, only create additional required reservations
Logger.debug("existingReservationQty", existingReservationQty);
reservationCount = existingReservationQty;
let newReservedQty = totalRequiredQty - existingReservationQty + 1;
let i = 1;
let newReservedQty;
if (reservationStatus === "reserved" && defaultStatus === "new") {
newReservedQty = totalRequiredQty - existingReservationQty + 1;
} else {
// when moving from one "reserved" type status, we don't need to deal with existingReservationQty
newReservedQty = totalRequiredQty + 1;
}

let i = 1;
while (i < newReservedQty) {
// updated existing new inventory to be reserved
Logger.info(
Expand All @@ -119,7 +128,7 @@ Meteor.methods({
"productId": item.productId,
"variantId": item.variants._id,
"shopId": item.shopId,
"workflow.status": "new"
"workflow.status": defaultStatus
}, {
$set: {
"orderItemId": item._id,
Expand Down
21 changes: 15 additions & 6 deletions imports/plugins/included/inventory/server/methods/inventory2.js
Expand Up @@ -191,10 +191,20 @@ Meteor.methods({
*/
"inventory/shipped": function (cartItems) {
check(cartItems, [Schemas.CartItem]);
return Meteor.call("inventory/setStatus", cartItems, "shipped");
return Meteor.call("inventory/setStatus", cartItems, "shipped", "sold");
},
/**
* inventory/shipped
* inventory/sold
* mark inventory as sold
* @param {Array} cartItems array of objects Schemas.CartItem
* @return {undefined}
*/
"inventory/sold": function (cartItems) {
check(cartItems, [Schemas.CartItem]);
return Meteor.call("inventory/setStatus", cartItems, "sold", "reserved");
},
/**
* inventory/return
* mark inventory as returned
* @param {Array} cartItems array of objects Schemas.CartItem
* @return {undefined}
Expand All @@ -204,14 +214,13 @@ Meteor.methods({
return Meteor.call("inventory/setStatus", cartItems, "return");
},
/**
* inventory/shipped
* inventory/returnToStock
* mark inventory as return and available for sale
* @param {Array} cartItems array of objects Schemas.CartItem
* @return {undefined}
*/
"inventory/returnToStock": function (productId, variantId) {
check(productId, String);
check(variantId, String);
"inventory/returnToStock": function (cartItems) {
check(cartItems, [Schemas.CartItem]);
return Meteor.call("inventory/clearStatus", cartItems, "new", "return");
}
});
51 changes: 50 additions & 1 deletion imports/plugins/included/inventory/server/startup/hooks.js
@@ -1,4 +1,4 @@
import { Cart, Products } from "/lib/collections";
import { Cart, Products, Orders } from "/lib/collections";
import { Logger } from "/server/api";

/**
Expand Down Expand Up @@ -89,3 +89,52 @@ Products.after.insert((userId, doc) => {
}
Meteor.call("inventory/register", doc);
});

function markInventoryShipped(doc) {
const order = Orders.findOne(doc._id);
const orderItems = order.items;
let cartItems = [];
for (let orderItem of orderItems) {
let cartItem = {
_id: orderItem.cartItemId,
shopId: orderItem.shopId,
quantity: orderItem.quantity,
productId: orderItem.productId,
variants: orderItem.variants,
title: orderItem.title
};
cartItems.push(cartItem);
}
Meteor.call("inventory/shipped", cartItems);
}

function markInventorySold(doc) {
const orderItems = doc.items;
let cartItems = [];
for (let orderItem of orderItems) {
let cartItem = {
_id: orderItem.cartItemId,
shopId: orderItem.shopId,
quantity: orderItem.quantity,
productId: orderItem.productId,
variants: orderItem.variants,
title: orderItem.title
};
cartItems.push(cartItem);
}
Meteor.call("inventory/sold", cartItems);
}

Orders.after.insert((userId, doc) => {
Logger.debug("Inventory module handling Order insert");
markInventorySold(doc);
});

Orders.after.update((userId, doc, fieldnames, modifier) => {
Logger.debug("Inventory module handling Order update");
if (modifier.$addToSet) {
if (modifier.$addToSet["workflow.workflow"] === "coreOrderWorkflow/completed") {
markInventoryShipped(doc);
}
}
});
@@ -0,0 +1,121 @@
/* eslint dot-notation: 0 */
import { Meteor } from "meteor/meteor";
import { Inventory, Orders } from "/lib/collections";
import { Reaction } from "/server/api";
import { expect } from "meteor/practicalmeteor:chai";
import { sinon } from "meteor/practicalmeteor:sinon";
import Fixtures from "/server/imports/fixtures";
import { getShop } from "/server/imports/fixtures/shops";

Fixtures();

describe("Inventory Hooks", function () {
let originals;
let sandbox;

before(function () {
originals = {
mergeCart: Meteor.server.method_handlers["cart/mergeCart"],
createCart: Meteor.server.method_handlers["cart/createCart"],
copyCartToOrder: Meteor.server.method_handlers["cart/copyCartToOrder"],
addToCart: Meteor.server.method_handlers["cart/addToCart"],
setShipmentAddress: Meteor.server.method_handlers["cart/setShipmentAddress"],
setPaymentAddress: Meteor.server.method_handlers["cart/setPaymentAddress"]
};
});

beforeEach(function () {
sandbox = sinon.sandbox.create();
});

afterEach(function () {
sandbox.restore();
});

function spyOnMethod(method, id) {
return sandbox.stub(Meteor.server.method_handlers, `cart/${method}`, function () {
check(arguments, [Match.Any]); // to prevent audit_arguments from complaining
this.userId = id;
return originals[method].apply(this, arguments);
});
}

it("should move allocated inventory to 'sold' when an order is created", function () {
this.timeout(50000);
Inventory.direct.remove({});
const cart = Factory.create("cartToOrder");
sandbox.stub(Reaction, "getShopId", function () {
return cart.shopId;
});
sandbox.stub(Meteor.server.method_handlers, "orders/sendNotification", function () {
check(arguments, [Match.Any]);
});
let shop = getShop();
let product = cart.items[0];
const inventoryItem = Inventory.insert({
productId: product.productId,
variantId: product.variants._id,
shopId: shop._id,
workflow: {
status: "reserved"
},
orderItemId: product._id
});
expect(inventoryItem).to.not.be.undefined;
Inventory.update(inventoryItem._id,
{
$set: {
"workflow.status": "reserved",
"orderItemId": product._id
}
});
spyOnMethod("copyCartToOrder", cart.userId);
Meteor.call("cart/copyCartToOrder", cart._id);
let updatedInventoryItem = Inventory.findOne({
productId: product.productId,
variantId: product.variants._id,
shopId: shop._id,
orderItemId: product._id
});
expect(updatedInventoryItem.workflow.status).to.equal("sold");
});

it("should move allocated inventory to 'shipped' when an order is shipped", function () {
this.timeout(50000);
Inventory.direct.remove({});
const cart = Factory.create("cartToOrder");
sandbox.stub(Reaction, "getShopId", function () {
return cart.shopId;
});
sandbox.stub(Reaction, "hasPermission", () => true);
sandbox.stub(Meteor.server.method_handlers, "orders/sendNotification", function () {
check(arguments, [Match.Any]);
});
let shop = getShop();
let product = cart.items[0];
const inventoryItem = Inventory.insert({
productId: product.productId,
variantId: product.variants._id,
shopId: shop._id,
workflow: {
status: "reserved"
},
orderItemId: product._id
});
expect(inventoryItem).to.not.be.undefined;
Inventory.update(inventoryItem._id,
{
$set: {
"workflow.status": "reserved",
"orderItemId": product._id
}
});
spyOnMethod("copyCartToOrder", cart.userId);
const orderId = Meteor.call("cart/copyCartToOrder", cart._id);
const order = Orders.findOne(orderId);
const shipping = { items: [] };
Meteor.call("orders/shipmentShipped", order, shipping);
const shippedInventoryItem = Inventory.findOne(inventoryItem._id);
expect(shippedInventoryItem.workflow.status).to.equal("shipped");
});
});
4 changes: 4 additions & 0 deletions lib/collections/schemas/cart.js
Expand Up @@ -40,6 +40,10 @@ export const CartItem = new SimpleSchema({
label: "Product Type",
type: String,
optional: true
},
cartItemId: { // Seems strange here but has to be here since we share schemas between cart and order
type: String,
optional: true
}
});

Expand Down
3 changes: 3 additions & 0 deletions server/imports/fixtures/fixtures.app-test.js
Expand Up @@ -44,6 +44,9 @@ describe("Fixtures:", function () {
sandbox.stub(Meteor.server.method_handlers, "inventory/register", function () {
check(arguments, [Match.Any]);
});
sandbox.stub(Meteor.server.method_handlers, "inventory/sold", function () {
check(arguments, [Match.Any]);
});
const order = Factory.create("order");
expect(order).to.not.be.undefined;
const orderCount = Collections.Orders.find().count();
Expand Down
1 change: 1 addition & 0 deletions server/methods/core/cart.js
Expand Up @@ -578,6 +578,7 @@ Meteor.methods({
itemClone.quantity = 1;

itemClone._id = Random.id();
itemClone.cartItemId = item._id; // used for transitioning inventry
itemClone.workflow = {
status: "new"
};
Expand Down
1 change: 1 addition & 0 deletions server/methods/core/orders.js
Expand Up @@ -2,6 +2,7 @@ import accounting from "accounting-js";
import Future from "fibers/future";
import { Meteor } from "meteor/meteor";
import { check } from "meteor/check";
import { Email } from "meteor/email";
import { Cart, Orders, Products, Shops } from "/lib/collections";
import * as Schemas from "/lib/collections/schemas";
import { Logger, Reaction } from "/server/api";
Expand Down
Expand Up @@ -57,6 +57,9 @@ describe("Order Publication", function () {
sandbox.stub(Meteor.server.method_handlers, "inventory/register", function () {
check(arguments, [Match.Any]);
});
sandbox.stub(Meteor.server.method_handlers, "inventory/sold", function () {
check(arguments, [Match.Any]);
});
sandbox.stub(Reaction, "getShopId", () => shop._id);
sandbox.stub(Roles, "userIsInRole", () => true);
order = Factory.create("order", { status: "created" });
Expand All @@ -73,6 +76,9 @@ describe("Order Publication", function () {
sandbox.stub(Meteor.server.method_handlers, "inventory/register", function () {
check(arguments, [Match.Any]);
});
sandbox.stub(Meteor.server.method_handlers, "inventory/sold", function () {
check(arguments, [Match.Any]);
});
sandbox.stub(Reaction, "getShopId", () => shop._id);
sandbox.stub(Roles, "userIsInRole", () => false);
order = Factory.create("order", { status: "created" });
Expand Down

0 comments on commit 0116262

Please sign in to comment.