Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Update reserved stock class to handle concurrent requests #1778

Merged
merged 7 commits into from
Mar 6, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Library.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public static function maybe_create_tables() {
`product_id` bigint(20) NOT NULL,
`stock_quantity` double NOT NULL DEFAULT 0,
`timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`, `product_id`)
) $collate;
"
Expand Down
26 changes: 8 additions & 18 deletions src/RestApi/StoreApi/Controllers/CartOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use \WP_REST_Request as RestRequest;
use \WC_REST_Exception as RestException;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\OrderSchema;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock;

/**
* Cart Order API.
Expand Down Expand Up @@ -127,9 +126,15 @@ public function create_item( $request ) {
// Create or retrieve the draft order for the current cart.
$order_object = $this->create_order_from_cart( $request );

// Try to reserve stock, if available.
$this->reserve_stock( $order_object );
// Try to reserve stock for 10 mins, if available.
// @todo Remove once min support for WC reaches 4.0.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}

$reserve_stock->reserve_stock_for_order( $order_object, 10 );
$response = $this->prepare_item_for_response( $order_object, $request );
$response->set_status( 201 );
return $response;
Expand Down Expand Up @@ -167,21 +172,6 @@ protected function update_session( RestRequest $request ) {
WC()->customer->save();
}

/**
* Put a temporary hold on stock for this order.
*
* @throws RestException Exception when stock cannot be reserved.
* @param \WC_Order $order Order object.
*/
protected function reserve_stock( \WC_Order $order ) {
$reserve_stock_helper = new ReserveStock();
$result = $reserve_stock_helper->reserve_stock_for_order( $order );

if ( is_wp_error( $result ) ) {
throw new RestException( $result->get_error_code(), $result->get_error_message(), $result->get_error_data( 'status' ) );
}
}

/**
* Create order and set props based on global settings.
*
Expand Down
12 changes: 9 additions & 3 deletions src/RestApi/StoreApi/Schemas/CartItemSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductImages;
use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductSummary;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock;

/**
* CartItemSchema class.
Expand Down Expand Up @@ -335,8 +334,15 @@ protected function get_low_stock_remaining( \WC_Product $product ) {
return null;
}

$draft_order = WC()->session->get( 'store_api_draft_order' );
$reserve_stock = new ReserveStock();
$draft_order = WC()->session->get( 'store_api_draft_order' );

// @todo Remove once min support for WC reaches 4.0.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}

$reserved_stock = $reserve_stock->get_reserved_stock( $product, isset( $draft_order['id'] ) ? $draft_order['id'] : 0 );
$remaining_stock = $product->get_stock_quantity() - $reserved_stock;

Expand Down
247 changes: 155 additions & 92 deletions src/RestApi/StoreApi/Utilities/ReserveStock.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Helper class to handle product stock reservation.
* Handle product stock reservation during checkout.
*
* @package WooCommerce/Blocks
*/
Expand All @@ -9,132 +9,195 @@

defined( 'ABSPATH' ) || exit;

use \WP_Error;

/**
* Stock Reservation class.
*/
class ReserveStock {
final class ReserveStock {
/**
* 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;

// 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.
* @return bool|WP_Error
* @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 ) {
$stock_to_reserve = [];
$items = array_filter(
$order->get_items(),
function( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product;
}
);
public function reserve_stock_for_order( \WC_Order $order, $minutes = 0 ) {
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );

foreach ( $items as $item ) {
$product = $item->get_product();

if ( ! $product->is_in_stock() ) {
return new WP_Error(
'product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
[ 'status' => 403 ]
);
}
if ( ! $minutes ) {
return;
}

// If stock management is off, no need to reserve any stock here.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
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(
'product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
}

// 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();
}

$product_id = $product->get_stock_managed_by_id();
$stock_to_reserve[ $product_id ] = isset( $stock_to_reserve[ $product_id ] ) ? $stock_to_reserve[ $product_id ] : 0;
$reserved_stock = $this->get_reserved_stock( $product, $order->get_id() );

if ( ( $product->get_stock_quantity() - $reserved_stock - $stock_to_reserve[ $product_id ] ) < $item->get_quantity() ) {
return new WP_Error(
'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->get_name()
),
[ 'status' => 403 ]
);
if ( ! empty( $rows ) ) {
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
}

// Queue for later DB insertion.
$stock_to_reserve[ $product_id ] += $item->get_quantity();
} catch ( ReserveStockException $e ) {
$this->release_stock_for_order( $order );
throw $e;
}

$this->reserve_stock( $stock_to_reserve, $order->get_id() );

return true;
}

/**
* Reserve stock by inserting rows into the DB.
* Release a temporary hold on stock for an order.
*
* @param array $stock_to_reserve Array of Product ID => Qty pairs.
* @param integer $order_id Order ID for which to reserve stock.
* @param \WC_Order $order Order object.
*/
protected function reserve_stock( $stock_to_reserve, $order_id ) {
public function release_stock_for_order( \WC_Order $order ) {
global $wpdb;

$stock_to_reserve = array_filter( $stock_to_reserve );

if ( ! $stock_to_reserve ) {
return;
}

$stock_to_reserve_rows = [];

foreach ( $stock_to_reserve as $product_id => $stock_quantity ) {
$stock_to_reserve_rows[] = '(' . esc_sql( $order_id ) . ',"' . esc_sql( $product_id ) . '","' . esc_sql( $stock_quantity ) . '")';
}

$values = implode( ',', $stock_to_reserve_rows );

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( "REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" );
$wpdb->delete(
$wpdb->wc_reserved_stock,
[
'order_id' => $order->get_id(),
]
);
}

/**
* Query for any existing holds on stock for this item.
* Reserve stock for a product by inserting rows into the DB.
*
* - Can ignore reserved stock for a specific order.
* - Ignores stock for orders which are no longer drafts (assuming real stock reduction was performed).
* - Ignores stock reserved over 10 mins ago.
* @throws ReserveStockException If a row cannot be inserted.
*
* @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.
* @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.
*/
public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) {
private function reserve_stock_for_product( $product_id, $stock_quantity, \WC_Order $order, $minutes ) {
global $wpdb;

$reserved_stock = $wpdb->get_var(
$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(
"
SELECT SUM( stock_table.`stock_quantity` ) FROM $wpdb->wc_reserved_stock stock_table
LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID
WHERE stock_table.`product_id` = %d
AND posts.post_status = 'wc-checkout-draft'
AND stock_table.`order_id` != %d
AND stock_table.`timestamp` > ( NOW() - INTERVAL 10 MINUTE )
REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity, expires )
SELECT %d, %d, %d, ( NOW() + INTERVAL %d MINUTE ) from DUAL
WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
",
$product->get_stock_managed_by_id(),
$exclude_order_id
$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
),
403
);
}
}

// Deals with legacy stock reservation which the core Woo checkout performs.
$hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 );
$reserved_stock += ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0;
/**
* 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
);
}

return $reserved_stock;
/**
* 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
);
}
}
Loading