Drop stock reservation when removing item from cart via the Store API (https://github.com/woocommerce/woocommerce-blocks/pull/3468)

* Remove Blocks version of ReserveStock Class

* When a cart item is removed, remove holds on stock

* Move maybe_release_stock to abstract

* Update ReserveStockException usage
This commit is contained in:
Mike Jolley 2020-12-02 16:03:24 +00:00 committed by GitHub
parent 7e656711b4
commit ea52a2a2d5
8 changed files with 29 additions and 309 deletions

View File

@ -44,6 +44,21 @@ abstract class AbstractCartRoute extends AbstractRoute {
}
}
/**
* If there is a draft order, releases stock.
*
* @return void
*/
protected function maybe_release_stock() {
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
if ( ! $draft_order ) {
return;
}
wc_release_stock_for_order( $draft_order );
}
/**
* Get route response when something went wrong.
*

View File

@ -57,6 +57,7 @@ class CartRemoveItem extends AbstractCartRoute {
}
$cart->remove_cart_item( $request['key'] );
$this->maybe_release_stock();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
}

View File

@ -11,8 +11,8 @@ use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\OrderController;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStock;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStockException;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\Blocks\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Payments\PaymentContext;
@ -332,7 +332,7 @@ class Checkout extends AbstractRoute {
private function create_or_update_draft_order() {
$cart_controller = new CartController();
$order_controller = new OrderController();
$reserve_stock = \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ? new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() : new ReserveStock();
$reserve_stock = new ReserveStock();
$this->order = $this->get_draft_order_id() ? wc_get_order( $this->get_draft_order_id() ) : null;
// Validate items etc are allowed in the order before it gets created.

View File

@ -2,7 +2,7 @@
namespace Automattic\WooCommerce\Blocks\StoreApi\Schemas;
use Automattic\WooCommerce\Blocks\Domain\Services\ExtendRestApi;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
/**
* CartItemSchema class.
@ -346,15 +346,10 @@ class CartItemSchema extends ProductSchema {
return null;
}
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock = new \Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStock();
}
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
$reserve_stock = new ReserveStock();
$reserved_stock = $reserve_stock->get_reserved_stock( $product, $draft_order );
return $product->get_stock_quantity() - $reserved_stock;
}

View File

@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
use Automattic\WooCommerce\Blocks\StoreApi\Routes\RouteException;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
/**
* Woo Cart Controller class.
@ -630,14 +631,9 @@ class CartController {
* @return int
*/
protected function get_remaining_stock_for_product( $product ) {
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock_controller = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock_controller = new \Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStock();
}
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
$qty_reserved = $reserve_stock_controller->get_reserved_stock( $product, $draft_order );
$reserve_stock = new ReserveStock();
$draft_order = wc()->session->get( 'store_api_draft_order', 0 );
$qty_reserved = $reserve_stock->get_reserved_stock( $product, $draft_order );
return $product->get_stock_quantity() - $qty_reserved;
}

View File

@ -1,230 +0,0 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* Stock Reservation class.
* Handle product stock reservation during checkout.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
final class ReserveStock {
/**
* Is stock reservation enabled?
*
* @var boolean
*/
private $enabled = true;
/**
* Constructor
*/
public function __construct() {
$this->enabled = get_option( 'wc_blocks_db_schema_version', 0 ) >= 260;
}
/**
* Is stock reservation enabled?
*
* @return boolean
*/
protected function is_enabled() {
return $this->enabled;
}
/**
* Query for any existing holds on stock for this item.
*
* @param \WC_Product $product Product to get reserved stock for.
* @param integer $exclude_order_id Optional order to exclude from the results.
*
* @return integer Amount of stock already reserved.
*/
public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) {
global $wpdb;
if ( ! $this->is_enabled() ) {
return 0;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
return (int) $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) );
}
/**
* Put a temporary hold on stock for an order if enough is available.
*
* @throws ReserveStockException If stock cannot be reserved.
*
* @param \WC_Order $order Order object.
* @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
*/
public function reserve_stock_for_order( \WC_Order $order, $minutes = 0 ) {
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
if ( ! $minutes || ! $this->is_enabled() ) {
return;
}
try {
$items = array_filter(
$order->get_items(),
function( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
}
);
$rows = [];
foreach ( $items as $item ) {
$product = $item->get_product();
if ( ! $product->is_in_stock() ) {
throw new ReserveStockException(
'woocommerce_product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '&quot;%s&quot; is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
400
);
}
// If stock management is off, no need to reserve any stock here.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
}
$managed_by_id = $product->get_stock_managed_by_id();
$rows[ $managed_by_id ] = isset( $rows[ $managed_by_id ] ) ? $rows[ $managed_by_id ] + $item->get_quantity() : $item->get_quantity();
}
if ( ! empty( $rows ) ) {
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
}
} catch ( ReserveStockException $e ) {
$this->release_stock_for_order( $order );
throw $e;
}
}
/**
* Release a temporary hold on stock for an order.
*
* @param \WC_Order $order Order object.
*/
public function release_stock_for_order( \WC_Order $order ) {
global $wpdb;
if ( ! $this->is_enabled() ) {
return;
}
$wpdb->delete(
$wpdb->wc_reserved_stock,
[
'order_id' => $order->get_id(),
]
);
}
/**
* Reserve stock for a product by inserting rows into the DB.
*
* @throws ReserveStockException If a row cannot be inserted.
*
* @param int $product_id Product ID which is having stock reserved.
* @param int $stock_quantity Stock amount to reserve.
* @param \WC_Order $order Order object which contains the product.
* @param int $minutes How long to reserve stock in minutes.
*/
private function reserve_stock_for_product( $product_id, $stock_quantity, \WC_Order $order, $minutes ) {
global $wpdb;
$query_for_stock = $this->get_query_for_stock( $product_id );
$query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
$required_stock = $stock_quantity;
// Deals with legacy stock reservations from woo core.
$support_legacy_held_stock = ! \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) && absint( get_option( 'woocommerce_hold_stock_minutes', 0 ) ) > 0;
if ( $support_legacy_held_stock ) {
$required_stock += wc_get_held_stock_quantity( wc_get_product( $product_id ) );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->query(
$wpdb->prepare(
"
INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL
WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` )
",
$order->get_id(),
$product_id,
$stock_quantity,
$minutes,
$required_stock
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
if ( ! $result ) {
$product = wc_get_product( $product_id );
throw new ReserveStockException(
'product_not_enough_stock',
sprintf(
/* translators: %s: product name */
__( 'Not enough units of %s are available in stock to fulfil this order.', 'woo-gutenberg-products-block' ),
$product ? $product->get_name() : '#' . $product_id
),
400
);
}
}
/**
* Returns query statement for getting current `_stock` of a product.
*
* @todo Once merged to woo core data store, this method can be removed.
* @internal MAX function below is used to make sure result is a scalar.
* @param int $product_id Product ID.
* @return string|void Query statement.
*/
private function get_query_for_stock( $product_id ) {
global $wpdb;
return $wpdb->prepare(
"
SELECT COALESCE ( MAX( meta_value ), 0 ) FROM $wpdb->postmeta as meta_table
WHERE meta_table.meta_key = '_stock'
AND meta_table.post_id = %d
",
$product_id
);
}
/**
* Returns query statement for getting reserved stock of a product.
*
* @param int $product_id Product ID.
* @param integer $exclude_order_id Optional order to exclude from the results.
* @return string|void Query statement.
*/
private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
global $wpdb;
return $wpdb->prepare(
"
SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table
LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID
WHERE posts.post_status IN ( 'wc-checkout-draft', 'wc-pending' )
AND stock_table.`expires` > NOW()
AND stock_table.`product_id` = %d
AND stock_table.`order_id` != %d
",
$product_id,
$exclude_order_id
);
}
}

View File

@ -1,57 +0,0 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Utilities;
/**
* ReserveStockException class.
* Exceptions for stock reservation.
*
* @internal This API is used internally by Blocks--it is still in flux and may be subject to revisions.
*/
class ReserveStockException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
protected $error_code;
/**
* Error extra data.
*
* @var array
*/
protected $error_data;
/**
* Setup exception.
*
* @param string $code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param string $message User-friendly translated error message, e.g. 'Product ID is invalid'.
* @param int $http_status_code Proper HTTP status code to respond with, e.g. 400.
* @param array $data Extra error data.
*/
public function __construct( $code, $message, $http_status_code = 400, $data = array() ) {
$this->error_code = $code;
$this->error_data = $data;
parent::__construct( $message, $http_status_code );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns error data.
*
* @return array
*/
public function getErrorData() {
return $this->error_data;
}
}

View File

@ -8,7 +8,7 @@ namespace Automattic\WooCommerce\Blocks\Tests\StoreApi\Utilities;
use PHPUnit\Framework\TestCase;
use \WC_Helper_Order as OrderHelper;
use \WC_Helper_Product as ProductHelper;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
/**
* ReserveStock Utility Tests.
@ -45,7 +45,7 @@ class ReserveStockTests extends TestCase {
/**
* Test that trying to reserve stock too much throws an exception.
*
* @expectedException Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStockException
* @expectedException Automattic\WooCommerce\Checkout\Helpers\ReserveStockException
*/
public function test_reserve_stock_for_order_throws_exception() {
$class = new ReserveStock();