'Cost of Goods', 'regular_price' => 'Regular Price', 'sale_price' => 'Sale Price', 'stock_quantity' => 'Stock Quantity', 'stock_status' => 'Stock Status', ]; public static function instance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } private function __construct() { $this->connection_manager = AIVectorSearch_Connection_Manager::instance(); $this->openai_client = AIVectorSearch_OpenAI_Client::instance(); $this->init_hooks(); } private function init_hooks() { add_action('woocommerce_update_product', [$this, 'auto_sync_product'], 10, 1); add_action('woocommerce_new_product', [$this, 'auto_sync_product'], 10, 1); } public function auto_sync_product($product_id) { if (get_option('aivesese_auto_sync') !== '1') { return; } $current_time = time(); // Check if we synced this product recently (within 10 seconds) if (isset($this->syncing_products[$product_id])) { $last_sync_time = $this->syncing_products[$product_id]; $time_diff = $current_time - $last_sync_time; if ($time_diff < 10) { return; } } // Record the sync time $this->syncing_products[$product_id] = $current_time; $product = wc_get_product($product_id); if (!$product) { return; } $with_embeddings = $this->should_generate_embeddings(); $this->sync_products([$product], $with_embeddings); } public function get_syncable_fields(): array { return self::SYNCABLE_FIELDS; } public function sync_all_products(): array { $products = $this->get_all_products(); $with_embeddings = $this->should_generate_embeddings(); return $this->sync_products($products, $with_embeddings); } public function get_syncable_products_count(): int { $query = new WP_Query(array_merge( $this->get_product_query_args(), [ 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, ] )); return (int) $query->found_posts; } public function sync_products_batch(int $batch_size, int $offset): array { $products = $this->get_products_batch($batch_size, $offset); if (empty($products)) { return [ 'success' => false, 'message' => sprintf('No products found at offset %d', $offset), 'synced' => 0, 'total' => 0, 'next_offset' => $offset + $batch_size ]; } $with_embeddings = $this->should_generate_embeddings(); return $this->sync_products($products, $with_embeddings); } public function sync_field_batch(int $batch_size, int $offset, string $field): array { if (!array_key_exists($field, self::SYNCABLE_FIELDS)) { return ['success' => false, 'message' => 'Invalid field: ' . $field]; } $products = $this->get_products_batch($batch_size, $offset); if (empty($products)) { return [ 'success' => false, 'message' => sprintf('No products found at offset %d', $offset), 'synced' => 0, 'total' => 0, ]; } $store_id = get_option('aivesese_store'); $payloads = []; foreach ($products as $product) { $payloads[] = [ 'woocommerce_id' => $product->get_id(), 'store_id' => $store_id, $field => $this->get_field_value($product, $field), ]; } $success = $this->connection_manager->update_products_field($payloads, $field); return [ 'success' => $success, 'synced' => $success ? count($payloads) : 0, 'total' => count($payloads), ]; } private function get_field_value(WC_Product $product, string $field) { switch ($field) { case 'cost_price': return $this->get_product_cost_price($product); case 'regular_price': return $this->get_product_price($product, 'regular'); case 'sale_price': return $this->get_product_price($product, 'sale'); case 'stock_quantity': return $product->get_stock_quantity(); case 'stock_status': return $product->get_stock_status() === 'instock' ? 'in' : 'out'; default: return null; } } public function sync_products(array $products, bool $with_embeddings = false): array { if (empty($products)) { return ['success' => false, 'message' => 'No products to sync']; } $transformed_products = []; $texts_for_embedding = []; foreach ($products as $product) { $transformed = $this->transform_product($product); $transformed_products[] = $transformed; // Only generate embeddings for self-hosted mode if ($with_embeddings && !$this->connection_manager->is_api_mode()) { $text = $this->openai_client->build_embedding_text_from_product($product); $texts_for_embedding[] = $text; } } // Generate embeddings if requested and in self-hosted mode if ($with_embeddings && !$this->connection_manager->is_api_mode() && !empty($texts_for_embedding)) { $embeddings = $this->openai_client->embed_batch($texts_for_embedding); if (!empty($embeddings)) { foreach ($transformed_products as $i => $product) { if (isset($embeddings[$i])) { $transformed_products[$i]['embedding'] = $embeddings[$i]; } } } } // Sync using connection manager (handles both API and self-hosted) $success_count = 0; $batches = array_chunk($transformed_products, 100); foreach ($batches as $batch) { if ($this->connection_manager->sync_products_batch($batch)) { $success_count += count($batch); } } return [ 'success' => true, 'synced' => $success_count, 'total' => count($products) ]; } public function generate_missing_embeddings(): array { // Use connection manager to handle both API and self-hosted modes return $this->connection_manager->generate_missing_embeddings(); } public function update_sold_counts(int $days): array { if (!class_exists('WooCommerce')) { return ['success' => false, 'message' => 'WooCommerce is not active.']; } if (!$this->connection_manager->is_self_hosted_mode()) { return ['success' => false, 'message' => 'Sold counts can only be updated in self-hosted mode.']; } $days = max(1, (int) $days); $since_timestamp = time() - ($days * DAY_IN_SECONDS); $since = gmdate('Y-m-d H:i:s', $since_timestamp); $statuses = function_exists('wc_get_is_paid_statuses') ? wc_get_is_paid_statuses() : ['processing', 'completed']; $order_ids = wc_get_orders([ 'status' => $statuses, 'limit' => -1, 'return' => 'ids', 'date_created' => '>=' . $since, ]); $supabase_client = AIVectorSearch_Supabase_Client::instance(); if (!$supabase_client->reset_sold_counts()) { return ['success' => false, 'message' => 'Unable to reset sold counts in Supabase.']; } if (empty($order_ids)) { return [ 'success' => true, 'message' => 'No orders found in the selected timeframe. Sold counts were reset.', 'updated' => 0, 'failed' => 0, 'orders' => 0 ]; } $counts = []; foreach ($order_ids as $order_id) { $order = wc_get_order($order_id); if (!$order) { continue; } foreach ($order->get_items('line_item') as $item) { $product_id = $item->get_product_id(); if ($product_id <= 0) { continue; } $qty = (int) $item->get_quantity(); if ($qty <= 0) { continue; } if (!isset($counts[$product_id])) { $counts[$product_id] = 0; } $counts[$product_id] += $qty; } } if (empty($counts)) { return [ 'success' => true, 'message' => 'No product sales found in the selected timeframe.', 'updated' => 0, 'failed' => 0, 'orders' => count($order_ids) ]; } $updated = 0; $failed = 0; foreach ($counts as $product_id => $sold_count) { $ok = $supabase_client->update_sold_count($product_id, $sold_count); if ($ok) { $updated++; } else { $failed++; } } return [ 'success' => $failed === 0, 'message' => $failed === 0 ? 'Sold counts updated successfully.' : 'Sold counts updated with some failures.', 'updated' => $updated, 'failed' => $failed, 'orders' => count($order_ids) ]; } private function transform_product(WC_Product $product): array { $store_id = get_option('aivesese_store'); $product_id = $product->get_id(); return [ 'id' => wp_generate_uuid4(), 'store_id' => $store_id, 'woocommerce_id' => $product_id, 'sku' => $this->limit_product_text((string) $product->get_sku(), self::API_SKU_MAX_LENGTH, 'sku', $product_id), 'gtin' => $this->get_product_gtin($product), 'name' => $this->limit_product_text((string) $product->get_name(), self::API_NAME_MAX_LENGTH, 'name', $product_id), 'description' => $this->limit_product_text(wp_strip_all_tags($product->get_description()), self::API_DESCRIPTION_MAX_LENGTH, 'description', $product_id), 'short_description' => $this->limit_product_text(wp_strip_all_tags($product->get_short_description()), self::API_SHORT_DESCRIPTION_MAX_LENGTH, 'short_description', $product_id), 'image_url' => $this->get_product_image_url($product), 'brand' => $this->get_product_brand($product), 'categories' => $this->get_product_categories($product), 'tags' => $this->get_product_tags($product), 'regular_price' => $this->get_product_price($product, 'regular'), 'sale_price' => $this->get_product_price($product, 'sale'), 'cost_price' => $this->get_product_cost_price($product), 'stock_quantity' => $product->get_stock_quantity(), 'stock_status' => $product->get_stock_status() === 'instock' ? 'in' : 'out', 'attributes' => $this->get_product_attributes($product), 'status' => $product->get_status(), 'featured' => $product->is_featured(), 'average_rating' => $product->get_average_rating() ? floatval($product->get_average_rating()) : null, 'review_count' => $product->get_review_count() ? intval($product->get_review_count()) : 0, 'sold_count' => $product->get_total_sales() ? intval($product->get_total_sales()) : 0, 'permalink' => get_permalink($product->get_id()), ]; } private function limit_product_text(string $value, int $max_length, string $field, int $product_id): string { if ($value === '') { return $value; } $length = mb_strlen($value); if ($length <= $max_length) { return $value; } error_log(sprintf( 'AI Vector Search: truncated %s for product %d from %d to %d characters before API sync.', $field, $product_id, $length, $max_length )); return mb_substr($value, 0, $max_length); } private function get_all_products(): array { $args = array_merge( $this->get_product_query_args(), ['posts_per_page' => -1] ); return $this->get_products_from_query($args); } private function get_products_batch(int $batch_size, int $offset): array { $args = array_merge($this->get_product_query_args(), [ 'posts_per_page' => $batch_size, 'offset' => $offset, ]); return $this->get_products_from_query($args); } private function get_product_query_args(): array { return [ 'post_type' => 'product', 'post_status' => 'publish', 'orderby' => 'ID', 'order' => 'ASC', 'tax_query' => [ [ 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => ['exclude-from-search', 'exclude-from-catalog'], 'operator' => 'NOT IN', ], ], ]; } private function get_products_from_query(array $args): array { $product_posts = get_posts($args); $products = []; foreach ($product_posts as $post) { $product = wc_get_product($post->ID); if ($product) { $products[] = $product; } } return $products; } private function should_generate_embeddings(): bool { return get_option('aivesese_semantic_toggle') === '1' && $this->connection_manager->is_semantic_search_available(); } // Product data extraction methods (unchanged) private function get_product_gtin(WC_Product $product): string { // Define priority order of GTIN/barcode meta keys $gtin_meta_keys = [ '_gtin', // Generic GTIN field '_global_unique_id', // WooCommerce core field '_ean', // European Article Number '_upc', // Universal Product Code '_isbn', // International Standard Book Number '_mpn', // Manufacturer Part Number '_barcode', // Generic barcode field '_product_gtin', // Some GTIN plugins '_woocommerce_gtin', // WooCommerce GTIN extensions '_yith_barcode', // YITH Barcode plugin '_atum_barcode', // ATUM Inventory plugin '_wc_barcode', // WC Barcode plugins '_product_barcode', // Generic product barcode '_gs1_gtin', // GS1 standard GTIN '_gtin14', // GTIN-14 format '_gtin13', // GTIN-13 format (EAN-13) '_gtin12', // GTIN-12 format (UPC-A) '_gtin8', // GTIN-8 format (EAN-8) '_article_number', // Article/item number '_manufacturer_sku', // Manufacturer SKU (sometimes contains GTIN) ]; // Try each meta key in priority order foreach ($gtin_meta_keys as $meta_key) { $value = get_post_meta($product->get_id(), $meta_key, true); if ($value && is_string($value)) { $cleaned_value = $this->clean_gtin_value($value); if ($this->is_valid_gtin($cleaned_value)) { return $cleaned_value; } } } // For variable products, try to get GTIN from variations if ($product->is_type('variable')) { $variation_ids = $product->get_children(); foreach ($variation_ids as $variation_id) { foreach ($gtin_meta_keys as $meta_key) { $value = get_post_meta($variation_id, $meta_key, true); if ($value && is_string($value)) { $cleaned_value = $this->clean_gtin_value($value); if ($this->is_valid_gtin($cleaned_value)) { return $cleaned_value; } } } } } // Check product attributes for GTIN-like values $attributes = $product->get_attributes(); $gtin_attribute_names = ['gtin', 'ean', 'upc', 'isbn', 'barcode', 'mpn']; foreach ($attributes as $attribute) { $attribute_name = strtolower($attribute->get_name()); foreach ($gtin_attribute_names as $gtin_name) { if (strpos($attribute_name, $gtin_name) !== false) { if ($attribute->is_taxonomy()) { $terms = wp_get_post_terms($product->get_id(), $attribute->get_name(), ['fields' => 'names']); if (!is_wp_error($terms) && !empty($terms)) { $cleaned_value = $this->clean_gtin_value($terms[0]); if ($this->is_valid_gtin($cleaned_value)) { return $cleaned_value; } } } else { $values = $attribute->get_options(); if (!empty($values)) { $cleaned_value = $this->clean_gtin_value($values[0]); if ($this->is_valid_gtin($cleaned_value)) { return $cleaned_value; } } } } } } return ''; } /** * Clean and normalize GTIN value */ private function clean_gtin_value(string $value): string { // Remove whitespace and common separators $cleaned = preg_replace('/[\s\-_\.]+/', '', trim($value)); // Remove non-numeric characters (GTINs should be numeric) $cleaned = preg_replace('/[^0-9]/', '', $cleaned); return $cleaned; } /** * Validate if a string is a valid GTIN format */ private function is_valid_gtin(string $value): bool { if (empty($value)) { return false; } // Check if it's numeric and has valid GTIN length if (!ctype_digit($value)) { return false; } $length = strlen($value); $valid_lengths = [8, 12, 13, 14]; // GTIN-8, GTIN-12 (UPC), GTIN-13 (EAN), GTIN-14 if (!in_array($length, $valid_lengths)) { return false; } // Optional: Add check digit validation for more robust validation // This is a simplified version - you could implement full GTIN check digit validation return true; } /** * Validate GTIN check digit (optional enhanced validation) */ private function validate_gtin_check_digit(string $gtin): bool { $length = strlen($gtin); if (!in_array($length, [8, 12, 13, 14])) { return false; } // Pad to 14 digits for uniform calculation $padded_gtin = str_pad($gtin, 14, '0', STR_PAD_LEFT); $sum = 0; for ($i = 0; $i < 13; $i++) { $digit = (int)$padded_gtin[$i]; $sum += ($i % 2 === 0) ? $digit * 3 : $digit; } $check_digit = (10 - ($sum % 10)) % 10; $actual_check_digit = (int)$padded_gtin[13]; return $check_digit === $actual_check_digit; } private function get_product_image_url(WC_Product $product): string { $image_id = $product->get_image_id(); if ($image_id) { return wp_get_attachment_image_url($image_id, 'full') ?: ''; } return ''; } private function get_product_brand(WC_Product $product): string { $brand_terms = wp_get_post_terms($product->get_id(), 'product_brand'); if (!empty($brand_terms) && !is_wp_error($brand_terms)) { return $brand_terms[0]->name; } return ''; } private function get_product_categories(WC_Product $product): array { $categories = []; $terms = wp_get_post_terms($product->get_id(), 'product_cat'); foreach ($terms as $term) { $categories[] = $term->name; } return $categories; } private function get_product_tags(WC_Product $product): array { $tags = []; $tag_terms = wp_get_post_terms($product->get_id(), 'product_tag'); foreach ($tag_terms as $term) { $tags[] = $term->name; } return $tags; } private function get_product_attributes(WC_Product $product): array { $attributes = []; $product_attributes = $product->get_attributes(); foreach ($product_attributes as $attribute) { if ($attribute->is_taxonomy()) { $terms = wp_get_post_terms($product->get_id(), $attribute->get_name()); if (!is_wp_error($terms) && !empty($terms)) { $values = array_map(function($term) { return $term->name; }, $terms); $attributes[$attribute->get_name()] = implode(', ', $values); } } else { $attributes[$attribute->get_name()] = $attribute->get_options()[0] ?? ''; } } return $attributes; } private function get_product_price(WC_Product $product, string $type): ?float { switch ($type) { case 'regular': $price = $product->get_regular_price(); break; case 'sale': $price = $product->get_sale_price(); break; default: return null; } return $price ? floatval($price) : null; } private function get_product_cost_price(WC_Product $product): ?float { $id = $product->get_id(); // 1. WooCommerce native COGS (WC 9.5+) — _cogs_total_value $cost = get_post_meta($id, '_cogs_total_value', true); if ($cost !== '' && is_numeric($cost)) { return floatval($cost); } // 2. Cost of Goods: Product Cost & Profit Calculator for WooCommerce (Algoritmika) // Only checked when WC native COGS is not present. $cost = get_post_meta($id, '_alg_wc_cog_cost', true); if ($cost !== '' && is_numeric($cost) && floatval($cost) > 0) { return floatval($cost); } // 3. WooCommerce Cost of Goods (SkyVerge / WooCommerce.com) $cost = get_post_meta($id, '_wc_cog_cost', true); if ($cost !== '' && is_numeric($cost) && floatval($cost) > 0) { return floatval($cost); } // 4. Generic fallbacks $fallback_keys = [ '_cost_price', '_purchase_price', '_product_cost', ]; foreach ($fallback_keys as $meta_key) { $cost = get_post_meta($id, $meta_key, true); if ($cost !== '' && is_numeric($cost) && floatval($cost) > 0) { return floatval($cost); } } // 5. For variable products, derive cost from variations (average) if ($product->is_type('variable')) { $variation_keys = ['_cogs_total_value', '_alg_wc_cog_cost', '_wc_cog_cost']; $costs = []; foreach ($product->get_children() as $variation_id) { foreach ($variation_keys as $key) { $variation_cost = get_post_meta($variation_id, $key, true); if ($variation_cost !== '' && is_numeric($variation_cost) && floatval($variation_cost) > 0) { $costs[] = floatval($variation_cost); break; // use the first matching key for this variation } } } if (!empty($costs)) { return array_sum($costs) / count($costs); } } return null; } }