first commit
This commit is contained in:
commit
29c27dae04
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
class WPI_Admin_Page
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
add_action('admin_menu', [$this, 'menu']);
|
||||
add_action('wp_ajax_wpi_import_api', [$this, 'import_api']);
|
||||
add_action('wp_ajax_wpi_import_csv', [$this, 'import_csv']);
|
||||
}
|
||||
|
||||
public function menu()
|
||||
{
|
||||
add_menu_page(
|
||||
'Product Importer',
|
||||
'Product Importer',
|
||||
'manage_woocommerce',
|
||||
'wpi-importer',
|
||||
[$this, 'render']
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
?>
|
||||
<h1>Woo Product Importer</h1>
|
||||
|
||||
<h2>Import from API</h2>
|
||||
<input type="text" id="api_url" placeholder="API URL">
|
||||
<button id="import_api">Import API</button>
|
||||
|
||||
<h2>Import from CSV</h2>
|
||||
<input type="file" id="csv_file">
|
||||
<button id="import_csv">Import CSV</button>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function import_api()
|
||||
{
|
||||
$url = sanitize_text_field($_POST['url']);
|
||||
$importer = new WPI_API_Importer();
|
||||
$result = $importer->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
class WPI_API_Importer
|
||||
{
|
||||
|
||||
private static function api_url($url)
|
||||
{
|
||||
$base_url = WPI_Config_Model::get_config('base_api');
|
||||
return $base_url . $url;
|
||||
}
|
||||
|
||||
public static function products(array $args = [])
|
||||
{
|
||||
$page = isset($args['page'])
|
||||
? (int) $args['page']
|
||||
: (int) WPI_Config_Model::get_config('current_page', 1);
|
||||
|
||||
$limit = isset($args['limit'])
|
||||
? (int) $args['limit']
|
||||
: (int) WPI_Config_Model::get_config('limit_per_time', 20);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$url = add_query_arg([
|
||||
'page' => $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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
class WPI_Cron
|
||||
{
|
||||
const HOOK = 'wpi_import_products_cron';
|
||||
const TRIGGER_HOOK = 'wpi_trigger_import_cron';
|
||||
const SYNC_CATEGORIES_HOOK = 'wpi_sync_categories_cron';
|
||||
|
||||
public static function init()
|
||||
{
|
||||
add_filter('cron_schedules', [self::class, 'add_schedule']);
|
||||
add_action(self::HOOK, [self::class, 'run']);
|
||||
add_action(self::TRIGGER_HOOK, [self::class, 'trigger']);
|
||||
add_action(self::SYNC_CATEGORIES_HOOK, [self::class, 'sync_categories']);
|
||||
}
|
||||
|
||||
public static function add_schedule($schedules)
|
||||
{
|
||||
if (!isset($schedules['every_minute'])) {
|
||||
$schedules['every_minute'] = [
|
||||
'interval' => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
class WPI_Logger
|
||||
{
|
||||
|
||||
public static function log($message)
|
||||
{
|
||||
$dir = WP_CONTENT_DIR . '/uploads/wpi-import';
|
||||
if (!file_exists($dir)) {
|
||||
wp_mkdir_p($dir);
|
||||
}
|
||||
|
||||
$file = $dir . '/wpi-cron.log';
|
||||
$time = current_time('Y-m-d H:i:s');
|
||||
|
||||
file_put_contents(
|
||||
$file,
|
||||
"[{$time}] {$message}\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static function log_product_item($item)
|
||||
{
|
||||
$log_file = WP_CONTENT_DIR . '/uploads/wpi-import/api-product-preview.log';
|
||||
|
||||
if (!file_exists(dirname($log_file))) {
|
||||
wp_mkdir_p(dirname($log_file));
|
||||
}
|
||||
|
||||
$time = current_time('Y-m-d H:i:s');
|
||||
|
||||
file_put_contents(
|
||||
$log_file,
|
||||
"================ ITEM {$item['sku']} | {$time} ================\n" .
|
||||
print_r($item, true) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
public static function log_category_item(array $category)
|
||||
{
|
||||
$log_file = WP_CONTENT_DIR . '/uploads/wpi-import/api-category-preview.log';
|
||||
|
||||
if (!file_exists(dirname($log_file))) {
|
||||
wp_mkdir_p(dirname($log_file));
|
||||
}
|
||||
|
||||
$time = current_time('Y-m-d H:i:s');
|
||||
|
||||
$id = $category['id'] ?? 'N/A';
|
||||
$name = $category['name'] ?? 'N/A';
|
||||
$parent_id = $category['parent_id'] ?? 0;
|
||||
|
||||
file_put_contents(
|
||||
$log_file,
|
||||
"================ CATEGORY {$id} | {$name} | {$time} ================\n" .
|
||||
print_r($category, true) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
class WPI_Product_Map
|
||||
{
|
||||
/**
|
||||
* Map raw API item → standardized product data
|
||||
*/
|
||||
public static function from_api(
|
||||
array $item,
|
||||
array $category_map
|
||||
): array {
|
||||
|
||||
return [
|
||||
'sku' => $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
class WPI_Product_Validator
|
||||
{
|
||||
/**
|
||||
* Validate product data after mapping
|
||||
*
|
||||
* @return array [bool $is_valid, array $errors]
|
||||
*/
|
||||
public static function validate(array $data): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Required fields
|
||||
if (empty($data['sku'])) {
|
||||
$errors[] = 'SKU is required';
|
||||
}
|
||||
|
||||
if (empty($data['name'])) {
|
||||
$errors[] = 'Product name is required';
|
||||
}
|
||||
|
||||
// Price
|
||||
if (!isset($data['price']) || !is_numeric($data['price'])) {
|
||||
$errors[] = 'Price must be numeric';
|
||||
} elseif ($data['price'] < 0) {
|
||||
$errors[] = 'Price must be >= 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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
class WPI_Category_Mapper
|
||||
{
|
||||
public static function upsert_wp_categories_from_tree(
|
||||
array $categories,
|
||||
int $parent_id = 0,
|
||||
string $taxonomy = 'category'
|
||||
) {
|
||||
foreach ($categories as $item) {
|
||||
|
||||
if (empty($item['id']) || empty($item['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$external_id = (string) $item['id'];
|
||||
$name = $item['name'];
|
||||
$slug = sanitize_title($name);
|
||||
|
||||
// Log category item
|
||||
WPI_Logger::log_category_item($item);
|
||||
|
||||
/**
|
||||
* Find existing term by description (external id)
|
||||
*/
|
||||
$existing_term = self::find_term_by_description(
|
||||
$external_id,
|
||||
$taxonomy
|
||||
);
|
||||
|
||||
if ($existing_term) {
|
||||
|
||||
$term_id = (int) $existing_term->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
class WPI_Product_Mapper
|
||||
{
|
||||
|
||||
public static function upsert($data)
|
||||
{
|
||||
if (empty($data['sku'])) return;
|
||||
|
||||
$product_id = wc_get_product_id_by_sku($data['sku']);
|
||||
|
||||
$product = $product_id
|
||||
? wc_get_product($product_id)
|
||||
: new WC_Product_Simple();
|
||||
|
||||
$product->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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
function wpi_create_config_table()
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->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);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
class WPI_Config_Model
|
||||
{
|
||||
private static function table()
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
- Run cron
|
||||
<!-- Run simple -->
|
||||
<!-- Cron run import product -->
|
||||
|
||||
wp cron event run wpi_import_products_cron --allow-root <!-- Call by cron -->
|
||||
wp eval "do_action('wpi_import_products_cron');" --allow-root <!-- Call by CLI -->
|
||||
|
||||
<!-- Cron sync categories -->
|
||||
|
||||
wp cron event run wpi_sync_categories_cron --allow-root <!-- Call by cron -->
|
||||
wp eval "do_action('wpi_sync_categories_cron');" --allow-root <!-- Call by CLI -->
|
||||
|
||||
<!-- Cron triger auto sync -->
|
||||
|
||||
wp cron event run wpi_trigger_import_cron --allow-root <!-- Call by cron -->
|
||||
wp eval "do_action('wpi_trigger_import_cron');" --allow-root <!-- Call by CLI -->
|
||||
|
||||
- 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
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Plugin Name: WP Product Importer
|
||||
* Description: Import WooCommerce products from API or CSV (Cron supported)
|
||||
* Version: 1.0.0
|
||||
* Author: You
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
define('WPI_PATH', plugin_dir_path(__FILE__));
|
||||
define('WPI_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* Require core files
|
||||
* =========================
|
||||
*/
|
||||
|
||||
// Migrations
|
||||
require_once WPI_PATH . 'includes/migrations/create-config-table.php';
|
||||
|
||||
// Models
|
||||
require_once WPI_PATH . 'includes/models/class-wpi-config-model.php';
|
||||
|
||||
// Helper
|
||||
require_once WPI_PATH . 'includes/helpers/class-product-helper.php';
|
||||
require_once WPI_PATH . 'includes/helpers/class-category-helper.php';
|
||||
|
||||
|
||||
// Core classes
|
||||
require_once WPI_PATH . 'includes/class-api-importer.php';
|
||||
require_once WPI_PATH . 'includes/class-cron.php';
|
||||
require_once WPI_PATH . 'includes/class-logger.php';
|
||||
require_once WPI_PATH . 'includes/class-admin-page.php';
|
||||
require_once WPI_PATH . 'includes/class-product-map.php';
|
||||
require_once WPI_PATH . 'includes/class-product-validator.php';
|
||||
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* Init plugin
|
||||
* =========================
|
||||
*/
|
||||
add_action('plugins_loaded', function () {
|
||||
|
||||
// Admin UI
|
||||
// if (is_admin()) {
|
||||
// new WPI_Admin_Page();
|
||||
// }
|
||||
|
||||
// Init cron only if WooCommerce active
|
||||
if (class_exists('WooCommerce')) {
|
||||
WPI_Cron::init();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* Plugin activation
|
||||
* =========================
|
||||
*/
|
||||
register_activation_hook(__FILE__, function () {
|
||||
|
||||
if (!class_exists('WooCommerce')) {
|
||||
deactivate_plugins(plugin_basename(__FILE__));
|
||||
wp_die('WooCommerce is required for WP Product Importer');
|
||||
}
|
||||
|
||||
// Create DB tables
|
||||
wpi_create_config_table();
|
||||
|
||||
// Default configs
|
||||
WPI_Config_Model::set_config(
|
||||
'base_api',
|
||||
'https://distidata.nswteam.net/api/ecom',
|
||||
'Api path to get data import'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'import_api',
|
||||
'/scraping-product',
|
||||
'Api path to get data import'
|
||||
);
|
||||
|
||||
// Default configs
|
||||
WPI_Config_Model::set_config(
|
||||
'categories_api',
|
||||
'/category',
|
||||
'Api path to get categories'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'authen_key',
|
||||
'abc',
|
||||
'Authentication key'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'limit_per_time',
|
||||
30,
|
||||
'Item sync per time'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'current_page',
|
||||
1,
|
||||
'Current page sync'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'sync_status',
|
||||
0,
|
||||
'Status sync process'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'status_cron',
|
||||
1,
|
||||
'Status cron sync'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'categories_sync_status',
|
||||
0,
|
||||
'Status sync categories process'
|
||||
);
|
||||
|
||||
WPI_Config_Model::set_config(
|
||||
'categories_status_cron',
|
||||
1,
|
||||
'Categoroies status cron'
|
||||
);
|
||||
|
||||
// Activate cron
|
||||
WPI_Cron::activate();
|
||||
|
||||
error_log('[WPI] Plugin activated');
|
||||
});
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* Plugin deactivation
|
||||
* =========================
|
||||
*/
|
||||
register_deactivation_hook(__FILE__, function () {
|
||||
|
||||
WPI_Cron::deactivate();
|
||||
|
||||
error_log('[WPI] Plugin deactivated');
|
||||
});
|
||||
Loading…
Reference in New Issue