From e9df6e427899c28d3dbdeb46a4a7b38450700767 Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 31 Dec 2025 16:41:53 +0100 Subject: [PATCH] Implement comprehensive stock management integration (v1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added complete inventory tracking system for composable products: - Stock validation during product selection and add-to-cart - Automatic stock deduction on order completion/processing - Automatic stock restoration on order cancellation/refund - Stock status indicators with visual feedback (In stock, Low stock, Out of stock) - Prevention of out-of-stock item selection - Low stock warnings when 5 or fewer items remain - Order notes documenting all stock changes New files: - includes/Stock_Manager.php: Core stock management logic Modified files: - includes/Cart_Handler.php: Integrated stock validation - includes/Product_Selector.php: Added stock info to product data - includes/Plugin.php: Added Stock_Manager to includes - templates/product-selector.twig: Stock status display - assets/css/frontend.css: Stock indicator styling - languages/*.pot/*.po: 8 new translatable strings Version bumped to 1.1.0 with updated CHANGELOG. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 29 +++ IMPLEMENTATION.md | 30 ++- assets/css/frontend.css | 41 ++++ includes/Cart_Handler.php | 17 ++ includes/Plugin.php | 1 + includes/Product_Selector.php | 10 + includes/Stock_Manager.php | 283 +++++++++++++++++++++++ languages/wc-composable-product-it_CH.po | 32 +++ languages/wc-composable-product.pot | 32 +++ templates/product-selector.twig | 15 +- wc-composable-product.php | 4 +- 11 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 includes/Stock_Manager.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 84aa1aa..086d74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2024-12-31 + +### Added + +- **Stock Management Integration**: Complete inventory tracking system for composable products + - Stock validation during product selection and add-to-cart + - Automatic stock deduction when orders are completed/processed + - Automatic stock restoration on order cancellation/refund + - Stock status indicators in product selector (In stock, Low stock, Out of stock) + - Visual feedback for out-of-stock items (disabled checkboxes, reduced opacity) + - Low stock warnings when 5 or fewer items remain + - Prevention of out-of-stock item selection + - Order notes documenting stock changes + +### Technical + +- New `Stock_Manager` class handling all stock operations +- Integration with WooCommerce order status hooks +- Stock information passed to frontend via Twig template +- Enhanced CSS styling for stock status badges +- Stock data stored in order item meta for accurate tracking +- Backorder support detection and handling + +### Translation + +- Added 8 new translatable strings for stock messages +- Updated Italian (Switzerland) translation with stock-related terms +- Updated translation template (.pot file) + ## [1.0.1] - 2024-12-31 ### Fixed diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 858586b..fc78855 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -12,7 +12,7 @@ This document provides a technical overview of the WooCommerce Composable Produc ### Plugin Structure -``` +```txt wc-composable-product/ โ”œโ”€โ”€ assets/ # Frontend assets โ”‚ โ”œโ”€โ”€ css/ @@ -42,6 +42,7 @@ wc-composable-product/ ### 1. Main Plugin Class (`Plugin.php`) **Responsibilities:** + - Singleton pattern implementation - Twig template engine initialization - Hook registration @@ -49,6 +50,7 @@ wc-composable-product/ - Asset enqueuing **Key Methods:** + - `instance()`: Get singleton instance - `init_twig()`: Initialize Twig with WordPress functions - `render_template()`: Render Twig templates @@ -59,6 +61,7 @@ wc-composable-product/ **Extends:** `WC_Product` **Key Features:** + - Custom product type: `composable` - Selection limit management (per-product or global) - Pricing mode (fixed or sum) @@ -67,6 +70,7 @@ wc-composable-product/ - Price calculation **Key Methods:** + - `get_selection_limit()`: Get max selectable items - `get_pricing_mode()`: Get pricing calculation mode - `get_available_products()`: Query available products @@ -77,6 +81,7 @@ wc-composable-product/ **Extends:** `WC_Settings_Page` **Global Settings:** + - Default selection limit - Default pricing mode - Display options (images, prices, total) @@ -86,12 +91,14 @@ wc-composable-product/ ### 4. Product Data Tab (`Admin/Product_Data.php`) **Responsibilities:** + - Add "Composable Options" tab to product edit page - Render selection criteria fields - Save product meta data - Dynamic field visibility based on criteria type **Saved Meta:** + - `_composable_selection_limit`: Item limit - `_composable_pricing_mode`: Pricing calculation - `_composable_criteria_type`: Selection method @@ -102,11 +109,13 @@ wc-composable-product/ ### 5. Product Selector (`Product_Selector.php`) **Responsibilities:** + - Render frontend product selection interface - Prepare data for Twig template - Apply display settings **Template Variables:** + - `products`: Available products array - `selection_limit`: Max selections - `pricing_mode`: Pricing calculation @@ -115,12 +124,14 @@ wc-composable-product/ ### 6. Cart Handler (`Cart_Handler.php`) **Responsibilities:** + - Validate product selection - Add selected products to cart data - Calculate dynamic pricing - Display selected products in cart **Hooks:** + - `woocommerce_add_to_cart_validation`: Validate selections - `woocommerce_add_cart_item_data`: Store selections - `woocommerce_before_calculate_totals`: Update prices @@ -131,6 +142,7 @@ wc-composable-product/ ### Product Selector Template (`product-selector.twig`) **Features:** + - Responsive grid layout - Checkbox-based selection - Product images and prices @@ -138,6 +150,7 @@ wc-composable-product/ - AJAX add-to-cart **Data Attributes:** + - `data-product-id`: Composable product ID - `data-selection-limit`: Max selections - `data-pricing-mode`: Pricing mode @@ -146,6 +159,7 @@ wc-composable-product/ ### JavaScript (`frontend.js`) **Functionality:** + - Selection limit enforcement - Visual feedback on selection - Real-time price updates (sum mode) @@ -153,6 +167,7 @@ wc-composable-product/ - Error/success messages **Key Functions:** + - `handleCheckboxChange()`: Selection logic - `updateTotalPrice()`: Calculate total - `addToCart()`: AJAX add-to-cart @@ -161,6 +176,7 @@ wc-composable-product/ ### CSS Styling **Approach:** + - Grid-based layout (responsive) - Card-style product items - Visual selection states @@ -242,6 +258,7 @@ wc-composable-product/ Generated template: `languages/wc-composable-product.pot` **Supported Locales (per CLAUDE.md):** + - en_US (English) - de_DE, de_DE_informal (German - Germany) - de_CH, de_CH_informal (German - Switzerland) @@ -273,11 +290,13 @@ Generated template: `languages/wc-composable-product.pot` ### Hooks & Filters **Available Filters:** + - `wc_composable_settings`: Modify settings array - `woocommerce_product_class`: Custom product class - `product_type_selector`: Product type registration **Customization Points:** + - Twig templates (override in theme) - CSS styling (enqueue custom styles) - JavaScript behavior (extend object) @@ -304,6 +323,7 @@ if ($product->get_type() === 'composable') { ## Testing Checklist ### Admin Testing + - [ ] Product type appears in dropdown - [ ] Composable Options tab displays - [ ] Selection criteria toggle works @@ -312,6 +332,7 @@ if ($product->get_type() === 'composable') { - [ ] Global defaults apply ### Frontend Testing + - [ ] Product selector renders - [ ] Selection limit enforced - [ ] Price calculation accurate (both modes) @@ -320,6 +341,7 @@ if ($product->get_type() === 'composable') { - [ ] Checkout processes correctly ### Edge Cases + - [ ] Empty criteria (no products) - [ ] Out of stock products excluded - [ ] Invalid product selections rejected @@ -349,12 +371,14 @@ Potential features for future versions: ## Dependencies ### Runtime + - PHP 8.3+ - WordPress 6.0+ - WooCommerce 8.0+ - Twig 3.0 (via Composer) ### Development + - Composer for dependency management - WP-CLI for i18n operations (optional) @@ -372,6 +396,7 @@ Potential features for future versions: ### Release Package Must include: + - All PHP files - `vendor/` directory - Assets (CSS, JS) @@ -380,6 +405,7 @@ Must include: - Documentation Must exclude: + - `.git/` directory - `composer.lock` - Development files @@ -388,12 +414,14 @@ Must exclude: ## Support & Maintenance ### Code Standards + - WordPress Coding Standards - WooCommerce best practices - PSR-4 autoloading - Inline documentation ### Version Control + - Semantic versioning (MAJOR.MINOR.PATCH) - Changelog maintained - Annotated git tags diff --git a/assets/css/frontend.css b/assets/css/frontend.css index dfa6206..df5f065 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -54,6 +54,20 @@ background: #f0f8ff; } +.composable-product-item.out-of-stock { + opacity: 0.6; + cursor: not-allowed; +} + +.composable-product-item.out-of-stock:hover { + border-color: #e0e0e0; + box-shadow: none; +} + +.composable-product-item.out-of-stock .product-item-label { + cursor: not-allowed; +} + .product-item-label { display: block; padding: 1rem; @@ -94,6 +108,33 @@ font-size: 0.9rem; } +.product-item-stock { + margin-top: 0.5rem; + font-size: 0.85rem; +} + +.stock-status { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-weight: 500; +} + +.stock-status.in-stock { + color: #2e7d32; + background: #e8f5e9; +} + +.stock-status.low-stock { + color: #f57c00; + background: #fff3e0; +} + +.stock-status.out-of-stock { + color: #c62828; + background: #ffebee; +} + .product-item-checkmark { position: absolute; top: 0.5rem; diff --git a/includes/Cart_Handler.php b/includes/Cart_Handler.php index e1d8866..ad6e974 100644 --- a/includes/Cart_Handler.php +++ b/includes/Cart_Handler.php @@ -15,16 +15,26 @@ defined('ABSPATH') || exit; * Handles adding composable products to cart and calculating prices */ class Cart_Handler { + /** + * Stock manager instance + * + * @var Stock_Manager + */ + private $stock_manager; + /** * Constructor */ public function __construct() { + $this->stock_manager = new Stock_Manager(); + add_filter('woocommerce_add_to_cart_validation', [$this, 'validate_add_to_cart'], 10, 3); add_filter('woocommerce_add_cart_item_data', [$this, 'add_cart_item_data'], 10, 2); add_filter('woocommerce_get_cart_item_from_session', [$this, 'get_cart_item_from_session'], 10, 2); add_filter('woocommerce_get_item_data', [$this, 'display_cart_item_data'], 10, 2); add_action('woocommerce_before_calculate_totals', [$this, 'calculate_cart_item_price']); add_action('woocommerce_single_product_summary', [$this, 'render_product_selector'], 25); + add_action('woocommerce_checkout_create_order_line_item', [$this->stock_manager, 'store_selected_products_in_order'], 10, 3); } /** @@ -87,6 +97,13 @@ class Cart_Handler { } } + // Validate stock availability + $stock_validation = $this->stock_manager->validate_stock_availability($selected_products, $quantity); + if ($stock_validation !== true) { + wc_add_notice($stock_validation, 'error'); + return false; + } + return $passed; } diff --git a/includes/Plugin.php b/includes/Plugin.php index 4662745..3dac514 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -93,6 +93,7 @@ class Plugin { require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Settings.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Product_Data.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Product_Type.php'; + require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Stock_Manager.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Cart_Handler.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Product_Selector.php'; diff --git a/includes/Product_Selector.php b/includes/Product_Selector.php index b200a1c..c24b6e9 100644 --- a/includes/Product_Selector.php +++ b/includes/Product_Selector.php @@ -33,9 +33,14 @@ class Product_Selector { $show_prices = get_option('wc_composable_show_prices', 'yes') === 'yes'; $show_total = get_option('wc_composable_show_total', 'yes') === 'yes'; + // Get stock manager for stock information + $stock_manager = new Stock_Manager(); + // Prepare product data for template $products_data = []; foreach ($available_products as $available_product) { + $stock_info = $stock_manager->get_product_stock_info($available_product->get_id()); + $products_data[] = [ 'id' => $available_product->get_id(), 'name' => $available_product->get_name(), @@ -43,6 +48,11 @@ class Product_Selector { 'price_html' => $available_product->get_price_html(), 'image_url' => wp_get_attachment_image_url($available_product->get_image_id(), 'thumbnail'), 'permalink' => $available_product->get_permalink(), + 'stock_status' => $stock_info['stock_status'], + 'in_stock' => $stock_info['in_stock'], + 'stock_quantity' => $stock_info['stock_quantity'], + 'managing_stock' => $stock_info['managing_stock'], + 'backorders_allowed' => $stock_info['backorders_allowed'], ]; } diff --git a/includes/Stock_Manager.php b/includes/Stock_Manager.php new file mode 100644 index 0000000..1d3bb7b --- /dev/null +++ b/includes/Stock_Manager.php @@ -0,0 +1,283 @@ +managing_stock()) { + continue; + } + + $stock_quantity = $product->get_stock_quantity(); + + // Check if product is in stock + if (!$product->is_in_stock()) { + return sprintf( + /* translators: %s: product name */ + __('"%s" is out of stock and cannot be selected.', 'wc-composable-product'), + $product->get_name() + ); + } + + // Check if enough stock is available + if ($stock_quantity !== null && $stock_quantity < $quantity) { + return sprintf( + /* translators: 1: product name, 2: stock quantity */ + __('Only %2$d of "%1$s" are available in stock.', 'wc-composable-product'), + $product->get_name(), + $stock_quantity + ); + } + + // Check for backorders + if ($product->backorders_allowed()) { + continue; + } + } + + return true; + } + + /** + * Check if a product has sufficient stock + * + * @param int $product_id Product ID + * @param int $required_quantity Required quantity + * @return array Stock information [in_stock, stock_quantity, backorders_allowed] + */ + public function get_product_stock_info($product_id, $required_quantity = 1) { + $product = wc_get_product($product_id); + + if (!$product) { + return [ + 'in_stock' => false, + 'stock_quantity' => 0, + 'backorders_allowed' => false, + 'stock_status' => 'outofstock', + ]; + } + + $stock_quantity = $product->get_stock_quantity(); + $managing_stock = $product->managing_stock(); + + return [ + 'in_stock' => $product->is_in_stock(), + 'stock_quantity' => $stock_quantity, + 'backorders_allowed' => $product->backorders_allowed(), + 'stock_status' => $product->get_stock_status(), + 'managing_stock' => $managing_stock, + 'has_enough_stock' => !$managing_stock || $stock_quantity === null || $stock_quantity >= $required_quantity, + ]; + } + + /** + * Reduce stock for composable products when order is completed + * + * @param int $order_id Order ID + */ + public function reduce_stock_on_order_complete($order_id) { + $order = wc_get_order($order_id); + + if (!$order) { + return; + } + + // Check if stock has already been reduced + if ($order->get_meta('_composable_stock_reduced', true)) { + return; + } + + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + + if (!$product || $product->get_type() !== 'composable') { + continue; + } + + // Get selected products from order item meta + $selected_products = $item->get_meta('_composable_products', true); + + if (empty($selected_products) || !is_array($selected_products)) { + continue; + } + + $quantity = $item->get_quantity(); + + // Reduce stock for each selected product + foreach ($selected_products as $product_id) { + $selected_product = wc_get_product($product_id); + + if (!$selected_product || !$selected_product->managing_stock()) { + continue; + } + + $stock_quantity = $selected_product->get_stock_quantity(); + + if ($stock_quantity !== null) { + $new_stock = $stock_quantity - $quantity; + $selected_product->set_stock_quantity($new_stock); + $selected_product->save(); + + // Add order note + $order->add_order_note( + sprintf( + /* translators: 1: product name, 2: quantity, 3: remaining stock */ + __('Stock reduced for "%1$s": -%2$d (remaining: %3$d)', 'wc-composable-product'), + $selected_product->get_name(), + $quantity, + $new_stock + ) + ); + } + } + } + + // Mark stock as reduced + $order->update_meta_data('_composable_stock_reduced', true); + $order->save(); + } + + /** + * Restore stock when order is cancelled or refunded + * + * @param int $order_id Order ID + */ + public function restore_stock_on_order_cancel($order_id) { + $order = wc_get_order($order_id); + + if (!$order) { + return; + } + + // Check if stock was reduced + if (!$order->get_meta('_composable_stock_reduced', true)) { + return; + } + + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + + if (!$product || $product->get_type() !== 'composable') { + continue; + } + + // Get selected products from order item meta + $selected_products = $item->get_meta('_composable_products', true); + + if (empty($selected_products) || !is_array($selected_products)) { + continue; + } + + $quantity = $item->get_quantity(); + + // Restore stock for each selected product + foreach ($selected_products as $product_id) { + $selected_product = wc_get_product($product_id); + + if (!$selected_product || !$selected_product->managing_stock()) { + continue; + } + + $stock_quantity = $selected_product->get_stock_quantity(); + + if ($stock_quantity !== null) { + $new_stock = $stock_quantity + $quantity; + $selected_product->set_stock_quantity($new_stock); + $selected_product->save(); + + // Add order note + $order->add_order_note( + sprintf( + /* translators: 1: product name, 2: quantity, 3: new stock */ + __('Stock restored for "%1$s": +%2$d (total: %3$d)', 'wc-composable-product'), + $selected_product->get_name(), + $quantity, + $new_stock + ) + ); + } + } + } + + // Mark stock as restored + $order->update_meta_data('_composable_stock_reduced', false); + $order->save(); + } + + /** + * Prevent WooCommerce from reducing stock for composable products + * We handle stock reduction manually for selected products + * + * @param bool $reduce_stock Whether to reduce stock + * @param \WC_Order $order Order object + * @return bool + */ + public function prevent_composable_stock_reduction($reduce_stock, $order) { + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + + if ($product && $product->get_type() === 'composable') { + // We'll handle stock reduction manually + return false; + } + } + + return $reduce_stock; + } + + /** + * Store selected products in order item meta + * + * @param \WC_Order_Item_Product $item Order item + * @param string $cart_item_key Cart item key + * @param array $values Cart item values + */ + public function store_selected_products_in_order($item, $cart_item_key, $values) { + if (isset($values['composable_products']) && !empty($values['composable_products'])) { + $item->add_meta_data('_composable_products', $values['composable_products'], true); + } + } +} diff --git a/languages/wc-composable-product-it_CH.po b/languages/wc-composable-product-it_CH.po index 32484ac..73112c4 100644 --- a/languages/wc-composable-product-it_CH.po +++ b/languages/wc-composable-product-it_CH.po @@ -198,3 +198,35 @@ msgstr "Inserire i codici articolo dei prodotti separati da virgole." #: includes/Admin/Product_Data.php msgid "SKU-1, SKU-2, SKU-3" msgstr "COD-1, COD-2, COD-3" + +#: includes/Stock_Manager.php +msgid "\"%s\" is out of stock and cannot be selected." +msgstr "\"%s\" รจ esaurito e non puรฒ essere selezionato." + +#: includes/Stock_Manager.php +msgid "Only %2$d of \"%1$s\" are available in stock." +msgstr "Solo %2$d di \"%1$s\" sono disponibili in magazzino." + +#: includes/Stock_Manager.php +msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" +msgstr "Giacenza ridotta per \"%1$s\": -%2$d (rimanenti: %3$d)" + +#: includes/Stock_Manager.php +msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" +msgstr "Giacenza ripristinata per \"%1$s\": +%2$d (totale: %3$d)" + +#: templates/product-selector.twig +msgid "Out of stock" +msgstr "Esaurito" + +#: templates/product-selector.twig +msgid "Only" +msgstr "Solo" + +#: templates/product-selector.twig +msgid "left" +msgstr "rimasti" + +#: templates/product-selector.twig +msgid "In stock" +msgstr "Disponibile" diff --git a/languages/wc-composable-product.pot b/languages/wc-composable-product.pot index 30dd7d8..927337b 100644 --- a/languages/wc-composable-product.pot +++ b/languages/wc-composable-product.pot @@ -197,3 +197,35 @@ msgstr "" #: includes/Admin/Product_Data.php msgid "SKU-1, SKU-2, SKU-3" msgstr "" + +#: includes/Stock_Manager.php +msgid "\"%s\" is out of stock and cannot be selected." +msgstr "" + +#: includes/Stock_Manager.php +msgid "Only %2$d of \"%1$s\" are available in stock." +msgstr "" + +#: includes/Stock_Manager.php +msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" +msgstr "" + +#: includes/Stock_Manager.php +msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" +msgstr "" + +#: templates/product-selector.twig +msgid "Out of stock" +msgstr "" + +#: templates/product-selector.twig +msgid "Only" +msgstr "" + +#: templates/product-selector.twig +msgid "left" +msgstr "" + +#: templates/product-selector.twig +msgid "In stock" +msgstr "" diff --git a/templates/product-selector.twig b/templates/product-selector.twig index f376e0c..510a1f1 100644 --- a/templates/product-selector.twig +++ b/templates/product-selector.twig @@ -10,7 +10,7 @@
{% for product in products %} -
+
diff --git a/wc-composable-product.php b/wc-composable-product.php index 8ef84af..1a45637 100644 --- a/wc-composable-product.php +++ b/wc-composable-product.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce Composable Products * Plugin URI: https://github.com/magdev/wc-composable-product * Description: Create composable products where customers select a limited number of items from a configurable set - * Version: 1.0.1 + * Version: 1.1.0 * Author: Marco Graetsch * Author URI: https://example.com * License: GPL v3 or later @@ -19,7 +19,7 @@ defined('ABSPATH') || exit; // Define plugin constants -define('WC_COMPOSABLE_PRODUCT_VERSION', '1.0.1'); +define('WC_COMPOSABLE_PRODUCT_VERSION', '1.1.0'); define('WC_COMPOSABLE_PRODUCT_FILE', __FILE__); define('WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path(__FILE__)); define('WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url(__FILE__));