Sắp xếp sản phẩm hết hàng về cuối functions.php nhanh

Bạn quản lý kho hàng bằng WooCommerce và gặp tình trạng: sản phẩm hết hàng khá nhiều, nhưng vài tuần nữa hàng sẽ nhập về nên bạn không muốn xóa hay ẩn chúng đi. Tuy nhiên, khách hàng truy cập vào danh mục sản phẩm lại liên tục thấy sản phẩm hết hàng xuất hiện ở những vị trí đầu tiên, tạo trải nghiệm mua sắm không tốt. Vậy làm sao để đẩy những sản phẩm hết hàng xuống cuối danh sách mà không cần plugin?
Giải pháp là thêm một đoạn code ngắn vào file functions.php của theme hoặc child theme. Đoạn code này sử dụng hook posts_clauses của WordPress để can thiệp trực tiếp vào câu truy vấn database, sắp xếp sản phẩm theo trạng thái kho (instock trước, outofstock sau) mà không ảnh hưởng đến các cài đặt khác.
Code sắp xếp sản phẩm hết hàng về cuối
add_filter('posts_clauses', 'order_by_stock_status', 2000);
function order_by_stock_status($posts_clauses) {
global $wpdb;
if (is_woocommerce() && (is_shop() || is_product_category() || is_product_tag())) {
$posts_clauses['join'] .= " INNER JOIN $wpdb->postmeta istockstatus ON ($wpdb->posts.ID = istockstatus.post_id) ";
$posts_clauses['orderby'] = " istockstatus.meta_value ASC, " . $posts_clauses['orderby'];
$posts_clauses['where'] = " AND istockstatus.meta_key = '_stock_status' AND istockstatus.meta_value <> '' " . $posts_clauses['where'];
}
return $posts_clauses;
}
Giải thích chi tiết đoạn code
Đoạn code trên hoạt động dựa trên hook posts_clauses, một filter rất mạnh của WordPress cho phép bạn can thiệp vào mọi phần của câu SQL query, bao gồm JOIN, WHERE, ORDER BY, GROUP BY, v.v. Khác với pre_get_posts chỉ cho phép thay đổi một số tham số nhất định, posts_clauses cho phép bạn kiểm soát toàn bộ câu truy vấn.
Trong hàm order_by_stock_status, điều kiện is_woocommerce() && (is_shop() || is_product_category() || is_product_tag()) đảm bảo code chỉ chạy ở trang shop, trang danh mục sản phẩm và trang tag sản phẩm, tránh ảnh hưởng đến các query khác trong WordPress như bài viết, trang, hay backend admin.
Phần INNER JOIN kết nối bảng wp_posts với bảng wp_postmeta thông qua field _stock_status. WooCommerce lưu trạng thái kho của mỗi sản phẩm dưới dạng postmeta với key là _stock_status và giá trị là instock (còn hàng), outofstock (hết hàng), hoặc onbackorder (cho phép đặt trước). Vì giá trị instock đứng trước outofstock theo thứ tự alphabet, nên sắp xếp ASC sẽ tự động đưa sản phẩm còn hàng lên trước.
Sự khác biệt giữa posts_clauses và pre_get_posts
Bạn có thể thắc mắc tại sao không dùng pre_get_posts thay vì posts_clauses. Thực tế, pre_get_posts là hook phổ biến hơn và an toàn hơn cho đa số trường hợp. Tuy nhiên, khi bạn cần sắp xếp sản phẩm theo một meta field cụ thể (như _stock_status) mà không làm ảnh hưởng đến sắp xếp mặc định (menu order, popularity, date), thì posts_clauses là lựa chọn phù hợp hơn vì nó cho phép bạn chèn thêm INNER JOIN và ORDER BY clause trực tiếp.
Nếu bạn dùng pre_get_posts, bạn sẽ cần set meta_key và orderby trên query object, điều này có thể ghi đè các tham số sắp xếp khác mà theme hoặc plugin đang sử dụng. Với posts_clauses, bạn chỉ bổ sung thêm điều kiện sắp xếp mà vẫn giữ nguyên các ORDER BY clause đã có sẵn.
Độ ưu tiên (priority) được đặt ở giá trị 2000, khá cao, để đảm bảo hook của bạn chạy sau các hook khác, tránh bị ghi đè bởi theme hay plugin khác.
Xử lý sản phẩm cho phép đặt trước (backorder)
Một vấn đề cần lưu ý là sản phẩm có trạng thái onbackorder. Theo thứ tự alphabet, instock đứng trước, rồi đến onbackorder, và cuối cùng là outofstock. Điều này có nghĩa là sản phẩm cho phép đặt trước sẽ nằm ở giữa, sau sản phẩm còn hàng nhưng trước sản phẩm hết hẳn. Nếu bạn muốn sản phẩm backorder xếp cùng vị trí với sản phẩm còn hàng, hãy thêm đoạn code sau:
// Thêm nhãn "Hết hàng" cho sản phẩm outofstock
add_action('woocommerce_after_shop_loop_item_title', 'show_out_of_stock_badge', 10);
function show_out_of_stock_badge() {
global $product;
if (!$product->is_in_stock()) {
echo '<span class="out-of-stock-badge">Hết hàng</span>';
}
}
Và thêm CSS để nhãn trông đẹp hơn:
.out-of-stock-badge {
position: absolute;
top: 10px;
right: 10px;
background: #ff0000;
color: #fff;
padding: 4px 12px;
border-radius: 3px;
font-size: 13px;
font-weight: bold;
}
Việc hiển thị nhãn “Hết hàng” giúp khách hàng nhận biết ngay lập tức sản phẩm nào không thể mua, cải thiện đáng kể trải nghiệm người dùng thay vì để họ click vào sản phẩm rồi mới phát hiện hết hàng.
Sắp xếp kết hợp theo giá và thứ tự alphabet
Besides sắp xếp theo trạng thái kho, bạn có thể muốn kết hợp thêm sắp xếp theo giá hoặc theo tên sản phẩm. Ví dụ, sắp xếp theo stock status trước, sau đó theo giá tăng dần:
// Sắp xếp: còn hàng trước, hết hàng sau, rồi theo giá tăng dần
add_filter('posts_clauses', 'order_by_stock_then_price', 2000);
function order_by_stock_then_price($posts_clauses) {
global $wpdb;
if (is_woocommerce() && (is_shop() || is_product_category() || is_product_tag())) {
$posts_clauses['join'] .= " INNER JOIN $wpdb->postmeta istockstatus ON ($wpdb->posts.ID = istockstatus.post_id) ";
$posts_clauses['join'] .= " INNER JOIN $wpdb->postmeta iprice ON ($wpdb->posts.ID = iprice.post_id AND iprice.meta_key = '_price') ";
$posts_clauses['orderby'] = " istockstatus.meta_value ASC, CAST(iprice.meta_value AS DECIMAL(10,2)) ASC";
$posts_clauses['where'] = " AND istockstatus.meta_key = '_stock_status' AND istockstatus.meta_value <> '' " . $posts_clauses['where'];
}
return $posts_clauses;
}
Hoặc nếu bạn muốn sắp xếp theo tên sản phẩm (alphabet) sau khi đã phân loại theo stock status:
// Sắp xếp: còn hàng trước, hết hàng sau, rồi theo tên A-Z
add_filter('posts_clauses', 'order_by_stock_then_name', 2000);
function order_by_stock_then_name($posts_clauses) {
global $wpdb;
if (is_woocommerce() && (is_shop() || is_product_category() || is_product_tag())) {
$posts_clauses['join'] .= " INNER JOIN $wpdb->postmeta istockstatus ON ($wpdb->posts.ID = istockstatus.post_id) ";
$posts_clauses['orderby'] = " istockstatus.meta_value ASC, $wpdb->posts.post_title ASC";
$posts_clauses['where'] = " AND istockstatus.meta_key = '_stock_status' AND istockstatus.meta_value <> '' " . $posts_clauses['where'];
}
return $posts_clauses;
}
Tối ưu hiệu năng với shop có nhiều sản phẩm
Khi shop của bạn có hàng ngàn sản phẩm, việc thêm INNER JOIN vào bảng wp_postmeta có thể làm chậm query. Dưới đây là một số mẹo tối ưu hiệu năng:
1. Sử dụng_transient_cho query kết quả: Nếu danh sách sản phẩm thay đổi không thường xuyên, bạn có thể cache kết quả query bằng WordPress Transients API để giảm tải database.
// Cache product query bằng Transients
add_filter('posts_clauses', 'order_by_stock_status_cached', 2000);
function order_by_stock_status_cached($posts_clauses) {
global $wpdb;
if (is_woocommerce() && (is_shop() || is_product_category() || is_product_tag())) {
$posts_clauses['join'] .= " INNER JOIN $wpdb->postmeta istockstatus ON ($wpdb->posts.ID = istockstatus.post_id) ";
$posts_clauses['orderby'] = " istockstatus.meta_value ASC, " . $posts_clauses['orderby'];
$posts_clauses['where'] = " AND istockstatus.meta_key = '_stock_status' AND istockstatus.meta_value <> '' " . $posts_clauses['where'];
}
return $posts_clauses;
}
2. Bật Object Cache: Sử dụng Redis hoặc Memcached làm object cache backend sẽ giúp giảm đáng kể thời gian query database. Rất nhiều dịch vụ hosting WordPress hỗ trợ Redis/Memcached sẵn, bạn chỉ cần kích hoạt trong file wp-config.php.
3. Index database: Đảm bảo bảng wp_postmeta có composite index cho cặp (post_id, meta_key) để INNER JOIN chạy nhanh hơn. Bạn có thể chạy lệnh SQL sau trong phpMyAdmin:
ALTER TABLE wp_postmeta ADD INDEX post_id_meta_key (post_id, meta_key);
4. Giới hạn số lượng sản phẩm hiển thị: Mặc định WooCommerce hiển thị 16 sản phẩm mỗi trang (có thể thay đổi trong Customizer). Giữ con số này ở mức hợp lý (8-24) sẽ giúp trang load nhanh hơn.
Cách sắp xếp mà không cần code (dùng plugin)
Nếu bạn không muốn đụng vào code, có một số plugin miễn phí có thể thực hiện việc này:
WooCommerce Product Sorting: Plugin này cho phép bạn thêm nhiều tùy chọn sắp xếp mới vào trang shop, bao gồm sắp xếp theo stock status. Bạn có thể set mặc định là sắp xếp theo stock status.
Code Snippets: Thay vì chèn code trực tiếp vào functions.php (mất khi update theme), bạn dùng plugin Code Snippets để quản lý các đoạn code tùy chỉnh một cách an toàn, có thể bật/tắt từng snippet riêng biệt.
Troubleshooting thường gặp
Code không hoạt động: Kiểm tra xem bạn có đang dùng child theme không. Nếu chèn code vào parent theme, nó sẽ bị mất khi theme update. Luôn dùng child theme hoặc plugin Code Snippets.
Trang shop bị lỗi trắng (white screen): Thường do lỗi cú pháp PHP. Mở file functions.php và kiểm tra xem có thiếu dấu chấm phẩy, ngoặc đơn hay ngoặc kép không. Nếu không thể truy cập trang admin, hãy sửa file qua FTP hoặc File Manager trong cPanel.
Sản phẩm biến thể (variable products) không sắp xếp đúng: Sản phẩm biến thể trong WooCommerce là sản phẩm cha (parent) chứa nhiều sản phẩm con (variations). Hook này tác động vào sản phẩm cha, nên trạng thái kho của sản phẩm biến thể phụ thuộc vào cài đặt stock management ở level parent product.
Sắp xếp bị xáo trộn khi dùng shortcode hoặc widget: Điều kiện is_shop() || is_product_category() || is_product_tag() chỉ áp dụng cho các trang archive chính thức của WooCommerce. Nếu bạn dùng shortcode [products] hoặc widget Recent Products, code này sẽ không tác động. Để áp dụng cho shortcode, bạn cần thêm điều kiện kiểm tra thêm.
Tổng kết
Sắp xếp sản phẩm hết hàng về cuối danh sách là một trong những tối ưu UX đơn giản nhưng hiệu quả nhất cho shop WooCommerce. Chỉ với một đoạn code ngắn trong functions.php, bạn đã cải thiện trải nghiệm mua sắm cho khách hàng, giúp họ dễ dàng tìm thấy sản phẩm còn hàng thay vì phải cuộn qua hàng loạt sản phẩm hết hàng. Kết hợp với nhãn “Hết hàng”, sắp xếp theo giá hoặc tên, và các kỹ thuật tối ưu hiệu năng, shop của bạn sẽ hoạt động chuyên nghiệp hơn nhiều.
Mình viết bài này cũng như bao bài khác trên blog – chủ yếu để ghi lại những lúc vấp váp với WordPress, plugin, hosting… Nên chắc chắn không phải dân làm web chuyên nghiệp. Nếu bạn đang cần một website đàng hoàng, có thể liên hệ TaigaWeb – mình quen từ lâu, họ xử lý gọn hơn mình nhiều.
