Created
February 22, 2020 04:02
-
-
Save micwallace/46a2d95113a1d89bd25cc3cfb6970060 to your computer and use it in GitHub Desktop.
Magento 2 Inventory reservaion logic for ERP synchronised systems.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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