diff --git a/assets/js/admin/meta-boxes-product-variation.js b/assets/js/admin/meta-boxes-product-variation.js index 55ac9327559..551ad33a114 100644 --- a/assets/js/admin/meta-boxes-product-variation.js +++ b/assets/js/admin/meta-boxes-product-variation.js @@ -763,6 +763,7 @@ jQuery( function( $ ) { case 'variable_regular_price' : case 'variable_sale_price' : case 'variable_stock' : + case 'variable_low_stock_amount' : case 'variable_weight' : case 'variable_length' : case 'variable_width' : diff --git a/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/includes/admin/meta-boxes/class-wc-meta-box-product-data.php index 8438a8ad3ea..13d70c47f40 100644 --- a/includes/admin/meta-boxes/class-wc-meta-box-product-data.php +++ b/includes/admin/meta-boxes/class-wc-meta-box-product-data.php @@ -509,6 +509,7 @@ class WC_Meta_Box_Product_Data { ), 'manage_stock' => isset( $_POST['variable_manage_stock'][ $i ] ), 'stock_quantity' => $stock, + 'low_stock_amount' => isset( $_POST['variable_low_stock_amount'][ $i ] ) && '' !== $_POST['variable_low_stock_amount'][ $i ] ? wc_stock_amount( wp_unslash( $_POST['variable_low_stock_amount'][ $i ] ) ) : '', 'backorders' => isset( $_POST['variable_backorders'], $_POST['variable_backorders'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_backorders'][ $i ] ) ) : null, 'stock_status' => isset( $_POST['variable_stock_status'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_stock_status'][ $i ] ) ) : null, 'image_id' => isset( $_POST['upload_image_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['upload_image_id'][ $i ] ) ) : null, diff --git a/includes/admin/meta-boxes/views/html-product-data-inventory.php b/includes/admin/meta-boxes/views/html-product-data-inventory.php index cb5d406b231..4bab3e0bd0e 100644 --- a/includes/admin/meta-boxes/views/html-product-data-inventory.php +++ b/includes/admin/meta-boxes/views/html-product-data-inventory.php @@ -75,10 +75,14 @@ if ( ! defined( 'ABSPATH' ) ) { array( 'id' => '_low_stock_amount', 'value' => $product_object->get_low_stock_amount( 'edit' ), - 'placeholder' => get_option( 'woocommerce_notify_low_stock_amount' ), + 'placeholder' => sprintf( + /* translators: %d: Amount of stock left */ + esc_attr__( 'Store-wide threshold (%d)', 'woocommerce' ), + esc_attr( get_option( 'woocommerce_notify_low_stock_amount' ) ) + ), 'label' => __( 'Low stock threshold', 'woocommerce' ), 'desc_tip' => true, - 'description' => __( 'When product stock reaches this amount you will be notified by email', 'woocommerce' ), + 'description' => __( 'When product stock reaches this amount you will be notified by email. It is possible to define different values for each variation individually. The shop default value can be set in Settings > Products > Inventory.', 'woocommerce' ), 'type' => 'number', 'custom_attributes' => array( 'step' => 'any', diff --git a/includes/admin/meta-boxes/views/html-product-data-variations.php b/includes/admin/meta-boxes/views/html-product-data-variations.php index 15af978f892..bc35103b2e6 100644 --- a/includes/admin/meta-boxes/views/html-product-data-variations.php +++ b/includes/admin/meta-boxes/views/html-product-data-variations.php @@ -75,6 +75,7 @@ if ( ! defined( 'ABSPATH' ) ) { + diff --git a/includes/admin/meta-boxes/views/html-variation-admin.php b/includes/admin/meta-boxes/views/html-variation-admin.php index 01a0a20e7eb..8570dc9ad3d 100644 --- a/includes/admin/meta-boxes/views/html-variation-admin.php +++ b/includes/admin/meta-boxes/views/html-variation-admin.php @@ -210,6 +210,35 @@ defined( 'ABSPATH' ) || exit; ) ); + $low_stock_placeholder = ( $product_object->get_manage_stock() && '' !== $product_object->get_low_stock_amount() ) + ? sprintf( + /* translators: %d: Amount of stock left */ + esc_attr__( 'Parent product\'s threshold (%d)', 'woocommerce' ), + esc_attr( $product_object->get_low_stock_amount() ) + ) + : sprintf( + /* translators: %d: Amount of stock left */ + esc_attr__( 'Store-wide threshold (%d)', 'woocommerce' ), + esc_attr( get_option( 'woocommerce_notify_low_stock_amount' ) ) + ); + + woocommerce_wp_text_input( + array( + 'id' => "variable_low_stock_amount{$loop}", + 'name' => "variable_low_stock_amount[{$loop}]", + 'value' => $variation_object->get_low_stock_amount( 'edit' ), + 'placeholder' => $low_stock_placeholder, + 'label' => __( 'Low stock threshold', 'woocommerce' ), + 'desc_tip' => true, + 'description' => __( 'When variation stock reaches this amount you will be notified by email. The default value for all variations can be set in the product Inventory tab. The shop default value can be set in Settings > Products > Inventory.', 'woocommerce' ), + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + ), + 'wrapper_class' => 'form-row', + ) + ); + /** * Variation options inventory action. * diff --git a/includes/class-wc-ajax.php b/includes/class-wc-ajax.php index 964c0e2a0bb..5dbd366f4a3 100644 --- a/includes/class-wc-ajax.php +++ b/includes/class-wc-ajax.php @@ -2301,6 +2301,32 @@ class WC_AJAX { } } + /** + * Bulk action - Set Low Stock Amount. + * + * @param array $variations List of variations. + * @param array $data Data to set. + * + * @used-by bulk_edit_variations + */ + private static function variation_bulk_action_variable_low_stock_amount( $variations, $data ) { + if ( ! isset( $data['value'] ) ) { + return; + } + + $low_stock_amount = wc_stock_amount( wc_clean( $data['value'] ) ); + + foreach ( $variations as $variation_id ) { + $variation = wc_get_product( $variation_id ); + if ( $variation->managing_stock() ) { + $variation->set_low_stock_amount( $low_stock_amount ); + } else { + $variation->set_low_stock_amount( '' ); + } + $variation->save(); + } + } + /** * Bulk action - Set Weight. * @@ -2547,6 +2573,7 @@ class WC_AJAX { * @uses WC_AJAX::variation_bulk_action_toggle_virtual() * @uses WC_AJAX::variation_bulk_action_toggle_downloadable() * @uses WC_AJAX::variation_bulk_action_toggle_enabled + * @uses WC_AJAX::variation_bulk_action_variable_low_stock_amount() */ public static function bulk_edit_variations() { ob_start(); diff --git a/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/includes/data-stores/class-wc-product-variation-data-store-cpt.php index 534ba382ef8..cf481afc41a 100644 --- a/includes/data-stores/class-wc-product-variation-data-store-cpt.php +++ b/includes/data-stores/class-wc-product-variation-data-store-cpt.php @@ -357,6 +357,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl 'date_on_sale_to' => get_post_meta( $id, '_sale_price_dates_to', true ), 'manage_stock' => get_post_meta( $id, '_manage_stock', true ), 'stock_status' => get_post_meta( $id, '_stock_status', true ), + 'low_stock_amount' => get_post_meta( $id, '_low_stock_amount', true ), 'shipping_class_id' => current( $this->get_term_ids( $id, 'product_shipping_class' ) ), 'virtual' => get_post_meta( $id, '_virtual', true ), 'downloadable' => get_post_meta( $id, '_downloadable', true ), @@ -404,7 +405,6 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl 'sku' => get_post_meta( $product->get_parent_id(), '_sku', true ), 'manage_stock' => get_post_meta( $product->get_parent_id(), '_manage_stock', true ), 'backorders' => get_post_meta( $product->get_parent_id(), '_backorders', true ), - 'low_stock_amount' => get_post_meta( $product->get_parent_id(), '_low_stock_amount', true ), 'stock_quantity' => wc_stock_amount( get_post_meta( $product->get_parent_id(), '_stock', true ) ), 'weight' => get_post_meta( $product->get_parent_id(), '_weight', true ), 'length' => get_post_meta( $product->get_parent_id(), '_length', true ), diff --git a/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php index 7d06b2573d2..2e80879f194 100644 --- a/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php +++ b/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php @@ -766,6 +766,9 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller { case 'backordered': $base_data['backordered'] = $product->is_on_backorder(); break; + case 'low_stock_amount': + $base_data['low_stock_amount'] = '' === $product->get_low_stock_amount() ? null : $product->get_low_stock_amount(); + break; case 'sold_individually': $base_data['sold_individually'] = $product->is_sold_individually(); break; diff --git a/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php index 417804ff383..9d2000b23eb 100644 --- a/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php +++ b/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php @@ -65,6 +65,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V 'backorders' => $object->get_backorders(), 'backorders_allowed' => $object->backorders_allowed(), 'backordered' => $object->is_on_backorder(), + 'low_stock_amount' => '' === $object->get_low_stock_amount() ? null : $object->get_low_stock_amount(), 'weight' => $object->get_weight(), 'dimensions' => array( 'length' => $object->get_length(), @@ -185,9 +186,18 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V $stock_quantity += wc_stock_amount( $request['inventory_delta'] ); $variation->set_stock_quantity( $stock_quantity ); } + // isset() returns false for value null, thus we need to check whether the value has been sent by the request. + if ( array_key_exists( 'low_stock_amount', $request->get_params() ) ) { + if ( null === $request['low_stock_amount'] ) { + $variation->set_low_stock_amount( '' ); + } else { + $variation->set_low_stock_amount( wc_stock_amount( $request['low_stock_amount'] ) ); + } + } } else { $variation->set_backorders( 'no' ); $variation->set_stock_quantity( '' ); + $variation->set_low_stock_amount( '' ); } // Regular Price. @@ -597,6 +607,11 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'low_stock_amount' => array( + 'description' => __( 'Low Stock amount for the variation.', 'woocommerce' ), + 'type' => array( 'integer', 'null' ), + 'context' => array( 'view', 'edit' ), + ), 'weight' => array( /* translators: %s: weight unit */ 'description' => sprintf( __( 'Variation weight (%s).', 'woocommerce' ), $weight_unit ), diff --git a/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index bad0b9bca14..00966cedc30 100644 --- a/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -551,11 +551,22 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { $stock_quantity += wc_stock_amount( $request['inventory_delta'] ); $product->set_stock_quantity( wc_stock_amount( $stock_quantity ) ); } + + // Low stock amount. + // isset() returns false for value null, thus we need to check whether the value has been sent by the request. + if ( array_key_exists( 'low_stock_amount', $request->get_params() ) ) { + if ( null === $request['low_stock_amount'] ) { + $product->set_low_stock_amount( '' ); + } else { + $product->set_low_stock_amount( wc_stock_amount( $request['low_stock_amount'] ) ); + } + } } else { // Don't manage stock. $product->set_manage_stock( 'no' ); $product->set_stock_quantity( '' ); $product->set_stock_status( $stock_status ); + $product->set_low_stock_amount( '' ); } } elseif ( ! $product->is_type( 'variable' ) ) { $product->set_stock_status( $stock_status ); @@ -985,6 +996,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'low_stock_amount' => array( + 'description' => __( 'Low Stock amount for the product.', 'woocommerce' ), + 'type' => array( 'integer', 'null' ), + 'context' => array( 'view', 'edit' ), + ), 'sold_individually' => array( 'description' => __( 'Allow one item to be bought in a single order.', 'woocommerce' ), 'type' => 'boolean', diff --git a/includes/wc-stock-functions.php b/includes/wc-stock-functions.php index 4cbf1ed90cc..390f2358bc1 100644 --- a/includes/wc-stock-functions.php +++ b/includes/wc-stock-functions.php @@ -390,15 +390,23 @@ add_action( 'woocommerce_order_status_on-hold', 'wc_release_stock_for_order', 11 /** * Return low stock amount to determine if notification needs to be sent * + * Since 5.3.0, this function no longer redirects from variation to its parent product. + * Low stock amount can now be attached to the variation itself and if it isn't, only + * then we check the parent product, and if it's not there, then we take the default + * from the store-wide setting. + * * @param WC_Product $product Product to get data from. * @since 3.5.0 * @return int */ function wc_get_low_stock_amount( WC_Product $product ) { - if ( $product->is_type( 'variation' ) ) { - $product = wc_get_product( $product->get_parent_id() ); - } $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(); + } + if ( '' === $low_stock_amount ) { $low_stock_amount = get_option( 'woocommerce_notify_low_stock_amount', 2 ); } diff --git a/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php index 98bfda23c6b..b0a3e805094 100644 --- a/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php +++ b/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php @@ -6,6 +6,9 @@ * @since 3.5.0 */ +/** + * Product_Variations_API class. + */ class Product_Variations_API extends WC_REST_Unit_Test_Case { /** @@ -397,7 +400,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 37, count( $properties ) ); + $this->assertEquals( 38, count( $properties ) ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'date_created', $properties ); $this->assertArrayHasKey( 'date_modified', $properties ); @@ -424,6 +427,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case { $this->assertArrayHasKey( 'backorders', $properties ); $this->assertArrayHasKey( 'backorders_allowed', $properties ); $this->assertArrayHasKey( 'backordered', $properties ); + $this->assertArrayHasKey( 'low_stock_amount', $properties ); $this->assertArrayHasKey( 'weight', $properties ); $this->assertArrayHasKey( 'dimensions', $properties ); $this->assertArrayHasKey( 'shipping_class', $properties ); diff --git a/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php b/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php index fa00aa68af9..72fa8055563 100644 --- a/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php +++ b/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php @@ -642,7 +642,7 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 65, count( $properties ) ); + $this->assertEquals( 66, count( $properties ) ); } /** diff --git a/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php index ebc6d0fdfdc..be242cded44 100644 --- a/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php +++ b/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php @@ -65,6 +65,7 @@ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case { 'backorders', 'backorders_allowed', 'backordered', + 'low_stock_amount', 'sold_individually', 'weight', 'dimensions', diff --git a/tests/php/includes/wc-stock-functions-tests.php b/tests/php/includes/wc-stock-functions-tests.php index d815eeee5d8..368eab017cf 100644 --- a/tests/php/includes/wc-stock-functions-tests.php +++ b/tests/php/includes/wc-stock-functions-tests.php @@ -191,4 +191,159 @@ class WC_Stock_Functions_Tests extends \WC_Unit_Test_Case { } } } + + /** + * Test wc_get_low_stock_amount with a simple product which has low stock amount set. + */ + public function test_wc_get_low_stock_amount_simple_set() { + $product_low_stock_amount = 5; + $site_wide_low_stock_amount = 3; + + // Set the store-wide default. + update_option( 'woocommerce_notify_low_stock_amount', $site_wide_low_stock_amount ); + + // Simple product, set low stock amount. + $product = WC_Helper_Product::create_simple_product( + true, + array( + 'manage_stock' => true, + 'stock_quantity' => 10, + 'low_stock_amount' => $product_low_stock_amount, + ) + ); + + $this->assertEquals( $product_low_stock_amount, wc_get_low_stock_amount( $product ) ); + } + + /** + * Test wc_get_low_stock_amount with a simple product which doesn't have low stock amount set. + */ + public function test_wc_get_low_stock_amount_simple_unset() { + $site_wide_low_stock_amount = 3; + + // Set the store-wide default. + update_option( 'woocommerce_notify_low_stock_amount', $site_wide_low_stock_amount ); + + // Simple product, don't set low stock amount. + $product = WC_Helper_Product::create_simple_product( + true, + array( + 'manage_stock' => true, + 'stock_quantity' => 10, + ) + ); + + $this->assertEquals( $site_wide_low_stock_amount, wc_get_low_stock_amount( $product ) ); + } + + /** + * Test wc_get_low_stock_amount with a variable product which has low stock amount set on the variation level, + * but not on the parent level. Should use the value from the variation. + */ + public function test_wc_get_low_stock_amount_variation_set_parent_unset() { + $site_wide_low_stock_amount = 3; + $variation_low_stock_amount = 7; + + // Set the store-wide default. + update_option( 'woocommerce_notify_low_stock_amount', $site_wide_low_stock_amount ); + + // Parent low stock amount NOT set. + $variable_product = WC_Helper_Product::create_variation_product(); + $variable_product->set_manage_stock( false ); + $variable_product->save(); + + // Set the variation low stock amount. + $variations = $variable_product->get_available_variations( 'objects' ); + $var1 = $variations[0]; + $var1->set_manage_stock( true ); + $var1->set_low_stock_amount( $variation_low_stock_amount ); + $var1->save(); + + $this->assertEquals( $variation_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); + + // Even after turning on manage stock on the parent, but with no value. + $variable_product->set_manage_stock( true ); + $variable_product->save(); + $this->assertEquals( $variation_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); + + // Ans also after turning the manage stock off again on the parent. + $variable_product->set_manage_stock( false ); + $variable_product->save(); + $this->assertEquals( $variation_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); + } + + /** + * Test wc_get_low_stock_amount with a variable product which has low stock amount set on the variation level, + * and also on the parent level. Should use the value from the variation. + */ + public function test_wc_get_low_stock_amount_variation_set_parent_set() { + $site_wide_low_stock_amount = 3; + $parent_low_stock_amount = 5; + $variation_low_stock_amount = 7; + + // Set the store-wide default. + update_option( 'woocommerce_notify_low_stock_amount', $site_wide_low_stock_amount ); + + // Set the parent low stock amount. + $variable_product = WC_Helper_Product::create_variation_product(); + $variable_product->set_manage_stock( true ); + $variable_product->set_low_stock_amount( $parent_low_stock_amount ); + $variable_product->save(); + + // Set the variation low stock amount. + $variations = $variable_product->get_available_variations( 'objects' ); + $var1 = $variations[0]; + $var1->set_manage_stock( true ); + $var1->set_low_stock_amount( $variation_low_stock_amount ); + $var1->save(); + + $this->assertEquals( $variation_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); + } + + /** + * Test wc_get_low_stock_amount with a variable product which has low stock amount set on the parent level, + * but NOT on the variation level. Should use the value from the parent. + */ + public function test_wc_get_low_stock_amount_variation_unset_parent_set() { + $site_wide_low_stock_amount = 3; + $parent_low_stock_amount = 5; + + // Set the store-wide default. + update_option( 'woocommerce_notify_low_stock_amount', $site_wide_low_stock_amount ); + + // Set the parent low stock amount. + $variable_product = WC_Helper_Product::create_variation_product(); + $variable_product->set_manage_stock( true ); + $variable_product->set_low_stock_amount( $parent_low_stock_amount ); + $variable_product->save(); + + // Don't set the variation low stock amount. + $variations = $variable_product->get_available_variations( 'objects' ); + $var1 = $variations[0]; + + $this->assertEquals( $parent_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); + } + + /** + * Test wc_get_low_stock_amount with a variable product which *doesn't have* low stock amount set either on the parent level, + * or on the variation level. Should use the value from the site-wide setting. + */ + public function test_wc_get_low_stock_amount_variation_unset_parent_unset() { + $site_wide_low_stock_amount = 3; + + // Set the store-wide default. + update_option( 'woocommerce_notify_low_stock_amount', $site_wide_low_stock_amount ); + + // Set the parent low stock amount. + $variable_product = WC_Helper_Product::create_variation_product(); + $variable_product->set_manage_stock( false ); + + // Don't set the variation low stock amount. + $variations = $variable_product->get_available_variations( 'objects' ); + $var1 = $variations[0]; + $var1->set_manage_stock( false ); + + $this->assertEquals( $site_wide_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); + } + }