Magento Admin Order Creation

As well as customers placing an order and going through the checkout, the Magento merchant has the ability to create orders in the admin area in the Sales -> Orders section.

You are then presented with a screen to choose a customer to associate the order to. The next step is then to specify the products to add to cart, the shipping and payment methods to use, and the billing/shipping addresses to use.

It’s important to note that the controller used for configuring the admin orders is the CreateController.php file found within the app/code/core/Mage/Adminhtml/controllers/Sales/Order directory.

This is quite a complicated section that uses both the controller file and .js files that work together to save the data. As the page doesn’t refresh when saving data, a lot of JavaScript functionality is used here, mainly the sales.js file

Any section updated here will use the loadBlockAction() method within the controller class.

public function loadBlockAction()
{
    $request = $this->getRequest();
    try {
        $this->_initSession()
            ->_processData();
    }
    catch (Mage_Core_Exception $e){
        $this->_reloadQuote();
        $this->_getSession()->addError($e->getMessage());
    }
    catch (Exception $e){
        $this->_reloadQuote();
        $this->_getSession()->addException($e, $e->getMessage());
    }


    $asJson= $request->getParam('json');
    $block = $request->getParam('block');

    $update = $this->getLayout()->getUpdate();
    if ($asJson) {
        $update->addHandle('adminhtml_sales_order_create_load_block_json');
    } else {
        $update->addHandle('adminhtml_sales_order_create_load_block_plain');
    }

    if ($block) {
        $blocks = explode(',', $block);
        if ($asJson && !in_array('message', $blocks)) {
            $blocks[] = 'message';
        }

        foreach ($blocks as $block) {
            $update->addHandle('adminhtml_sales_order_create_load_block_' . $block);
        }
    }
    $this->loadLayoutUpdates()->generateLayoutXml()->generateLayoutBlocks();
    $result = $this->getLayout()->getBlock('content')->toHtml();
    if ($request->getParam('as_js_varname')) {
        Mage::getSingleton('adminhtml/session')->setUpdateResult($result);
        $this->_redirect('*/*/showUpdateResult');
    } else {
        $this->getResponse()->setBody($result);
    }
}

For each block passed in the loadBlock() method, the block name forms part of a layout handle that are used when generating the layout XML.

As the page never refreshes when creating an new order from the admin, the sales.js and other inline JavaScript is responsible for ensuring that the URL route hits the loadBlock() action method.

For example, a setLoadBaseUrl() method is used to set our update URL.

<script type="text/javascript">
    var order = new AdminOrder({"customer_id":1,"addresses":[],"store_id":"1","currency_symbol":"\u00a3","shipping_method_reseted":true,"payment_method":"free"});
    order.setLoadBaseUrl('http://www.yoursite.com/index.php/admin/sales_order_create/loadBlock/key/e8f7ae2fb45a9f507a36a90127e25ef1/');
    var payment = {};
    payment.switchMethod = order.switchPaymentMethod.bind(order);
</script>

This form.phtml is added through the sales.xml admin layout file.

The AdminOrder class is the class defined in sales.js.

var AdminOrder = new Class.create();
AdminOrder.prototype = {
    ....
}

And the setLoadBaseUrl() function is located in this class.

var AdminOrder = new Class.create();
AdminOrder.prototype = {
    ....
    setLoadBaseUrl : function(url){
        this.loadBaseUrl = url;
    },
    ...
}

When products have been selected in the admin, when clicking Add selected products to order, we head to a JavaScript method.

<button id="id_c21001675b52fbae69136266d6f57714" title="Add Selected Product(s) to Order" type="button" class="scalable add" onclick="order.productGridAddSelected()" style="">
    ....
</button>

The class used here is actually the Mage_Adminhtml_Block_Sales_Order_Create_Search class.

<?php

class Mage_Adminhtml_Block_Sales_Order_Create_Search extends Mage_Adminhtml_Block_Sales_Order_Create_Abstract
{

    public function __construct()
    {
        parent::__construct();
        $this->setId('sales_order_create_search');
    }

    public function getHeaderText()
    {
        return Mage::helper('sales')->__('Please Select Products to Add');
    }

    public function getButtonsHtml()
    {
        $addButtonData = array(
            'label' => Mage::helper('sales')->__('Add Selected Product(s) to Order'),
            'onclick' => 'order.productGridAddSelected()',
            'class' => 'add',
        );
        return $this->getLayout()->createBlock('adminhtml/widget_button')->setData($addButtonData)->toHtml();
    }

    public function getHeaderCssClass()
    {
        return 'head-catalog-product';
    }

}

We can see within the getButtonsHtml() method that this is where our onClick value came from within the button element.

productGridAddSelected : function(){
    if(this.productGridShowButton) Element.show(this.productGridShowButton);
    var area = ['search', 'items', 'shipping_method', 'totals', 'giftmessage','billing_method'];
    // prepare additional fields and filtered items of products
    var fieldsPrepare = {};
    var itemsFilter = [];
    var products = this.gridProducts.toObject();
    for (var productId in products) {
        itemsFilter.push(productId);
        var paramKey = 'item['+productId+']';
        for (var productParamKey in products[productId]) {
            paramKey += '['+productParamKey+']';
            fieldsPrepare[paramKey] = products[productId][productParamKey];
        }
    }
    this.productConfigureSubmit('product_to_add', area, fieldsPrepare, itemsFilter);
    productConfigure.clean('quote_items');
    this.hideArea('search');
    this.gridProducts = $H({});
}

The main points to take away from here are that the product IDs are gathered in the first for loop and push to an itemsFilter array and any product parameters are added via the second for loop.

The productConfigureSubmit() method is where we finally submit our information.

productConfigureSubmit : function(listType, area, fieldsPrepare, itemsFilter) {
    // prepare loading areas and build url
    area = this.prepareArea(area);
    this.loadingAreas = area;
    var url = this.loadBaseUrl + 'block/' + area + '?isAjax=true';

    // prepare additional fields
    fieldsPrepare = this.prepareParams(fieldsPrepare);
    fieldsPrepare.reset_shipping = 1;
    fieldsPrepare.json = 1;

    // create fields
    var fields = [];
    for (var name in fieldsPrepare) {
        fields.push(new Element('input', {type: 'hidden', name: name, value: fieldsPrepare[name]}));
    }
    productConfigure.addFields(fields);

    // filter items
    if (itemsFilter) {
        productConfigure.addItemsFilter(listType, itemsFilter);
    }

    // prepare and do submit
    productConfigure.addListType(listType, {urlSubmit: url});
    productConfigure.setOnLoadIFrameCallback(listType, function(response){
        this.loadAreaResponseHandler(response);
    }.bind(this));
    productConfigure.submit(listType);
    // clean
    this.productConfigureAddFields = {};
}

The productConfigureSubmit() method in simple terms prepares the url variable and submits the form.

If we want to load our shipping methods for example, this is done using the loadArea() method.

loadArea : function(area, indicator, params){
    var url = this.loadBaseUrl;
    if (area) {
        area = this.prepareArea(area);
        url += 'block/' + area;
    }
    if (indicator === true) indicator = 'html-body';
    params = this.prepareParams(params);
    params.json = true;
    if (!this.loadingAreas) this.loadingAreas = [];
    if (indicator) {
        this.loadingAreas = area;
        new Ajax.Request(url, {
            parameters:params,
            loaderArea: indicator,
            onSuccess: function(transport) {
                var response = transport.responseText.evalJSON();
                this.loadAreaResponseHandler(response);
            }.bind(this)
        });
    }
    else {
        new Ajax.Request(url, {parameters:params,loaderArea: indicator});
    }
    if (typeof productConfigure != 'undefined' && area instanceof Array && area.indexOf('items') != -1) {
        productConfigure.clean('quote_items');
    }
}

Moving on from some of the JavaScript functionality, as mentioned before the main CreateController.php file such some interesting methods.

The _construct~() method is an example.

protected function _construct()
{
    $this->setUsedModuleName('Mage_Sales');

    // During order creation in the backend admin has ability to add any products to order
    Mage::helper('catalog/product')->setSkipSaleableCheck(true);
}

As the comment suggests, this allows us to add any products to our order like out of stock products for example.

protected function _processData()
{
    return $this->_processActionData();
}

_processActionData() method is probably too large to paste into one code block, so it’ll be broken down into chunks.

$eventData = array(
    'order_create_model' => $this->_getOrderCreateModel(),
    'request_model'      => $this->getRequest(),
    'session'            => $this->_getSession(),
);

Mage::dispatchEvent('adminhtml_sales_order_create_process_data_before', $eventData);

Firstly, an $eventData array is created using the session, request model and order create mode. The order create model is just a singleton of the Mage_Adminhtml_Model_Sales_Order_Create model.

protected function _getOrderCreateModel()
{
    return Mage::getSingleton('adminhtml/sales_order_create');
}

Back to the _processActionData() method, next up is a whether or not we’ve called the save() action method of the controller.

/**
 * Saving order data
 */
 if ($data = $this->getRequest()->getPost('order')) {
    $this->_getOrderCreateModel()->importPostData($data);
 }

/**
 * Initialize catalog rule data
 */
 $this->_getOrderCreateModel()->initRuleData();

The save() method will make a call to _processActionData() that we’ll see later on. Any catalog rules that should be applied against the order are also initialised.

We then collect the billing and shipping address information.

/**
 * init first billing address, need for virtual products
 */
$this->_getOrderCreateModel()->getBillingAddress();

/**
 * Flag for using billing address for shipping
 */
if (!$this->_getOrderCreateModel()->getQuote()->isVirtual()) {
    $syncFlag = $this->getRequest()->getPost('shipping_as_billing');
    $shippingMethod = $this->_getOrderCreateModel()->getShippingAddress()->getShippingMethod();
    if (is_null($syncFlag)
        && $this->_getOrderCreateModel()->getShippingAddress()->getSameAsBilling()
        && empty($shippingMethod)
    ) {
        $this->_getOrderCreateModel()->setShippingAsBilling(1);
    } else {
        $this->_getOrderCreateModel()->setShippingAsBilling((int)$syncFlag);
    }
}

/**
 * Change shipping address flag
 */
if (!$this->_getOrderCreateModel()->getQuote()->isVirtual() && $this->getRequest()->getPost('reset_shipping')) {
    $this->_getOrderCreateModel()->resetShippingMethod(true);
}

Shipping rates are then collected.

/**
 * Collecting shipping rates
 */
if (!$this->_getOrderCreateModel()->getQuote()->isVirtual() && $this->getRequest()->getPost('collect_shipping_rates')) {
    $this->_getOrderCreateModel()->collectShippingRates();
}

/**
 * Apply mass changes from sidebar
 */
if ($data = $this->getRequest()->getPost('sidebar')) {
    $this->_getOrderCreateModel()->applySidebarData($data);
}

We then deal with products that have been added, or moved from the customer’s shopping cart or wishlist.

/**
 * Adding product to quote from shopping cart, wishlist etc.
 */
if ($productId = (int) $this->getRequest()->getPost('add_product')) {
    $this->_getOrderCreateModel()->addProduct($productId, $this->getRequest()->getPost());
}

/**
 * Adding products to quote from special grid
 */
if ($this->getRequest()->has('item') && !$this->getRequest()->getPost('update_items') && !($action == 'save')) {
    $items = $this->getRequest()->getPost('item');
    $items = $this->_processFiles($items);
    $this->_getOrderCreateModel()->addProducts($items);
}

/**
 * Update quote items
 */
if ($this->getRequest()->getPost('update_items')) {
    $items = $this->getRequest()->getPost('item', array());
    $items = $this->_processFiles($items);
    $this->_getOrderCreateModel()->updateQuoteItems($items);
}

/**
 * Remove quote item
 */
$removeItemId = (int) $this->getRequest()->getPost('remove_item');
$removeFrom = (string) $this->getRequest()->getPost('from');
if ($removeItemId && $removeFrom) {
    $this->_getOrderCreateModel()->removeItem($removeItemId, $removeFrom);
}

/**
 * Move quote item
 */
$moveItemId = (int) $this->getRequest()->getPost('move_item');
$moveTo = (string) $this->getRequest()->getPost('to');
if ($moveItemId && $moveTo) {
    $this->_getOrderCreateModel()->moveQuoteItem($moveItemId, $moveTo);
}

Interestingly within the updateQuoteItems() method, this is where the custom price is set against the product.

public function updateQuoteItems($data)
{
    ....
    if (empty($info['action']) || !empty($info['configured'])) {
        $item->setQty($itemQty);
        $item->setCustomPrice($itemPrice);
        $item->setOriginalCustomPrice($itemPrice);
        $item->setNoDiscount($noDiscount);
        $item->getProduct()->setIsSuperMode(true);
        $item->getProduct()->unsSkipCheckRequiredOption();
        $item->checkData();
    }
    ....
}

Back to the _processActionData() method, the payment method is added next and saved.

if ($paymentData = $this->getRequest()->getPost('payment')) {
    $this->_getOrderCreateModel()->getQuote()->getPayment()->addData($paymentData);
}

$eventData = array(
    'order_create_model' => $this->_getOrderCreateModel(),
    'request'            => $this->getRequest()->getPost(),
);

Mage::dispatchEvent('adminhtml_sales_order_create_process_data', $eventData);

$this->_getOrderCreateModel()
    ->saveQuote();

if ($paymentData = $this->getRequest()->getPost('payment')) {
    $this->_getOrderCreateModel()->getQuote()->getPayment()->addData($paymentData);
}

Other sections at the bottom of the method include applying gift messages and validatin coupon codes if they have been used.

So when we go and save the created order, we call the saveAction() method of the CreateController.php class.

public function saveAction()
{
    try {
        $this->_processActionData('save');
        $paymentData = $this->getRequest()->getPost('payment');
        if ($paymentData) {
            $paymentData['checks'] = Mage_Payment_Model_Method_Abstract::CHECK_USE_INTERNAL
                | Mage_Payment_Model_Method_Abstract::CHECK_USE_FOR_COUNTRY
                | Mage_Payment_Model_Method_Abstract::CHECK_USE_FOR_CURRENCY
                | Mage_Payment_Model_Method_Abstract::CHECK_ORDER_TOTAL_MIN_MAX
                | Mage_Payment_Model_Method_Abstract::CHECK_ZERO_TOTAL;
            $this->_getOrderCreateModel()->setPaymentData($paymentData);
            $this->_getOrderCreateModel()->getQuote()->getPayment()->addData($paymentData);
        }

        $order = $this->_getOrderCreateModel()
            ->setIsValidate(true)
            ->importPostData($this->getRequest()->getPost('order'))
            ->createOrder();

        $this->_getSession()->clear();
        Mage::getSingleton('adminhtml/session')->addSuccess($this->__('The order has been created.'));
        if (Mage::getSingleton('admin/session')->isAllowed('sales/order/actions/view')) {
            $this->_redirect('*/sales_order/view', array('order_id' => $order->getId()));
        } else {
            $this->_redirect('*/sales_order/index');
        }
    } catch (Mage_Payment_Model_Info_Exception $e) {
        $this->_getOrderCreateModel()->saveQuote();
        $message = $e->getMessage();
        if( !empty($message) ) {
            $this->_getSession()->addError($message);
        }
        $this->_redirect('*/*/');
    } catch (Mage_Core_Exception $e){
        $message = $e->getMessage();
        if( !empty($message) ) {
            $this->_getSession()->addError($message);
        }
        $this->_redirect('*/*/');
    }
    catch (Exception $e){
        $this->_getSession()->addException($e, $this->__('Order saving error: %s', $e->getMessage()));
        $this->_redirect('*/*/');
     }
}

Some checks at the top of the method include whether the payment method selected can be used internally (i.e. in the admin area) and checks for zero total etc.

Eventually, the createOrder() method is called in the Mage_Adminhtml_Model_Sales_Order_Create class.

public function createOrder()
{
    $this->_prepareCustomer();
    $this->_validate();
    $quote = $this->getQuote();
    $this->_prepareQuoteItems();

    $service = Mage::getModel('sales/service_quote', $quote);
    /** @var Mage_Sales_Model_Order $oldOrder */
    $oldOrder = $this->getSession()->getOrder();
    if ($oldOrder->getId()) {
        $originalId = $oldOrder->getOriginalIncrementId();
        if (!$originalId) {
            $originalId = $oldOrder->getIncrementId();
        }
        $orderData = array(
            'original_increment_id'     => $originalId,
            'relation_parent_id'        => $oldOrder->getId(),
            'relation_parent_real_id'   => $oldOrder->getIncrementId(),
            'edit_increment'            => $oldOrder->getEditIncrement()+1,
            'increment_id'              => $originalId.'-'.($oldOrder->getEditIncrement()+1)
        );
        $quote->setReservedOrderId($orderData['increment_id']);
        $service->setOrderData($orderData);

        $oldOrder->cancel();
    }

    /** @var Mage_Sales_Model_Order $order */
    $order = $service->submit();
    $customer = $quote->getCustomer();
    if ((!$customer->getId() || !$customer->isInStore($this->getSession()->getStore()))
        && !$quote->getCustomerIsGuest()
    ) {
        $customer->setCreatedAt($order->getCreatedAt());
        $customer
            ->save()
            ->sendNewAccountEmail('registered', '', $quote->getStoreId());;
    }
    if ($oldOrder->getId()) {
        $oldOrder->setRelationChildId($order->getId());
        $oldOrder->setRelationChildRealId($order->getIncrementId());
        $oldOrder->save();
        $order->save();
    }
    if ($this->getSendConfirmation()) {
        $order->queueNewOrderEmail();
    }

    Mage::dispatchEvent('checkout_submit_all_after', array('order' => $order, 'quote' => $quote));

    return $order;
}

Interestingly here is a check for an $oldOrder variable. If you are editing an existing order, this variable is stored in the session, and will eventually be cancelled when the new one is created.

The relation_child_id, relation_child_real_id, relation_parent_id and relation_parent_real_id columns are then updated in the sales_flat_order table. Note that the real_id comes from the order increment ID.

Notice the checkout_submit_all_after event is dispatched at the end of the method. The main purpose of an observer that listens to this event is to decrement stock levels from the system.

So some of the differences we’ve seen when creating an order from the admin compared to creating an order from the frontend include:

  • Skip saleable check
  • Set custom price against products

Note: This article is based on Magento Community/Open Source version 1.9.