-
Notifications
You must be signed in to change notification settings - Fork 248
Reservations
- Reservation Mechanism and its APIs
- Description of Order Placement operation Step by Step
- Description of Order Processing
- More sophisticated Computation of Salable Quantity
- Reservation API
- Story Tasks on GitHub
Reservation - is an entity which is used to keep calculation of Salable product quantity consistent, and prevent overselling. It is created as an "inventory request" when an order is placed and exists until the time when the order would be processed and corresponding source deduction (deduction of specific SourceItems) happen, along with that the initial Reservation should be compensated. Introducing Reservation entity we could be sure that merchant would not sell more products than he has in stock even if latency between order placement and order processing (deduction of specific SourceItems) is high. Reservations are append-only operations and help us to prevent blocking operations and race conditions at the time of checkout.
There are 3 physical sources: Source A, Source B, Source C and There is Product with SKU-1 which is stored on each of these sources in next quantity:
- SourceItem A — 20
- SourceItem B — 25
- SourceItem C — 10
There is the only sales channel (Default Website). For this sales channel, we create a virtual aggregated stock - Stock A and assign all existing physical sources to it. Thus, StockItem A for SKU-1 has Quantity 20+25+10 = 55
When a customer comes to the website, the system detects Stock which should be used (in our case Website -> Stock A), in the scope of this Stock the system calculates Salable Quantities for each product by this formula:
StockItem Qty (qty from Stock Item index) + All Reservations created for given SKU on the given StockId
In our case for SKU-1 and Stock A
Let's assume customer comes to Default Website and wants to buy product SKU-1 in the amount of 30 items.
Magento needs to decide whether we can sell (do we have enough products to sell in stock), Quantity of SKU-1 on StockItem A is 55, plus an aggregated quantity of all the reservations for product SKU-1 on Stock A. In our case there are no reservations created, so the number is 0, 55 - 0 > 30, so we can proceed to checkout and place an order.
At the time of order placement, the system is agnostic to the fact from which physical sources the order would be fulfilled and the qty of SKU-1 would be deducted afterwards, that's why we don't use SourceItem interfaces during this process (order placement). Also, we can't deduct Qty of StockItem A, because it's read-only interface and represents index value. Thus, we create a Reservation for SKU-1 on Stock A in the amount of (-30) items. Reservation creation is append-only operation, so there are no checks and blocking operations (locks) needed.
Amount of SKU-1 on physical sources:
- SourceItem A — 20
- SourceItem B — 25
- SourceItem C — 10
The quantity of SKU-1 on StockItem A — 55 (has not changed) Reservation for SKU-1 on Stock A created in the amount of (-30) items.
While we didn't process first order yet, because of high latency, another customer comes to the website and wants to order SKU-1 in amount of 10 items.
Magento starts to follow the steps mentioned above. Magento needs to decide whether we can sell (do we have enough products to sell in stock), Quantity of SKU-1 on StockItem A is 55, plus an aggregated quantity of all the reservations for product SKU-1 on Stock A 55 + (-30) = 25 > 10 Thus, we make a decision that we can proceed to checkout.
We do not bind a Reservation to Order placed (by order id, or order item ids), because Order is not the only business event which could emit reservations. For example, one of the features out of MSI product backlog is to introduce Shopping Cart reservations where fixed time reservation (say, reservation for 15 minutes) would be created as soon as customer will add a product into his shopping cart to guarantee that particular product is reserved for the customer for particular amount of time, thus he/she can continue shopping and no need immediately proceed to checkout to make sure that he can get desirable product. But along with that MSI provides an ability to log Business event which produced given reservation. This data could be found in Reservation Metadata, and looks like:
mysql> select * from inventory_reservation;
+----------------+----------+------------------------+-----------+----------------------------------------------------------------------------+
| reservation_id | stock_id | sku | quantity | metadata |
+----------------+----------+------------------------+-----------+----------------------------------------------------------------------------+
| 21 | 2 | configurable -red | -13.0000 | {"event_type":"order_placed","object_type":"order","object_id":"8"} |
| 22 | 2 | configurable -red | 13.0000 | {"event_type":"creditmemo_created","object_type":"order","object_id":"8"} |
| 23 | 2 | testSimpleProduct2 | -10.0000 | {"event_type":"order_placed","object_type":"order","object_id":"9"} |
| 24 | 2 | testSimpleProduct2 | 5.0000 | {"event_type":"shipment_created","object_type":"order","object_id":"9"} |
| 25 | 2 | testSimpleProduct2 | 5.0000 | {"event_type":"shipment_created","object_type":"order","object_id":"9"} |
| 29 | 2 | testSimpleProduct2 | -15.0000 | {"event_type":"order_placed","object_type":"order","object_id":"11"} |
| 30 | 2 | testSimpleProduct2 | 5.0000 | {"event_type":"shipment_created","object_type":"order","object_id":"11"} |
| 31 | 2 | testSimpleProduct2 | 5.0000 | {"event_type":"creditmemo_created","object_type":"order","object_id":"11"} |
| 32 | 2 | testSimpleProduct2 | 5.0000 | {"event_type":"creditmemo_created","object_type":"order","object_id":"11"} |
| 33 | 1 | testSimpleProduct | -10.0000 | {"event_type":"order_placed","object_type":"order","object_id":"12"} |
| 34 | 1 | testSimpleProduct | 10.0000 | {"event_type":"shipment_created","object_type":"order","object_id":"12"} |
| 35 | 1 | testSimpleProduct | -10.0000 | {"event_type":"order_placed","object_type":"order","object_id":"13"} |
| 36 | 1 | testSimpleProduct | 10.0000 | {"event_type":"order_canceled","object_type":"order","object_id":"13"} |
We consider Reservation as append-only operation. Like a log of events (in Event Sourcing terms). Our stock calculation for product(SKU) is next: get StockItem Quantity (which represents aggregated amount among all the physical sources for the current Scope/SalesChannel) for particular SKU plus all created reservations for this SKU made in the same Scope/SalesChannel. So, let’s imagine that Customer A bought 30 items of some product - system creates a reservation for this sale.
ReservationID - 1 , StockId - 1, SKU - SKU-1, Qty - (-30)
if the order is canceled the system just creates another Reservation
ReservationID - 2 , StockId - 1, SKU - SKU-1, Qty - (+30)
So, new Inventory infrastructure doesn't remove or modify already created reservations, just append another one, which makes quantity correction (we call these kinds of reservations - compensational ones).
So, the second reservation will compensate the 1st one Like (-30) + 30 = 0
As from the calculation perspective it would be easier to have both negative (<0) and positive (>0) Qty values in Reservations. Like when we placed an order -> we created Reservation with Qty -30, when we processed the Order and deducted SourceItems -> we created a reservation with Qty +30 That will provide an efficient way of how we can get Sum of Grouped Reservations. For example, executing this query:
select
SUM(r.qty) as total_reservation_qty
from
Reservations as r
where
stockId = {%id%} and sku = {%sku%}
pretty simple query, and we've given total reservation qty.
As it was mentioned above, just initial reservation (when the order is placed) has negative quantity value, all further reservations created while processing given order suppose to compensate the initial one, and finally when the order will come to the finite state (complete|canceled) - the sum of all created reservations while working with it should be ZERO.
There are some tricky cases of computation for complex order lifecycles, especially if the partial invoice involved, because Magento currently does not provide a possibility to track particular item in the scope of ordered quantity. That's why system should make an assumption what merchant wants to accomplish making some operation with an order. Sometimes this is especially tricky taking into account that magento allows to ship non invoiced products.
Here is the main assumption MSI does. Let's consider next example:
- Order Placed for SKU-1 in Qty = 10 (Reservation for SKU-1 in Qty -10 created)
- Partial Invoice created for Qty = 7
- Partial Shipment created for Qty = 3 (Source Selection Algorithm suggests which source should be deducted and merchant creates shipment compensating Qty +3 with reservation and deducting SourceItem Qty on phisical source)
- Credit Memo created for Qty = 5
???
At this point the system should make an assumption and decide whether already Shipped products should be refunded and send back to merchant or system will preferably refund Invoiced, but not shipped products.
As Magento currently does not provide this possibility, the inventory system should do it by itself. Thus, MSI tries to refund Invoiced, but not shipped items first and if there are not enough such items, MSI would refund already shipped items then.
Based on the above the system will handle Credit Memo created for Qty = 5
next way:
- Current amount of invoiced but not shipped items is 7 - 3 = 4, so system will compensate them first, creating compensational reservation in Qty = +4
- Then the system should refund and return to stock one of 3 items which were already shipped, but in this case no need to create Compensational Reservation, as this Qty has been already compensated when Merchant shipped this item. So, in this case just SourceItem quantity is increased on +1 directly.
After step 4. the system will have next reservations created (-10 +3 +4) and the SourceItem quantity deducted by 2, and there are still 3 products awaiting to be handled in the scope of this Order.
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
namespace Magento\InventoryApi\Api\Data;
use Magento\Framework\Api\ExtensibleDataInterface;
use Magento\InventoryApi\Api\Data\ReservationExtensionInterface;
/**
* The entity responsible for reservations, created to keep inventory amount (product quantity) up-to-date.
* It is created to have a state between order creation and inventory deduction (deduction of specific SourceItems)
*
* @api
*/
interface ReservationInterface
{
/**
* Constants for keys of data array. Identical to the name of the getter in snake case
*/
const RESERVATION_ID = 'reservation_id';
const STOCK_ID = 'stock_id';
const SKU = 'sku';
const QUANTITY = 'quantity';
const METADATA = 'metadata';
/**
* Get Reservation id
*
* @return int|null
*/
public function getReservationId();
/**
* Get stock id
*
* @param int $stockId
* @return void
*/
public function getStockId($stockId);
/**
* Get Product SKU
*
* @return string
*/
public function getSku();
/**
* Get Product Qty
*
* @return float
*/
public function getQuantity();
/**
* Get Reservation Metadata
*
* @return string|null
*/
public function getMetadata();
}
We no need to expose Reservation API for WebAPI (REST and SOAP), because we can consider Reservations as SPI, which created as a side-effect of some particular business operation (like order placement, or return).
Currently, in Magento 2 WebAPI imposes some restrictions for entity interfaces (existence getter and setter methods).
Thus, if we would not expose Reservation entity for WebAPI (REST, SOAP) -> we could use any interface we want (don't have mandatory setter methods).
And because we agreed that Reservations are append-only immutable entities we could eliminate all the setter methods.
So, we will end-up with ReservationInterface consisting of just getter methods.
And we need to introduce ReservationBuilderInterface
which will allow the possibility to set data into the reservation when we need to create one.
After that, we could build Reservation entity.
$reservationBuilder->setStockId(1);
$reservationBuilder->setSku('sku');
$reservationBuilder->setQty(10);
$newReservation = $reservationBuilder->build();
//now we could save Reservation entity
$reservationAppend->execute([$newReservation]);
Doing so, we could ensure immutability on the level of Reservation interface.
Append Reservation Service - used at a time when Order placed or canceled. At this time, we create a bunch of Reservations, each one responsible for particular SKU And add these reservations for processing. This service must ensure that client doesn't use ReservationAppend service to update already created reservations. Because Reservations are append-only entities. For example, if we will use Database generated IDs, we could check the ReservationId which is passed in the scope of ReservationInterface is nullified. Or if UUIDs are used, we could check that there are no reservations with the same UUID placed already.
/**
* Command which appends reservations when order placed or canceled
*
* @api
*/
interface ReservationAppend
{
/**
* Append reservations when Order Placed (or Cancelled)
*
* @param Reservation[] $reservations
* @return void
* @throws \Magento\Framework\Exception\InputException
* @throws \Magento\Framework\Exception\CouldNotSaveException
*/
public function execute(array $reservations);
}
To get Product Quantity available to be sold for specified Stock we need to introduce another service which will use StockItem Qty and deduct all existing reservations for this SKU in provided scope (StockId) from it.
/**
* Service which returns Quantity of products available to be sold by Product SKU and Stock Id
*
* @api
*/
interface GetProductQuantityInStock
{
/**
* Get Product Quantity for given SKU in a given Stock
*
* @param string $sku
* @param int $stockId
* @return float
*/
public function execute($sku, $stockId);
}
Similar service needs to be added for Checking whether Product is In Stock.
/**
* Service which detects whether Product is In Stock for a given Stock
*
* @api
*/
interface IsProductInStock
{
/**
* Is given SKU inStock for a given Stock Id
*
* @param string $sku
* @param int $stockId
* @return bool
*/
public function execute($sku, $stockId);
}
At the time of StockItem re-indexation it's too late to clear Reservations. Because StockItem index accepts SourceItem records which were updated as the result of Source Selection Algorithm. And it's impossible to detect in which scope (sales channel/stock id) the order has been placed. Thus, it's impossible to decide which Reservations should be removed, because Reservation created in the scope of stock_id (which is absent at this moment, Source could be assigned to More than 1 Stock).
That's why we need to create NEW reservation at the Order processing time when we processed the Order and got a result from Source Selection Algorithm (Sources we will use to fulfill the order placed, and the precise quantity of SKUs we need to deduct from the SourceItems).
As reservations are append-only operations it's proposed not to modify the status of created Reservation, but add another reservation which neglects already existing Reservation (like in the example above -30 +30 = 0). From the inventory point of view we don't bind Reservation to Order or other business operation, that's why we don't introduce Reservation Statuses (and apply State Machine design pattern for changing the reservation from one state to another one). All we need to do is to create another reservation. That's all.
Order Placed for SKU-1 in Qty 30 => Created Reservation for SKU-1 with Qty (-30)
Canceled above order => Created Reservation for SKU-1 with Qty = (+30)
or
Completed above order => Created Reservation for SKU-1 with Qty = (+30)
Idea is to clear reservation table (if needed) to prevent overloading, finding Complete pairs of reservations. When we have a pair of reservations, the sum of which is equal to O (Zero), like -30 and +30. These two reservations don't affect the final quantity, thus could be deleted.
Launching a script periodically we could find such pairs and remove them from the table not affecting the calculation.
select
reservation_id, qty
from
Reservations as r
where
stockId = {%id%} and sku = {%sku%}
After executing this query we will get a list of all created Reservations for the product in a given scope. Looping through these reservations we could find pairs which in sum gives 0 (Zero) and remove them.
It doesn't matter how fast is above processing (how much time takes to proceed one) because it's launched for service purposes only to remove unneeded reservations.
GitHub Story Label for current Story is "Reservation" and Thus, you can find all the tickets related to the story following Story Label
Multi-Source Inventory developed by Magento 2 Community
- Technical Vision. Catalog Inventory
- Installation Guide
- List of Inventory APIs and their legacy analogs
- MSI Roadmap
- Known Issues in Order Lifecycle
- MSI User Guide
- 2.3 LIVE User Guide
- MSI Release Notes and Installation
- Overview
- Get Started with MSI
- MSI features and processes
- Global and Product Settings
- Configure Source Selection Algorithm
- Create Sources
- Create Stock
- Assign Inventory and Product Notifications
- Configure MSI backorders
- MSI Import and Export Product Data
- Mass Action Tool
- Shipment and Order Management
- CLI reference
- Reports and MSI
- MSI FAQs
- DevDocs Documentation
- Manage Inventory Management Modules (install/upgrade info)
- Inventory Management
- Reservations
- Inventory CLI reference
- Inventory API reference
- Inventory In-Store Pickup API reference
- Order Processing with Inventory Management
- Managing sources
- Managing stocks
- Link and unlink stocks and sources
- Manage source items
- Perform bulk actions
- Manage Low-Quantity Notifications
- Check salable quantities
- Manage source selection algorithms
- User Stories
- Support of Store Pickup for MSI
- Product list assignment per Source
- Source assignment per Product
- Stocks to Sales Channel Mapping
- Adapt Product Import/Export to support multi Sourcing
- Introduce SourceCode attribute for Source and SourceItem entities
- Assign Source Selector for Processing of Returns Credit Memo
- User Scenarios:
- Technical Designs:
- Module Structure in MSI
- When should an interface go into the Model directory and when should it go in the Api directory?
- Source and Stock Item configuration Design and DB structure
- Stock and Source Configuration design
- Open Technical Questions
- Inconsistent saving of Stock Data
- Source API
- Source WebAPI
- Sources to Sales Channels mapping
- Service Contracts MSI
- Salable Quantity Calculation and Mechanism of Reservations
- StockItem indexation
- Web API and How To cover them with Functional Testing
- Source Selection Algorithms
- Validation of Domain Entities
- PHP 7 Syntax usage for Magento contribution
- The first step towards pre generated IDs. And how this will improve your Integration tests
- The Concept of Default Source and Domain Driven Design
- Extension Point of Product Import/Export
- Source Selection Algorithm
- SourceItem Entity Extension
- Design Document for changing SerializerInterface
- Stock Management for Order Cancelation
- Admin UI
- MFTF Extension Tests
- Weekly MSI Demos
- Tutorials