PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.7.2
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.7.2
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / includes / classes / zoho-inventory / class-cmbird-categories-zi.php
commercebird / includes / classes / zoho-inventory Last commit date
class-cmbird-categories-zi.php 8 months ago class-cmbird-image-zi.php 7 months ago class-import-items.php 6 months ago class-import-price-list.php 11 months ago class-multi-currency.php 8 months ago class-order-sync.php 6 months ago class-product.php 6 months ago class-users-contact.php 6 months ago index.php 1 year ago
class-cmbird-categories-zi.php
2147 lines
1 <?php
2
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 /**
8 * Handles synchronization of categories between Zoho Inventory and WooCommerce.
9 *
10 * Provides methods to import, export, and manage categories and subcategories,
11 * ensuring data consistency between Zoho Inventory and WooCommerce product categories.
12 */
13 class CMBIRD_Categories_ZI {
14
15 /**
16 * Configuration options for Zoho Inventory connection.
17 *
18 * @var array
19 */
20 private $config;
21 /**
22 * Class constructor.
23 *
24 * Retrieves the Zoho Inventory connection configuration and hooks for category
25 * synchronization actions.
26 */
27 public function __construct() {
28 $this->config = array(
29 'ConnectZI' => array(
30 'OID' => get_option( 'cmbird_zoho_inventory_oid' ),
31 'APIURL' => get_option( 'cmbird_zoho_inventory_url' ),
32 ),
33 );
34 add_action( 'wp_ajax_zoho_ajax_call_parent_categories', array( $this, 'cmbird_zi_parent_category_sync' ) );
35 add_action( 'wp_ajax_zoho_ajax_call_subcategories', array( $this, 'cmbird_zi_subcategory_sync' ) );
36 add_action( 'wp_ajax_zoho_ajax_call_remove_duplicates', array( $this, 'cmbird_zi_remove_duplicates_sync' ) );
37
38 // Async batch processing handlers.
39 add_action( 'wp_ajax_zoho_ajax_call_subcategories_start', array( $this, 'cmbird_zi_subcategory_sync_start' ) );
40 add_action( 'wp_ajax_zoho_ajax_call_subcategories_batch', array( $this, 'cmbird_zi_subcategory_sync_batch' ) );
41 add_action( 'wp_ajax_zoho_ajax_call_subcategories_status', array( $this, 'cmbird_zi_subcategory_sync_status' ) );
42 add_action( 'wp_ajax_zoho_ajax_call_duplicate_removal_batch', array( $this, 'cmbird_zi_duplicate_removal_batch' ) );
43 add_action( 'wp_ajax_zoho_ajax_call_duplicate_removal_status', array( $this, 'cmbird_zi_duplicate_removal_status' ) );
44 }
45
46 /**
47 * Create response array based on data for Vue Table compatibility.
48 *
49 * @param mixed $index_col - Index value error message.
50 * @param string $message - Response message.
51 * @param string $woo_id - Woo product ID.
52 *
53 * @return array
54 */
55 private function cmbird_zi_response_message( $index_col, $message, $woo_id = '' ) {
56 return array(
57 'resp_id' => $index_col,
58 'message' => $message,
59 'woo_prod_id' => $woo_id,
60 );
61 }
62
63 /**
64 * Check if Zoho API rate limit has been exceeded.
65 *
66 * @return bool True if rate limit exceeded, false if OK to proceed.
67 */
68 private function is_zoho_rate_limit_exceeded() {
69 $zoho_rate_limit_exceeded = get_option( 'cmbird_zoho_rate_limit_exceeded', false );
70
71 if ( $zoho_rate_limit_exceeded ) {
72 // Check if enough time has passed since the rate limit was hit.
73 $rate_limit_time = get_option( 'cmbird_zoho_rate_limit_time', 0 );
74 $current_time = time();
75
76 // Zoho typically resets limits every minute, so wait 65 seconds to be safe.
77 if ( ( $current_time - $rate_limit_time ) < 65 ) {
78 return true; // Rate limit is still active.
79 } else {
80 // Enough time has passed, reset the rate limit flag.
81 update_option( 'cmbird_zoho_rate_limit_exceeded', false );
82 delete_option( 'cmbird_zoho_rate_limit_time' );
83 return false; // Rate limit has been reset.
84 }
85 }
86
87 return false; // No rate limit active.
88 }
89
90 /**
91 * Track and limit Zoho API calls to respect 100 calls per minute limit.
92 *
93 * @return void
94 */
95 private function respect_zoho_api_rate_limit() {
96 $current_time = time();
97 $current_minute = floor( $current_time / 60 ); // Get current minute as identifier.
98
99 // Get the API call count for current minute.
100 $api_calls_key = 'cmbird_zoho_api_calls_' . $current_minute;
101 $api_call_count = get_option( $api_calls_key, 0 );
102
103 // Clean up old minute counters (keep only last 2 minutes).
104 $previous_minute = $current_minute - 1;
105 $old_minute = $current_minute - 2;
106 delete_option( 'cmbird_zoho_api_calls_' . $old_minute );
107
108 // If we've hit 95 calls, wait for the next minute to be safe
109 if ( $api_call_count >= 95 ) {
110 $seconds_to_wait = 60 - ( $current_time % 60 ) + 2; // +2 for safety margin.
111 sleep( $seconds_to_wait );
112
113 // Update to new minute.
114 $current_minute = floor( time() / 60 );
115 $api_calls_key = 'cmbird_zoho_api_calls_' . $current_minute;
116 $api_call_count = 0;
117 }
118
119 // Increment the counter for current minute.
120 update_option( $api_calls_key, $api_call_count + 1, false );
121
122 // Add a minimal delay between calls (0.7 seconds to stay under 100/minute).
123 // This allows ~85 calls per minute with safety margin.
124 usleep( 700000 ); // 0.7 seconds in microseconds.
125 }
126
127 /**
128 * Check API response for rate limit error and set rate limit flag if detected.
129 *
130 * @param object $json_response The JSON response from Zoho API.
131 * @return bool True if rate limit detected, false otherwise.
132 */
133 private function check_and_handle_rate_limit_error( $json_response ) {
134 if ( ! $json_response ) {
135 return false;
136 }
137
138 // Check for rate limit error messages from Zoho.
139 if ( isset( $json_response->message ) ) {
140 $message = strtolower( $json_response->message );
141 if ( strpos( $message, 'rate limit' ) !== false ||
142 strpos( $message, 'exceeded the maximum number of requests' ) !== false ||
143 strpos( $message, 'too many requests' ) !== false ) {
144
145 // Set rate limit flag.
146 update_option( 'cmbird_zoho_rate_limit_exceeded', true );
147 update_option( 'cmbird_zoho_rate_limit_time', time() );
148
149 return true;
150 }
151 }
152
153 return false;
154 }
155
156 /**
157 * Parent category sync - handles only root/parent categories bidirectionally.
158 */
159 public function cmbird_zi_parent_category_sync() {
160 // Check Zoho API rate limit before proceeding.
161 if ( $this->is_zoho_rate_limit_exceeded() ) {
162 wp_send_json_error(
163 array(
164 'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
165 'data' => array(),
166 )
167 );
168 return;
169 }
170
171 $response = array();
172 $response[] = $this->cmbird_zi_response_message( '-', '-', '--- Parent Categories Sync (Root Level Only) ---' );
173
174 // Get all categories from Zoho and WooCommerce.
175 $zoho_categories = $this->cmbird_get_zoho_item_categories();
176 $all_categories = $this->get_all_categories_via_wc_api();
177
178 if ( ! $zoho_categories || ! $all_categories ) {
179 $response[] = $this->cmbird_zi_response_message( '-', 'Error: Could not fetch categories from Zoho or WooCommerce', '-' );
180 wp_send_json_success(
181 array(
182 'message' => 'Parent category sync failed - could not fetch categories',
183 'data' => $response,
184 )
185 );
186 return;
187 }
188
189 // Filter only parent categories (level 0) from WooCommerce.
190 $parent_categories = array_filter(
191 $all_categories,
192 function ( $category ) {
193 return $category['parent'] === 0 && 'uncategorized' !== $category['slug'];
194 }
195 );
196
197 // Filter only parent categories from Zoho (parent_category_id = '-1').
198 $zoho_parent_categories = array_filter(
199 $zoho_categories,
200 function ( $category ) {
201 return empty( $category['parent_category_id'] ) || '-1' === $category['parent_category_id'];
202 }
203 );
204
205 $response[] = $this->cmbird_zi_response_message( '-', sprintf( 'Found %d WooCommerce parent categories and %d Zoho parent categories', count( $parent_categories ), count( $zoho_parent_categories ) ), '-' );
206
207 // Debug: Show available Zoho parent categories for reference.
208 if ( ! empty( $zoho_parent_categories ) ) {
209 $zoho_names = array_map(
210 function ( $cat ) {
211 return $cat['category_name'];
212 },
213 array_slice( $zoho_parent_categories, 0, 5 )
214 );
215 $more_count = count( $zoho_parent_categories ) > 5 ? ' (+' . ( count( $zoho_parent_categories ) - 5 ) . ' more)' : '';
216 $response[] = $this->cmbird_zi_response_message( '-', '📋 Zoho parent categories: ' . implode( ', ', $zoho_names ) . $more_count, '-' );
217 }
218
219 // Process WC to Zoho sync for parent categories.
220 $exported_count = 0;
221 foreach ( $parent_categories as $category ) {
222 // Check existing mapping first WITHOUT validation to avoid deleting valid mappings.
223 $existing_mapping = get_option( 'cmbird_zoho_id_for_term_id_' . $category['id'], '' );
224
225 if ( ! empty( $existing_mapping ) ) {
226 // Verify the mapping exists in Zoho categories.
227 $mapping_valid = false;
228 foreach ( $zoho_categories as $zoho_category ) {
229 if ( $zoho_category['category_id'] === $existing_mapping ) {
230 $mapping_valid = true;
231 break;
232 }
233 }
234
235 if ( $mapping_valid ) {
236 $response[] = $this->cmbird_zi_response_message( '-', "✓ VERIFIED: Parent category '{$category['name']}' properly mapped to Zoho (ID: {$existing_mapping})", $category['id'] );
237 continue;
238 } else {
239 $response[] = $this->cmbird_zi_response_message( '-', "⚠️ INVALID: Mapping found but Zoho category doesn't exist, will remap '{$category['name']}'", $category['id'] );
240 delete_option( 'cmbird_zoho_id_for_term_id_' . $category['id'] );
241 }
242 }
243
244 // Check if category already exists in Zoho by name (case-insensitive).
245 $existing_zoho_category = $this->find_zoho_category_by_name( $category['name'], '', $zoho_categories );
246
247 if ( $existing_zoho_category ) {
248 // Category exists in Zoho, create the mapping.
249 update_option( 'cmbird_zoho_id_for_term_id_' . $category['id'], $existing_zoho_category['category_id'] );
250 $response[] = $this->cmbird_zi_response_message( '-', "✓ MAPPED: Found existing Zoho parent category '{$category['name']}' → '{$existing_zoho_category['category_name']}' (ID: {$existing_zoho_category['category_id']})", $category['id'] );
251
252 // Verify the mapping was saved correctly.
253 $saved_mapping = get_option( 'cmbird_zoho_id_for_term_id_' . $category['id'], '' );
254 if ( $saved_mapping !== $existing_zoho_category['category_id'] ) {
255 $response[] = $this->cmbird_zi_response_message( '-', "⚠️ WARNING: Mapping save verification failed for '{$category['name']}' - Expected: {$existing_zoho_category['category_id']}, Got: {$saved_mapping}", $category['id'] );
256 }
257 } else {
258 // Category doesn't exist in Zoho, create it.
259 $response[] = $this->cmbird_zi_response_message( '-', "🔍 NOT FOUND: WC category '{$category['name']}' not found in Zoho, creating new category...", $category['id'] );
260
261 $term_obj = new stdClass();
262 $term_obj->term_id = $category['id'];
263 $term_obj->name = $category['name'];
264 $term_obj->slug = $category['slug'];
265 $term_obj->parent = 0; // Ensure it's marked as parent.
266
267 $export_result = $this->cmbird_zi_category_export( $term_obj->name, $term_obj->term_id, '' );
268 $decoded_result = json_decode( $export_result, true );
269
270 // Check if export was successful and mapping was created.
271 if ( $decoded_result && isset( $decoded_result['code'] ) && ( '0' === $decoded_result['code'] || 0 === $decoded_result['code'] ) ) {
272 $response[] = $decoded_result;
273 ++$exported_count;
274 } else {
275 $response[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to export parent category '{$category['name']}' to Zoho", $category['id'] );
276 }
277 }
278 }
279
280 // Process Zoho to WC sync for parent categories.
281 $imported_count = 0;
282 foreach ( $zoho_parent_categories as $zoho_category ) {
283 // skip if there is no name.
284 if ( empty( $zoho_category['category_name'] ) ) {
285 continue;
286 }
287 $category_slug = sanitize_title( $zoho_category['category_name'] );
288 $existing_term = get_term_by( 'slug', $category_slug, 'product_cat' );
289
290 if ( ! $existing_term ) {
291 // Create new parent category in WooCommerce.
292 $new_term = wp_insert_term(
293 $zoho_category['category_name'],
294 'product_cat',
295 array( 'parent' => 0 )
296 );
297
298 if ( ! is_wp_error( $new_term ) ) {
299 // Store the mapping.
300 update_option( 'cmbird_zoho_id_for_term_id_' . $new_term['term_id'], $zoho_category['category_id'] );
301 $response[] = $this->cmbird_zi_response_message( '-', "IMPORT: Created parent category '{$zoho_category['category_name']}' in WooCommerce", $new_term['term_id'] );
302 ++$imported_count;
303 } else {
304 $response[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to create parent category '{$zoho_category['category_name']}': " . $new_term->get_error_message(), '-' );
305 }
306 } else {
307 // Update mapping if missing.
308 $existing_mapping = get_option( 'cmbird_zoho_id_for_term_id_' . $existing_term->term_id, '' );
309 if ( empty( $existing_mapping ) ) {
310 update_option( 'cmbird_zoho_id_for_term_id_' . $existing_term->term_id, $zoho_category['category_id'] );
311 $response[] = $this->cmbird_zi_response_message( '-', "MAPPED: Linked existing parent category '{$existing_term->name}' to Zoho", $existing_term->term_id );
312 } else {
313 $response[] = $this->cmbird_zi_response_message( '-', "SKIP: Parent category '{$existing_term->name}' already properly mapped", $existing_term->term_id );
314 }
315 }
316 }
317
318 // Check if WooCommerce hierarchy enforcement is enabled.
319 $cron_settings = get_option( 'cmbird_zoho_inventory_cron', array() );
320 if ( is_string( $cron_settings ) ) {
321 $cron_settings = json_decode( $cron_settings, true );
322 }
323 $use_wc_hierarchy = isset( $cron_settings['use_wc_hierarchy'] ) ? $cron_settings['use_wc_hierarchy'] : true; // Default to true.
324
325 if ( $use_wc_hierarchy ) {
326 $response[] = $this->cmbird_zi_response_message( '-', '🔧 Enforcing WooCommerce hierarchy in Zoho...', '-' );
327
328 // Get fresh data for hierarchy enforcement.
329 $fresh_zoho_categories = $this->cmbird_get_zoho_item_categories();
330 $fresh_wc_categories = $this->get_all_categories_via_wc_api();
331
332 if ( $fresh_zoho_categories && $fresh_wc_categories ) {
333 $response = $this->enforce_woocommerce_hierarchy_in_zoho( $fresh_wc_categories, $fresh_zoho_categories, $response );
334 }
335 }
336
337 $response[] = $this->cmbird_zi_response_message( '-', "�
338 PARENT SYNC COMPLETE: Exported {$exported_count} to Zoho, Imported {$imported_count} from Zoho", '-' );
339
340 wp_send_json_success(
341 array(
342 'message' => 'Parent category sync completed successfully',
343 'data' => $response,
344 )
345 );
346 }
347
348 /**
349 * Subcategory sync - handles only child/subcategories bidirectionally.
350 */
351 public function cmbird_zi_subcategory_sync() {
352 // Check Zoho API rate limit before proceeding.
353 if ( $this->is_zoho_rate_limit_exceeded() ) {
354 wp_send_json_error(
355 array(
356 'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
357 'data' => array(),
358 )
359 );
360 return;
361 }
362
363 $response = array();
364 $response[] = $this->cmbird_zi_response_message( '-', '-', '--- Subcategories Sync (Child Categories Only) ---' );
365
366 // Get all categories from Zoho and WooCommerce.
367 $zoho_categories = $this->cmbird_get_zoho_item_categories();
368 $all_categories = $this->get_all_categories_via_wc_api();
369
370 if ( ! $zoho_categories || ! $all_categories ) {
371 $response[] = $this->cmbird_zi_response_message( '-', 'Error: Could not fetch categories from Zoho or WooCommerce', '-' );
372 wp_send_json_success(
373 array(
374 'message' => 'Subcategory sync failed - could not fetch categories',
375 'data' => $response,
376 )
377 );
378 return;
379 }
380
381 // Filter only subcategories (level > 0) from WooCommerce.
382 $subcategories = array_filter(
383 $all_categories,
384 function ( $category ) {
385 return $category['parent'] > 0 && 'uncategorized' !== $category['slug'];
386 }
387 );
388
389 // Filter only subcategories from Zoho (has parent_category_id that's not '-1').
390 $zoho_subcategories = array_filter(
391 $zoho_categories,
392 function ( $category ) {
393 return ! empty( $category['parent_category_id'] ) && '-1' !== $category['parent_category_id'];
394 }
395 );
396
397 $response[] = $this->cmbird_zi_response_message( '-', sprintf( 'Found %d WooCommerce subcategories and %d Zoho subcategories', count( $subcategories ), count( $zoho_subcategories ) ), '-' );
398
399 // Process WC to Zoho sync for subcategories using hierarchical approach.
400 $exported_count = 0;
401
402 // Organize subcategories by hierarchy level for proper sequential processing.
403 $subcategory_levels = $this->organize_categories_by_hierarchy( $subcategories );
404 $response[] = $this->cmbird_zi_response_message( '-', 'Processing subcategories in hierarchical order (level 1, 2, 3...)', '-' );
405
406 // Process each hierarchy level sequentially.
407 foreach ( $subcategory_levels as $level => $level_categories ) {
408 if ( 0 === $level ) {
409 continue; // Skip level 0 (already processed as parent categories).
410 }
411
412 $response[] = $this->cmbird_zi_response_message( '-', "Processing hierarchy level {$level} (" . count( $level_categories ) . ' categories)', '-' );
413
414 foreach ( $level_categories as $category ) {
415 // First, validate existing mapping.
416 $valid_zoho_id = $this->validate_and_fix_category_mapping( $category['id'], $zoho_categories );
417
418 if ( $valid_zoho_id ) {
419 $response[] = $this->cmbird_zi_response_message( '-', " VERIFIED: Subcategory '{$category['name']}' properly mapped to Zoho (ID: {$valid_zoho_id})", $category['id'] );
420 continue;
421 }
422
423 // Get parent category's Zoho ID.
424 $parent_zoho_id = '';
425 if ( $category['parent'] > 0 ) {
426 $parent_zoho_id = get_option( 'cmbird_zoho_id_for_term_id_' . $category['parent'], '' );
427
428 // If parent doesn't have a Zoho mapping, try to resolve it automatically.
429 if ( empty( $parent_zoho_id ) ) {
430 // Get parent category details from WooCommerce.
431 $parent_term = get_term( $category['parent'], 'product_cat' );
432 if ( ! is_wp_error( $parent_term ) && $parent_term ) {
433 $response[] = $this->cmbird_zi_response_message( '-', "⚠️ RESOLVING: Parent category '{$parent_term->name}' (ID: {$category['parent']}) not mapped, attempting to resolve...", $category['id'] );
434
435 // Try to find and map the parent category.
436 $existing_parent_zoho = $this->find_zoho_category_by_name( $parent_term->name, '', $zoho_categories );
437 if ( $existing_parent_zoho ) {
438 // Found parent in Zoho, create mapping.
439 update_option( 'cmbird_zoho_id_for_term_id_' . $category['parent'], $existing_parent_zoho['category_id'] );
440 $parent_zoho_id = $existing_parent_zoho['category_id'];
441 $response[] = $this->cmbird_zi_response_message( '-', " RESOLVED: Mapped parent category '{$parent_term->name}' to Zoho (ID: {$existing_parent_zoho['category_id']})", $category['parent'] );
442 } else {
443 // Parent not found in Zoho, need to create it first.
444 $response[] = $this->cmbird_zi_response_message( '-', "🔍 CREATING: Parent category '{$parent_term->name}' not found in Zoho, creating...", $category['parent'] );
445 $parent_export_result = $this->cmbird_zi_category_export( $parent_term->name, $category['parent'], '' );
446 $parent_decoded = json_decode( $parent_export_result, true );
447 if ( $parent_decoded && isset( $parent_decoded['code'] ) && ( '0' === $parent_decoded['code'] || 0 === $parent_decoded['code'] ) ) {
448 $parent_zoho_id = get_option( 'cmbird_zoho_id_for_term_id_' . $category['parent'], '' );
449 $response[] = $this->cmbird_zi_response_message( '-', "�
450 CREATED: Parent category '{$parent_term->name}' created and mapped (ID: {$parent_zoho_id})", $category['parent'] );
451 } else {
452 $response[] = $this->cmbird_zi_response_message( '-', "❌ FAILED: Could not create parent category '{$parent_term->name}' in Zoho", $category['parent'] );
453 continue;
454 }
455 }
456 } else {
457 $response[] = $this->cmbird_zi_response_message( '-', "ERROR: Parent category (ID: {$category['parent']}) not found in WooCommerce for '{$category['name']}'", $category['id'] );
458 continue;
459 }
460 }
461
462 // Final check - if we still don't have a parent mapping, skip this subcategory.
463 if ( empty( $parent_zoho_id ) ) {
464 $response[] = $this->cmbird_zi_response_message( '-', "ERROR: Could not resolve parent mapping for subcategory '{$category['name']}'", $category['id'] );
465 continue;
466 }
467 }
468
469 // Check if category already exists in Zoho by name and parent.
470 $existing_zoho_category = $this->find_zoho_category_by_name( $category['name'], $parent_zoho_id, $zoho_categories );
471
472 if ( $existing_zoho_category ) {
473 // Category exists in Zoho, create the mapping.
474 update_option( 'cmbird_zoho_id_for_term_id_' . $category['id'], $existing_zoho_category['category_id'] );
475 $response[] = $this->cmbird_zi_response_message( '-', "✓ MAPPED: Found existing Zoho subcategory '{$category['name']}' (ID: {$existing_zoho_category['category_id']})", $category['id'] );
476 } else {
477 // Category doesn't exist in Zoho, create it.
478 $term_obj = new stdClass();
479 $term_obj->term_id = $category['id'];
480 $term_obj->name = $category['name'];
481 $term_obj->slug = $category['slug'];
482 $term_obj->parent = $category['parent'];
483
484 $export_result = $this->cmbird_zi_category_export( $term_obj->name, $term_obj->term_id, $parent_zoho_id );
485 $response[] = json_decode( $export_result, true );
486 ++$exported_count;
487 }
488 }
489 }
490
491 // Process Zoho to WC sync for subcategories.
492 $imported_count = 0;
493 foreach ( $zoho_subcategories as $zoho_category ) {
494 $category_slug = sanitize_title( $zoho_category['category_name'] );
495 $existing_term = get_term_by( 'slug', $category_slug, 'product_cat' );
496
497 if ( ! $existing_term ) {
498 // Find parent WC category using Zoho parent ID.
499 $parent_wc_id = 0;
500 if ( ! empty( $zoho_category['parent_category_id'] ) ) {
501 global $wpdb;
502 $like_base = 'cmbird_zoho_id_for_term_id_';
503 $like_pattern = $wpdb->esc_like( $like_base ) . '%';
504 $parent_option = $wpdb->get_var(
505 $wpdb->prepare(
506 "SELECT option_name FROM {$wpdb->options} WHERE option_value = %s AND option_name LIKE %s",
507 $zoho_category['parent_category_id'],
508 $like_pattern
509 )
510 );
511 if ( $parent_option ) {
512 $parent_wc_id = str_replace( $like_base, '', $parent_option );
513 }
514 }
515
516 // Create new subcategory in WooCommerce.
517 $new_term = wp_insert_term(
518 $zoho_category['category_name'],
519 'product_cat',
520 array( 'parent' => $parent_wc_id )
521 );
522
523 if ( ! is_wp_error( $new_term ) ) {
524 // Store the mapping.
525 update_option( 'cmbird_zoho_id_for_term_id_' . $new_term['term_id'], $zoho_category['category_id'] );
526 $parent_info = $parent_wc_id ? " (under parent ID: {$parent_wc_id})" : ' (as root - parent not found)';
527 $response[] = $this->cmbird_zi_response_message( '-', "IMPORT: Created subcategory '{$zoho_category['category_name']}'{$parent_info}", $new_term['term_id'] );
528 ++$imported_count;
529 } else {
530 $response[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to create subcategory '{$zoho_category['category_name']}': " . $new_term->get_error_message(), '-' );
531 }
532 } else {
533 // Update mapping if missing.
534 $existing_mapping = get_option( 'cmbird_zoho_id_for_term_id_' . $existing_term->term_id, '' );
535 if ( empty( $existing_mapping ) ) {
536 update_option( 'cmbird_zoho_id_for_term_id_' . $existing_term->term_id, $zoho_category['category_id'] );
537 $response[] = $this->cmbird_zi_response_message( '-', "MAPPED: Linked existing subcategory '{$existing_term->name}' to Zoho", $existing_term->term_id );
538 } else {
539 $response[] = $this->cmbird_zi_response_message( '-', "SKIP: Subcategory '{$existing_term->name}' already properly mapped", $existing_term->term_id );
540 }
541 }
542 }
543
544 // Check if WooCommerce hierarchy enforcement is enabled.
545 $cron_settings = get_option( 'cmbird_zoho_inventory_cron', array() );
546 if ( is_string( $cron_settings ) ) {
547 $cron_settings = json_decode( $cron_settings, true );
548 }
549 $use_wc_hierarchy = isset( $cron_settings['use_wc_hierarchy'] ) ? $cron_settings['use_wc_hierarchy'] : true; // Default to true.
550
551 if ( $use_wc_hierarchy ) {
552 $response[] = $this->cmbird_zi_response_message( '-', '🔧 Enforcing WooCommerce hierarchy in Zoho...', '-' );
553
554 // Get fresh data for hierarchy enforcement.
555 $fresh_zoho_categories = $this->cmbird_get_zoho_item_categories();
556 $fresh_wc_categories = $this->get_all_categories_via_wc_api();
557
558 if ( $fresh_zoho_categories && $fresh_wc_categories ) {
559 $response = $this->enforce_woocommerce_hierarchy_in_zoho( $fresh_wc_categories, $fresh_zoho_categories, $response );
560 }
561 }
562
563 $response[] = $this->cmbird_zi_response_message( '-', "�
564 SUBCATEGORY SYNC COMPLETE: Exported {$exported_count} to Zoho, Imported {$imported_count} from Zoho", '-' );
565
566 wp_send_json_success(
567 array(
568 'message' => 'Subcategory sync completed successfully',
569 'data' => $response,
570 )
571 );
572 }
573
574 /**
575 * AJAX handler for removing duplicate categories from both WooCommerce and Zoho.
576 */
577 public function cmbird_zi_remove_duplicates_sync() {
578 // Check Zoho API rate limit before proceeding.
579 if ( $this->is_zoho_rate_limit_exceeded() ) {
580 wp_send_json_error(
581 array(
582 'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
583 'data' => array(),
584 )
585 );
586 return;
587 }
588
589 $response = array();
590 $response[] = $this->cmbird_zi_response_message( '-', '-', '--- Removing Duplicate Categories (WooCommerce & Zoho) ---' );
591
592 // Remove duplicates from WooCommerce first.
593 $response[] = $this->cmbird_zi_response_message( '-', 'Removing duplicate WooCommerce categories...', '-' );
594 $this->cmbird_remove_duplicate_woocommerce_categories();
595 $response[] = $this->cmbird_zi_response_message( '-', '�
596 WooCommerce duplicate removal completed', '-' );
597
598 // Remove duplicates from Zoho using new async batch processing.
599 $zoho_duplicate_results = $this->cmbird_remove_duplicate_zoho_categories();
600
601 // Check if batch processing is required (new async method).
602 if ( isset( $zoho_duplicate_results['requires_batching'] ) && $zoho_duplicate_results['requires_batching'] ) {
603 // Return session data for async processing.
604 wp_send_json_success(
605 array(
606 'message' => 'Async duplicate removal started successfully',
607 'session_id' => $zoho_duplicate_results['session_id'],
608 'total_pages' => $zoho_duplicate_results['total_pages'],
609 'total_categories' => $zoho_duplicate_results['total_categories'],
610 'requires_async' => true,
611 )
612 );
613 } else {
614 // Old synchronous method fallback.
615 $response = array_merge( $response, $zoho_duplicate_results );
616 wp_send_json_success(
617 array(
618 'message' => 'Duplicate category cleanup completed successfully',
619 'data' => $response,
620 )
621 );
622 }
623 }
624
625 /**
626 * Remove duplicate WooCommerce categories based on Zoho category IDs.
627 */
628 public function cmbird_get_zoho_item_categories( $auto_remove_duplicates = false ) {
629 // $fd = fopen( __DIR__ . '/cmbird_get_zoho_item_categories.txt', 'a+' );
630
631 // First remove duplicates from WooCommerce categories.
632 // $this->cmbird_remove_duplicate_woocommerce_categories();
633
634 // Optionally remove duplicates from Zoho categories (only when explicitly requested).
635 if ( $auto_remove_duplicates ) {
636 $this->cmbird_remove_duplicate_zoho_categories();
637 }
638
639 $zoho_inventory_oid = $this->config['ConnectZI']['OID'];
640 $zoho_inventory_url = $this->config['ConnectZI']['APIURL'];
641
642 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
643 $all_categories = array();
644 $page = 1;
645
646 do {
647 // Respect Zoho API rate limit before making the call.
648 $this->respect_zoho_api_rate_limit();
649
650 $url = $zoho_inventory_url . 'inventory/v1/categories/?organization_id=' . $zoho_inventory_oid . '&page=' . $page;
651 $json = $execute_curl_call_handle->execute_curl_call_get( $url );
652
653 // Check for rate limit errors and handle them.
654 if ( $this->check_and_handle_rate_limit_error( $json ) ) {
655 break;
656 }
657
658 // Check if the API call was successful and we got a valid response.
659 if ( ! $json || is_wp_error( $json ) ) {
660 break;
661 }
662
663 $code = isset( $json->code ) ? $json->code : null;
664
665 if ( '0' === $code || 0 === $code ) {
666 if ( isset( $json->categories ) && is_array( $json->categories ) ) {
667 $categories_on_page = $json->categories;
668 $all_categories = array_merge( $all_categories, $categories_on_page );
669 // fwrite( $fd, print_r( 'Fetched ' . count( $categories_on_page ) . ' categories from page ' . $page . "\n", true ) );
670 }
671
672 // Check if we have more pages.
673 $has_more_page = isset( $json->page_context ) && isset( $json->page_context->has_more_page ) ? $json->page_context->has_more_page : false;
674
675 if ( ! $has_more_page ) {
676 break;
677 }
678 ++$page;
679 } else {
680 // Error occurred, break the loop.
681 break;
682 }
683 } while ( $page <= 50 ); // Safety limit to prevent infinite loops.
684
685 if ( ! empty( $all_categories ) ) {
686 $response = $all_categories;
687 // Initialize an array to store unique categories.
688 $unique_categories = array();
689
690 // Show ALL categories for selection - no filtering by name duplicates.
691 // Users should be able to see and select from all available Zoho categories.
692 foreach ( $response as $category ) {
693 if ( is_object( $category ) && isset( $category->category_id ) ) {
694 // Only skip the default -1 category_id.
695 // if ( '-1' === $category->category_id ) {.
696 // continue;
697 // }.
698
699 // Include all categories - let users see everything available in Zoho.
700 $unique_categories[] = $category;
701 }
702 }
703
704 // Reset keys to have a sequential array.
705 $unique_categories = array_values( $unique_categories );
706
707 } else {
708 $response = array();
709 return $response;
710 }
711
712 // Debug JSON encoding.
713 $response = wp_json_encode( $unique_categories );
714 // fwrite( $fd, print_r( $response, true ) );
715 // fclose( $fd );
716
717 return json_decode( $response, true );
718 }
719
720 /**
721 * Create woocommerce category in zoho inventory.
722 *
723 * @param string $cat_name Category name.
724 * @param string $term_id Term ID.
725 * @param string $pid Parent ID.
726 * @return string JSON encoded response.
727 */
728 public function cmbird_zi_category_export( $cat_name, $term_id = '0', $pid = '' ) {
729
730 // Ensure $cat_name is a string (in case an object is passed by mistake).
731 if ( is_object( $cat_name ) ) {
732 if ( isset( $cat_name->name ) ) {
733 $cat_name = $cat_name->name;
734 } else {
735 return false; // Invalid category name.
736 }
737 }
738 $cat_name = (string) $cat_name;
739
740 $zoho_inventory_oid = $this->config['ConnectZI']['OID'];
741 $zoho_inventory_url = $this->config['ConnectZI']['APIURL'];
742
743 if ( ! empty( $pid ) || $pid > 0 ) {
744 $zidata = '"name" : "' . $cat_name . '","parent_category_id" : "' . $pid . '",';
745 } else {
746 $zidata = '"name" : "' . $cat_name . '",';
747 }
748
749 $data = array(
750 'JSONString' => '{' . $zidata . '}',
751 );
752
753 $url = $zoho_inventory_url . 'inventory/v1/categories/?organization_id=' . $zoho_inventory_oid;
754
755 // Respect Zoho API rate limit before making the call.
756 $this->respect_zoho_api_rate_limit();
757
758 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
759 $json = $execute_curl_call_handle->execute_curl_call_post( $url, $data );
760
761 // Check for rate limit errors and handle them.
762 if ( $this->check_and_handle_rate_limit_error( $json ) ) {
763 return false;
764 }
765
766 // Check if the response is valid and has a code property.
767 if ( ! $json || ! isset( $json->code ) ) {
768 return false;
769 }
770
771 $code = $json->code;
772
773 if ( '0' === $code || 0 === $code ) {
774 $category_id_saved = false;
775
776 // Handle different response formats.
777 if ( isset( $json->category ) ) {
778 if ( is_object( $json->category ) ) {
779 // Object format - iterate through properties.
780 foreach ( $json->category as $key => $value ) {
781 if ( 'category_id' === $key ) {
782 update_option( 'cmbird_zoho_id_for_term_id_' . $term_id, $value );
783 $category_id_saved = true;
784 }
785 }
786 } elseif ( is_array( $json->category ) && isset( $json->category['category_id'] ) ) {
787 // Array format - direct access.
788 update_option( 'cmbird_zoho_id_for_term_id_' . $term_id, $json->category['category_id'] );
789 $category_id_saved = true;
790 }
791 }
792
793 // If no category_id was saved, try direct access to category_id.
794 if ( ! $category_id_saved && isset( $json->category_id ) ) {
795 update_option( 'cmbird_zoho_id_for_term_id_' . $term_id, $json->category_id );
796 $category_id_saved = true;
797 }
798
799 $response_msg = $category_id_saved ? 'Category created and mapped successfully' : 'Category created but mapping failed';
800 if ( isset( $json->message ) ) {
801 $response_msg = $json->message . ( $category_id_saved ? ' (mapped)' : ' (not mapped)' );
802 }
803 } else {
804 $response_msg = isset( $json->message ) ? $json->message : 'Unknown error response';
805 }
806
807 $return = $this->cmbird_zi_response_message( $code, $response_msg, $term_id );
808 return wp_json_encode( $return );
809 }
810
811 /**
812 * Remove duplicate WooCommerce categories.
813 */
814 public function cmbird_remove_duplicate_woocommerce_categories() {
815 // Get all product categories, including empty ones.
816 $terms = get_terms(
817 array(
818 'taxonomy' => 'product_cat',
819 'hide_empty' => false, // Include categories with zero product count.
820 )
821 );
822
823 if ( is_wp_error( $terms ) || empty( $terms ) ) {
824 return; // No terms found or an error occurred.
825 }
826
827 // Create an array to store category names and their associated terms.
828 $categories_by_name = array();
829
830 foreach ( $terms as $term ) {
831 $name = strtolower( $term->name ); // Normalize the name for comparison.
832
833 // Check if a category with this name is already processed.
834 if ( isset( $categories_by_name[ $name ] ) ) {
835 $existing_term = $categories_by_name[ $name ];
836
837 if ( 0 < $term->count && 0 === $existing_term->count ) {
838 // Keep the current term, remove the existing one.
839 wp_delete_term( $existing_term->term_id, 'product_cat' );
840 $categories_by_name[ $name ] = $term;
841 } elseif ( 0 === $term->count ) {
842 // Remove the current term as it has zero product count.
843 wp_delete_term( $term->term_id, 'product_cat' );
844 }
845 } else {
846 // Add the term to the array if not already processed.
847 $categories_by_name[ $name ] = $term;
848 }
849 }
850 }
851
852 /**
853 * Remove duplicate categories from Zoho Inventory.
854 *
855 * Detects categories with the same name in the same parent hierarchy level
856 * and removes duplicates, keeping the first occurrence.
857 *
858 * @return array Array of results showing what duplicates were found and removed.
859 */
860 public function cmbird_remove_duplicate_zoho_categories() {
861 // Initialize page-by-page processing for duplicate removal (starting from the end).
862 $session_id = 'cmbird_duplicate_removal_' . time();
863
864 // Get all categories and page info.
865 $page_info = $this->get_zoho_categories_page_info();
866
867 if ( ! $page_info || ! isset( $page_info['all_categories'] ) ) {
868 return array(
869 $this->cmbird_zi_response_message( '-', 'Failed to get categories from Zoho API', '-' ),
870 );
871 }
872
873 $all_categories = $page_info['all_categories'];
874 $total_categories = count( $all_categories );
875 $per_page = $page_info['per_page'];
876 $total_pages = max( 1, ceil( $total_categories / $per_page ) );
877
878 if ( $total_categories <= 0 ) {
879 return array(
880 $this->cmbird_zi_response_message( '-', 'No categories found in Zoho', '-' ),
881 );
882 }
883
884 // Split categories into pages (working backwards from end).
885 $category_pages = array_chunk( $all_categories, $per_page );
886 $category_pages = array_reverse( $category_pages ); // Start from last page.
887
888 // Initialize page-by-page processing session.
889 $batch_data = array(
890 'total_pages' => $total_pages,
891 'total_categories' => $total_categories,
892 'current_page' => $total_pages, // Start from last page.
893 'processed_pages' => 0,
894 'processed_categories' => 0,
895 'removed_successfully' => 0,
896 'removal_errors' => 0,
897 'session_id' => $session_id,
898 'start_time' => time(),
899 'category_pages' => $category_pages, // Store pre-split categories.
900 );
901
902 // Store session data.
903 set_transient( $session_id, $batch_data, 3600 ); // 1 hour expiry.
904
905 return array(
906 'session_id' => $session_id,
907 'total_pages' => $total_pages,
908 'total_categories' => $total_categories,
909 'requires_batching' => true,
910 );
911 }
912
913
914
915 /**
916 * Delete a category from Zoho Inventory.
917 *
918 * @param string $category_id Zoho category ID to delete.
919 * @param string $category_name Category name for logging purposes.
920 * @return array Result with success status and message.
921 */
922 private function delete_zoho_category( $category_id, $category_name ) {
923 $zoho_inventory_oid = $this->config['ConnectZI']['OID'];
924 $zoho_inventory_url = $this->config['ConnectZI']['APIURL'];
925
926 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
927 $url = $zoho_inventory_url . 'inventory/v1/categories/' . $category_id . '?organization_id=' . $zoho_inventory_oid;
928
929 try {
930 // Respect Zoho API rate limit before making the call.
931 $this->respect_zoho_api_rate_limit();
932
933 $json = $execute_curl_call_handle->execute_curl_call_delete( $url );
934
935 if ( isset( $json->code ) && ( '0' === $json->code || 0 === $json->code ) ) {
936 return array(
937 'success' => true,
938 'message' => 'Category deleted successfully from Zoho',
939 );
940 } else {
941 return array(
942 'success' => false,
943 'message' => isset( $json->message ) ? $json->message : 'Unknown error deleting category from Zoho',
944 );
945 }
946 } catch ( Exception $e ) {
947 return array(
948 'success' => false,
949 'message' => 'Exception: ' . $e->getMessage(),
950 );
951 }
952 }
953
954 /**
955 * Clean up WooCommerce mappings that point to a deleted Zoho category.
956 *
957 * @param string $zoho_category_id The Zoho category ID that was deleted.
958 */
959 private function cleanup_wc_mappings_for_zoho_category( $zoho_category_id ) {
960 global $wpdb;
961
962 // Find all WooCommerce term mappings that point to this Zoho category.
963 $like_base = 'cmbird_zoho_id_for_term_id_';
964 $like_pattern = $wpdb->esc_like( $like_base ) . '%';
965 $mappings = $wpdb->get_results(
966 $wpdb->prepare(
967 "SELECT option_name FROM {$wpdb->options}
968 WHERE option_name LIKE %s
969 AND option_value = %s",
970 $like_pattern,
971 $zoho_category_id
972 )
973 );
974
975 foreach ( $mappings as $mapping ) {
976 // Extract WC term ID from option name.
977 $wc_term_id = str_replace( 'cmbird_zoho_id_for_term_id_', '', $mapping->option_name );
978
979 // Delete the mapping.
980 delete_option( $mapping->option_name );
981 }
982 }
983
984 /**
985 * Get page information for Zoho categories using existing raw categories data.
986 * This avoids additional API calls since we already fetch all categories.
987 *
988 * @return array|false Page info or false on error.
989 */
990 private function get_zoho_categories_page_info() {
991 // Get all categories using existing function that already loops through all pages.
992 $all_categories = $this->cmbird_get_zoho_item_categories_raw();
993
994 if ( empty( $all_categories ) || ! is_array( $all_categories ) ) {
995 return false;
996 }
997
998 $total_categories = count( $all_categories );
999 $per_page = 25; // Standard page size.
1000 $total_pages = max( 1, ceil( $total_categories / $per_page ) );
1001
1002 return array(
1003 'total_pages' => $total_pages,
1004 'total_categories' => $total_categories,
1005 'per_page' => $per_page,
1006 'has_more_page' => $total_pages > 1,
1007 'all_categories' => $all_categories, // Include the actual data.
1008 );
1009 }
1010
1011
1012
1013 /**
1014 * Get categories from Zoho without duplicate removal (raw data).
1015 * This prevents infinite loops when calling from duplicate removal methods.
1016 *
1017 * @return array Raw categories from Zoho API.
1018 */
1019 private function cmbird_get_zoho_item_categories_raw() {
1020 $zoho_inventory_oid = $this->config['ConnectZI']['OID'];
1021 $zoho_inventory_url = $this->config['ConnectZI']['APIURL'];
1022
1023 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
1024 $all_categories = array();
1025 $page = 1;
1026
1027 do {
1028 // Respect Zoho API rate limit before making the call.
1029 $this->respect_zoho_api_rate_limit();
1030
1031 $url = $zoho_inventory_url . 'inventory/v1/categories/?organization_id=' . $zoho_inventory_oid . '&page=' . $page;
1032 $json = $execute_curl_call_handle->execute_curl_call_get( $url );
1033
1034 // Check if the API call was successful and we got a valid response.
1035 if ( ! $json || is_wp_error( $json ) ) {
1036 break;
1037 }
1038
1039 $code = isset( $json->code ) ? $json->code : null;
1040
1041 if ( '0' === $code || 0 === $code ) {
1042 if ( isset( $json->categories ) && is_array( $json->categories ) ) {
1043 $categories_on_page = $json->categories;
1044 $all_categories = array_merge( $all_categories, $categories_on_page );
1045 }
1046
1047 // Check if we have more pages.
1048 $has_more_page = isset( $json->page_context ) && isset( $json->page_context->has_more_page ) ? $json->page_context->has_more_page : false;
1049
1050 if ( ! $has_more_page ) {
1051 break;
1052 }
1053 ++$page;
1054 } else {
1055 // Error occurred, break the loop.
1056 break;
1057 }
1058 } while ( $page <= 50 ); // Safety limit to prevent infinite loops.
1059
1060 if ( ! empty( $all_categories ) ) {
1061 // Convert objects to arrays for consistency.
1062 $response = array();
1063 foreach ( $all_categories as $category ) {
1064 if ( is_object( $category ) && isset( $category->category_id ) ) {
1065 $response[] = array(
1066 'category_id' => $category->category_id,
1067 'name' => isset( $category->category_name ) ? $category->category_name : '',
1068 'category_name' => isset( $category->category_name ) ? $category->category_name : '',
1069 'parent_category_id' => isset( $category->parent_category_id ) ? $category->parent_category_id : '-1',
1070 'url' => isset( $category->url ) ? $category->url : '',
1071 );
1072 }
1073 }
1074 return $response;
1075 } else {
1076 return array();
1077 }
1078 }
1079
1080 /**
1081 * Get all categories using WooCommerce REST API.
1082 *
1083 * @return array Array of categories with hierarchical information.
1084 */
1085 private function get_all_categories_via_wc_api() {
1086 $endpoint = '/wc/v3/products/categories';
1087 $request = new \WP_REST_Request( 'GET', $endpoint );
1088 $request->set_query_params(
1089 array(
1090 'per_page' => 100, // Get up to 100 categories per page.
1091 'orderby' => 'name',
1092 'order' => 'asc',
1093 )
1094 );
1095
1096 $categories = array();
1097 $page = 1;
1098
1099 do {
1100 $request->set_query_params(
1101 array(
1102 'per_page' => 100,
1103 'page' => $page,
1104 'orderby' => 'name',
1105 'order' => 'asc',
1106 )
1107 );
1108
1109 $response = rest_do_request( $request );
1110
1111 if ( $response->is_error() ) {
1112 break;
1113 }
1114
1115 $data = $response->get_data();
1116
1117 if ( empty( $data ) ) {
1118 break;
1119 }
1120
1121 // Process each category and add hierarchy information.
1122 foreach ( $data as $category ) {
1123 // Skip uncategorized.
1124 if ( 'uncategorized' === $category['slug'] ) {
1125 continue;
1126 }
1127
1128 $categories[] = array(
1129 'id' => $category['id'],
1130 'name' => $category['name'],
1131 'slug' => $category['slug'],
1132 'parent' => $category['parent'],
1133 'parent_name' => $this->get_parent_category_name( $category['parent'] ),
1134 'level' => $this->calculate_category_level( $category['parent'] ),
1135 );
1136 }
1137
1138 ++$page;
1139 $data_count = count( $data );
1140 } while ( 100 === $data_count ); // Continue if we got a full page.
1141
1142 return $categories;
1143 }
1144
1145 /**
1146 * Calculate category level based on parent hierarchy.
1147 *
1148 * @param int $parent_id Parent category ID.
1149 * @return int Category level (0 for root, 1+ for subcategories).
1150 */
1151 private function calculate_category_level( $parent_id ) {
1152 if ( 0 === $parent_id ) {
1153 return 0; // Root category.
1154 }
1155
1156 $level = 1;
1157 $current_id = $parent_id;
1158
1159 // Walk up the hierarchy to count levels.
1160 while ( $current_id > 0 ) {
1161 $parent_term = get_term( $current_id, 'product_cat' );
1162 if ( is_wp_error( $parent_term ) || ! $parent_term ) {
1163 break;
1164 }
1165
1166 if ( 0 === $parent_term->parent ) {
1167 break;
1168 }
1169
1170 $current_id = $parent_term->parent;
1171 ++$level;
1172 }
1173
1174 return $level;
1175 }
1176
1177 /**
1178 * Get parent category name by ID.
1179 *
1180 * @param int $parent_id Parent category ID.
1181 * @return string|null Parent category name or null if no parent.
1182 */
1183 private function get_parent_category_name( $parent_id ) {
1184 if ( 0 === $parent_id ) {
1185 return null;
1186 }
1187
1188 $parent_term = get_term( $parent_id, 'product_cat' );
1189 if ( is_wp_error( $parent_term ) || ! $parent_term ) {
1190 return null;
1191 }
1192
1193 return $parent_term->name;
1194 }
1195
1196 /**
1197 * Enforce WooCommerce hierarchy in Zoho by updating parent-child relationships.
1198 *
1199 * @param array $wc_categories WooCommerce categories from API.
1200 * @param array $zoho_categories Zoho categories.
1201 * @param array $response Response array for logging.
1202 * @return array Updated response array.
1203 */
1204 private function enforce_woocommerce_hierarchy_in_zoho( $wc_categories, $zoho_categories, $response ) {
1205 // Create mapping of WooCommerce category names to their hierarchy info.
1206 $wc_hierarchy_map = array();
1207 foreach ( $wc_categories as $wc_cat ) {
1208 $wc_hierarchy_map[ $wc_cat['name'] ] = array(
1209 'id' => $wc_cat['id'],
1210 'parent_id' => $wc_cat['parent'],
1211 'parent_name' => $wc_cat['parent_name'],
1212 'level' => $wc_cat['level'],
1213 );
1214 }
1215
1216 // Create mapping of Zoho categories by name for quick lookup.
1217 $zoho_by_name = array();
1218 foreach ( $zoho_categories as $zoho_cat ) {
1219 $zoho_by_name[ $zoho_cat['name'] ] = $zoho_cat;
1220 }
1221
1222 $hierarchy_fixes = 0;
1223 $hierarchy_errors = 0;
1224
1225 foreach ( $wc_categories as $wc_cat ) {
1226 if ( ! isset( $zoho_by_name[ $wc_cat['name'] ] ) ) {
1227 continue; // Category doesn't exist in Zoho yet.
1228 }
1229
1230 $zoho_cat = $zoho_by_name[ $wc_cat['name'] ];
1231 $expected_parent_zoho_id = '-1'; // Default to root.
1232
1233 // If WooCommerce category has a parent, find the corresponding Zoho parent ID.
1234 if ( $wc_cat['parent'] > 0 && ! empty( $wc_cat['parent_name'] ) ) {
1235 if ( isset( $zoho_by_name[ $wc_cat['parent_name'] ] ) ) {
1236 $expected_parent_zoho_id = $zoho_by_name[ $wc_cat['parent_name'] ]['category_id'];
1237 } else {
1238 $response[] = $this->cmbird_zi_response_message(
1239 $zoho_cat['category_id'],
1240 "WARNING: Parent '{$wc_cat['parent_name']}' not found in Zoho for '{$wc_cat['name']}'",
1241 $wc_cat['id']
1242 );
1243 ++$hierarchy_errors;
1244 continue;
1245 }
1246 }
1247
1248 // Check if hierarchy needs fixing.
1249 if ( $zoho_cat['parent_category_id'] !== $expected_parent_zoho_id ) {
1250 $response[] = $this->cmbird_zi_response_message(
1251 $zoho_cat['category_id'],
1252 "HIERARCHY MISMATCH: '{$wc_cat['name']}' - Current parent: {$zoho_cat['parent_category_id']}, Expected: {$expected_parent_zoho_id}",
1253 $wc_cat['id']
1254 );
1255
1256 // Update the category hierarchy in Zoho.
1257 $result = $this->update_zoho_category_parent( $zoho_cat['category_id'], $expected_parent_zoho_id );
1258
1259 if ( $result['success'] ) {
1260 $response[] = $this->cmbird_zi_response_message(
1261 $zoho_cat['category_id'],
1262 "�
1263 HIERARCHY FIXED: '{$wc_cat['name']}' now has correct parent",
1264 $wc_cat['id']
1265 );
1266 ++$hierarchy_fixes;
1267 } else {
1268 $response[] = $this->cmbird_zi_response_message(
1269 $zoho_cat['category_id'],
1270 "❌ HIERARCHY FIX FAILED: '{$wc_cat['name']}' - {$result['message']}",
1271 $wc_cat['id']
1272 );
1273 ++$hierarchy_errors;
1274 }
1275 }
1276 }
1277
1278 $response[] = $this->cmbird_zi_response_message(
1279 '-',
1280 "HIERARCHY ENFORCEMENT COMPLETE: {$hierarchy_fixes} fixed, {$hierarchy_errors} errors",
1281 '-'
1282 );
1283
1284 return $response;
1285 }
1286
1287 /**
1288 * Update a Zoho category's parent relationship.
1289 *
1290 * @param string $category_id Zoho category ID.
1291 * @param string $new_parent_id New parent category ID ('-1' for root).
1292 * @return array Result with success status and message.
1293 */
1294 private function update_zoho_category_parent( $category_id, $new_parent_id ) {
1295 $zoho_inventory_oid = $this->config['ConnectZI']['OID'];
1296 $zoho_inventory_url = $this->config['ConnectZI']['APIURL'];
1297 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
1298
1299 // Prepare the update data.
1300 $json_data = array();
1301
1302 if ( '-1' === $new_parent_id ) {
1303 $json_data['parent_category_id'] = ''; // Empty string makes it root category.
1304 } else {
1305 $json_data['parent_category_id'] = $new_parent_id;
1306 }
1307
1308 $update_data = array(
1309 'JSONString' => wp_json_encode( $json_data ),
1310 );
1311
1312 $url = $zoho_inventory_url . 'inventory/v1/categories/' . $category_id . '?organization_id=' . $zoho_inventory_oid;
1313
1314 try {
1315 // Respect Zoho API rate limit before making the call.
1316 $this->respect_zoho_api_rate_limit();
1317
1318 $json = $execute_curl_call_handle->execute_curl_call_put( $url, $update_data );
1319
1320 if ( isset( $json->code ) && ( '0' === $json->code || 0 === $json->code ) ) {
1321 return array(
1322 'success' => true,
1323 'message' => 'Category hierarchy updated successfully',
1324 );
1325 } else {
1326 return array(
1327 'success' => false,
1328 'message' => isset( $json->message ) ? $json->message : 'Unknown error updating category',
1329 );
1330 }
1331 } catch ( Exception $e ) {
1332 return array(
1333 'success' => false,
1334 'message' => 'Exception: ' . $e->getMessage(),
1335 );
1336 }
1337 }
1338
1339 /**
1340 * Recursively ensure a parent category and its ancestors exist in Zoho.
1341 *
1342 * @param int $parent_term_id The WooCommerce term ID of the parent category.
1343 * @return array Result array with 'success' boolean and 'messages' array.
1344 */
1345 private function ensure_parent_category_exists_in_zoho( $parent_term_id ) {
1346 $result = array(
1347 'success' => false,
1348 'messages' => array(),
1349 );
1350
1351 // Get the parent term.
1352 $parent_term = get_term( $parent_term_id, 'product_cat' );
1353 if ( ! $parent_term || is_wp_error( $parent_term ) ) {
1354 return $result;
1355 }
1356
1357 // Check if this parent already exists in Zoho.
1358 $parent_zoho_id = get_option( 'cmbird_zoho_id_for_term_id_' . $parent_term_id, '' );
1359 if ( ! empty( $parent_zoho_id ) ) {
1360 $result['success'] = true;
1361 return $result;
1362 }
1363
1364 // If the parent has its own parent, ensure that exists first (recursive).
1365 $grandparent_zoho_id = '';
1366 if ( $parent_term->parent > 0 ) {
1367 $grandparent_result = $this->ensure_parent_category_exists_in_zoho( $parent_term->parent );
1368 if ( ! $grandparent_result['success'] ) {
1369 return $grandparent_result; // Propagate failure up the chain.
1370 }
1371 $result['messages'] = array_merge( $result['messages'], $grandparent_result['messages'] );
1372 $grandparent_zoho_id = get_option( 'cmbird_zoho_id_for_term_id_' . $parent_term->parent, '' );
1373 }
1374
1375 // Now create this parent category with proper parent ID.
1376 $parent_export_result = $this->cmbird_zi_category_export( $parent_term->name, $parent_term->term_id, $grandparent_zoho_id );
1377 $parent_result_data = json_decode( $parent_export_result, true );
1378
1379 if ( $parent_result_data ) {
1380 $result['messages'][] = $parent_result_data;
1381 $result['success'] = true;
1382 }
1383
1384 return $result;
1385 }
1386
1387 /**
1388 * Start async subcategory sync process.
1389 * Prepares the data and creates a progress tracking session.
1390 */
1391 public function cmbird_zi_subcategory_sync_start() {
1392 // Security check.
1393 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
1394 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
1395 if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
1396 wp_send_json_error( array( 'message' => 'Security check failed' ) );
1397 return;
1398 }
1399
1400 // Check Zoho API rate limit before proceeding.
1401 if ( $this->is_zoho_rate_limit_exceeded() ) {
1402 wp_send_json_error(
1403 array(
1404 'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
1405 )
1406 );
1407 return;
1408 }
1409
1410 // Get all categories from Zoho and WooCommerce.
1411 $zoho_categories = $this->cmbird_get_zoho_item_categories();
1412 $all_categories = $this->get_all_categories_via_wc_api();
1413
1414 if ( ! $zoho_categories || ! $all_categories ) {
1415 wp_send_json_error(
1416 array(
1417 'message' => 'Could not fetch categories from Zoho or WooCommerce',
1418 )
1419 );
1420 return;
1421 }
1422
1423 // Filter subcategories for processing.
1424 $wc_subcategories_raw = array_filter(
1425 $all_categories,
1426 function ( $category ) {
1427 return $category['parent'] > 0 && 'uncategorized' !== $category['slug'];
1428 }
1429 );
1430
1431 $zoho_subcategories = array_filter(
1432 $zoho_categories,
1433 function ( $category ) {
1434 return ! empty( $category['parent_category_id'] ) && '-1' !== $category['parent_category_id'];
1435 }
1436 );
1437
1438 // Organize WC subcategories by hierarchy for proper processing order.
1439 $subcategory_levels = $this->organize_categories_by_hierarchy( $wc_subcategories_raw );
1440
1441 // Flatten hierarchical categories into processing order (level 1, then 2, then 3, etc.).
1442 $wc_subcategories = array();
1443 foreach ( $subcategory_levels as $level => $level_categories ) {
1444 if ( 0 === $level ) {
1445 continue; // Skip level 0 (parent categories).
1446 }
1447 $wc_subcategories = array_merge( $wc_subcategories, $level_categories );
1448 }
1449
1450 // Prepare data for batch processing.
1451 $batch_data = array(
1452 'wc_subcategories' => $wc_subcategories,
1453 'zoho_subcategories' => $zoho_subcategories,
1454 'total_wc' => count( $wc_subcategories ),
1455 'total_zoho' => count( $zoho_subcategories ),
1456 'processed_wc' => 0,
1457 'processed_zoho' => 0,
1458 'exported_count' => 0,
1459 'imported_count' => 0,
1460 'current_step' => 'wc_to_zoho',
1461 'batch_size' => 5, // Process 5 categories per batch to avoid timeouts.
1462 'messages' => array(),
1463 );
1464
1465 // Store batch data in WordPress options with expiration (1 hour).
1466 $session_id = 'subcategory_sync_' . time() . '_' . wp_generate_password( 8, false );
1467 set_transient( $session_id, $batch_data, 3600 );
1468
1469 wp_send_json_success(
1470 array(
1471 'session_id' => $session_id,
1472 'total_wc' => $batch_data['total_wc'],
1473 'total_zoho' => $batch_data['total_zoho'],
1474 'message' => 'Async subcategory sync initialized',
1475 )
1476 );
1477 }
1478
1479 /**
1480 * Process a batch of subcategories async.
1481 */
1482 public function cmbird_zi_subcategory_sync_batch() {
1483 // Security check.
1484 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
1485 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
1486 if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
1487 wp_send_json_error( array( 'message' => 'Security check failed' ) );
1488 return;
1489 }
1490 $session_id = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
1491 if ( empty( $session_id ) ) {
1492 wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
1493 return;
1494 }
1495
1496 // Get batch data.
1497 $batch_data = get_transient( $session_id );
1498 if ( false === $batch_data ) {
1499 wp_send_json_error( array( 'message' => 'Session expired or invalid' ) );
1500 return;
1501 }
1502
1503 $processed_this_batch = 0;
1504 $batch_messages = array();
1505
1506 // Process WC to Zoho sync.
1507 if ( 'wc_to_zoho' === $batch_data['current_step'] ) {
1508 $remaining_wc = array_slice( $batch_data['wc_subcategories'], $batch_data['processed_wc'] );
1509 $batch_items = array_slice( $remaining_wc, 0, $batch_data['batch_size'] );
1510
1511 foreach ( $batch_items as $category ) {
1512 // First, validate existing mapping and get current Zoho categories.
1513 $fresh_zoho_categories = $this->cmbird_get_zoho_item_categories_raw();
1514 $valid_zoho_id = $this->validate_and_fix_category_mapping( $category['id'], $fresh_zoho_categories );
1515
1516 if ( $valid_zoho_id ) {
1517 $batch_messages[] = $this->cmbird_zi_response_message( '-', "✓ VERIFIED: Subcategory '{$category['name']}' properly mapped to Zoho (ID: {$valid_zoho_id})", $category['id'] );
1518 ++$processed_this_batch;
1519 continue;
1520 }
1521
1522 // Get parent category's Zoho ID.
1523 $parent_zoho_id = '';
1524 if ( $category['parent'] > 0 ) {
1525 $parent_zoho_id = get_option( 'cmbird_zoho_id_for_term_id_' . $category['parent'], '' );
1526
1527 // If parent doesn't have a Zoho mapping, try to resolve it automatically.
1528 if ( empty( $parent_zoho_id ) ) {
1529 // Get parent category details from WooCommerce.
1530 $parent_term = get_term( $category['parent'], 'product_cat' );
1531 if ( ! is_wp_error( $parent_term ) && $parent_term ) {
1532 $batch_messages[] = $this->cmbird_zi_response_message( '-', "⚠️ RESOLVING: Parent category '{$parent_term->name}' (ID: {$category['parent']}) not mapped, attempting to resolve...", $category['id'] );
1533
1534 // Try to find and map the parent category.
1535 $existing_parent_zoho = $this->find_zoho_category_by_name( $parent_term->name, '', $fresh_zoho_categories );
1536 if ( $existing_parent_zoho ) {
1537 // Found parent in Zoho, create mapping.
1538 update_option( 'cmbird_zoho_id_for_term_id_' . $category['parent'], $existing_parent_zoho['category_id'] );
1539 $parent_zoho_id = $existing_parent_zoho['category_id'];
1540 $batch_messages[] = $this->cmbird_zi_response_message( '-', "✓ RESOLVED: Mapped parent category '{$parent_term->name}' to Zoho (ID: {$existing_parent_zoho['category_id']})", $category['parent'] );
1541 } else {
1542 // Parent not found in Zoho, need to create it first.
1543 $batch_messages[] = $this->cmbird_zi_response_message( '-', "🔍 CREATING: Parent category '{$parent_term->name}' not found in Zoho, creating...", $category['parent'] );
1544 $parent_export_result = $this->cmbird_zi_category_export( $parent_term->name, $category['parent'], '' );
1545 $parent_decoded = json_decode( $parent_export_result, true );
1546 if ( $parent_decoded && isset( $parent_decoded['code'] ) && ( '0' === $parent_decoded['code'] || 0 === $parent_decoded['code'] ) ) {
1547 $parent_zoho_id = get_option( 'cmbird_zoho_id_for_term_id_' . $category['parent'], '' );
1548 $batch_messages[] = $this->cmbird_zi_response_message( '-', "�
1549 CREATED: Parent category '{$parent_term->name}' created and mapped (ID: {$parent_zoho_id})", $category['parent'] );
1550 } else {
1551 $batch_messages[] = $this->cmbird_zi_response_message( '-', " FAILED: Could not create parent category '{$parent_term->name}' in Zoho", $category['parent'] );
1552 ++$processed_this_batch;
1553 continue;
1554 }
1555 }
1556 } else {
1557 $batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Parent category (ID: {$category['parent']}) not found in WooCommerce for '{$category['name']}'", $category['id'] );
1558 ++$processed_this_batch;
1559 continue;
1560 }
1561 }
1562
1563 // Final check - if we still don't have a parent mapping, skip this subcategory.
1564 if ( empty( $parent_zoho_id ) ) {
1565 $batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Could not resolve parent mapping for subcategory '{$category['name']}'", $category['id'] );
1566 ++$processed_this_batch;
1567 continue;
1568 }
1569 }
1570
1571 // Check if category already exists in Zoho by name and parent.
1572 $existing_zoho_category = $this->find_zoho_category_by_name( $category['name'], $parent_zoho_id, $fresh_zoho_categories );
1573
1574 if ( $existing_zoho_category ) {
1575 // Category exists in Zoho, create the mapping.
1576 update_option( 'cmbird_zoho_id_for_term_id_' . $category['id'], $existing_zoho_category['category_id'] );
1577 $batch_messages[] = $this->cmbird_zi_response_message( '-', " MAPPED: Found existing Zoho subcategory '{$category['name']}' (ID: {$existing_zoho_category['category_id']})", $category['id'] );
1578 } else {
1579 // Category doesn't exist in Zoho, create it.
1580 $term_obj = new stdClass();
1581 $term_obj->term_id = $category['id'];
1582 $term_obj->name = $category['name'];
1583 $term_obj->slug = $category['slug'];
1584 $term_obj->parent = $category['parent'];
1585
1586 $export_result = $this->cmbird_zi_category_export( $term_obj->name, $term_obj->term_id, $parent_zoho_id );
1587 $batch_messages[] = json_decode( $export_result, true );
1588 ++$batch_data['exported_count'];
1589 }
1590
1591 ++$processed_this_batch;
1592 }
1593
1594 $batch_data['processed_wc'] += $processed_this_batch;
1595
1596 // Check if WC to Zoho sync is complete.
1597 if ( $batch_data['processed_wc'] >= $batch_data['total_wc'] ) {
1598 $batch_data['current_step'] = 'zoho_to_wc';
1599 }
1600 } elseif ( 'zoho_to_wc' === $batch_data['current_step'] ) {
1601 $remaining_zoho = array_slice( $batch_data['zoho_subcategories'], $batch_data['processed_zoho'] );
1602 $batch_items = array_slice( $remaining_zoho, 0, $batch_data['batch_size'] );
1603
1604 foreach ( $batch_items as $zoho_category ) {
1605 $category_slug = sanitize_title( $zoho_category['category_name'] );
1606 $existing_term = get_term_by( 'slug', $category_slug, 'product_cat' );
1607
1608 if ( ! $existing_term ) {
1609 // Find parent WC category using Zoho parent ID.
1610 $parent_wc_id = 0;
1611 if ( ! empty( $zoho_category['parent_category_id'] ) ) {
1612 global $wpdb;
1613 $like_base = 'cmbird_zoho_id_for_term_id_';
1614 $like_pattern = $wpdb->esc_like( $like_base ) . '%';
1615 $parent_option = $wpdb->get_var(
1616 $wpdb->prepare(
1617 "SELECT option_name FROM {$wpdb->options} WHERE option_value = %s AND option_name LIKE %s",
1618 $zoho_category['parent_category_id'],
1619 $like_pattern
1620 )
1621 );
1622 if ( $parent_option ) {
1623 $parent_wc_id = str_replace( $like_base, '', $parent_option );
1624 }
1625 }
1626
1627 // Create new subcategory in WooCommerce.
1628 $new_term = wp_insert_term(
1629 $zoho_category['category_name'],
1630 'product_cat',
1631 array( 'parent' => $parent_wc_id )
1632 );
1633
1634 if ( ! is_wp_error( $new_term ) ) {
1635 // Store the mapping.
1636 update_option( 'cmbird_zoho_id_for_term_id_' . $new_term['term_id'], $zoho_category['category_id'] );
1637 $parent_info = $parent_wc_id ? " (under parent ID: {$parent_wc_id})" : ' (as root - parent not found)';
1638 $batch_messages[] = $this->cmbird_zi_response_message( '-', "IMPORT: Created subcategory '{$zoho_category['category_name']}'{$parent_info}", $new_term['term_id'] );
1639 ++$batch_data['imported_count'];
1640 } else {
1641 $batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to create subcategory '{$zoho_category['category_name']}': " . $new_term->get_error_message(), '-' );
1642 }
1643 } else {
1644 // Update mapping if missing.
1645 $existing_mapping = get_option( 'cmbird_zoho_id_for_term_id_' . $existing_term->term_id, '' );
1646 if ( empty( $existing_mapping ) ) {
1647 update_option( 'cmbird_zoho_id_for_term_id_' . $existing_term->term_id, $zoho_category['category_id'] );
1648 $batch_messages[] = $this->cmbird_zi_response_message( '-', "MAPPED: Linked existing subcategory '{$existing_term->name}' to Zoho", $existing_term->term_id );
1649 } else {
1650 $batch_messages[] = $this->cmbird_zi_response_message( '-', "SKIP: Subcategory '{$existing_term->name}' already properly mapped", $existing_term->term_id );
1651 }
1652 }
1653
1654 ++$processed_this_batch;
1655 }
1656
1657 $batch_data['processed_zoho'] += $processed_this_batch;
1658
1659 // Check if Zoho to WC sync is complete.
1660 if ( $batch_data['processed_zoho'] >= $batch_data['total_zoho'] ) {
1661 $batch_data['current_step'] = 'hierarchy_enforcement';
1662 }
1663 } elseif ( 'hierarchy_enforcement' === $batch_data['current_step'] ) {
1664 // Check if WooCommerce hierarchy enforcement is enabled.
1665 $cron_settings = get_option( 'cmbird_zoho_inventory_cron', array() );
1666 if ( is_string( $cron_settings ) ) {
1667 $cron_settings = json_decode( $cron_settings, true );
1668 }
1669 $use_wc_hierarchy = isset( $cron_settings['use_wc_hierarchy'] ) ? $cron_settings['use_wc_hierarchy'] : true;
1670
1671 if ( $use_wc_hierarchy ) {
1672 $batch_messages[] = $this->cmbird_zi_response_message( '-', '🔧 Enforcing WooCommerce hierarchy in Zoho...', '-' );
1673
1674 // Get fresh data for hierarchy enforcement.
1675 $fresh_zoho_categories = $this->cmbird_get_zoho_item_categories();
1676 $fresh_wc_categories = $this->get_all_categories_via_wc_api();
1677
1678 if ( $fresh_zoho_categories && $fresh_wc_categories ) {
1679 $hierarchy_messages = $this->enforce_woocommerce_hierarchy_in_zoho( $fresh_wc_categories, $fresh_zoho_categories, array() );
1680 $batch_messages = array_merge( $batch_messages, $hierarchy_messages );
1681 }
1682 }
1683
1684 $batch_data['current_step'] = 'completed';
1685 }
1686
1687 // Add batch messages to overall messages.
1688 $batch_data['messages'] = array_merge( $batch_data['messages'], $batch_messages );
1689
1690 // Update batch data.
1691 set_transient( $session_id, $batch_data, 3600 );
1692
1693 // Calculate progress.
1694 $total_operations = $batch_data['total_wc'] + $batch_data['total_zoho'];
1695 $completed = $batch_data['processed_wc'] + $batch_data['processed_zoho'];
1696 $progress = $total_operations > 0 ? ( $completed / $total_operations ) * 100 : 100;
1697
1698 // Add hierarchy enforcement to progress if needed.
1699 if ( 'hierarchy_enforcement' === $batch_data['current_step'] ) {
1700 $progress = 95; // Almost complete.
1701 } elseif ( 'completed' === $batch_data['current_step'] ) {
1702 $progress = 100;
1703 }
1704
1705 wp_send_json_success(
1706 array(
1707 'progress' => round( $progress, 1 ),
1708 'current_step' => $batch_data['current_step'],
1709 'processed_wc' => $batch_data['processed_wc'],
1710 'processed_zoho' => $batch_data['processed_zoho'],
1711 'total_wc' => $batch_data['total_wc'],
1712 'total_zoho' => $batch_data['total_zoho'],
1713 'exported_count' => $batch_data['exported_count'],
1714 'imported_count' => $batch_data['imported_count'],
1715 'messages' => $batch_messages,
1716 'is_complete' => 'completed' === $batch_data['current_step'],
1717 )
1718 );
1719 }
1720
1721 /**
1722 * Get the status of async subcategory sync.
1723 */
1724 public function cmbird_zi_subcategory_sync_status() {
1725 // Security check.
1726 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
1727 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
1728 if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
1729 wp_send_json_error( array( 'message' => 'Security check failed' ) );
1730 return;
1731 }
1732
1733 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed before sanitization below.
1734 $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
1735 if ( empty( $session_id ) ) {
1736 wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
1737 return;
1738 }
1739
1740 // Get batch data.
1741 $batch_data = get_transient( $session_id );
1742 if ( false === $batch_data ) {
1743 wp_send_json_error( array( 'message' => 'Session expired or invalid' ) );
1744 return;
1745 }
1746
1747 // Calculate progress.
1748 $total_operations = $batch_data['total_wc'] + $batch_data['total_zoho'];
1749 $completed = $batch_data['processed_wc'] + $batch_data['processed_zoho'];
1750 $progress = $total_operations > 0 ? ( $completed / $total_operations ) * 100 : 100;
1751
1752 if ( 'hierarchy_enforcement' === $batch_data['current_step'] ) {
1753 $progress = 95;
1754 } elseif ( 'completed' === $batch_data['current_step'] ) {
1755 $progress = 100;
1756
1757 // Clean up session data on completion.
1758 delete_transient( $session_id );
1759 }
1760
1761 wp_send_json_success(
1762 array(
1763 'progress' => round( $progress, 1 ),
1764 'current_step' => $batch_data['current_step'],
1765 'processed_wc' => $batch_data['processed_wc'],
1766 'processed_zoho' => $batch_data['processed_zoho'],
1767 'total_wc' => $batch_data['total_wc'],
1768 'total_zoho' => $batch_data['total_zoho'],
1769 'exported_count' => $batch_data['exported_count'],
1770 'imported_count' => $batch_data['imported_count'],
1771 'all_messages' => $batch_data['messages'],
1772 'is_complete' => 'completed' === $batch_data['current_step'],
1773 )
1774 );
1775 }
1776
1777 /**
1778 * Organize WooCommerce categories by hierarchy level for proper sequential processing.
1779 *
1780 * @param array $categories All WooCommerce categories.
1781 * @return array Categories organized by hierarchy level [level => [categories]].
1782 */
1783 private function organize_categories_by_hierarchy( $categories ) {
1784 $levels = array();
1785
1786 // Create a lookup for parent-child relationships.
1787 $category_lookup = array();
1788 foreach ( $categories as $category ) {
1789 $category_lookup[ $category['id'] ] = $category;
1790 }
1791
1792 // Function to calculate hierarchy level.
1793 $get_hierarchy_level = function ( $category_id, $visited = array() ) use ( &$get_hierarchy_level, $category_lookup ) {
1794 // Prevent infinite loops.
1795 if ( in_array( $category_id, $visited, true ) ) {
1796 return 0;
1797 }
1798
1799 if ( ! isset( $category_lookup[ $category_id ] ) ) {
1800 return 0;
1801 }
1802
1803 $category = $category_lookup[ $category_id ];
1804 if ( 0 === $category['parent'] || empty( $category['parent'] ) ) {
1805 return 0; // Root level.
1806 }
1807
1808 $visited[] = $category_id;
1809 return 1 + $get_hierarchy_level( $category['parent'], $visited );
1810 };
1811
1812 // Organize categories by their hierarchy level.
1813 foreach ( $categories as $category ) {
1814 if ( 'uncategorized' === $category['slug'] ) {
1815 continue; // Skip uncategorized.
1816 }
1817
1818 $level = $get_hierarchy_level( $category['id'] );
1819 if ( ! isset( $levels[ $level ] ) ) {
1820 $levels[ $level ] = array();
1821 }
1822 $levels[ $level ][] = $category;
1823 }
1824
1825 // Sort levels in ascending order (0, 1, 2, ...).
1826 ksort( $levels );
1827
1828 return $levels;
1829 }
1830
1831 /**
1832 * Find a Zoho category by name and parent ID.
1833 *
1834 * @param string $category_name The category name to search for.
1835 * @param string $parent_zoho_id The parent Zoho category ID (empty for root categories).
1836 * @param array $zoho_categories All Zoho categories.
1837 * @return array|null The matching Zoho category or null if not found.
1838 */
1839 private function find_zoho_category_by_name( $category_name, $parent_zoho_id, $zoho_categories ) {
1840 // Normalize the search name for comparison.
1841 $normalized_search_name = trim( strtolower( $category_name ) );
1842
1843 foreach ( $zoho_categories as $zoho_category ) {
1844 // Normalize the Zoho category name for comparison.
1845 $normalized_zoho_name = trim( strtolower( $zoho_category['category_name'] ) );
1846
1847 // Match by normalized name and parent.
1848 if ( $normalized_zoho_name === $normalized_search_name ) {
1849 $zoho_parent_id = $zoho_category['parent_category_id'] ?? '-1';
1850
1851 // For root categories, both should be empty or '-1'.
1852 if ( empty( $parent_zoho_id ) ) {
1853 if ( empty( $zoho_parent_id ) || '-1' === $zoho_parent_id ) {
1854 return $zoho_category;
1855 }
1856 } elseif ( $zoho_parent_id === $parent_zoho_id ) {
1857 // For subcategories, parent IDs should match.
1858 return $zoho_category;
1859 } else {
1860 continue; // Parent mismatch.
1861 }
1862 }
1863 }
1864 return null;
1865 }
1866
1867 /**
1868 * Validate and fix category mapping, ensuring the Zoho category still exists.
1869 *
1870 * @param int $wc_category_id WooCommerce category ID.
1871 * @param array $zoho_categories All Zoho categories.
1872 * @return string|false Valid Zoho category ID or false if invalid/not found.
1873 */
1874 private function validate_and_fix_category_mapping( $wc_category_id, $zoho_categories ) {
1875 $existing_mapping = get_option( 'cmbird_zoho_id_for_term_id_' . $wc_category_id, '' );
1876
1877 if ( empty( $existing_mapping ) ) {
1878 return false; // No mapping exists.
1879 }
1880
1881 // Check if the mapped Zoho category still exists.
1882 foreach ( $zoho_categories as $zoho_category ) {
1883 if ( $zoho_category['category_id'] === $existing_mapping ) {
1884 return $existing_mapping; // Valid mapping.
1885 }
1886 }
1887
1888 // Invalid mapping - remove it.
1889 delete_option( 'cmbird_zoho_id_for_term_id_' . $wc_category_id );
1890 return false;
1891 }
1892
1893 /**
1894 * Identify duplicate categories and sort them by depth (deepest first).
1895 *
1896 * @param array $zoho_categories All Zoho categories.
1897 * @return array Array of duplicate categories sorted by depth.
1898 */
1899 private function identify_duplicate_categories_by_depth( $zoho_categories ) {
1900 global $wpdb;
1901 $duplicate_categories = array();
1902 foreach ( $zoho_categories as $category ) {
1903 if ( '-1' === $category['category_id'] ) {
1904 continue; // Skip placeholder category.
1905 }
1906
1907 // Check if this category is a duplicate based on various patterns.
1908 $is_duplicate = false;
1909 $duplicate_reason = '';
1910
1911 // Get category properties for enhanced duplicate detection.
1912 $has_active_items = isset( $category['has_active_items'] ) ? (bool) $category['has_active_items'] : true;
1913 $category_url = $category['url'] ?? '';
1914
1915 // Check if this category exists in wp_options (mapped to WooCommerce).
1916 $like_pattern = $wpdb->esc_like( 'cmbird_zoho_id_for_term_id_' ) . '%';
1917 $category_exists_in_wp = $wpdb->get_var( $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s AND option_value = %s LIMIT 1", $like_pattern, $category['category_id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Need to check if category is mapped.
1918
1919 // If category is not mapped in wp_options, it should be removed.
1920 if ( empty( $category_exists_in_wp ) ) {
1921 $is_duplicate = true;
1922 $duplicate_reason = 'Category not mapped in WooCommerce';
1923 } elseif ( ! $has_active_items && ! empty( $category_url ) && preg_match( '/_\d+$/', $category_url ) ) {
1924 // Primary check: Parent categories with number suffix URL and no active items.
1925 $is_duplicate = true;
1926 $duplicate_reason = 'Parent category with URL suffix and no active items';
1927 } elseif ( preg_match( '/_\d+$/', $category['name'] ) ) {
1928 $is_duplicate = true;
1929 $duplicate_reason = 'Name ends with number suffix';
1930 } elseif ( preg_match( '/^Copy of |Copy \d+$|\(\d+\)$/', $category['name'] ) ) {
1931 $is_duplicate = true;
1932 $duplicate_reason = 'Name contains copy pattern';
1933 }
1934
1935 if ( $is_duplicate ) {
1936 $duplicate_categories[] = array(
1937 'category' => $category,
1938 'depth' => isset( $category['depth'] ) ? $category['depth'] : 0,
1939 'reason' => $duplicate_reason,
1940 );
1941 }
1942 }
1943
1944 // Sort by depth (highest first) - categories with depth 2, 3, etc. will be processed before depth 0, 1.
1945 usort(
1946 $duplicate_categories,
1947 function ( $a, $b ) {
1948 return $b['depth'] <=> $a['depth']; // Descending order (highest depth first).
1949 }
1950 );
1951
1952 return $duplicate_categories;
1953 }
1954
1955 /**
1956 * Process duplicate removal page by page (AJAX handler).
1957 */
1958 public function cmbird_zi_duplicate_removal_batch() {
1959 // Security check.
1960 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
1961 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
1962 if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
1963 wp_send_json_error( array( 'message' => 'Security check failed' ) );
1964 return;
1965 }
1966
1967 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed before sanitization below.
1968 $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
1969 if ( empty( $session_id ) ) {
1970 wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
1971 return;
1972 }
1973
1974 // Get batch data.
1975 $batch_data = get_transient( $session_id );
1976 if ( false === $batch_data ) {
1977 wp_send_json_error( array( 'message' => 'Session expired or invalid' ) );
1978 return;
1979 }
1980
1981 $batch_messages = array();
1982
1983 // Process current page (starting from the end).
1984 $current_page = $batch_data['current_page'];
1985
1986 if ( $current_page > 0 && isset( $batch_data['category_pages'] ) ) {
1987 // Get categories from pre-split pages (no additional API call needed).
1988 $page_index = $batch_data['total_pages'] - $current_page; // Convert page number to array index.
1989 $page_categories = $batch_data['category_pages'][ $page_index ] ?? array();
1990
1991 if ( ! empty( $page_categories ) ) {
1992 $batch_messages[] = $this->cmbird_zi_response_message(
1993 '-',
1994 "📄 Processing page {$current_page} with " . count( $page_categories ) . ' categories (identifying duplicates by depth)',
1995 '-'
1996 );
1997
1998 // Identify duplicates in this page using depth-based logic.
1999 $duplicate_categories = $this->identify_duplicate_categories_by_depth( $page_categories );
2000
2001 if ( ! empty( $duplicate_categories ) ) {
2002 // Sort duplicates by depth (highest depth first - deepest categories).
2003 usort(
2004 $duplicate_categories,
2005 function ( $a, $b ) {
2006 return $b['depth'] <=> $a['depth'];
2007 }
2008 );
2009
2010 $batch_messages[] = $this->cmbird_zi_response_message(
2011 '-',
2012 '🔍 Found ' . count( $duplicate_categories ) . ' duplicate categories on page ' . $current_page . ' (starting with highest depth)',
2013 '-'
2014 );
2015
2016 // Process duplicates starting with highest depth.
2017 foreach ( $duplicate_categories as $duplicate ) {
2018 $category = $duplicate['category'];
2019 $depth = $duplicate['depth'];
2020 $reason = $duplicate['reason'];
2021
2022 if ( '-1' === $category['category_id'] ) {
2023 continue; // Skip placeholder category.
2024 }
2025
2026 // Attempt to delete the duplicate category.
2027 $delete_result = $this->delete_zoho_category( $category['category_id'], $category['name'] );
2028
2029 if ( $delete_result['success'] ) {
2030 ++$batch_data['removed_successfully'];
2031 $batch_messages[] = $this->cmbird_zi_response_message(
2032 $category['category_id'],
2033 "�
2034 REMOVED DUPLICATE: '{$category['name']}' (ID: {$category['category_id']}, Depth: {$depth}, Reason: {$reason})",
2035 '-'
2036 );
2037
2038 // Remove any WooCommerce mappings to this deleted Zoho category.
2039 $this->cleanup_wc_mappings_for_zoho_category( $category['category_id'] );
2040 } else {
2041 ++$batch_data['removal_errors'];
2042 $batch_messages[] = $this->cmbird_zi_response_message(
2043 $category['category_id'],
2044 "❌ FAILED DUPLICATE: '{$category['name']}' (ID: {$category['category_id']}, Depth: {$depth}) - {$delete_result['message']}",
2045 '-'
2046 );
2047 }
2048
2049 ++$batch_data['processed_categories'];
2050 }
2051 } else {
2052 $batch_messages[] = $this->cmbird_zi_response_message(
2053 '-',
2054 "📄 No duplicates found on page {$current_page} - skipping",
2055 '-'
2056 );
2057 }
2058 }
2059
2060 // Move to previous page.
2061 --$batch_data['current_page'];
2062 ++$batch_data['processed_pages'];
2063 }
2064
2065 // Check if we're done (no more pages).
2066 $is_complete = $batch_data['current_page'] <= 0;
2067
2068 if ( $is_complete ) {
2069 // Mark as completed but don't delete session yet - let status endpoint handle cleanup.
2070 $batch_data['is_completed'] = true;
2071 $batch_data['completion_time'] = time();
2072
2073 $batch_messages[] = $this->cmbird_zi_response_message(
2074 '-',
2075 "🎉 CATEGORY REMOVAL COMPLETE: Processed {$batch_data['processed_pages']} pages, removed {$batch_data['removed_successfully']} categories, errors: {$batch_data['removal_errors']}",
2076 '-'
2077 );
2078 }
2079
2080 // Always update session data (will be cleaned up by status endpoint or expiry).
2081 set_transient( $session_id, $batch_data, 3600 );
2082
2083 wp_send_json_success(
2084 array(
2085 'messages' => $batch_messages,
2086 'processed_pages' => $batch_data['processed_pages'],
2087 'total_pages' => $batch_data['total_pages'],
2088 'current_page' => $batch_data['current_page'],
2089 'processed_categories' => $batch_data['processed_categories'],
2090 'removed_successfully' => $batch_data['removed_successfully'],
2091 'removal_errors' => $batch_data['removal_errors'],
2092 'is_complete' => $is_complete,
2093 'progress' => $batch_data['processed_pages'] > 0 ? round( ( $batch_data['processed_pages'] / $batch_data['total_pages'] ) * 100 ) : 0,
2094 'status' => $is_complete ? 'completed' : 'processing',
2095 )
2096 );
2097 }
2098
2099 /**
2100 * Get duplicate removal batch processing status (AJAX handler).
2101 */
2102 public function cmbird_zi_duplicate_removal_status() {
2103 // Security check.
2104 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
2105 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
2106 if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
2107 wp_send_json_error( array( 'message' => 'Security check failed' ) );
2108 return;
2109 }
2110
2111 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed before sanitization below.
2112 $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : ( isset( $_GET['session_id'] ) ? sanitize_text_field( wp_unslash( $_GET['session_id'] ) ) : '' );
2113 if ( empty( $session_id ) ) {
2114 wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
2115 return;
2116 }
2117
2118 // Get batch data.
2119 $batch_data = get_transient( $session_id );
2120 if ( false === $batch_data ) {
2121 wp_send_json_error( array( 'message' => 'Session expired or not found' ) );
2122 return;
2123 }
2124
2125 $is_complete = $batch_data['current_page'] <= 0 || isset( $batch_data['is_completed'] );
2126
2127 // If completed and this is a status check (not the first completion response), clean up session.
2128 if ( $is_complete && isset( $batch_data['completion_time'] ) && ( time() - $batch_data['completion_time'] ) > 5 ) {
2129 // Clean up after 5 seconds to allow frontend to get completion status.
2130 delete_transient( $session_id );
2131 }
2132
2133 wp_send_json_success(
2134 array(
2135 'processed_pages' => $batch_data['processed_pages'],
2136 'total_pages' => $batch_data['total_pages'],
2137 'current_page' => $batch_data['current_page'],
2138 'processed_categories' => $batch_data['processed_categories'],
2139 'removed_successfully' => $batch_data['removed_successfully'],
2140 'removal_errors' => $batch_data['removal_errors'],
2141 'is_complete' => $is_complete,
2142 'progress' => $batch_data['processed_pages'] > 0 ? round( ( $batch_data['processed_pages'] / $batch_data['total_pages'] ) * 100 ) : 0,
2143 )
2144 );
2145 }
2146 }
2147