rule_type = 'category'; // Load category-specific data. if ( isset( $rule_data['category_ids'] ) ) { $this->category_ids = is_array( $rule_data['category_ids'] ) ? $rule_data['category_ids'] : explode( ',', $rule_data['category_ids'] ); $this->category_ids = array_map( 'intval', $this->category_ids ); } // Load settings. if ( isset( $this->adjustments['include_subcategories'] ) ) { $this->include_subcategories = (bool) $this->adjustments['include_subcategories']; } if ( isset( $this->adjustments['excluded_products'] ) ) { $this->excluded_products = array_map( 'intval', (array) $this->adjustments['excluded_products'] ); } } /** * Check if rule is applicable. * * @param array $context Context data for evaluation. * @return bool */ public function is_applicable( $context ) { // Check if rule is active. if ( 'active' !== $this->status ) { return false; } // Check date range. if ( ! $this->is_within_date_range() ) { return false; } // Check if product is excluded. $product_id = isset( $context['product_id'] ) ? intval( $context['product_id'] ) : 0; $parent_id = isset( $context['parent_id'] ) ? intval( $context['parent_id'] ) : 0; if ( in_array( $product_id, $this->excluded_products, true ) ) { return false; } // Also check parent ID for variations. if ( $parent_id > 0 && in_array( $parent_id, $this->excluded_products, true ) ) { return false; } // Check if product belongs to specified categories. // If no categories are specified, treat it as "apply to all products". if ( ! empty( $this->category_ids ) ) { // Categories are specified - check if product matches. $product_categories = isset( $context['categories'] ) ? $context['categories'] : array(); // If no categories in context, fetch them. if ( empty( $product_categories ) && $product_id > 0 ) { $product_categories = $this->get_product_categories( $product_id ); } $matching_categories = array_intersect( $this->category_ids, $product_categories ); if ( empty( $matching_categories ) && $this->include_subcategories ) { // Check subcategories. $matching_categories = $this->check_subcategories( $product_categories ); } if ( empty( $matching_categories ) ) { return false; } } // No categories specified - apply to all products (like product rule with apply_to_all). // Just check conditions and let it pass through. // Check conditions. if ( ! $this->check_conditions( $context ) ) { return false; } return true; } /** * Calculate price based on rule. * * @param float $original_price Original price. * @param array $context Context data. * @return float Modified price. */ public function calculate_price( $original_price, $context ) { // Get adjustment based on quantity if quantity ranges are defined. $adjustment = $this->get_applicable_adjustment( $context ); if ( ! $adjustment ) { return $original_price; } // Apply the adjustment. return $this->apply_adjustment( $original_price, $adjustment ); } /** * Get applicable adjustment based on context. * * @param array $context Context data. * @return array|null Adjustment data or null. */ private function get_applicable_adjustment( $context ) { $quantity = isset( $context['quantity'] ) ? intval( $context['quantity'] ) : 1; // Check for quantity-based adjustments. if ( isset( $this->adjustments['quantity_ranges'] ) && ! empty( $this->adjustments['quantity_ranges'] ) ) { // Sort ranges by min quantity descending to check higher quantities first. $sorted_ranges = $this->adjustments['quantity_ranges']; usort( $sorted_ranges, function ( $a, $b ) { $min_a = isset( $a['min_quantity'] ) ? intval( $a['min_quantity'] ) : 0; $min_b = isset( $b['min_quantity'] ) ? intval( $b['min_quantity'] ) : 0; return $min_b - $min_a; // Descending order. } ); foreach ( $sorted_ranges as $range ) { $min_qty = isset( $range['min_quantity'] ) ? intval( $range['min_quantity'] ) : 1; // Parse max_qty more carefully - handle empty strings, nulls, and zeros. $max_qty = null; if ( isset( $range['max_quantity'] ) ) { if ( '' === $range['max_quantity'] || null === $range['max_quantity'] || '0' === $range['max_quantity'] || 0 === $range['max_quantity'] ) { // Empty, null, or 0 means unlimited. $max_qty = null; } else { // Parse as integer. $max_qty = intval( $range['max_quantity'] ); // If parsing resulted in 0 but the original wasn't explicitly 0, treat as unlimited. if ( 0 === $max_qty && '0' !== $range['max_quantity'] && 0 !== $range['max_quantity'] ) { $max_qty = null; } } } // Check if quantity is within range. $in_range = false; if ( null === $max_qty ) { // No max limit means unlimited, only check minimum. $in_range = ( $quantity >= $min_qty ); } else { // Check both min and max - quantity must be within bounds. $in_range = ( $quantity >= $min_qty && $quantity <= $max_qty ); } if ( $in_range && isset( $range['discount_value'] ) && '' !== $range['discount_value'] && null !== $range['discount_value'] ) { return array( 'type' => isset( $range['discount_type'] ) ? $range['discount_type'] : 'percentage', 'value' => floatval( $range['discount_value'] ), ); } } // No matching range found - return null to indicate no quantity-based discount applies. return null; } // IMPORTANT: Only apply non-quantity-based discounts if NO quantity ranges were defined. if ( empty( $this->adjustments['quantity_ranges'] ) ) { // Check for new array format first [{'type': 'percentage', 'value': 40}]. if ( is_array( $this->adjustments ) && isset( $this->adjustments[0] ) && is_array( $this->adjustments[0] ) ) { $first_adjustment = $this->adjustments[0]; if ( isset( $first_adjustment['type'] ) && isset( $first_adjustment['value'] ) ) { return array( 'type' => $first_adjustment['type'], 'value' => floatval( $first_adjustment['value'] ), ); } } // Return default adjustment. if ( isset( $this->adjustments['default'] ) ) { return $this->adjustments['default']; } // Fallback to old simple adjustment format (backward compatibility). if ( isset( $this->adjustments['discount_type'] ) && isset( $this->adjustments['discount_value'] ) ) { return array( 'type' => $this->adjustments['discount_type'], 'value' => floatval( $this->adjustments['discount_value'] ), ); } } // No discount applies. return null; } /** * Get display price for frontend. * * @param WC_Product $product Product object. * @param string $user_role User role. * @return float|string */ public function get_display_price( $product, $user_role = null ) { if ( ! $product ) { return ''; } // Build context. $context = array( 'product_id' => $product->get_id(), 'categories' => $this->get_product_categories( $product->get_id() ), 'quantity' => 1, 'user_role' => $user_role ? $user_role : 'guest', ); // Check if rule applies. if ( ! $this->is_applicable( $context ) ) { return $product->get_price(); } // Calculate and return price. return $this->calculate_price( $product->get_price(), $context ); } /** * Get product categories. * * @param int $product_id Product ID. * @return array Category IDs. */ private function get_product_categories( $product_id ) { // Check if this is a variation and get parent ID if it is. $product = wc_get_product( $product_id ); if ( $product && $product->get_parent_id() > 0 ) { // This is a variation, get categories from parent. $product_id = $product->get_parent_id(); } $terms = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) ); return ! is_wp_error( $terms ) ? $terms : array(); } /** * Check if product categories match rule subcategories. * * @param array $product_categories Product category IDs. * @return array Matching category IDs. */ private function check_subcategories( $product_categories ) { $matching = array(); foreach ( $this->category_ids as $category_id ) { // Get all subcategory IDs. $subcategories = get_term_children( $category_id, 'product_cat' ); if ( ! is_wp_error( $subcategories ) && ! empty( $subcategories ) ) { $found = array_intersect( $subcategories, $product_categories ); if ( ! empty( $found ) ) { $matching = array_merge( $matching, $found ); } } } return array_unique( $matching ); } /** * Set category IDs for this rule. * * @param array $category_ids Array of category IDs. */ public function set_category_ids( $category_ids ) { $this->category_ids = array_map( 'intval', (array) $category_ids ); } /** * Get category IDs. * * @return array */ public function get_category_ids() { return $this->category_ids; } /** * Set whether to include subcategories. * * @param bool $include Whether to include subcategories. */ public function set_include_subcategories( $include ) { $this->include_subcategories = (bool) $include; $this->adjustments['include_subcategories'] = $this->include_subcategories; } /** * Set excluded products. * * @param array $product_ids Product IDs to exclude. */ public function set_excluded_products( $product_ids ) { $this->excluded_products = array_map( 'intval', (array) $product_ids ); $this->adjustments['excluded_products'] = $this->excluded_products; } /** * Get products in categories affected by this rule. * * @param int $limit Maximum number of products to return. * @return array Product IDs. */ public function get_affected_products( $limit = -1 ) { if ( empty( $this->category_ids ) ) { return array(); } // Try cache first. $cache_key = 'dp_aicoso_affected_products_' . md5( implode( ',', $this->category_ids ) . '_' . (int) $this->include_subcategories . '_' . implode( ',', $this->excluded_products ) . '_' . $limit ); $cached_products = wp_cache_get( $cache_key, 'dp_aicoso_rules' ); if ( false !== $cached_products ) { return $cached_products; } // Build WP_Query args. $args = array( 'post_type' => 'product', 'posts_per_page' => $limit, 'fields' => 'ids', 'post_status' => 'publish', 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => $this->category_ids, 'include_children' => $this->include_subcategories, ), ), ); // Exclude specific products if needed. if ( ! empty( $this->excluded_products ) ) { $args['post__not_in'] = $this->excluded_products; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in } $query = new WP_Query( $args ); $product_ids = $query->posts; // Cache the result for 10 minutes. wp_cache_set( $cache_key, $product_ids, 'dp_aicoso_rules', 600 ); return $product_ids; } /** * Save rule with category relationships. * * @return int|WP_Error Rule ID on success, WP_Error on failure. */ public function save() { // Save the rule. $result = parent::save(); if ( is_wp_error( $result ) ) { return $result; } // Save category relationships. if ( ! empty( $this->category_ids ) ) { global $wpdb; $relationships_table = $wpdb->prefix . 'dp_aicoso_rule_relationships'; // Delete existing relationships. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->delete( $relationships_table, array( 'rule_id' => $this->rule_id ), array( '%d' ) ); // Clear relationship cache for this rule. wp_cache_delete( "dp_aicoso_rule_categories_{$this->rule_id}", 'dp_aicoso_rules' ); // Insert new relationships. foreach ( $this->category_ids as $category_id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $relationships_table, array( 'rule_id' => $this->rule_id, 'object_id' => $category_id, 'object_type' => 'category', ), array( '%d', '%d', '%s' ) ); } // Clear category cache for affected categories. foreach ( $this->category_ids as $category_id ) { wp_cache_delete( "dp_aicoso_category_rules_{$category_id}", 'dp_aicoso_rules' ); } } return $this->rule_id; } /** * Load category relationships from database. */ public function load_relationships() { if ( ! $this->rule_id ) { return; } // Check cache first. $cache_key = "dp_aicoso_rule_categories_{$this->rule_id}"; $cached_ids = wp_cache_get( $cache_key, 'dp_aicoso_rules' ); if ( false !== $cached_ids ) { $this->category_ids = $cached_ids; return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Category rule loading operation $category_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT object_id FROM `' . $wpdb->prefix . 'dp_aicoso_rule_relationships` WHERE rule_id = %d AND object_type = %s', intval( $this->rule_id ), 'category' ) ); $this->category_ids = array_map( 'intval', $category_ids ); // Cache the result for 5 minutes. wp_cache_set( $cache_key, $this->category_ids, 'dp_aicoso_rules', 300 ); } /** * Get category discount information for display. * * @return array Discount information. */ public function get_discount_info() { $info = array( 'categories' => array(), 'discount_type' => '', 'discount_value' => 0, 'include_subcategories' => $this->include_subcategories, 'excluded_products' => $this->excluded_products, ); // Get category names. foreach ( $this->category_ids as $cat_id ) { $term = get_term( $cat_id, 'product_cat' ); if ( $term && ! is_wp_error( $term ) ) { $info['categories'][] = array( 'id' => $cat_id, 'name' => $term->name, 'slug' => $term->slug, ); } } // Get discount details. $adjustment = $this->get_applicable_adjustment( array( 'quantity' => 1 ) ); if ( $adjustment ) { $info['discount_type'] = $adjustment['type']; $info['discount_value'] = $adjustment['value']; } return $info; } }