Skip to content

Instantly share code, notes, and snippets.

@micwallace
Created February 22, 2020 04:02
Show Gist options
  • Save micwallace/46a2d95113a1d89bd25cc3cfb6970060 to your computer and use it in GitHub Desktop.
Save micwallace/46a2d95113a1d89bd25cc3cfb6970060 to your computer and use it in GitHub Desktop.
Magento 2 Inventory reservaion logic for ERP synchronised systems.
Magento 2 Inventory reservaion logic for ERP synchronised systems.
This example show how you can use Magento 2 inventory reservations to reserve stock until the order is synced with an ERP system. Reversing reservations and source deduction happens after an order is successfully synced, using the InventoryProcessor helper.
This ensures stock is held for stuck orders and that the source quantity immediately reflects the new value in the ERP system once the order is synced, without waiting for a scheduled inventory sync.
<?php
namespace Zhik\Myoba\Plugin\InventorySales\Observer\CatalogInventory;
use Magento\Framework\Event\Observer;
use Magento\InventorySales\Observer\CatalogInventory\CancelOrderItemObserver as OriginalCancelOrderItemObserver;
use Magento\Sales\Model\Order;
class CancelOrderItemObserver {
/**
* @param OriginalCancelOrderItemObserver $subject
* @param callable $proceed
* @param Observer $observer
*/
public function aroundExecute(
OriginalCancelOrderItemObserver $subject,
callable $proceed,
Observer $observer
){
// If the order has been synced with MYOB, the reservations have already been reversed.
/** @var Order $order */
$order = $observer->getEvent()->getItem()->getOrder();
if ($order->getMyobaId())
return;
$proceed($observer);
}
}
<?php
namespace Zhik\Myoba\Plugin\InventorySales\Model\ReturnProcessor;
use Magento\InventorySales\Model\ReturnProcessor\DeductSourceItemQuantityOnRefund as OriginalDeductSourceItemQuantityOnRefund;
use Magento\Sales\Api\Data\OrderInterface;
class DeductSourceItemQuantityOnRefund {
/**
* @param OriginalDeductSourceItemQuantityOnRefund $subject
* @param callable $proceed
* @param OrderInterface $order
* @param array $itemsToRefund
* @param array $returnToStockItems
*/
public function aroundExecute(
OriginalDeductSourceItemQuantityOnRefund $subject,
callable $proceed,
OrderInterface $order,
array $itemsToRefund,
array $returnToStockItems
){
// If the order has been synced with MYOB, the reservations have already been reversed.
if ($order->getMyobaId())
return;
$proceed($order, $itemsToRefund, $returnToStockItems);
}
}
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\InventorySales\Observer\CatalogInventory\CancelOrderItemObserver">
<plugin name="zhik_myoba_stock_reservations" type="Zhik\Myoba\Plugin\InventorySales\Observer\CatalogInventory\CancelOrderItemObserver" />
</type>
<type name="Magento\InventorySales\Model\ReturnProcessor\ProcessRefundItems">
<plugin name="zhik_myoba_stock_reservations" type="Zhik\Myoba\Plugin\InventorySales\Model\ReturnProcessor\ProcessRefundItems" />
</type>
<type name="Magento\InventorySales\Model\ReturnProcessor\DeductSourceItemQuantityOnRefund">
<plugin name="zhik_myoba_stock_reservations" type="Zhik\Myoba\Plugin\InventorySales\Model\ReturnProcessor\DeductSourceItemQuantityOnRefund" />
</type>
<type name="Magento\SalesInventory\Model\Order\ReturnProcessor">
<plugin name="zhik_myoba_stock_reservations" type="Zhik\Myoba\Plugin\SalesInventory\Model\Order\ReturnProcessor" />
</type>
<type name="Magento\CatalogInventory\Observer\CancelOrderItemObserver">
<plugin name="zhik_myoba_stock_reservations" type="Zhik\Myoba\Plugin\CatalogInventory\Observer\CancelOrderItemObserver" />
</type>
</config>
<?php
declare(strict_types=1);
namespace Zhik\Myoba\Helper;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface;
use Magento\InventorySalesApi\Api\Data\ItemToSellInterface;
use Magento\InventorySalesApi\Api\Data\ItemToSellInterfaceFactory;
use Magento\InventorySalesApi\Api\Data\SalesChannelInterface;
use Magento\InventorySalesApi\Api\Data\SalesChannelInterfaceFactory;
use Magento\InventorySalesApi\Api\Data\SalesEventInterface;
use Magento\InventorySalesApi\Api\Data\SalesEventInterfaceFactory;
use Magento\InventorySalesApi\Model\GetSkuFromOrderItemInterface;
use Magento\InventorySalesApi\Model\StockByWebsiteIdResolverInterface;
use Magento\InventorySourceDeductionApi\Model\ItemToDeductInterface;
use Magento\InventorySourceDeductionApi\Model\ItemToDeductInterfaceFactory;
use Magento\InventorySourceDeductionApi\Model\SourceDeductionServiceInterface;
use Magento\InventoryCatalogApi\Api\DefaultSourceProviderInterface;
use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface;
use Magento\InventorySalesApi\Api\PlaceReservationsForSalesEventInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Order\Item;
use Magento\Store\Api\WebsiteRepositoryInterface;
use Magento\InventorySourceDeductionApi\Model\SourceDeductionRequestInterfaceFactory;
/**
* Class InventoryProcessor
*/
class InventoryProcessor
{
/**
* @var IsSingleSourceModeInterface
*/
private $isSingleSourceMode;
/**
* @var DefaultSourceProviderInterface
*/
private $defaultSourceProvider;
/**
* @var SourceDeductionServiceInterface
*/
private $sourceDeductionService;
/**
* @var ItemToSellInterfaceFactory
*/
private $itemsToSellFactory;
/**
* @var PlaceReservationsForSalesEventInterface
*/
private $placeReservationsForSalesEvent;
/**
* @var GetSkuFromOrderItemInterface
*/
private $getSkuFromOrderItem;
/**
* @var Json
*/
private $jsonSerializer;
/**
* @var ItemToDeductInterface
*/
private $itemToDeduct;
/**
* @var StockByWebsiteIdResolverInterface
*/
private $stockByWebsiteIdResolver;
/**
* @var GetSourcesAssignedToStockOrderedByPriorityInterface
*/
private $getSourcesAssignedToStockOrderedByPriority;
/**
* @var SourceDeductionRequestInterfaceFactory
*/
private $sourceDeductionRequestFactory;
/**
* @var SalesChannelInterfaceFactory
*/
private $salesChannelFactory;
/**
* @var SalesEventInterfaceFactory
*/
private $salesEventFactory;
/**
* @var WebsiteRepositoryInterface
*/
private $websiteRepository;
/**
* @param IsSingleSourceModeInterface $isSingleSourceMode
* @param DefaultSourceProviderInterface $defaultSourceProvider
* @param SourceDeductionServiceInterface $sourceDeductionService
* @param ItemToSellInterfaceFactory $itemsToSellFactory
* @param PlaceReservationsForSalesEventInterface $placeReservationsForSalesEvent
* @param GetSkuFromOrderItemInterface $getSkuFromOrderItem
* @param Json $jsonSerializer
* @param ItemToDeductInterfaceFactory $itemToDeduct
* @param StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver
* @param GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority
* @param SourceDeductionRequestInterfaceFactory $sourceDeductionRequestFactory
* @param SalesChannelInterfaceFactory $salesChannelFactory
* @param SalesEventInterfaceFactory $salesEventFactory
* @param WebsiteRepositoryInterface $websiteRepository
*/
public function __construct(
IsSingleSourceModeInterface $isSingleSourceMode,
DefaultSourceProviderInterface $defaultSourceProvider,
SourceDeductionServiceInterface $sourceDeductionService,
ItemToSellInterfaceFactory $itemsToSellFactory,
PlaceReservationsForSalesEventInterface $placeReservationsForSalesEvent,
GetSkuFromOrderItemInterface $getSkuFromOrderItem,
Json $jsonSerializer,
ItemToDeductInterfaceFactory $itemToDeduct,
StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver,
GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesAssignedToStockOrderedByPriority,
SourceDeductionRequestInterfaceFactory $sourceDeductionRequestFactory,
SalesChannelInterfaceFactory $salesChannelFactory,
SalesEventInterfaceFactory $salesEventFactory,
WebsiteRepositoryInterface $websiteRepository
) {
$this->isSingleSourceMode = $isSingleSourceMode;
$this->defaultSourceProvider = $defaultSourceProvider;
$this->sourceDeductionService = $sourceDeductionService;
$this->itemsToSellFactory = $itemsToSellFactory;
$this->placeReservationsForSalesEvent = $placeReservationsForSalesEvent;
$this->jsonSerializer = $jsonSerializer;
$this->itemToDeduct = $itemToDeduct;
$this->getSkuFromOrderItem = $getSkuFromOrderItem;
$this->stockByWebsiteIdResolver = $stockByWebsiteIdResolver;
$this->getSourcesAssignedToStockOrderedByPriority = $getSourcesAssignedToStockOrderedByPriority;
$this->defaultSourceProvider = $defaultSourceProvider;
$this->sourceDeductionRequestFactory = $sourceDeductionRequestFactory;
$this->salesChannelFactory = $salesChannelFactory;
$this->salesEventFactory = $salesEventFactory;
$this->websiteRepository = $websiteRepository;
}
/**
* @param Order $order
* @return void
* @throws \Magento\Framework\Exception\InputException
* @throws \Magento\Framework\Exception\LocalizedException
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function execute(Order $order)
{
$websiteId = $order->getStore()->getWebsiteId();
$stockId = $this->stockByWebsiteIdResolver->execute((int)$websiteId)->getStockId();
$sources = $this->getSourcesAssignedToStockOrderedByPriority->execute((int)$stockId);
//TODO: need ro rebuild this logic | create separate service
if (!empty($sources) && count($sources) == 1) {
$sourceCode = $sources[0]->getSourceCode();
} else {
$sourceCode = $this->defaultSourceProvider->getCode();
}
$items = $this->getItemsToDeductFromOrder($order);
if (!empty($items)) {
$salesEvent = $this->salesEventFactory->create([
'type' => SalesEventInterface::EVENT_SHIPMENT_CREATED,
'objectType' => SalesEventInterface::OBJECT_TYPE_ORDER,
'objectId' => $order->getId()
]);
$websiteCode = $order->getStore()->getWebsite()->getCode();
$salesChannel = $this->salesChannelFactory->create([
'data' => [
'type' => SalesChannelInterface::TYPE_WEBSITE,
'code' => $websiteCode
]
]);
$sourceDeductionRequest = $this->sourceDeductionRequestFactory->create([
'sourceCode' => $sourceCode,
'items' => $this->groupItemsBySku($items),
'salesChannel' => $salesChannel,
'salesEvent' => $salesEvent
]);
$this->sourceDeductionService->execute($sourceDeductionRequest);
$this->placeReservationsForSalesEvent->execute(
$this->groupItemsBySku($items, 'reserved_qty'),
$salesChannel,
$salesEvent
);
}
}
/**
* @param array $orderItems
* @param string $qtyKey
* @return ItemToDeductInterface[]|ItemToSellInterface[]
*/
private function groupItemsBySku(array $orderItems, $qtyKey = 'qty'): array
{
$processingItems = $groupedItems = [];
foreach ($orderItems as $orderItem) {
if (empty($processingItems[$orderItem['sku']])) {
$processingItems[$orderItem['sku']] = $orderItem[$qtyKey];
} else {
$processingItems[$orderItem['sku']] += $orderItem[$qtyKey];
}
}
foreach ($processingItems as $sku => $qty) {
if ($qtyKey == 'qty') {
$groupedItems[] = $this->itemToDeduct->create([
'sku' => $sku,
'qty' => $qty
]);
} else {
$groupedItems[] = $this->itemsToSellFactory->create([
'sku' => $sku,
'qty' => $qty
]);
}
}
return $groupedItems;
}
/**
* @param Order $order
* @return array
*/
private function getItemsToDeductFromOrder(Order $order): array
{
$itemsToDeduct = [];
/** @var \Magento\Sales\Model\Order\Item $orderItem */
foreach ($order->getAllItems() as $orderItem) {
if (sizeof($orderItem->getChildrenItems()) > 0) {
if (!$orderItem->isDummy(true)) {
foreach ($this->processComplexItem($orderItem) as $item) {
$itemsToDeduct[] = $item;
}
}
} else {
$itemSku = $this->getSkuFromOrderItem->execute($orderItem);
$qty = $this->castQty($orderItem, ($orderItem->getQtyOrdered() - $orderItem->getQtyCanceled() - $orderItem->getQtyRefunded()));
$itemsToDeduct[] = [
'sku' => $itemSku,
'qty' => $qty,
'reserved_qty' => $qty
];
}
}
return $itemsToDeduct;
}
/**
* @param Item $orderItem
* @return array
*/
private function processComplexItem(Item $orderItem): array
{
$itemsToDuduct = [];
/** @var \Magento\Sales\Model\Order\Item $item */
foreach ($orderItem->getChildrenItems() as $item) {
if ($item->getIsVirtual() || $item->getLockedDoShip()) {
continue;
}
$productOptions = $item->getProductOptions();
if (isset($productOptions['bundle_selection_attributes'])) {
$bundleSelectionAttributes = $this->jsonSerializer->unserialize(
$productOptions['bundle_selection_attributes']
);
if ($bundleSelectionAttributes) {
$qty = $bundleSelectionAttributes['qty'] * ($orderItem->getQtyOrdered() - $orderItem->getQtyCanceled() - $orderItem->getQtyRefunded());
$qty = $this->castQty($item, $qty);
$itemSku = $this->getSkuFromOrderItem->execute($item);
$itemsToDeduct[] = [
'sku' => $itemSku,
'qty' => $qty,
'reserved_qty' => $qty
];
continue;
}
} else {
// configurable product
$itemSku = $this->getSkuFromOrderItem->execute($orderItem);
$qty = $this->castQty($orderItem, ($orderItem->getQtyOrdered() - $orderItem->getQtyCanceled() - $orderItem->getQtyRefunded()));
$itemsToDeduct[] = [
'sku' => $itemSku,
'qty' => $qty,
'reserved_qty' => $qty
];
}
}
return $itemsToDuduct;
}
/**
* @param Item $item
* @param string|int|float $qty
* @return float|int
*/
private function castQty(Item $item, $qty)
{
if ($item->getIsQtyDecimal()) {
$qty = (double)$qty;
} else {
$qty = (int)$qty;
}
return $qty > 0 ? $qty : 0;
}
}
<?php
namespace Zhik\Myoba\Plugin\CatalogInventory\Observer;
use Magento\CatalogInventory\Observer\CancelOrderItemObserver as OrigCancelOrderItemObserver;
use Magento\Framework\Event\Observer;
use Magento\Sales\Model\Order;
class CancelOrderItemObserver {
/**
* @param OrigCancelOrderItemObserver $subject
* @param callable $proceed
* @param Observer $observer
*/
public function aroundExecute(
OrigCancelOrderItemObserver $subject,
callable $proceed,
Observer $observer
){
// If the order has been synced with MYOB, the reservations have already been reversed.
/** @var Order $order */
$order = $observer->getEvent()->getItem()->getOrder();
if ($order->getMyobaId())
return;
$proceed($observer);
}
}
<?php
namespace Zhik\Myoba\Plugin\InventorySales\Model\ReturnProcessor;
use Magento\InventorySales\Model\ReturnProcessor\ProcessRefundItems as OriginalProcessRefundItems;
use Magento\Sales\Api\Data\OrderInterface;
class ProcessRefundItems {
/**
* @param OriginalProcessRefundItems $subject
* @param callable $proceed
* @param OrderInterface $order
* @param array $itemsToRefund
* @param array $returnToStockItems
*/
public function aroundExecute(
OriginalProcessRefundItems $subject,
callable $proceed,
OrderInterface $order,
array $itemsToRefund,
array $returnToStockItems
){
// If the order has been synced with MYOB, the reservations have already been reversed.
if ($order->getMyobaId())
return;
$proceed($order, $itemsToRefund, $returnToStockItems);
}
}
<?php
namespace Zhik\Myoba\Plugin\SalesInventory\Model\Order;
use Magento\Sales\Api\Data\CreditmemoInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\SalesInventory\Model\Order\ReturnProcessor as OrigReturnProcessor;
class ReturnProcessor {
/**
* @param OrigReturnProcessor $subject
* @param callable $proceed
* @param CreditmemoInterface $creditmemo
* @param OrderInterface $order
* @param array $returnToStockItems
* @param bool $isAutoReturn
*/
public function aroundExecute(
OrigReturnProcessor $subject,
callable $proceed,
CreditmemoInterface $creditmemo,
OrderInterface $order,
array $returnToStockItems = [],
$isAutoReturn = false
){
// If the order has been synced with MYOB, the reservations have already been reversed.
if ($order->getMyobaId())
return;
$proceed($creditmemo, $order, $returnToStockItems, $isAutoReturn);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment