update structure

This commit is contained in:
Admin 2026-01-09 10:05:06 +07:00
parent 17e4eea464
commit dc26d50c6b
12 changed files with 294 additions and 33 deletions

View File

@ -0,0 +1,64 @@
<?php
add_action('init', function () {
if (!function_exists('wc_create_attribute')) {
return;
}
$attribute_name = 'Source';
$attribute_slug = 'source';
$taxonomy = 'pa_' . $attribute_slug;
global $wpdb;
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT attribute_id
FROM {$wpdb->prefix}woocommerce_attribute_taxonomies
WHERE attribute_name = %s",
$attribute_slug
)
);
if (!$exists) {
wc_create_attribute([
'name' => $attribute_name,
'slug' => $attribute_slug,
'type' => 'select',
'order_by' => 'menu_order',
'has_archives' => false,
]);
// Clear cache Woo
delete_transient('wc_attribute_taxonomies');
}
if (!taxonomy_exists($taxonomy)) {
register_taxonomy(
$taxonomy,
'product',
[
'label' => $attribute_name,
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'query_var' => true,
'rewrite' => false,
// Cho phép gán trong Product data → Attributes
'meta_box_cb' => 'post_tags_meta_box',
]
);
}
foreach (['on_store', 'links'] as $term) {
if (!term_exists($term, $taxonomy)) {
wp_insert_term($term, $taxonomy);
}
}
});

View File

@ -0,0 +1,69 @@
<?php
add_action('woocommerce_order_status_changed', function ($order_id, $old, $new, $order) {
// Chỉ khi chuyển sang completed
if ($new !== 'completed') {
return;
}
if (!$order instanceof WC_Order) {
$order = wc_get_order($order_id);
}
if (!$order) return;
$items = [];
foreach ($order->get_items() as $item_id => $item) {
/** @var WC_Order_Item_Product $item */
$product = $item->get_product();
$items[] = [
'item_id' => $item_id,
'product_id' => $product ? $product->get_id() : null,
'variation_id' => $item->get_variation_id(),
'type' => $product ? $product->get_type() : null,
'name' => $item->get_name(),
'sku' => $product ? $product->get_sku() : null,
'quantity' => $item->get_quantity(),
// Giá
'subtotal' => $item->get_subtotal(),
'total' => $item->get_total(),
'tax' => $item->get_total_tax(),
// Giá đơn vị
'price_per_item' => $item->get_quantity() > 0
? round($item->get_total() / $item->get_quantity(), 2)
: 0,
// Attributes (variation)
'attributes' => $item->get_variation_id()
? wc_get_formatted_variation($product, true, false, true)
: null,
// Meta item
'item_meta' => array_map(function ($meta) {
return [
'key' => $meta->key,
'value' => $meta->value,
];
}, $item->get_meta_data()),
];
}
$log = [
'event' => 'order_completed',
'order_id' => $order_id,
'old_status' => $old,
'new_status' => $new,
'currency' => $order->get_currency(),
'total' => $order->get_total(),
'items' => $items,
];
WPI_Logger::log(json_encode(
$log,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
));
}, 10, 4);

View File

@ -115,4 +115,77 @@ class WPI_Category_Mapper
return $map; return $map;
} }
public static function map_categories_to_tree(
array $categories,
$rootParentId = '0',
array $ignoreIds = []
) {
$map = [];
$roots = [];
// Map ignore
$ignoreMap = array_flip(array_map('strval', $ignoreIds));
// Map id => parentId để truy ngược nhanh
$parentLookup = [];
foreach ($categories as $cat) {
$parentLookup[(string) $cat['id']] = (string) $cat['parentId'];
}
// Step 1: tạo node
foreach ($categories as $item) {
if (isset($ignoreMap[(string) $item['id']])) {
continue;
}
$map[(string) $item['id']] = [
'id' => (int) $item['id'],
'name' => trim($item['name']),
'level' => 0,
'parent_id' => (int) $item['parentId'],
'category_origin_id' => '',
'group_category_id' => null,
'is_active' => 1,
'value' => (int) $item['id'],
'label' => trim($item['name']),
'children' => [],
];
}
// Step 2: gắn children
foreach ($map as $id => &$node) {
$parentId = (string) $node['parent_id'];
// Nếu parent bị ignore → leo lên cha tiếp theo
while (
isset($ignoreMap[$parentId]) &&
isset($parentLookup[$parentId])
) {
$parentId = $parentLookup[$parentId];
}
if ($parentId == (string) $rootParentId || !isset($map[$parentId])) {
$roots[] = &$node;
} else {
$map[$parentId]['children'][] = &$node;
}
}
unset($node);
// Step 3: set level
$setLevel = function (&$nodes, $level) use (&$setLevel) {
foreach ($nodes as &$node) {
$node['level'] = $level;
if (!empty($node['children'])) {
$setLevel($node['children'], $level + 1);
}
}
};
$setLevel($roots, 1);
return $roots;
}
} }

View File

@ -5,10 +5,22 @@ class WPI_API_Importer
private static function api_url($url) private static function api_url($url)
{ {
$base_url = WPI_Config_Model::get_config('base_api'); if (empty($url)) {
return $base_url . $url; return $url;
}
// Nếu đã là absolute URL (http / https)
$parsed = parse_url($url);
if (isset($parsed['scheme']) && in_array($parsed['scheme'], ['http', 'https'], true)) {
return $url;
}
$base_url = rtrim(WPI_Config_Model::get_config('base_api'), '/');
return $base_url . ltrim($url, '/');
} }
public static function products(array $args = []) public static function products(array $args = [])
{ {
$page = isset($args['page']) $page = isset($args['page'])

View File

@ -2,23 +2,23 @@
class WPI_Cron class WPI_Cron
{ {
const HOOK = 'wpi_import_products_cron'; const IMPORT_PRODUCTS_HOOK = 'wpi_import_products_cron';
const TRIGGER_HOOK = 'wpi_trigger_import_cron'; const TRIGGER_IMPORT_PRODUCTS_HOOK = 'wpi_trigger_import_cron';
const SYNC_CATEGORIES_HOOK = 'wpi_sync_categories_cron'; const IMPORT_CATEGORIES_HOOK = 'wpi_sync_categories_cron';
const DISPATCH_HOOK = 'wpi_dispatch_import_workers'; const DISPATCH_HOOK = 'wpi_dispatch_import_workers';
public static function init() public static function init()
{ {
add_filter('cron_schedules', [self::class, 'add_schedule']); add_filter('cron_schedules', [self::class, 'add_schedule']);
add_action( add_action(
self::HOOK, self::IMPORT_PRODUCTS_HOOK,
[self::class, 'run'], [self::class, 'import_products'],
10, 10,
1 1
); );
add_action(self::DISPATCH_HOOK, [self::class, 'dispatch_workers']); add_action(self::DISPATCH_HOOK, [self::class, 'dispatch_workers']);
add_action(self::TRIGGER_HOOK, [self::class, 'trigger']); add_action(self::TRIGGER_IMPORT_PRODUCTS_HOOK, [self::class, 'trigger_import_products']);
add_action(self::SYNC_CATEGORIES_HOOK, [self::class, 'sync_categories']); add_action(self::IMPORT_CATEGORIES_HOOK, [self::class, 'import_categories']);
} }
public static function add_schedule($schedules) public static function add_schedule($schedules)
@ -39,24 +39,26 @@ class WPI_Cron
// } // }
// // Trigger cron (2h sáng mỗi ngày) // // Trigger cron (2h sáng mỗi ngày)
// if (!wp_next_scheduled(self::TRIGGER_HOOK)) { // if (!wp_next_scheduled(self::TRIGGER_IMPORT_PRODUCTS_HOOK)) {
// $timestamp = strtotime('tomorrow 02:00'); // $timestamp = strtotime('tomorrow 02:00');
// wp_schedule_event( // wp_schedule_event(
// $timestamp, // $timestamp,
// 'daily', // 'daily',
// self::TRIGGER_HOOK // self::TRIGGER_IMPORT_PRODUCTS_HOOK
// ); // );
// } // }
} }
public static function deactivate() public static function deactivate()
{ {
wp_clear_scheduled_hook(self::HOOK); wp_clear_scheduled_hook(self::IMPORT_PRODUCTS_HOOK);
wp_clear_scheduled_hook(self::TRIGGER_HOOK); wp_clear_scheduled_hook(self::TRIGGER_IMPORT_PRODUCTS_HOOK);
wp_clear_scheduled_hook(self::IMPORT_CATEGORIES_HOOK);
wp_clear_scheduled_hook(self::DISPATCH_HOOK);
} }
public static function run(array $args = []) public static function import_products(array $args = [])
{ {
$worker = $args['worker'] ?? 'default'; $worker = $args['worker'] ?? 'default';
@ -165,6 +167,8 @@ class WPI_Cron
} }
WPI_Logger::log("Page {$page} done"); WPI_Logger::log("Page {$page} done");
} catch (\Throwable $th) {
WPI_Config_Model::set_config('status_workers', 0);
} finally { } finally {
WPI_Config_Model::set_config('sync_status_' . $worker, 0); WPI_Config_Model::set_config('sync_status_' . $worker, 0);
} }
@ -212,7 +216,7 @@ class WPI_Cron
} }
public static function trigger() public static function trigger_import_products()
{ {
if (WPI_Config_Model::get_config('status_workers')) { if (WPI_Config_Model::get_config('status_workers')) {
WPI_Logger::log('Trigger skipped: sync is running'); WPI_Logger::log('Trigger skipped: sync is running');
@ -226,7 +230,7 @@ class WPI_Cron
} }
public static function sync_categories() public static function import_categories()
{ {
if (!class_exists('WooCommerce')) { if (!class_exists('WooCommerce')) {
WPI_Logger::log('WooCommerce not active'); WPI_Logger::log('WooCommerce not active');
@ -266,7 +270,13 @@ class WPI_Cron
$json = json_decode($body, true); $json = json_decode($body, true);
if (empty($json['status']) || empty($json['data']) || !is_array($json['data'])) {
// if (empty($json['status']) || empty($json['data']) || !is_array($json['data'])) {
// WPI_Logger::log('Invalid API response structure');
// return;
// }
if (empty($json['code']) || empty($json['data']) || !is_array($json['data'])) {
WPI_Logger::log('Invalid API response structure'); WPI_Logger::log('Invalid API response structure');
return; return;
} }
@ -274,8 +284,10 @@ class WPI_Cron
WPI_Logger::log('Fetched ' . count($json['data']) . ' root categories'); WPI_Logger::log('Fetched ' . count($json['data']) . ' root categories');
$data = WPI_Category_Mapper::map_categories_to_tree($json['data']);
WPI_Category_Mapper::upsert_wp_categories_from_tree( WPI_Category_Mapper::upsert_wp_categories_from_tree(
$json['data'], $data,
0, 0,
'product_cat' 'product_cat'
); );

View File

@ -28,9 +28,24 @@
- CLI WP - CLI WP
# Xoá toàn bộ products # Xoá dữ liệu
wp --allow-root post delete $(wp --allow-root post list --post_type=product,product_variation --format=ids) --force <!-- Products -->
- wp --allow-root post delete $(wp --allow-root post list --post_type=product,product_variation --format=ids) --force
<!-- Categories -->
- wp term delete product_cat \
$(wp term list product_cat --field=term_id --allow-root) \
--allow-root
<!-- Medias -->
- wp post delete \
$(wp post list --post_type=attachment --format=ids --allow-root) \
--force \
--allow-root
# Cài WP CLI # Cài WP CLI
@ -38,3 +53,7 @@ curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.pha
php wp-cli.phar --info php wp-cli.phar --info
chmod +x wp-cli.phar chmod +x wp-cli.phar
mv wp-cli.phar /usr/local/bin/wp mv wp-cli.phar /usr/local/bin/wp
# Tìm kiếm tiến trình
ps aux | grep "wp eval" | grep "wpi_import_products_cron" | grep -v grep

View File

@ -21,23 +21,33 @@ define('WPI_URL', plugin_dir_url(__FILE__));
* ========================= * =========================
*/ */
// Init
require_once WPI_PATH . 'includes/configs/wpi-init.php';
// Migrations // Migrations
require_once WPI_PATH . 'includes/migrations/create-config-table.php'; require_once WPI_PATH . 'includes/migrations/create-config-table.php';
// Models // Models
require_once WPI_PATH . 'includes/models/class-wpi-config-model.php'; require_once WPI_PATH . 'includes/models/wpi-config-model.php';
// Helper // Helper
require_once WPI_PATH . 'includes/helpers/class-product-helper.php'; require_once WPI_PATH . 'includes/helpers/wpi-product-helper.php';
require_once WPI_PATH . 'includes/helpers/class-category-helper.php'; require_once WPI_PATH . 'includes/helpers/wpi-category-helper.php';
// Mappings
require_once WPI_PATH . 'includes/mappings/wpi-product-map.php';
// Core WPIS
require_once WPI_PATH . 'includes/wpi-api-importer.php';
require_once WPI_PATH . 'includes/wpi-cron.php';
require_once WPI_PATH . 'includes/wpi-logger.php';
require_once WPI_PATH . 'includes/wpi-product-validator.php';
// Events
require_once WPI_PATH . 'includes/events/wpi-order-event.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-product-map.php';
require_once WPI_PATH . 'includes/class-product-validator.php';
/** /**
@ -53,9 +63,10 @@ add_action('plugins_loaded', function () {
// } // }
// Init cron only if WooCommerce active // Init cron only if WooCommerce active
if (class_exists('WooCommerce')) { // if (class_exists('WooCommerce')) {
WPI_Cron::init(); // WPI_Cron::init();
} // }
WPI_Cron::init();
}); });
/** /**
@ -89,7 +100,8 @@ register_activation_hook(__FILE__, function () {
// Default configs // Default configs
WPI_Config_Model::set_config( WPI_Config_Model::set_config(
'categories_api', 'categories_api',
'/category', 'https://prology.net/rest/V1/client/get-all-categories',
// '/category',
'Api path to get categories' 'Api path to get categories'
); );