ReserveStock.php
307 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Handle product stock reservation during checkout. |
| 4 | */ |
| 5 | |
| 6 | namespace Automattic\WooCommerce\Checkout\Helpers; |
| 7 | |
| 8 | use Automattic\WooCommerce\Enums\OrderInternalStatus; |
| 9 | use Automattic\WooCommerce\Enums\OrderItemType; |
| 10 | use Automattic\WooCommerce\Utilities\OrderUtil; |
| 11 | use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup; |
| 12 | |
| 13 | defined( 'ABSPATH' ) || exit; |
| 14 | |
| 15 | /** |
| 16 | * Stock Reservation class. |
| 17 | */ |
| 18 | final class ReserveStock { |
| 19 | |
| 20 | /** |
| 21 | * Is stock reservation enabled? |
| 22 | * |
| 23 | * @var boolean |
| 24 | */ |
| 25 | private $enabled = true; |
| 26 | |
| 27 | /** |
| 28 | * Constructor |
| 29 | */ |
| 30 | public function __construct() { |
| 31 | // Table needed for this feature are added in 4.3. |
| 32 | $this->enabled = get_option( 'woocommerce_schema_version', 0 ) >= 430; |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Is stock reservation enabled? |
| 37 | * |
| 38 | * @return boolean |
| 39 | */ |
| 40 | protected function is_enabled() { |
| 41 | return $this->enabled; |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Query for any existing holds on stock for this item. |
| 46 | * |
| 47 | * @param \WC_Product $product Product to get reserved stock for. |
| 48 | * @param int $exclude_order_id Optional order to exclude from the results. |
| 49 | * |
| 50 | * @return int|float Amount of stock already reserved. |
| 51 | */ |
| 52 | public function get_reserved_stock( $product, $exclude_order_id = 0 ) { |
| 53 | global $wpdb; |
| 54 | |
| 55 | if ( ! $this->is_enabled() ) { |
| 56 | return 0; |
| 57 | } |
| 58 | |
| 59 | return wc_stock_amount( |
| 60 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared |
| 61 | $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) ) |
| 62 | ); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Put a temporary hold on stock for an order if enough is available. |
| 67 | * |
| 68 | * @throws ReserveStockException If stock cannot be reserved. |
| 69 | * |
| 70 | * @param \WC_Order $order Order object. |
| 71 | * @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes. |
| 72 | */ |
| 73 | public function reserve_stock_for_order( $order, $minutes = 0 ) { |
| 74 | if ( ! $this->is_enabled() ) { |
| 75 | return; |
| 76 | } |
| 77 | |
| 78 | $minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 ); |
| 79 | /** |
| 80 | * Filters the number of minutes an order should reserve stock for. |
| 81 | * |
| 82 | * This hook allows the number of minutes that stock in an order should be reserved for to be filtered, useful for third party developers to increase/reduce the number of minutes if the order meets certain criteria, or to exclude an order from stock reservation using a zero value. |
| 83 | * |
| 84 | * @since 8.8.0 |
| 85 | * |
| 86 | * @param int $minutes How long to reserve stock for the order in minutes. Defaults to woocommerce_hold_stock_minutes or 10 if block checkout entry. |
| 87 | * @param \WC_Order $order Order object. |
| 88 | */ |
| 89 | $minutes = (int) apply_filters( 'woocommerce_order_hold_stock_minutes', $minutes, $order ); |
| 90 | if ( ! $minutes ) { |
| 91 | return; |
| 92 | } |
| 93 | |
| 94 | $held_stock_notes = array(); |
| 95 | try { |
| 96 | $rows = array(); |
| 97 | |
| 98 | foreach ( $order->get_items() as $item ) { |
| 99 | $is_target_item = $item->is_type( OrderItemType::LINE_ITEM ) && $item->get_quantity() > 0; |
| 100 | if ( ! $is_target_item ) { |
| 101 | continue; |
| 102 | } |
| 103 | |
| 104 | /** @var \WC_Order_Item_Product $item */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort |
| 105 | $product = $item->get_product(); |
| 106 | if ( ! $product instanceof \WC_Product ) { |
| 107 | continue; |
| 108 | } |
| 109 | |
| 110 | if ( ! $product->is_in_stock() ) { |
| 111 | throw new ReserveStockException( |
| 112 | 'woocommerce_product_out_of_stock', |
| 113 | sprintf( |
| 114 | /* translators: %s: product name */ |
| 115 | __( '"%s" is out of stock and cannot be purchased.', 'woocommerce' ), |
| 116 | $product->get_name() |
| 117 | ), |
| 118 | 403 |
| 119 | ); |
| 120 | } |
| 121 | |
| 122 | // If stock management is off, no need to reserve any stock here. |
| 123 | if ( ! $product->managing_stock() || $product->backorders_allowed() ) { |
| 124 | continue; |
| 125 | } |
| 126 | |
| 127 | $managed_by_id = $product->get_stock_managed_by_id(); |
| 128 | |
| 129 | /** |
| 130 | * Filter order item quantity. |
| 131 | * |
| 132 | * @since 4.5.0 |
| 133 | * @param int|float $quantity Quantity. |
| 134 | * @param \WC_Order $order Order data. |
| 135 | * @param \WC_Order_Item_Product $item Order item data. |
| 136 | */ |
| 137 | $item_quantity = apply_filters( 'woocommerce_order_item_quantity', $item->get_quantity(), $order, $item ); |
| 138 | |
| 139 | $rows[ $managed_by_id ] = $item_quantity + ( $rows[ $managed_by_id ] ?? 0 ); |
| 140 | |
| 141 | if ( count( $held_stock_notes ) < 5 ) { |
| 142 | // translators: %1$s is a product's formatted name, %2$d: is the quantity of said product to which the stock hold applied. |
| 143 | $held_stock_notes[] = sprintf( _x( '- %1$s × %2$d', 'held stock note', 'woocommerce' ), $product->get_formatted_name(), $rows[ $managed_by_id ] ); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | if ( ! empty( $rows ) ) { |
| 148 | // Reliability: consistent lock order = no cross-product ordering deadlocks from concurrent orders with same products added in different sequences. |
| 149 | ksort( $rows ); |
| 150 | foreach ( $rows as $product_id => $quantity ) { |
| 151 | $this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes ); |
| 152 | } |
| 153 | } |
| 154 | } catch ( ReserveStockException $e ) { |
| 155 | $this->release_stock_for_order( $order ); |
| 156 | throw $e; |
| 157 | } |
| 158 | |
| 159 | // Add order note after successfully holding the stock. |
| 160 | if ( ! empty( $held_stock_notes ) ) { |
| 161 | $remaining_count = count( $rows ) - count( $held_stock_notes ); |
| 162 | if ( $remaining_count > 0 ) { |
| 163 | $held_stock_notes[] = sprintf( |
| 164 | // translators: %d is the remaining order items count. |
| 165 | _nx( '- ...and %d more item.', '- ... and %d more items.', $remaining_count, 'held stock note', 'woocommerce' ), |
| 166 | $remaining_count |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | $order->add_order_note( |
| 171 | sprintf( |
| 172 | // translators: %1$s is a time in minutes, %2$s is a list of products and quantities. |
| 173 | _x( 'Stock hold of %1$s minutes applied to: %2$s', 'held stock note', 'woocommerce' ), |
| 174 | $minutes, |
| 175 | '<br>' . implode( '<br>', $held_stock_notes ) |
| 176 | ), |
| 177 | false, |
| 178 | false, |
| 179 | array( |
| 180 | 'note_group' => OrderNoteGroup::PRODUCT_STOCK, |
| 181 | ) |
| 182 | ); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Release a temporary hold on stock for an order. |
| 188 | * |
| 189 | * @param \WC_Order $order Order object. |
| 190 | */ |
| 191 | public function release_stock_for_order( $order ) { |
| 192 | global $wpdb; |
| 193 | |
| 194 | if ( ! $this->is_enabled() ) { |
| 195 | return; |
| 196 | } |
| 197 | |
| 198 | $wpdb->delete( |
| 199 | $wpdb->wc_reserved_stock, |
| 200 | array( |
| 201 | 'order_id' => $order->get_id(), |
| 202 | ) |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Reserve stock for a product by inserting rows into the DB. |
| 208 | * |
| 209 | * @throws ReserveStockException If a row cannot be inserted. |
| 210 | * |
| 211 | * @param int $product_id Product ID which is having stock reserved. |
| 212 | * @param int $stock_quantity Stock amount to reserve. |
| 213 | * @param \WC_Order $order Order object which contains the product. |
| 214 | * @param int $minutes How long to reserve stock in minutes. |
| 215 | */ |
| 216 | private function reserve_stock_for_product( $product_id, $stock_quantity, $order, $minutes ) { |
| 217 | global $wpdb; |
| 218 | |
| 219 | $query_for_stock = \WC_Data_Store::load( 'product' )->get_query_for_stock( $product_id ); |
| 220 | $query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() ); |
| 221 | |
| 222 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 223 | $sql = $wpdb->prepare( |
| 224 | " |
| 225 | INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` ) |
| 226 | SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL |
| 227 | WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d |
| 228 | ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` ) |
| 229 | ", |
| 230 | $order->get_id(), |
| 231 | $product_id, |
| 232 | $stock_quantity, |
| 233 | $minutes, |
| 234 | $stock_quantity |
| 235 | ); |
| 236 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 237 | |
| 238 | // Reliability: high concurrency on the same product reservation can trigger deadlocks (error codes 1213 and 1205). |
| 239 | // We currently do not have a reliable method to identify lock errors. The $wpdb interface does not consistently provide |
| 240 | // error codes, and error messages can vary by database locale. Previously, we matched messages to 'try restarting transaction'. |
| 241 | for ( $attempt = 0; $attempt < 3; ++$attempt ) { |
| 242 | $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared |
| 243 | if ( false !== $result ) { |
| 244 | break; |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | if ( ! $result ) { |
| 249 | $product = wc_get_product( $product_id ); |
| 250 | throw new ReserveStockException( |
| 251 | 'woocommerce_product_not_enough_stock', |
| 252 | sprintf( |
| 253 | /* translators: %s: product name */ |
| 254 | __( 'Not enough units of %s are available in stock to fulfil this order.', 'woocommerce' ), |
| 255 | $product ? $product->get_name() : '#' . $product_id |
| 256 | ), |
| 257 | 403 |
| 258 | ); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Returns query statement for getting reserved stock of a product. |
| 264 | * |
| 265 | * @param int $product_id Product ID. |
| 266 | * @param int $exclude_order_id Optional order to exclude from the results. |
| 267 | * @return string|void Query statement. |
| 268 | */ |
| 269 | private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) { |
| 270 | global $wpdb; |
| 271 | |
| 272 | if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { |
| 273 | $join = "{$wpdb->prefix}wc_orders orders ON stock_table.`order_id` = orders.id"; |
| 274 | $where_status = "orders.status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )"; |
| 275 | } else { |
| 276 | $join = "{$wpdb->posts} posts ON stock_table.`order_id` = posts.ID"; |
| 277 | $where_status = "posts.post_status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )"; |
| 278 | } |
| 279 | |
| 280 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 281 | $query = $wpdb->prepare( |
| 282 | " |
| 283 | SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table |
| 284 | LEFT JOIN $join |
| 285 | WHERE $where_status |
| 286 | AND stock_table.`expires` > NOW() |
| 287 | AND stock_table.`product_id` = %d |
| 288 | AND stock_table.`order_id` != %d |
| 289 | ", |
| 290 | $product_id, |
| 291 | $exclude_order_id |
| 292 | ); |
| 293 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 294 | |
| 295 | /** |
| 296 | * Filter: woocommerce_query_for_reserved_stock |
| 297 | * Allows to filter the query for getting reserved stock of a product. |
| 298 | * |
| 299 | * @since 4.5.0 |
| 300 | * @param string $query The query for getting reserved stock of a product. |
| 301 | * @param int $product_id Product ID. |
| 302 | * @param int $exclude_order_id Order to exclude from the results. |
| 303 | */ |
| 304 | return apply_filters( 'woocommerce_query_for_reserved_stock', $query, $product_id, $exclude_order_id ); |
| 305 | } |
| 306 | } |
| 307 |