is_woocommerce_active()) { // Check if sync is enabled in settings $enable_woocommerce_sync = Aiflab_Chatweb_Config::get_setting('enable_woocommerce_sync', false); if ($enable_woocommerce_sync) { $this->init_hooks(); } } // Initialize API $this->api = new Aiflab_Chatweb_API(); } /** * Check if WooCommerce is active * * @return bool */ public function is_woocommerce_active() { return in_array( 'woocommerce/woocommerce.php', apply_filters('active_plugins', get_option('active_plugins')) ); } /** * Initialize hooks */ private function init_hooks() { // Product created add_action('woocommerce_new_product', array($this, 'sync_product_created'), 10, 2); // Product updated add_action('woocommerce_update_product', array($this, 'sync_product_updated'), 10, 2); // Product deleted add_action('before_delete_post', array($this, 'sync_product_deleted'), 10, 1); // Product status changed (e.g., draft to publish) add_action('transition_post_status', array($this, 'sync_product_status_changed'), 10, 3); // Variation changes affect the parent product payload sent to API. add_action('woocommerce_new_product_variation', array($this, 'sync_variation_parent_product'), 10, 2); add_action('woocommerce_update_product_variation', array($this, 'sync_variation_parent_product'), 10, 2); // Fallback for sites without a working external cron setup. add_action('wp_loaded', array($this, 'maybe_sync_due_scheduled_sales')); } /** * Sync product when created * * @param int $product_id * @param WC_Product $product */ public function sync_product_created($product_id, $product) { $this->sync_product($product_id, 'create'); } /** * Sync product when updated * * @param int $product_id * @param WC_Product $product */ public function sync_product_updated($product_id, $product) { // Get product status $status = get_post_status($product_id); // Log update event for debugging Aiflab_Chatweb_Debug_Logger::log(array( 'method' => 'PRODUCT_UPDATE', 'url' => 'product/' . $product_id, 'request_headers' => array(), 'request_body' => json_encode(array( 'product_id' => $product_id, 'product_name' => $product->get_name(), 'status' => $status, 'action' => 'sync' )), 'response_status' => 0, 'response_body' => null, 'duration_ms' => 0 )); // Always sync product with current status // API will handle visibility based on status (publish, draft, pending, private, trash) $this->sync_product($product_id, 'update'); } /** * Sync product when deleted * * @param int $post_id */ public function sync_product_deleted($post_id) { // Check if the deleted post is a product if (get_post_type($post_id) !== 'product') { return; } // Call API to delete product $this->delete_product_from_api($post_id); $this->refresh_next_scheduled_sale_change_timestamp_cache(); } /** * Sync product when status changed * * @param string $new_status * @param string $old_status * @param WP_Post $post */ public function sync_product_status_changed($new_status, $old_status, $post) { // Only proceed if this is a product if ($post->post_type !== 'product') { return; } // Skip if status hasn't changed if ($old_status === $new_status) { return; } // Log status change for debugging Aiflab_Chatweb_Debug_Logger::log(array( 'method' => 'STATUS_CHANGE', 'url' => 'product/' . $post->ID, 'request_headers' => array(), 'request_body' => json_encode(array( 'product_id' => $post->ID, 'product_name' => $post->post_title, 'old_status' => $old_status, 'new_status' => $new_status )), 'response_status' => 0, 'response_body' => null, 'duration_ms' => 0 )); // Sync product with new status to API (including trash, draft, pending, private, publish) // DELETE is only called when product is permanently deleted (handled by sync_product_deleted) Aiflab_Chatweb_Debug_Logger::log(array( 'method' => 'INFO', 'url' => 'product/' . $post->ID . '/sync', 'request_body' => 'Syncing product to API (status changed from ' . $old_status . ' to ' . $new_status . ')', 'response_status' => 200, 'duration_ms' => 0 )); $this->sync_product($post->ID, 'status_change', array( 'old_status' => $old_status, 'new_status' => $new_status )); } /** * Sync the parent product when a variation changes. * * @param int $variation_id * @param WC_Product_Variation $variation */ public function sync_variation_parent_product($variation_id, $variation) { $parent_id = $this->normalize_sync_product_id($variation_id); if (!$parent_id) { return; } $this->sync_product($parent_id, 'variation_update', array( 'variation_id' => $variation_id )); } /** * Sync products affected by scheduled sales. * * @param array $product_ids */ public function sync_scheduled_sale_products($product_ids) { $normalized_ids = array(); foreach ((array) $product_ids as $product_id) { $normalized_id = $this->normalize_sync_product_id($product_id); if ($normalized_id) { $normalized_ids[$normalized_id] = $normalized_id; } } foreach ($normalized_ids as $product_id) { $this->sync_product($product_id, 'scheduled_sale', array( 'sync_reason' => 'scheduled_sale' )); } } /** * Lightweight fallback for environments without a reliable cron setup. */ public function maybe_sync_due_scheduled_sales() { if (wp_doing_cron()) { return; } $current_time = time(); $transient_key = 'aiflab_chatweb_due_sale_sync_check'; $has_recent_check = (false !== get_transient($transient_key)); $next_sale_change = (int) get_option(self::NEXT_SALE_CHANGE_OPTION, 0); if ($has_recent_check && (!$next_sale_change || $current_time < $next_sale_change)) { return; } set_transient($transient_key, $current_time, 5 * MINUTE_IN_SECONDS); $product_ids = $this->get_due_scheduled_sale_product_ids(); if (empty($product_ids)) { $this->refresh_next_scheduled_sale_change_timestamp_cache(); return; } $this->sync_scheduled_sale_products($product_ids); } /** * Sync product with third-party API * * @param int $product_id * @param string $action Type of sync (create, update, delete, status_change) * @param array $additional_data Additional data for the sync */ private function sync_product($product_id, $action, $additional_data = array()) { // Get product data $product_data = $this->get_product_data($product_id, $action); if (!$product_data && $action !== 'delete') { return; } // Merge with additional data $data = array_merge( array( 'product_id' => $product_id, 'action' => $action, 'product' => $product_data ), $additional_data ); // Log sync attempt with detailed data $this->log_sync_attempt($data); // Format data for API $formatted_data = $this->format_data_for_api($data); // Call API $response = $this->call_api($data); $this->refresh_next_scheduled_sale_change_timestamp_cache(); return $response; } /** * Get product data for syncing * * @param int $product_id * @param string $action * @return array|false Product data or false if not a product */ private function get_product_data($product_id, $action) { // For delete action, we might not need full product data if ($action === 'delete') { return array( 'id' => $product_id ); } $product = wc_get_product($product_id); if (!$product) { return false; } // Basic product data $data = array( 'id' => $product_id, 'name' => $product->get_name(), 'type' => $product->get_type(), 'status' => $this->get_product_sync_status($product), 'featured' => $product->get_featured(), 'catalog_visibility' => $product->get_catalog_visibility(), 'description' => $product->get_description(), 'short_description' => $product->get_short_description(), 'sku' => $product->get_sku(), 'price' => $product->get_price(), 'regular_price' => $product->get_regular_price(), 'sale_price' => $this->get_active_sale_price($product), 'date_created' => $product->get_date_created() ? $product->get_date_created()->format('Y-m-d H:i:s') : null, 'date_modified' => $product->get_date_modified() ? $product->get_date_modified()->format('Y-m-d H:i:s') : null, 'stock_status' => $product->get_stock_status(), 'stock_quantity' => $product->get_stock_quantity(), 'allow_backorder' => in_array($product->get_backorders(), array('yes', 'notify'), true), 'categories' => $this->get_product_categories($product), 'images' => $this->get_product_images($product) ); return $data; } /** * Get the effective product status used for sync. * * Password-protected products should be treated as private on ChatWeb. * * @param WC_Product $product * @return string */ private function get_product_sync_status($product) { $status = $product->get_status(); $post_password = method_exists($product, 'get_post_password') ? $product->get_post_password('edit') : ''; if ($status === 'publish' && !empty($post_password)) { return 'private'; } return $status; } /** * Get product categories * * @param WC_Product $product * @return array */ private function get_product_categories($product) { $categories = array(); $terms = get_the_terms($product->get_id(), 'product_cat'); if (!empty($terms) && !is_wp_error($terms)) { foreach ($terms as $term) { $categories[] = array( 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug ); } } return $categories; } /** * Get product images * * @param WC_Product $product * @return array */ private function get_product_images($product) { $images = array(); // Main image if ($product->get_image_id()) { $images[] = array( 'id' => $product->get_image_id(), 'src' => wp_get_attachment_url($product->get_image_id()), 'position' => 0 ); } // Gallery images $gallery_image_ids = $product->get_gallery_image_ids(); if (!empty($gallery_image_ids)) { $position = 1; foreach ($gallery_image_ids as $image_id) { $images[] = array( 'id' => $image_id, 'src' => wp_get_attachment_url($image_id), 'position' => $position++ ); } } return $images; } /** * Log sync attempt * * @param array $data */ private function log_sync_attempt($data) { } /** * Call third-party API to sync product * * @param array $data Product data * @return array Response from API */ private function call_api($data) { // Format data according to API requirements $api_data = $this->format_data_for_api($data); // Call API $response = $this->api->sync_product($api_data); // Log response to file $this->log_api_response($data['product_id'], $data['action'], $response); return $response; } /** * Format product data for API * * @param array $data Raw product data * @return array Formatted data for API */ private function format_data_for_api($data) { $product = $data['product']; $action = $data['action']; // Get main image URL $image_url = null; $additional_images = array(); if (!empty($product['images'])) { if (isset($product['images'][0])) { $image_url = $product['images'][0]['src']; } // Get additional images (skip the first one) for ($i = 1; $i < count($product['images']); $i++) { if (isset($product['images'][$i]['src'])) { $additional_images[] = $product['images'][$i]['src']; } } } // Format data according to API requirements $formatted_data = array( 'source' => 'woocommerce', 'source_product_id' => isset($product['id']) ? (string)$product['id'] : '', 'name' => isset($product['name']) ? $product['name'] : '', 'description' => isset($product['description']) ? $product['description'] : '', 'price' => isset($product['regular_price']) ? (float)$product['regular_price'] : 0, 'sale_price' => isset($product['sale_price']) && $product['sale_price'] !== null && $product['sale_price'] !== '' ? (float)$product['sale_price'] : null, 'currency' => get_woocommerce_currency(), 'stock_quantity' => isset($product['stock_quantity']) ? (int)$product['stock_quantity'] : null, 'is_in_stock' => isset($product['stock_status']) ? ($product['stock_status'] === 'instock') : true, 'allow_backorder' => isset($product['allow_backorder']) ? $product['allow_backorder'] : false, 'status' => isset($product['status']) ? $product['status'] : 'publish', 'image_url' => $image_url, 'additional_images' => !empty($additional_images) ? $additional_images : null, 'normalized_data' => array( // Thêm các thuộc tính trực tiếp vào normalized_data thay vì đặt trong attributes 'sku' => isset($product['sku']) ? $product['sku'] : '', 'type' => isset($product['type']) ? $product['type'] : 'simple', 'featured' => isset($product['featured']) ? (bool)$product['featured'] : false, 'catalog_visibility' => isset($product['catalog_visibility']) ? $product['catalog_visibility'] : 'visible', 'short_description' => isset($product['short_description']) ? $product['short_description'] : '', 'categories' => isset($product['categories']) ? $product['categories'] : array(), 'tags' => $this->get_product_tags($product['id']), // Thêm variations nếu sản phẩm là variable 'variations' => $this->get_formatted_variations($product['id']) ), 'crawled_at' => current_time('mysql') ); // Thêm user_website_id nếu có $settings = Aiflab_Chatweb_Config::get_settings(); if (isset($settings['website_id']) && !empty($settings['website_id'])) { $formatted_data['user_website_id'] = $settings['website_id']; } return $formatted_data; } /** * Log API response * * @param int $product_id * @param string $action * @param array $response */ private function log_api_response($product_id, $action, $response) { $success = isset($response['success']) ? $response['success'] : false; $message = isset($response['error']) ? $response['error'] : (isset($response['data']['message']) ? $response['data']['message'] : 'No message'); } /** * Sync product manually (for bulk sync) * * @param int $product_id * @return array Result of sync operation */ public function sync_product_manually($product_id) { try { // Get product data $product_data = $this->get_product_data($product_id, 'update'); if (!$product_data) { return array( 'success' => false, 'error' => 'Invalid product' ); } // Prepare data $data = array( 'product_id' => $product_id, 'action' => 'update', 'product' => $product_data ); // Log sync attempt with detailed data $this->log_sync_attempt($data); // Format data for API $formatted_data = $this->format_data_for_api($data); // Call API $response = $this->call_api($data); return $response; } catch (Exception $e) { return array( 'success' => false, 'error' => $e->getMessage() ); } } /** * Prepare product data for bulk sync (without sending to API) * * @param int $product_id Product ID * @return array|false Product data array or false if invalid */ public function prepare_product_data_for_bulk($product_id) { try { // Get product data $product_data = $this->get_product_data($product_id, 'update'); if (!$product_data) { return false; } // Prepare data structure (same as sync_product_manually but without API call) $data = array( 'product_id' => $product_id, 'action' => 'update', 'product' => $product_data ); // Format data for API $formatted_data = $this->format_data_for_api($data); return $formatted_data; } catch (Exception $e) { return false; } } /** * Bulk sync multiple products at once * * @param array $products_data Array of product data * @return array Result with success status */ public function bulk_sync_products($products_data) { if (empty($products_data)) { return array( 'success' => false, 'error' => 'No products to sync' ); } try { // Prepare bulk data $bulk_data = array( 'data' => $products_data ); // Log bulk sync request $this->log_bulk_sync_request($bulk_data); // Call bulk sync API endpoint using request() method $response = $this->api->request('/' . Aiflab_Chatweb_Config::API_VERSION . '/extension/products/bulksync', $bulk_data, 'POST'); // Log bulk sync response $this->log_bulk_sync_response($bulk_data, $response); // Check if response is successful (status 200) if (isset($response['success']) && $response['success']) { return array( 'success' => true, 'synced_count' => count($products_data) ); } else { $error_message = isset($response['error']) ? $response['error'] : 'Unknown error'; return array( 'success' => false, 'error' => $error_message ); } } catch (Exception $e) { // Log exception $this->log_bulk_sync_error($products_data, $e->getMessage()); return array( 'success' => false, 'error' => $e->getMessage() ); } } /** * Log bulk sync request * * @param array $bulk_data Request data */ private function log_bulk_sync_request($bulk_data) { $log_data = array( 'time' => current_time('mysql'), 'action' => 'bulk_sync_request', 'product_count' => isset($bulk_data['data']) ? count($bulk_data['data']) : 0, 'request_url' => Aiflab_Chatweb_Config::API_BASE_URL . '/' . Aiflab_Chatweb_Config::API_VERSION . '/extension/products/bulksync', 'request_method' => 'POST', 'request_data' => $bulk_data, 'request_size_bytes' => strlen(json_encode($bulk_data)) ); $this->write_to_bulk_sync_log($log_data); } /** * Log bulk sync response * * @param array $bulk_data Request data * @param array $response API response */ private function log_bulk_sync_response($bulk_data, $response) { $log_data = array( 'time' => current_time('mysql'), 'action' => 'bulk_sync_response', 'product_count' => isset($bulk_data['data']) ? count($bulk_data['data']) : 0, 'success' => isset($response['success']) ? $response['success'] : false, 'status_code' => isset($response['status_code']) ? $response['status_code'] : 0, 'error' => isset($response['error']) ? $response['error'] : null, 'response_data' => $response ); $this->write_to_bulk_sync_log($log_data); } /** * Log bulk sync error * * @param array $products_data Products data * @param string $error_message Error message */ private function log_bulk_sync_error($products_data, $error_message) { $log_data = array( 'time' => current_time('mysql'), 'action' => 'bulk_sync_error', 'product_count' => count($products_data), 'error' => $error_message ); $this->write_to_bulk_sync_log($log_data); } /** * Write to bulk sync log file * * @param array $log_data * @return bool Success or failure */ private function write_to_bulk_sync_log($log_data) { try { // Get upload directory $upload_dir = wp_upload_dir(); // Check if upload directory is writable if (isset($upload_dir['error']) && $upload_dir['error'] !== false) { return false; } $logs_dir = $upload_dir['basedir'] . '/chatweb-logs'; // Try to create logs directory if it doesn't exist if (!file_exists($logs_dir)) { $dir_created = wp_mkdir_p($logs_dir); if (!$dir_created) { return false; } // Create .htaccess to protect logs $htaccess_file = $logs_dir . '/.htaccess'; $htaccess_content = "Order deny,allow\nDeny from all"; @file_put_contents($htaccess_file, $htaccess_content); // Create index.php to prevent directory listing $index_file = $logs_dir . '/index.php'; $index_content = 'delete_product($product_id); return $response; } /** * Return product IDs whose scheduled sale state should already have changed. * * @return array */ private function get_due_scheduled_sale_product_ids() { if (!class_exists('WC_Data_Store')) { return array(); } $data_store = WC_Data_Store::load('product'); if (!is_object($data_store)) { return array(); } $starting_sales = method_exists($data_store, 'get_starting_sales') ? (array) $data_store->get_starting_sales() : array(); $ending_sales = method_exists($data_store, 'get_ending_sales') ? (array) $data_store->get_ending_sales() : array(); $normalized_ids = array(); foreach (array_merge($starting_sales, $ending_sales) as $product_id) { $normalized_id = $this->normalize_sync_product_id($product_id); if ($normalized_id) { $normalized_ids[$normalized_id] = $normalized_id; } } return array_values($normalized_ids); } /** * Refresh the cached nearest future sale boundary. */ private function refresh_next_scheduled_sale_change_timestamp_cache() { update_option( self::NEXT_SALE_CHANGE_OPTION, $this->get_next_scheduled_sale_change_timestamp(), false ); } /** * Get the nearest future sale start/end timestamp. * * @return int */ private function get_next_scheduled_sale_change_timestamp() { global $wpdb; $current_time = time(); $post_types = array('product', 'product_variation'); $post_statuses = array('publish', 'private', 'draft', 'pending'); $meta_keys = array('_sale_price_dates_from', '_sale_price_dates_to'); $type_placeholders = implode(', ', array_fill(0, count($post_types), '%s')); $status_placeholders = implode(', ', array_fill(0, count($post_statuses), '%s')); $meta_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s')); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $meta_placeholders, $type_placeholders, $status_placeholders contain only '%s' literals generated by array_fill(), not user input. $query = $wpdb->prepare( " SELECT MIN(CAST(pm.meta_value AS UNSIGNED)) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key IN ($meta_placeholders) AND CAST(pm.meta_value AS UNSIGNED) > %d AND p.post_type IN ($type_placeholders) AND p.post_status IN ($status_placeholders) ", array_merge($meta_keys, array($current_time), $post_types, $post_statuses) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is the return value of $wpdb->prepare() above. $next_timestamp = (int) $wpdb->get_var($query); return $next_timestamp > 0 ? $next_timestamp : 0; } /** * Normalize product IDs so sync always targets the parent product payload. * * @param int $product_id * @return int */ private function normalize_sync_product_id($product_id) { $product_id = absint($product_id); if (!$product_id) { return 0; } $post_type = get_post_type($product_id); if ($post_type === 'product_variation') { return absint(wp_get_post_parent_id($product_id)); } return $post_type === 'product' ? $product_id : 0; } /** * Get active sale price based on scheduled sale dates * * @param WC_Product $product * @return float|null Sale price if active, null if not on sale */ private function get_active_sale_price($product) { $sale_price = $product->get_sale_price(); // If no sale price set (empty string), return null if ($sale_price === '') { return null; } // Get sale date range $date_from = $product->get_date_on_sale_from(); $date_to = $product->get_date_on_sale_to(); // If no date restrictions, sale price is always active if (!$date_from && !$date_to) { return (float)$sale_price; } // Check current time against date range $current_time = current_time('timestamp'); // Check if sale has started if ($date_from && $current_time < $date_from->getTimestamp()) { return null; // Sale hasn't started yet } // Check if sale has ended if ($date_to && $current_time > $date_to->getTimestamp()) { return null; // Sale has ended } return (float)$sale_price; } /** * Get product tags * * @param int $product_id * @return array */ private function get_product_tags($product_id) { $tags = array(); $terms = get_the_terms($product_id, 'product_tag'); if (!empty($terms) && !is_wp_error($terms)) { foreach ($terms as $term) { $tags[] = array( 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug ); } } return $tags; } /** * Get formatted variations for API * * @param int $product_id * @return array */ private function get_formatted_variations($product_id) { $product = wc_get_product($product_id); if (!$product || $product->get_type() !== 'variable') { return array(); } $variations = array(); $product_variations = $product->get_available_variations(); foreach ($product_variations as $variation) { $variation_id = $variation['variation_id']; $variation_obj = wc_get_product($variation_id); if (!$variation_obj) { continue; } // Lấy thuộc tính biến thể $attributes = array(); $variation_attributes = $variation_obj->get_variation_attributes(); foreach ($variation_attributes as $attribute_name => $attribute_value) { $taxonomy = str_replace('attribute_', '', $attribute_name); $attribute_label = wc_attribute_label($taxonomy, $product); // Lấy tên hiển thị của giá trị thuộc tính $term_name = $attribute_value; if (taxonomy_exists($taxonomy)) { $term = get_term_by('slug', $attribute_value, $taxonomy); if ($term && !is_wp_error($term)) { $term_name = $term->name; } } // Thêm vào mảng thuộc tính với định dạng "Tên thuộc tính": "Giá trị" $attributes[$attribute_label] = $term_name; } // Tạo dữ liệu biến thể theo định dạng yêu cầu $variation_data = array( 'platform_product_id' => (string)$variation_id, 'sku' => $variation_obj->get_sku(), 'attributes' => $attributes, 'price' => (float)$variation_obj->get_price(), 'regular_price' => (float)$variation_obj->get_regular_price(), 'sale_price' => $this->get_active_sale_price($variation_obj), 'stock_quantity' => (int)$variation_obj->get_stock_quantity(), 'is_in_stock' => $variation_obj->is_in_stock() ); // Thêm hình ảnh nếu có if (!empty($variation['image']['url'])) { $variation_data['image_url'] = $variation['image']['url']; } $variations[] = $variation_data; } return $variations; } }