commit 29c27dae04b35356f27df74b59c496b8fbbf3856 Author: Admin Date: Wed Dec 31 14:49:50 2025 +0700 first commit diff --git a/wp-product-importer/assets/admin.js b/wp-product-importer/assets/admin.js new file mode 100644 index 0000000..623cb03 --- /dev/null +++ b/wp-product-importer/assets/admin.js @@ -0,0 +1,28 @@ +jQuery(function ($) { + $("#import_api").on("click", function () { + $.post( + ajaxurl, + { + action: "wpi_import_api", + url: $("#api_url").val(), + }, + alert + ); + }); + + $("#import_csv").on("click", function () { + const file = $("#csv_file")[0].files[0]; + const form = new FormData(); + form.append("action", "wpi_import_csv"); + form.append("file", file); + + $.ajax({ + url: ajaxurl, + type: "POST", + data: form, + processData: false, + contentType: false, + success: alert, + }); + }); +}); diff --git a/wp-product-importer/includes/class-admin-page.php b/wp-product-importer/includes/class-admin-page.php new file mode 100644 index 0000000..6bf7426 --- /dev/null +++ b/wp-product-importer/includes/class-admin-page.php @@ -0,0 +1,56 @@ + +

Woo Product Importer

+ +

Import from API

+ + + +

Import from CSV

+ + +import($url); + wp_send_json_success($result); + } + + public function import_csv() + { + if (empty($_FILES['file'])) { + wp_send_json_error('No file'); + } + $importer = new WPI_CSV_Importer(); + $result = $importer->import($_FILES['file']['tmp_name']); + wp_send_json_success($result); + } +} diff --git a/wp-product-importer/includes/class-api-importer.php b/wp-product-importer/includes/class-api-importer.php new file mode 100644 index 0000000..3073f29 --- /dev/null +++ b/wp-product-importer/includes/class-api-importer.php @@ -0,0 +1,66 @@ + $page, + 'perPage' => $limit, + ], $import_api); + + WPI_Logger::log("Calling API: {$url}"); + + return wp_remote_get(self::api_url($url), [ + 'timeout' => 30, + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $authen_key, + ], + ]); + } + + + public static function categories() + { + + $url = WPI_Config_Model::get_config('categories_api'); + $authen_key = WPI_Config_Model::get_config('authen_key'); + + if (!$url || !$authen_key) { + WPI_Logger::log('Missing API config'); + return; + } + + return wp_remote_get(self::api_url($url), [ + 'timeout' => 30, + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $authen_key, + ], + ]); + } +} diff --git a/wp-product-importer/includes/class-cron.php b/wp-product-importer/includes/class-cron.php new file mode 100644 index 0000000..325b39a --- /dev/null +++ b/wp-product-importer/includes/class-cron.php @@ -0,0 +1,227 @@ + 60, + 'display' => 'Every Minute' + ]; + } + return $schedules; + } + + public static function activate() + { + // if (!wp_next_scheduled(self::HOOK)) { + // wp_schedule_event(time(), 'every_minute', self::HOOK); + // } + + // // Trigger cron (2h sáng mỗi ngày) + // if (!wp_next_scheduled(self::TRIGGER_HOOK)) { + // $timestamp = strtotime('tomorrow 02:00'); + + // wp_schedule_event( + // $timestamp, + // 'daily', + // self::TRIGGER_HOOK + // ); + // } + } + + public static function deactivate() + { + wp_clear_scheduled_hook(self::HOOK); + wp_clear_scheduled_hook(self::TRIGGER_HOOK); + } + + + public static function run() + { + if (!class_exists('WooCommerce')) { + WPI_Logger::log('WooCommerce not active'); + return; + } + + if (!WPI_Config_Model::get_config('status_cron')) { + WPI_Logger::log('Status cron is OFF'); + return; + } + + if (WPI_Config_Model::get_config('sync_status')) { + WPI_Logger::log('Sync already running'); + return; + } + + $import_api = WPI_Config_Model::get_config('import_api'); + $authen_key = WPI_Config_Model::get_config('authen_key'); + + if (!$import_api || !$authen_key) { + WPI_Logger::log('Missing API config'); + return; + } + + $limit = (int) WPI_Config_Model::get_config('limit_per_time', 20); + $page = (int) WPI_Config_Model::get_config('current_page', 1); + + WPI_Config_Model::set_config('sync_status', 1); + + try { + + $response = WPI_API_Importer::products([ + 'page' => $page, + 'limit' => $limit, + ]); + + if (is_wp_error($response)) { + WPI_Logger::log('API Error: ' . $response->get_error_message()); + return; + } + + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + + WPI_Logger::log("HTTP Code: {$code}"); + + if ($code !== 200) { + WPI_Logger::log("API failed: {$body}"); + return; + } + + $data = json_decode($body, true)['data']; + + if (!is_array($data)) { + WPI_Logger::log('Invalid JSON response'); + return; + } + + WPI_Logger::log('Fetched ' . count($data) . ' items'); + + $category_map = WPI_Category_Mapper::get_category_map(); + + + WPI_Logger::log(json_encode($category_map)); + + foreach ($data as $item) { + + $import_data = WPI_Product_Map::from_api($item, $category_map); + + $validation = WPI_Product_Validator::validate($import_data); + + if (!$validation['valid']) { + WPI_Logger::log( + 'Skip product SKU ' . ($import_data['sku'] ?? 'N/A') . + ' | Errors: ' . implode(' | ', $validation['errors']) + ); + continue; + } + + + WPI_Logger::log_product_item($import_data); + + WPI_Product_Mapper::upsert_from_api($import_data); + } + + + WPI_Config_Model::set_config('current_page', $page + 1); + + if (count($data) < $limit) { + WPI_Config_Model::set_config('status_cron', 0); + WPI_Config_Model::set_config('current_page', 1); + } + + WPI_Logger::log("Page {$page} done"); + } finally { + WPI_Config_Model::set_config('sync_status', 0); + } + } + + public static function trigger() + { + if (WPI_Config_Model::get_config('sync_status')) { + WPI_Logger::log('Trigger skipped: sync is running'); + return; + } + + WPI_Logger::log('Trigger import cron ON'); + + WPI_Config_Model::set_config('status_cron', 1); + WPI_Config_Model::set_config('current_page', 1); + } + + + public static function sync_categories() + { + if (!class_exists('WooCommerce')) { + WPI_Logger::log('WooCommerce not active'); + return; + } + + if (!WPI_Config_Model::get_config('categories_status_cron')) { + WPI_Logger::log('Status sync categories cron is OFF'); + return; + } + + if (WPI_Config_Model::get_config('categories_sync_status')) { + WPI_Logger::log('Sync categories already running'); + return; + } + + // Lock + WPI_Config_Model::set_config('categories_sync_status', 1); + + try { + $response = WPI_API_Importer::categories(); + + if (is_wp_error($response)) { + WPI_Logger::log('API Error: ' . $response->get_error_message()); + return; + } + + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + + WPI_Logger::log("HTTP Code: {$code}"); + + if ($code !== 200) { + WPI_Logger::log("API failed: {$body}"); + return; + } + + $json = json_decode($body, true); + + if (empty($json['status']) || empty($json['data']) || !is_array($json['data'])) { + WPI_Logger::log('Invalid API response structure'); + return; + } + + WPI_Logger::log('Fetched ' . count($json['data']) . ' root categories'); + + + WPI_Category_Mapper::upsert_wp_categories_from_tree( + $json['data'], + 0, + 'product_cat' + ); + + WPI_Logger::log('Category sync completed successfully'); + } finally { + // Unlock ONLY + WPI_Config_Model::set_config('categories_sync_status', 0); + } + } +} diff --git a/wp-product-importer/includes/class-logger.php b/wp-product-importer/includes/class-logger.php new file mode 100644 index 0000000..305d6c7 --- /dev/null +++ b/wp-product-importer/includes/class-logger.php @@ -0,0 +1,64 @@ + $item['sku'] ?? null, + 'name' => $item['name'] ?? '', + + 'price' => (float) ($item['price'] ?? 0), + 'stock' => (int) ($item['stock'] ?? 0), + + 'description' => $item['description'] ?? '', + 'short_description' => $item['short_description'] ?? '', + + 'brand' => $item['brand'] ?? null, + 'categories' => self::map_categories($item, $category_map), + 'images' => self::map_images($item), + ]; + } + + + protected static function map_categories( + array $item, + array $category_map + ): array { + + if (empty($item['categories']) || !is_array($item['categories'])) { + return []; + } + + $wp_category_ids = []; + + foreach ($item['categories'] as $cat) { + if (empty($cat['id'])) { + continue; + } + + $external_id = (string) $cat['id']; + + // map external id → wp term id + if (isset($category_map[$external_id])) { + $wp_category_ids[] = $category_map[$external_id]; + } else { + // log category chưa map được (rất quan trọng) + WPI_Logger::log( + "[CATEGORY_MAP] Missing category external_id={$external_id} ({$cat['name']})" + ); + } + } + + return array_values(array_unique($wp_category_ids)); + } + + + protected static function map_images(array $item): array + { + if (empty($item['images']) || !is_array($item['images'])) { + return []; + } + + $default = []; + $others = []; + + foreach ($item['images'] as $img) { + if (empty($img['url'])) { + continue; + } + + if (!empty($img['is_default'])) { + $default[] = $img['url']; + } else { + $others[] = $img['url']; + } + } + + // default image lên trước + return array_merge($default, $others); + } +} diff --git a/wp-product-importer/includes/class-product-validator.php b/wp-product-importer/includes/class-product-validator.php new file mode 100644 index 0000000..dfad088 --- /dev/null +++ b/wp-product-importer/includes/class-product-validator.php @@ -0,0 +1,50 @@ += 0'; + } + + // Stock + if (!isset($data['stock']) || !is_numeric($data['stock'])) { + $errors[] = 'Stock must be numeric'; + } + + // Images + if (!empty($data['images']) && !is_array($data['images'])) { + $errors[] = 'Images must be an array'; + } + + // Categories + if (!empty($data['categories']) && !is_array($data['categories'])) { + $errors[] = 'Categories must be an array'; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } +} diff --git a/wp-product-importer/includes/helpers/class-category-helper.php b/wp-product-importer/includes/helpers/class-category-helper.php new file mode 100644 index 0000000..2f279ec --- /dev/null +++ b/wp-product-importer/includes/helpers/class-category-helper.php @@ -0,0 +1,118 @@ +term_id; + + // Update name / slug / parent if needed + wp_update_term($term_id, $taxonomy, [ + 'name' => $name, + 'slug' => $slug, + 'parent' => $parent_id, + ]); + } else { + + /** + * Insert new term + */ + $result = wp_insert_term($name, $taxonomy, [ + 'slug' => $slug, + 'parent' => $parent_id, + 'description' => $external_id, + ]); + + if (is_wp_error($result)) { + WPI_Logger::log('[CATEGORY_SYNC_ERROR] ' . $result->get_error_message()); + continue; + } + + $term_id = (int) $result['term_id']; + } + + /** + * Recursive children + */ + if (!empty($item['children']) && is_array($item['children'])) { + self::upsert_wp_categories_from_tree( + $item['children'], + $term_id, + $taxonomy + ); + } + } + } + + /** + * Find term by description (external ID) + */ + private static function find_term_by_description( + string $description, + string $taxonomy + ) { + $terms = get_terms([ + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'number' => 1, + 'meta_query' => [], + 'description__like' => $description, + ]); + + return !empty($terms) ? $terms[0] : null; + } + + + public static function get_category_map( + string $taxonomy = 'product_cat' + ): array { + + $map = []; + + $terms = get_terms([ + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + ]); + + if (is_wp_error($terms) || empty($terms)) { + return $map; + } + + foreach ($terms as $term) { + $external_id = trim($term->description); + + if ($external_id === '') { + continue; + } + + $map[(string) $external_id] = (int) $term->term_id; + } + + return $map; + } +} diff --git a/wp-product-importer/includes/helpers/class-product-helper.php b/wp-product-importer/includes/helpers/class-product-helper.php new file mode 100644 index 0000000..b393dc0 --- /dev/null +++ b/wp-product-importer/includes/helpers/class-product-helper.php @@ -0,0 +1,227 @@ +set_name($data['name'] ?? ''); + $product->set_sku($data['sku']); + $product->set_regular_price($data['price'] ?? 0); + $product->set_stock_quantity($data['stock'] ?? 0); + $product->set_manage_stock(true); + + + if (!empty($data['description'])) { + $product->set_description(wp_kses_post($data['description'])); + } + + if (!empty($data['short_description'])) { + $product->set_short_description(wp_kses_post($data['short_description'])); + } + + return $product->save(); + } + + protected static function attach_images(int $product_id, array $urls) + { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $gallery_ids = []; + + foreach ($urls as $index => $url) { + if (!filter_var($url, FILTER_VALIDATE_URL)) { + continue; + } + + $attachment_id = media_sideload_image($url, $product_id, null, 'id'); + + if (is_wp_error($attachment_id)) { + continue; + } + + if ($index === 0) { + set_post_thumbnail($product_id, $attachment_id); + } else { + $gallery_ids[] = $attachment_id; + } + } + + if ($gallery_ids) { + update_post_meta( + $product_id, + '_product_image_gallery', + implode(',', $gallery_ids) + ); + } + } + + + public static function upsert_from_api(array $data) + { + if (empty($data['sku'])) return; + + $product_id = self::upsert($data); + + if (!$product_id) return; + + if (!empty($data['brand'])) { + self::sync_brand($product_id, $data['brand']); + } + + if (!empty($data['categories'])) { + self::sync_categories($product_id, $data['categories']); + } + + if (!empty($data['images'])) { + self::sync_images($product_id, $data['images']); + } + + return $product_id; + } + + protected static function sync_categories(int $product_id, array $categories) + { + if (empty($categories)) return; + + $term_ids = []; + + foreach ($categories as $cat_id) { + // chỉ nhận số + if (is_numeric($cat_id)) { + $term_ids[] = (int) $cat_id; + } + } + + if (!$term_ids) return; + + // gán trực tiếp, KHÔNG tạo mới + wp_set_object_terms( + $product_id, + array_unique($term_ids), + 'product_cat' + ); + } + + + + protected static function sync_images(int $product_id, array $images) + { + if (empty($images)) return; + + $urls = array_filter(array_map('trim', $images)); + if (!$urls) return; + + self::attach_images($product_id, $urls); + } + + + protected static function sync_brand(int $product_id, string $brand) + { + $brand = trim($brand); + if (!$brand) return; + + $taxonomy = 'product_brand'; + + // Kiểm tra taxonomy tồn tại + if (!taxonomy_exists($taxonomy)) { + return; + } + + // Check brand đã tồn tại chưa + $term = term_exists($brand, $taxonomy); + + // Chưa có → tạo mới + if (!$term) { + $term = wp_insert_term($brand, $taxonomy); + } + + if (is_wp_error($term)) { + return; + } + + $term_id = (int) $term['term_id']; + + // Gán brand cho product + wp_set_object_terms($product_id, [$term_id], $taxonomy); + + // Đảm bảo attribute xuất hiện trong product + $product = wc_get_product($product_id); + if (!$product) return; + + $attributes = $product->get_attributes(); + + if (!isset($attributes[$taxonomy])) { + $attr = new WC_Product_Attribute(); + $attr->set_name($taxonomy); + $attr->set_options([$brand]); + $attr->set_visible(true); + $attr->set_variation(false); + + $attributes[$taxonomy] = $attr; + $product->set_attributes($attributes); + $product->save(); + } + } + + protected static function create_wp_categories_from_tree( + array $categories, + int $parent_id = 0, + string $taxonomy = 'category' + ) { + foreach ($categories as $item) { + if (empty($item['name']) || empty($item['id'])) { + continue; + } + + $name = $item['name']; + $slug = sanitize_title($name); + $description = (string) $item['id']; // 👈 external ID + + // Check if term exists under same parent + $existing = term_exists($slug, $taxonomy, $parent_id); + + if ($existing) { + $term_id = is_array($existing) + ? $existing['term_id'] + : $existing; + + // Update description if changed + wp_update_term($term_id, $taxonomy, [ + 'description' => $description, + ]); + } else { + $result = wp_insert_term($name, $taxonomy, [ + 'slug' => $slug, + 'parent' => $parent_id, + 'description' => $description, + ]); + + if (is_wp_error($result)) { + error_log('[CATEGORY_SYNC] ' . $result->get_error_message()); + continue; + } + + $term_id = $result['term_id']; + } + + // Recurse children + if (!empty($item['children']) && is_array($item['children'])) { + self::create_wp_categories_from_tree( + $item['children'], + $term_id, + $taxonomy + ); + } + } + } +} diff --git a/wp-product-importer/includes/migrations/create-config-table.php b/wp-product-importer/includes/migrations/create-config-table.php new file mode 100644 index 0000000..491c28d --- /dev/null +++ b/wp-product-importer/includes/migrations/create-config-table.php @@ -0,0 +1,21 @@ +prefix . 'wpi_configs'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(191) NOT NULL UNIQUE, + config_value LONGTEXT NULL, + description VARCHAR(255) NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta($sql); +} diff --git a/wp-product-importer/includes/models/class-wpi-config-model.php b/wp-product-importer/includes/models/class-wpi-config-model.php new file mode 100644 index 0000000..39e2e9d --- /dev/null +++ b/wp-product-importer/includes/models/class-wpi-config-model.php @@ -0,0 +1,84 @@ +prefix . 'wpi_configs'; + } + + /** + * Check config exists + */ + private static function exists(string $key): bool + { + global $wpdb; + + return (bool) $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM " . self::table() . " WHERE config_key = %s LIMIT 1", + $key + ) + ); + } + + /** + * Insert or update config + */ + public static function set_config(string $key, $value, string $description = null) + { + global $wpdb; + + $table = self::table(); + $now = current_time('mysql'); + $data = [ + 'config_value' => wp_json_encode($value), + 'updated_at' => $now, + ]; + + if ($description !== null) { + $data['description'] = $description; + } + + if (self::exists($key)) { + + // UPDATE only + $wpdb->update( + $table, + $data, + ['config_key' => $key], + ['%s', '%s', '%s'], + ['%s'] + ); + } else { + + // INSERT new + $wpdb->insert( + $table, + array_merge($data, [ + 'config_key' => $key, + 'created_at' => $now, + ]), + ['%s', '%s', '%s', '%s'] + ); + } + } + + /** + * Get config (auto json decode) + */ + public static function get_config(string $key, $default = null) + { + global $wpdb; + + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT config_value FROM " . self::table() . " WHERE config_key = %s", + $key + ) + ); + + return $value !== null ? json_decode($value, true) : $default; + } +} diff --git a/wp-product-importer/readme.md b/wp-product-importer/readme.md new file mode 100644 index 0000000..9a03e46 --- /dev/null +++ b/wp-product-importer/readme.md @@ -0,0 +1,35 @@ +- Run cron + + + + wp cron event run wpi_import_products_cron --allow-root + wp eval "do_action('wpi_import_products_cron');" --allow-root + + + + wp cron event run wpi_sync_categories_cron --allow-root + wp eval "do_action('wpi_sync_categories_cron');" --allow-root + + + + wp cron event run wpi_trigger_import_cron --allow-root + wp eval "do_action('wpi_trigger_import_cron');" --allow-root + +- Run Cron tabs + 0 2 \* \* \* cd /var/www/html && wp cron event run wpi_trigger_import_cron --allow-root >> /var/log/wp-trigger-import.log 2>&1 + + - - - - - cd /var/www/html && wp cron event run wpi_import_products_cron --allow-root >> /var/log/wp-import-products.log 2>&1 + +- CLI WP + +# Xoá toàn bộ products + +wp post delete \ +$(wp post list --post_type=product --field=ID --allow-root) \ +--force --allow-root + +# Xoá toàn bộ product categories + +wp term delete product_cat \ +$(wp term list product_cat --field=term_id --allow-root) \ +--allow-root diff --git a/wp-product-importer/wp-product-importer.php b/wp-product-importer/wp-product-importer.php new file mode 100644 index 0000000..e0e440e --- /dev/null +++ b/wp-product-importer/wp-product-importer.php @@ -0,0 +1,155 @@ +