Change when stock notif emails are triggered (#49583)

Moves the check for low/no stock to the point when a product's stock quantity is updated. This decouples the check from orders, since stock can also change in other contexts (such as updates via REST API).

Fixes #31664
This commit is contained in:
Corey McKrill 2024-08-08 10:22:54 -07:00 committed by GitHub
parent c6732011d6
commit 623b1f320f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 195 additions and 27 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Ensure low and no stock email notification routine is triggered whenever product stock changes

View File

@ -642,21 +642,21 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
// Fire actions to let 3rd parties know the stock is about to be changed.
if ( $product->is_type( 'variation' ) ) {
/**
* Action to signal that the value of 'stock_quantity' for a variation is about to change.
*
* @since 4.9
*
* @param int $product The variation whose stock is about to change.
*/
* Action to signal that the value of 'stock_quantity' for a variation is about to change.
*
* @param WC_Product $product The variation whose stock is about to change.
*
* @since 4.9
*/
do_action( 'woocommerce_variation_before_set_stock', $product );
} else {
/**
* Action to signal that the value of 'stock_quantity' for a product is about to change.
*
* @since 4.9
*
* @param int $product The product whose stock is about to change.
*/
* Action to signal that the value of 'stock_quantity' for a product is about to change.
*
* @param WC_Product $product The product whose stock is about to change.
*
* @since 4.9
*/
do_action( 'woocommerce_product_before_set_stock', $product );
}
break;
@ -732,16 +732,52 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
if ( in_array( 'stock_quantity', $this->updated_props, true ) ) {
if ( $product->is_type( 'variation' ) ) {
do_action( 'woocommerce_variation_set_stock', $product );
/**
* Action to signal that the value of 'stock_quantity' for a variation has changed.
*
* @since 3.0
* @since 9.2 Added $stock parameter.
*
* @param WC_Product $product The variation whose stock has changed.
* @param int|float $stock The new stock value.
*/
do_action( 'woocommerce_variation_set_stock', $product, $product->get_stock_quantity() );
} else {
do_action( 'woocommerce_product_set_stock', $product );
/**
* Action to signal that the value of 'stock_quantity' for a product has changed.
*
* @since 3.0
* @since 9.2 Added $stock parameter.
*
* @param WC_Product $product The variation whose stock has changed.
* @param int|float $stock The new stock value.
*/
do_action( 'woocommerce_product_set_stock', $product, $product->get_stock_quantity() );
}
}
if ( in_array( 'stock_status', $this->updated_props, true ) ) {
if ( $product->is_type( 'variation' ) ) {
/**
* Action to signal that the `stock_status` for a variation has changed.
*
* @since 3.0
*
* @param int $product_id The ID of the variation.
* @param string $stock_status The new stock status of the variation.
* @param WC_Product $product The product object.
*/
do_action( 'woocommerce_variation_set_stock_status', $product->get_id(), $product->get_stock_status(), $product );
} else {
/**
* Action to signal that the `stock_status` for a product has changed.
*
* @since 3.0
*
* @param int $product_id The ID of the product.
* @param string $stock_status The new stock status of the product.
* @param WC_Product $product The product object.
*/
do_action( 'woocommerce_product_set_stock_status', $product->get_id(), $product->get_stock_status(), $product );
}
}

View File

@ -42,8 +42,12 @@ function wc_update_product_stock( $product, $stock_quantity = null, $operation =
// Fire actions to let 3rd parties know the stock is about to be changed.
if ( $product_with_stock->is_type( 'variation' ) ) {
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */
do_action( 'woocommerce_variation_before_set_stock', $product_with_stock );
} else {
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */
do_action( 'woocommerce_product_before_set_stock', $product_with_stock );
}
@ -60,9 +64,13 @@ function wc_update_product_stock( $product, $stock_quantity = null, $operation =
// Fire actions to let 3rd parties know the stock changed.
if ( $product_with_stock->is_type( 'variation' ) ) {
do_action( 'woocommerce_variation_set_stock', $product_with_stock );
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */
do_action( 'woocommerce_variation_set_stock', $product_with_stock, $new_stock );
} else {
do_action( 'woocommerce_product_set_stock', $product_with_stock );
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/** This action is documented in includes/data-stores/class-wc-product-data-store-cpt.php */
do_action( 'woocommerce_product_set_stock', $product_with_stock, $new_stock );
}
return $product_with_stock->get_stock_quantity();
@ -234,19 +242,23 @@ function wc_trigger_stock_change_notifications( $order, $changes ) {
return;
}
$order_notes = array();
$no_stock_amount = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) );
$order_notes = array();
foreach ( $changes as $change ) {
$order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to'];
$low_stock_amount = absint( wc_get_low_stock_amount( wc_get_product( $change['product']->get_id() ) ) );
if ( $change['to'] <= $no_stock_amount ) {
do_action( 'woocommerce_no_stock', wc_get_product( $change['product']->get_id() ) );
} elseif ( $change['to'] <= $low_stock_amount ) {
do_action( 'woocommerce_low_stock', wc_get_product( $change['product']->get_id() ) );
}
$order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '&rarr;' . $change['to'];
if ( $change['to'] < 0 ) {
/**
* Action fires when an item in an order is backordered.
*
* @since 3.0
*
* @param array $args {
* @type WC_Product $product The product that is on backorder.
* @type int $order_id The ID of the order.
* @type int|float $quantity The amount of product on backorder.
* }
*/
do_action(
'woocommerce_product_on_backorder',
array(
@ -261,6 +273,44 @@ function wc_trigger_stock_change_notifications( $order, $changes ) {
$order->add_order_note( __( 'Stock levels reduced:', 'woocommerce' ) . ' ' . implode( ', ', $order_notes ) );
}
/**
* Check if a product's stock quantity has reached certain thresholds and trigger appropriate actions.
*
* This functionality was moved out of `wc_trigger_stock_change_notifications` in order to decouple it from orders,
* since stock quantity can also be updated in other ways.
*
* @param WC_Product $product The product whose stock level has changed.
* @param int|float $stock_quantity The new quantity of stock.
*
* @return void
*/
function wc_trigger_stock_change_actions( $product, $stock_quantity ) {
$no_stock_amount = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) );
$low_stock_amount = absint( wc_get_low_stock_amount( $product ) );
if ( $stock_quantity <= $no_stock_amount ) {
/**
* Action fires when a product's stock quantity reaches the "no stock" threshold.
*
* @since 3.0
*
* @param WC_Product $product The product whose stock quantity has changed.
*/
do_action( 'woocommerce_no_stock', $product );
} elseif ( $stock_quantity <= $low_stock_amount ) {
/**
* Action fires when a product's stock quantity reaches the "low stock" threshold.
*
* @since 3.0
*
* @param WC_Product $product The product whose stock quantity has changed.
*/
do_action( 'woocommerce_low_stock', $product );
}
}
add_action( 'woocommerce_variation_set_stock', 'wc_trigger_stock_change_actions', 10, 2 );
add_action( 'woocommerce_product_set_stock', 'wc_trigger_stock_change_actions', 10, 2 );
/**
* Increase stock levels for items within an order.
*
@ -431,8 +481,11 @@ function wc_get_low_stock_amount( WC_Product $product ) {
$low_stock_amount = $product->get_low_stock_amount();
if ( '' === $low_stock_amount && $product->is_type( 'variation' ) ) {
$product = wc_get_product( $product->get_parent_id() );
$low_stock_amount = $product->get_low_stock_amount();
$parent_product = wc_get_product( $product->get_parent_id() );
if ( $parent_product instanceof WC_Product ) {
$low_stock_amount = $parent_product->get_low_stock_amount();
}
}
if ( '' === $low_stock_amount ) {

View File

@ -357,4 +357,79 @@ class WC_Stock_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertIsIntAndEquals( $site_wide_low_stock_amount, wc_get_low_stock_amount( $var1 ) );
}
/**
* @testdox Test that the `woocommerce_low_stock` action fires when a product stock hits the low stock threshold.
*/
public function test_wc_update_product_stock_low_stock_action() {
$product = WC_Helper_Product::create_simple_product();
$product->set_manage_stock( true );
$product->save();
$low_stock_amount = wc_get_low_stock_amount( $product );
$initial_stock = $low_stock_amount + 2;
wc_update_product_stock( $product->get_id(), $initial_stock );
$action_fired = false;
$callback = function () use ( &$action_fired ) {
$action_fired = true;
};
add_action( 'woocommerce_low_stock', $callback );
// Test with `wc_update_product_stock`.
wc_update_product_stock( $product->get_id(), 1, 'decrease' );
$this->assertFalse( $action_fired );
wc_update_product_stock( $product->get_id(), 1, 'decrease' );
$this->assertTrue( $action_fired );
$action_fired = false;
// Test with the data store.
$product->set_stock_quantity( $initial_stock );
$product->save();
$this->assertFalse( $action_fired );
$product->set_stock_quantity( $low_stock_amount );
$product->save();
$this->assertTrue( $action_fired );
remove_action( 'woocommerce_low_stock', $callback );
}
/**
* @testdox Test that the `woocommerce_no_stock` action fires when a product stock hits the no stock threshold.
*/
public function test_wc_update_product_stock_no_stock_action() {
$product = WC_Helper_Product::create_simple_product();
$product->set_manage_stock( true );
$product->save();
$no_stock_amount = get_option( 'woocommerce_notify_no_stock_amount', 0 );
$initial_stock = $no_stock_amount + 2;
wc_update_product_stock( $product->get_id(), $initial_stock );
$action_fired = false;
$callback = function () use ( &$action_fired ) {
$action_fired = true;
};
add_action( 'woocommerce_no_stock', $callback );
// Test with `wc_update_product_stock`.
wc_update_product_stock( $product->get_id(), 1, 'decrease' );
$this->assertFalse( $action_fired );
wc_update_product_stock( $product->get_id(), 1, 'decrease' );
$this->assertTrue( $action_fired );
$action_fired = false;
// Test with the data store.
$product->set_stock_quantity( $initial_stock );
$product->save();
$this->assertFalse( $action_fired );
$product->set_stock_quantity( $no_stock_amount );
$product->save();
$this->assertTrue( $action_fired );
remove_action( 'woocommerce_no_stock', $callback );
}
}