PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.8.3
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.8.3
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-import-items.php
commercebird / includes / classes / zoho-inventory Last commit date
class-cmbird-categories-zi.php 2 months ago class-cmbird-image-zi.php 5 months ago class-import-items.php 2 months ago class-import-price-list.php 9 months ago class-multi-currency.php 7 months ago class-order-sync.php 3 months ago class-product.php 4 months ago class-users-contact.php 5 months ago index.php 1 year ago
class-import-items.php
1933 lines
1 <?php
2
3 /**
4 * Class to import Products from Zoho to WooCommerce
5 *
6 * @package zoho_inventory_api
7 */
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
13
14 /**
15 * Class to import Products from Zoho Inventory to WooCommerce.
16 *
17 * Handles syncing of simple products, variable products, grouped items,
18 * and composite items from Zoho Inventory to WooCommerce.
19 *
20 * @package zoho_inventory_api
21 */
22 class CMBIRD_Products_ZI {
23
24 /**
25 * Configuration array for Zoho Inventory and WooCommerce settings.
26 *
27 * @var array
28 */
29 private $config;
30
31 /**
32 * Flag indicating whether WooCommerce taxes are enabled.
33 *
34 * @var bool
35 */
36 private $is_tax_enabled;
37
38 /**
39 * Constructor to initialize Zoho Inventory and WooCommerce configuration.
40 *
41 * Sets up API credentials, sync settings, and WooCommerce price formatting options.
42 * Exits early if WooCommerce is not active.
43 */
44 public function __construct() {
45 // return if WooCommerce plugin is not active.
46 if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) {
47 return;
48 }
49
50 $this->config = array(
51 'ProductZI' => array(
52 'OID' => get_option( 'cmbird_zoho_inventory_oid' ),
53 'APIURL' => get_option( 'cmbird_zoho_inventory_url' ),
54 ),
55 'Settings' => array(
56 'disable_description' => get_option( 'cmbird_zoho_disable_description_sync_status' ),
57 'disable_name' => get_option( 'cmbird_zoho_disable_name_sync_status' ),
58 'disable_price' => get_option( 'cmbird_zoho_disable_price_sync_status' ),
59 'disable_stock' => get_option( 'cmbird_zoho_disable_stock_sync_status' ),
60 'enable_accounting_stock' => get_option( 'cmbird_zoho_enable_accounting_stock_status' ),
61 'enable_location_stock' => get_option( 'cmbird_zoho_enable_locationstock_status' ),
62 'zoho_location_id' => get_option( 'cmbird_zoho_location_id_status' ),
63 'disable_image' => get_option( 'cmbird_zoho_disable_image_sync_status' ),
64 ),
65 );
66
67 // Check if WooCommerce taxes are enabled and store the result.
68 $this->is_tax_enabled = 'yes' === get_option( 'woocommerce_calc_taxes' );
69 }
70
71 /**
72 * Update or Create Custom Fields of Product
73 *
74 * @param array|object $custom_fields - item object coming in from simple item recursive.
75 * @param int $pdt_id - product id.
76 * @return void
77 */
78 public function sync_item_custom_fields( $custom_fields, $pdt_id ) {
79 if ( empty( $custom_fields ) || empty( $pdt_id ) ) {
80 return;
81 }
82
83 foreach ( $custom_fields as $custom_field ) {
84 // Extract data from custom field.
85 $api_name = isset( $custom_field->api_name ) ? $custom_field->api_name : $custom_field['api_name'];
86 $value = isset( $custom_field->value ) ? $custom_field->value : $custom_field['value'];
87
88 // Check if both API name and value are present.
89 if ( ! empty( $api_name ) && ! empty( $value ) ) {
90 // Check if ACF function exists.
91 if ( function_exists( 'update_field' ) ) {
92 // Update ACF field.
93 update_field( $api_name, $value, $pdt_id );
94 } else {
95 // Fall back to update post meta.
96 update_post_meta( $pdt_id, $api_name, $value );
97 }
98 }
99 }
100 }
101
102
103 /**
104 * Function to add group items recursively by manual sync
105 *
106 * Accepts arguments via func_get_args(): page number and category ID for pagination.
107 *
108 * @return mixed
109 */
110 public function sync_groupitem_recursively() {
111 // $fd = fopen( __DIR__ . '/sync_groupitem_recursively.txt', 'a+' );
112
113 $args = func_get_args();
114 if ( ! empty( $args ) ) {
115 if ( is_array( $args ) ) {
116 if ( isset( $args['page'] ) && isset( $args['category'] ) ) {
117 $page = $args['page'];
118 $category = $args['category'];
119 } elseif ( isset( $args[0] ) && isset( $args[1] ) ) {
120 $page = $args[0];
121 $category = $args[1];
122 } elseif ( isset( $args[0] ) && ! isset( $args[1] ) ) {
123 $page = $args[0]['page'];
124 $category = $args[0]['category'];
125 } else {
126 return;
127 }
128 } else {
129 return;
130 }
131
132 // Memory management: Set memory limit if needed.
133 if ( function_exists( 'wp_raise_memory_limit' ) ) {
134 wp_raise_memory_limit( 'admin' );
135 }
136
137 global $wpdb;
138 $zoho_inventory_oid = $this->config['ProductZI']['OID'];
139 $zoho_inventory_url = $this->config['ProductZI']['APIURL'];
140 $url = $zoho_inventory_url . 'inventory/v1/itemgroups/?organization_id=' . $zoho_inventory_oid . '&category_id=' . $category . '&page=' . $page . '&per_page=20&filter_by=Status.Active';
141 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
142 $json = $execute_curl_call->execute_curl_call_get( $url );
143 $code = $json->code;
144 // $message = $json->message;
145 $response_msg = array();
146
147 if ( '0' === $code || 0 === $code ) {
148 $zi_disable_description_sync = $this->config['Settings']['disable_description'];
149 $zi_disable_name_sync = $this->config['Settings']['disable_name'];
150 // Find the term_id by searching for the option where value matches the category.
151 global $wpdb;
152 $term_id = null;
153 if ( ! empty( $category ) ) {
154 // Query directly for the matching option.
155 $option_name = $wpdb->get_var( $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s AND option_value = %s LIMIT 1", 'cmbird_zoho_id_for_term_id_%', $category ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Need to find category mapping.
156 if ( ! empty( $option_name ) ) {
157 // Extract term_id from option_name (e.g., 'cmbird_zoho_id_for_term_id_37' -> '37').
158 $term_id = str_replace( 'cmbird_zoho_id_for_term_id_', '', $option_name );
159 }
160 }
161 $processed_count = 0;
162 foreach ( $json->itemgroups as $gp_arr ) {
163 // Memory cleanup every 10 group items.
164 if ( $processed_count > 0 && $processed_count % 10 === 0 ) {
165 wp_cache_flush();
166 if ( function_exists( 'gc_collect_cycles' ) ) {
167 gc_collect_cycles();
168 }
169 }
170 $zi_group_id = $gp_arr->group_id;
171 $zi_group_name = $gp_arr->group_name;
172 // skip if there is no first attribute.
173 $zi_group_attribute1 = $gp_arr->attribute_id1;
174 if ( empty( $zi_group_attribute1 ) ) {
175 continue;
176 }
177
178 // Get Group ID.
179 $group_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = 'zi_item_id' AND meta_value = %s LIMIT 1", $zi_group_id ) );
180 if ( empty( $group_id ) ) {
181 // search by zi_group_name if not found by zi_item_id.
182 $group_id = $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM $wpdb->posts p WHERE p.post_title LIKE %s AND p.post_type = 'product' AND p.post_status IN ('publish', 'draft', 'private') LIMIT 1", '%' . $wpdb->esc_like( $zi_group_name ) . '%' ) );
183 }
184 // array_push( $response_msg, $this->zi_response_message( 'SUCCESS', 'Zoho Group Item Synced: ' . $zi_group_name, $group_id ) );
185 // end insert group product.
186 // variable items.
187 if ( ! empty( $group_id ) ) {
188 $existing_parent_product = wc_get_product( $group_id );
189 if ( ! empty( $gp_arr->description ) && ! $zi_disable_description_sync ) {
190 $existing_parent_product->set_short_description( $gp_arr->description );
191 }
192 if ( ! empty( $gp_arr->name ) && ! $zi_disable_name_sync ) {
193 $existing_parent_product->set_name( $gp_arr->name );
194 // santize the name for slug and save the slug.
195 $slug = sanitize_title( $gp_arr->name );
196 $existing_parent_product->set_slug( $slug );
197 }
198 // add zi_category_id as meta.
199 $existing_parent_product->update_meta_data( 'zi_category_id', $gp_arr->category_id );
200 // add zi_item_id as meta.
201 $existing_parent_product->update_meta_data( 'zi_item_id', $gp_arr->group_id );
202 // set the product category.
203 if ( ! empty( $term_id ) ) {
204 wp_set_object_terms( $group_id, (int) $term_id, 'product_cat' );
205 // remove the uncategorized term from the product.
206 wp_remove_object_terms( $group_id, 'uncategorized', 'product_cat' );
207 }
208 // create attributes if not exists.
209 $attributes = $existing_parent_product->get_attributes();
210 if ( empty( $attributes ) ) {
211 // Create or Update the Attributes.
212 $attr_created = $this->sync_attributes_of_group( $gp_arr, $group_id );
213 }
214 $variations_check = $existing_parent_product->get_children();
215 if ( empty( $variations_check ) ) {
216 $this->import_variable_product_variations( $gp_arr, $group_id );
217 }
218 $existing_parent_product->save();
219 // ACF Fields.
220 if ( ! empty( $gp_arr->custom_fields ) ) {
221 $this->sync_item_custom_fields( $gp_arr->custom_fields, $group_id );
222 }
223 // update Brand.
224 if ( ! empty( $gp_arr->brand ) ) {
225 // check if the Brand or Brands taxonomy exists and then update the term.
226 if ( taxonomy_exists( 'product_brand' ) ) {
227 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
228 } elseif ( taxonomy_exists( 'product_brand' ) ) {
229 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
230 }
231 }
232 } else {
233 // Create the parent variable product.
234 $parent_product = new WC_Product_Variable();
235 $parent_product->set_name( $zi_group_name );
236 $parent_product->set_status( 'publish' );
237 $parent_product->set_short_description( $gp_arr->description );
238 $parent_product->add_meta_data( 'zi_item_id', $zi_group_id );
239 $parent_product->add_meta_data( 'zi_category_id', $category );
240 $group_id = $parent_product->save();
241 // Sync category by finding it first.
242 if ( ! empty( $term_id ) && term_exists( $term_id, 'product_cat' ) ) {
243 wp_set_object_terms( $group_id, (int) $term_id, 'product_cat' );
244 }
245 // Add category.
246 if ( ! empty( $term_id ) ) {
247 wp_set_object_terms( $group_id, (int) $term_id, 'product_cat' );
248 }
249 // update Brand.
250 if ( ! empty( $gp_arr->brand ) ) {
251 // check if the Brand or Brands taxonomy exists and then update the term.
252 if ( taxonomy_exists( 'product_brand' ) ) {
253 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
254 } elseif ( taxonomy_exists( 'product_brand' ) ) {
255 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
256 }
257 }
258 // ACF Fields.
259 if ( ! empty( $gp_arr->custom_fields ) ) {
260 $this->sync_item_custom_fields( $gp_arr->custom_fields, $group_id );
261 }
262 // Create or Update the Attributes.
263 $attr_created = $this->sync_attributes_of_group( $gp_arr, $group_id );
264
265 if ( ! empty( $group_id ) && $attr_created ) {
266 $this->import_variable_product_variations( $gp_arr, $group_id );
267 }
268 } // end of create variable product.
269
270 ++$processed_count;
271 } // end foreach group items.
272
273 // Final memory cleanup after processing all group items.
274 wp_cache_flush();
275 if ( function_exists( 'gc_collect_cycles' ) ) {
276 gc_collect_cycles();
277 }
278
279 // Schedule next page if available.
280 if ( isset( $json->page_context ) && $json->page_context->has_more_page ) {
281 $data = array(
282 'page' => $page + 1,
283 'category' => $category,
284 );
285 $existing_schedule = as_has_scheduled_action( 'import_group_items_cron', $data, 'commercebird' );
286 // Check if the scheduled action exists.
287 if ( ! $existing_schedule ) {
288 as_schedule_single_action( time(), 'import_group_items_cron', $data, 'commercebird' );
289 }
290 }
291
292 // Store message before unsetting json.
293 $message = isset( $json->message ) ? $json->message : '';
294 // Clear any temporary variables.
295 unset( $json, $execute_curl_call );
296 array_push( $response_msg, $this->zi_response_message( $code, $message ) );
297 }
298 // End of logging.
299 // fclose( $fd );
300 return $response_msg;
301 } else {
302 return;
303 }
304 }
305
306 /**
307 * Callback function for importing a variable product and its variations.
308 *
309 * @param object $gp_arr - Group item object.
310 * @param int $group_id - Group item id.
311 * @return void
312 */
313 public function import_variable_product_variations( $gp_arr, $group_id ): void {
314 // $fd = fopen( __DIR__ . '/import_variable_product_variations.txt', 'a+' );
315
316 if ( empty( $gp_arr ) || empty( $group_id ) ) {
317 return;
318 }
319
320 global $wpdb;
321 $product = wc_get_product( $group_id );
322
323 if ( $product ) {
324 $item_group = $gp_arr;
325 $items = $item_group->items;
326 $attribute_name1 = $item_group->attribute_name1;
327 $attribute_name2 = $item_group->attribute_name2;
328 $attribute_name3 = $item_group->attribute_name3;
329
330 // fwrite( $fd, PHP_EOL . 'Items : ' . print_r( $items, true ) );
331 // get the options for stock sync.
332 $accounting_stock = $this->config['Settings']['enable_accounting_stock'];
333 $zi_disable_stock_sync = $this->config['Settings']['disable_stock'];
334 $zi_disable_image_sync = $this->config['Settings']['disable_image'];
335
336 $to_create = array();
337 $to_update = array();
338 $update = false;
339 $batch_count = 0;
340 $max_batch_size = 100;
341 $parent_image_set = false;
342
343 foreach ( $items as $item ) {
344 // reset this array.
345 $attribute_arr = array();
346 $variation_id = '';
347 $status = $item->status === 'active' ? 'publish' : 'draft';
348
349 $zi_item_id = $item->item_id;
350 // Only consider existing records that are actual variation posts and belong to this parent.
351 $variation_id = $wpdb->get_var(
352 $wpdb->prepare(
353 "SELECT p.ID FROM $wpdb->posts p
354 JOIN $wpdb->postmeta pm ON pm.post_id = p.ID
355 WHERE pm.meta_key = 'zi_item_id' AND pm.meta_value = %s
356 AND p.post_type = 'product_variation' AND p.post_parent = %d
357 LIMIT 1",
358 $zi_item_id,
359 $group_id
360 )
361 );
362
363 if ( ! empty( $variation_id ) ) {
364 $v_product = wc_get_product( $variation_id );
365 // Check if the product object is valid.
366 if ( $v_product && is_a( $v_product, 'WC_Product' ) ) {
367 if ( $v_product->is_type( 'simple' ) ) {
368 wp_delete_post( $variation_id, true );
369 }
370 }
371 }
372 // SKU check of the variation — if SKU exists on a SIMPLE product, remove that SIMPLE product (not the variation).
373 if ( ! empty( $item->sku ) ) {
374 $sku_prod_id = wc_get_product_id_by_sku( $item->sku );
375 $v_product = wc_get_product( $sku_prod_id );
376 // Check if the product object is valid.
377 if ( $v_product && is_a( $v_product, 'WC_Product' ) ) {
378 if ( $v_product->is_type( 'simple' ) ) {
379 wp_delete_post( $sku_prod_id, true );
380 }
381 }
382 }
383 if ( ! empty( $variation_id ) ) {
384 $update = true;
385 }
386 $stock = 0;
387 if ( $accounting_stock ) {
388 $stock = $item->available_stock;
389 } else {
390 $stock = $item->actual_available_stock;
391 }
392
393 $attribute_name11 = $item->attribute_option_name1;
394 $attribute_name12 = $item->attribute_option_name2;
395 $attribute_name13 = $item->attribute_option_name3;
396 // fwrite( $fd, PHP_EOL . '>>> Item: ' . $item->item_id . ' | SKU: ' . $item->sku . ' | opt1: ' . $attribute_name11 . ' | opt2: ' . $attribute_name12 . ' | opt3: ' . $attribute_name13 );
397 // Prepare the variation data.
398 if ( ! empty( $attribute_name1 ) ) {
399 $sanitized_name1 = wc_sanitize_taxonomy_name( $attribute_name1 );
400 $attribute_arr[ $sanitized_name1 ] = $attribute_name11;
401 }
402 if ( ! empty( $attribute_name2 ) ) {
403 $sanitized_name2 = wc_sanitize_taxonomy_name( $attribute_name2 );
404 $attribute_arr[ $sanitized_name2 ] = $attribute_name12;
405 }
406 if ( ! empty( $attribute_name3 ) ) {
407 $sanitized_name3 = wc_sanitize_taxonomy_name( $attribute_name3 );
408 $attribute_arr[ $sanitized_name3 ] = $attribute_name13;
409 }
410
411 // Process and set variation attributes.
412 $variation_attributes = array();
413 foreach ( $attribute_arr as $attribute => $term_name ) {
414 $taxonomy = 'pa_' . $attribute;
415
416 // If taxonomy doesn't exist, create it.
417 if ( ! taxonomy_exists( $taxonomy ) ) {
418 register_taxonomy(
419 $taxonomy,
420 'product_variation',
421 array(
422 'hierarchical' => false,
423 'label' => ucfirst( $attribute ),
424 'query_var' => true,
425 'rewrite' => array( 'slug' => sanitize_title( $attribute ) ),
426 ),
427 );
428 }
429
430 // Check if the term exists and create if needed.
431 if ( ! term_exists( $term_name, $taxonomy ) ) {
432 wp_insert_term( $term_name, $taxonomy );
433 }
434
435 // Get the term slug.
436 $term_object = get_term_by( 'name', $term_name, $taxonomy );
437 $term_slug = $term_object ? $term_object->slug : sanitize_title( $term_name );
438
439 // Get the post terms from the parent variable product.
440 $post_term_names = wp_get_post_terms( $group_id, $taxonomy, array( 'fields' => 'names' ) );
441
442 // Set the term on the parent product if not already set.
443 if ( ! in_array( $term_name, $post_term_names, true ) ) {
444 wp_set_post_terms( $group_id, $term_name, $taxonomy, true );
445 }
446
447 // Build variation attributes array for batch API.
448 $variation_attributes[ $taxonomy ] = array(
449 'slug' => $term_slug,
450 'name' => $term_name,
451 );
452 }
453
454 // Prepare variation data for batch API.
455 $variation_data = array(
456 'type' => 'variation',
457 'status' => $status,
458 'regular_price' => (string) $item->rate,
459 'sku' => $item->sku,
460 );
461
462 // Set attributes.
463 if ( ! empty( $variation_attributes ) ) {
464 $variation_data['attributes'] = array();
465 foreach ( $variation_attributes as $taxonomy => $term_data ) {
466 $variation_data['attributes'][] = array(
467 'id' => wc_attribute_taxonomy_id_by_name( str_replace( 'pa_', '', $taxonomy ) ),
468 'option' => $term_data['name'],
469 );
470 }
471 }
472
473 // Handle stock.
474 if ( ! $zi_disable_stock_sync && $stock > 0 ) {
475 $variation_data['manage_stock'] = true;
476 $variation_data['stock_quantity'] = $stock;
477 } else {
478 $variation_data['manage_stock'] = false;
479 }
480
481 // Handle image. Check setting first, then confirm the item actually has an image in Zoho.
482 if ( ! $zi_disable_image_sync && ! empty( $item->image_document_id ) && ! empty( $item->image_name ) ) {
483 $image_class = new CMBIRD_Image_ZI();
484 $attachment_id = $image_class->cmbird_zi_get_image( $item->item_id, $item->name, $item->image_name, null );
485 if ( $attachment_id ) {
486 $variation_data['image'] = array( 'id' => $attachment_id );
487 // Set parent product thumbnail if not already set.
488 if ( ! $parent_image_set && ! has_post_thumbnail( $group_id ) ) {
489 set_post_thumbnail( $group_id, $attachment_id );
490 $parent_image_set = true;
491 }
492 }
493 }
494
495 // Add meta data.
496 $variation_data['meta_data'] = array(
497 array(
498 'key' => 'zi_item_id',
499 'value' => $item->item_id,
500 ),
501 );
502
503 // Add purchase price if available.
504 if ( ! empty( $item->purchase_rate ) ) {
505 $variation_data['meta_data'][] = array(
506 'key' => 'cogs_total_value',
507 'value' => $item->purchase_rate,
508 );
509 }
510
511 // Add to batch array.
512 if ( ! $update ) {
513 $to_create[] = $variation_data;
514 } else {
515 $variation_data['id'] = $variation_id;
516 $to_update[] = $variation_data;
517 }
518 ++$batch_count;
519
520 // Process batch when we hit the limit.
521 if ( $batch_count >= $max_batch_size ) {
522 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import(
523 'variations',
524 array(
525 'create' => $to_create,
526 'update' => $to_update,
527 ),
528 false,
529 '/wc/v3/products/' . $group_id . '/variations/batch'
530 );
531 $to_create = array();
532 $to_update = array();
533 $batch_count = 0;
534 }
535 }
536
537 // Process remaining variations in the batch.
538 if ( ! empty( $to_create ) || ! empty( $to_update ) ) {
539 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import(
540 'variations',
541 array(
542 'create' => $to_create,
543 'update' => $to_update,
544 ),
545 false,
546 '/wc/v3/products/' . $group_id . '/variations/batch'
547 );
548
549 // Ensure WooCommerce transient/cache & attribute lookup are up to date so variations appear in admin.
550 wc_delete_product_transients( $group_id );
551 $parent_product = wc_get_product( $group_id );
552 if ( $parent_product ) {
553 $lookup_data_store = new LookupDataStore();
554 $lookup_data_store->create_data_for_product( $parent_product );
555 }
556 }
557 // End group item add process.
558 // array_push($response_msg, $this->zi_response_message('SUCCESS', 'Zoho variable item created for zoho item id ' . $zi_item_id, $variation_id));
559 // End of Logging.
560 // fclose( $fd );
561 }
562 }
563
564 /**
565 * Update or Create the Product Attributes for the Variable Item Sync
566 *
567 * @param object $gp_arr - the group item object from Zoho Inventory.
568 * @param int $group_id - the parent product id in WooCommerce.
569 * @return bool - true if attributes were created successfully, false otherwise
570 */
571 public function sync_attributes_of_group( $gp_arr, $group_id ) {
572 // $fd = fopen( __DIR__ . '/sync_attributes_of_group.txt', 'a+' );
573 // Check if the group item has attributes.
574 if ( empty( $gp_arr->attribute_name1 ) ) {
575 return false;
576 }
577 // Create attributes.
578 $success = true; // Track the success of attribute creation.
579 $attributes_data = array();
580 $attribute_count = 0;
581
582 // Loop through the attribute names.
583 for ( $i = 1; $i <= 3; $i++ ) {
584 $attribute_name_key = 'attribute_name' . $i;
585 $attribute_option_name_key = 'attribute_option_name' . $i;
586
587 // Get the attribute name.
588 $attribute_name = $gp_arr->$attribute_name_key;
589
590 if ( ! empty( $attribute_name ) ) {
591 // Check if the attribute is already added to the attributes array.
592 if ( ! isset( $attributes_data[ $attribute_name ] ) ) {
593 // Create the attribute and add it to the attributes array.
594 $attribute = array(
595 'name' => $attribute_name,
596 'position' => $attribute_count,
597 'visible' => true,
598 'variation' => true,
599 'options' => array(),
600 );
601
602 // Loop through the items and retrieve attribute options.
603 $attribute_options = array();
604 foreach ( $gp_arr->items as $item ) {
605 $attribute_option = $item->$attribute_option_name_key;
606 if ( ! empty( $attribute_option ) && ! in_array( $attribute_option, $attribute_options, true ) ) {
607 $attribute_options[] = $attribute_option;
608 }
609 }
610
611 // Set the attribute options.
612 $attribute['options'] = $attribute_options;
613
614 $attributes_data[] = $attribute;
615 ++$attribute_count;
616 }
617 }
618 }
619 // fwrite( $fd, PHP_EOL . '$attributes : ' . print_r( $attributes_data, true ) );
620
621 // Assign the attributes to the parent product.
622 if ( count( $attributes_data ) > 0 ) {
623 $product_attributes = array();
624
625 // Loop through defined attribute data.
626 foreach ( $attributes_data as $key => $attribute_array ) {
627 if ( isset( $attribute_array['name'] ) && isset( $attribute_array['options'] ) ) {
628 // Clean attribute name to get the taxonomy.
629 $taxonomy = 'pa_' . wc_sanitize_taxonomy_name( $attribute_array['name'] );
630 $option_term_ids = array();
631 $existing_terms = array();
632 // Create the attribute if it doesn't exist.
633 if ( ! taxonomy_exists( $taxonomy ) ) {
634 // Clean attribute label for better display.
635 $attribute_label = ucfirst( $attribute_array['name'] );
636
637 // Register the new attribute taxonomy.
638 $attribute_args = array(
639 'slug' => $taxonomy,
640 'name' => $attribute_label,
641 'type' => 'select',
642 'order_by' => 'menu_order',
643 'has_archives' => false,
644 );
645
646 $result = wc_create_attribute( $attribute_args );
647 register_taxonomy( $taxonomy, array( 'product' ), array() );
648
649 if ( ! is_wp_error( $result ) ) {
650 // fwrite($fd, PHP_EOL . 'result : ' . $result);
651 // Loop through defined attribute data options (terms values).
652 foreach ( $attribute_array['options'] as $option ) {
653 // Check if the term exists for the attribute taxonomy.
654 $term = term_exists( $option, $taxonomy );
655 // Also check by slug in case option is slug-formatted.
656 $term_by_slug = get_term_by( 'slug', sanitize_title( $option ), $taxonomy );
657 if ( $term_by_slug ) {
658 $term = array( 'term_id' => $term_by_slug->term_id );
659 }
660 if ( ! $term ) {
661 // Term doesn't exist, create a new one.
662 // Convert slug-formatted names to proper names.
663 $term_name = str_replace( array( '-', '_' ), ' ', $option );
664 $term_name = ucwords( $term_name );
665 $term = wp_insert_term(
666 $term_name,
667 $taxonomy,
668 array(
669 'slug' => sanitize_title( $term_name ),
670 )
671 );
672 if ( is_wp_error( $term ) ) {
673 // fwrite( $fd, PHP_EOL . 'Error creating term: ' . $term->get_error_message() );
674 continue;
675 }
676 }
677 // Add term ID to the array for assignment to parent product.
678 $term_id = is_array( $term ) ? $term['term_id'] : $term;
679 $option_term_ids[] = $term_id;
680 }
681 } else {
682 $success = false;
683 // return error message.
684 $error_string = $result->get_error_message();
685 return $success;
686 }
687 } else {
688 // Taxonomy exists, get existing terms.
689 $existing_terms = get_terms(
690 array(
691 'taxonomy' => $taxonomy,
692 'hide_empty' => false,
693 )
694 );
695 }
696
697 if ( $existing_terms ) {
698 foreach ( $attribute_array['options'] as $option ) {
699 $match_found = false;
700 foreach ( $existing_terms as $existing_term ) {
701 // Check both name and slug to handle cases where Zoho sends slug-formatted names.
702 if ( $existing_term->name === $option || $existing_term->slug === sanitize_title( $option ) ) {
703 $option_term_ids[] = $existing_term->term_id;
704 $match_found = true;
705 break;
706 }
707 }
708 // fwrite( $fd, PHP_EOL . 'Option "' . $option . '" match found: ' . ( $match_found ? 'Yes' : 'No' ) );
709 if ( ! $match_found ) {
710 // Check if the term exists for the attribute taxonomy.
711 $term = get_term_by( 'slug', sanitize_title( $option ), $taxonomy );
712 // fwrite( $fd, PHP_EOL . 'Term by slug check for option "' . $option . '": ' . print_r( $term, true ) );
713 if ( $term ) {
714 $term = array( 'term_id' => $term->term_id );
715 }
716 if ( ! $term ) {
717 // Term doesn't exist, create it.
718 // Convert slug-formatted names to proper names.
719 $term_name = str_replace( array( '-', '_' ), ' ', $option );
720 $term_name = ucwords( $term_name );
721 $term = wp_insert_term( $term_name, $taxonomy );
722 if ( ! is_wp_error( $term ) ) {
723 // Get the term ID.
724 $term_id = is_array( $term ) ? $term['term_id'] : $term;
725 $option_term_ids[] = $term_id;
726 } else {
727 $success = false;
728 }
729 } else {
730 // Get the existing term ID.
731 $term_id = $term['term_id'];
732 $option_term_ids[] = $term_id;
733 }
734 }
735 }
736 }
737 // Set the selected terms for the product.
738 // fwrite( $fd, PHP_EOL . '[SYNC_ATTR] Setting terms for taxonomy: ' . $taxonomy . ' | Term IDs: ' . print_r( $option_term_ids, true ) );
739 wp_set_object_terms( $group_id, $option_term_ids, $taxonomy, false );
740
741 $attribute_object = new WC_Product_Attribute();
742 $attribute_object->set_id( wc_attribute_taxonomy_id_by_name( $taxonomy ) );
743 $attribute_object->set_name( $taxonomy );
744 $attribute_object->set_options( $option_term_ids );
745 $attribute_object->set_visible( $attribute_array['visible'] ?? true );
746 $attribute_object->set_variation( $attribute_array['variation'] ?? true );
747 $product_attributes[] = $attribute_object;
748 }
749 }
750 }
751
752 // Save the attributes to the parent product using WooCommerce 10+ API.
753 if ( ! empty( $product_attributes ) ) {
754 $product = wc_get_product( $group_id );
755 if ( $product ) {
756 $product->set_attributes( $product_attributes );
757 $product->save();
758 }
759 }
760 // fclose($fd);
761 return $success;
762 }
763
764 /**
765 * Update or create variation in WooCommerce if Group-ID already exists in wpdB
766 *
767 * @param object $item - item object coming in from simple item recursive function.
768 * @return: void
769 */
770 public function sync_variation_of_group( $item ) {
771 // $fd = fopen( __DIR__ . '/sync_variation_of_group.txt', 'a+' );
772
773 // log the item.
774 // fwrite( $fd, PHP_EOL . 'Item : ' . print_r( $item, true ) );
775
776 global $wpdb;
777 // Stock mode check.
778 $zi_disable_stock_sync = $this->config['Settings']['disable_stock'];
779 $accounting_stock = $this->config['Settings']['enable_accounting_stock'];
780 $disable_image_sync = $this->config['Settings']['disable_image'];
781 $is_tax_enabled = $this->is_tax_enabled;
782
783 if ( $accounting_stock ) {
784 $stock = $item->available_stock;
785 } else {
786 $stock = $item->actual_available_stock;
787 }
788
789 $item_id = $item->item_id;
790 // $item_category = $item->category_name;
791 $groupid = property_exists( $item, 'group_id' ) ? $item->group_id : 0;
792 // find parent variable product.
793 $group_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = 'zi_item_id' AND meta_value = %s LIMIT 1", $groupid ) );
794 if ( empty( $group_id ) ) {
795 // search by group name if not found by group id.
796 $group_id = $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM $wpdb->posts p WHERE p.post_title LIKE %s AND p.post_type = 'product' AND p.post_status IN ('publish', 'draft', 'private') LIMIT 1", '%' . $wpdb->esc_like( $item->group_name ) . '%' ) );
797 }
798 $stock_quantity = $stock < 0 ? 0 : $stock;
799 // fwrite($fd, PHP_EOL . 'Before group item sync : ' . $group_id);
800 if ( ! empty( $group_id ) ) {
801 // fwrite( $fd, PHP_EOL . 'Inside item sync : ' . $item->name );
802 // Brand.
803 if ( isset( $item->brand ) && ! empty( $group_id ) ) {
804 if ( taxonomy_exists( 'product_brand' ) ) {
805 wp_set_object_terms( $group_id, $item->brand, 'product_brand' );
806 }
807 }
808 // update the zi_item_id of parent variable product if not already set.
809 $existing_zi_item_id = get_post_meta( $group_id, 'zi_item_id', true );
810 if ( empty( $existing_zi_item_id ) ) {
811 update_post_meta( $group_id, 'zi_item_id', $groupid );
812 }
813
814 // Only accept a matching variation that belongs to this parent product.
815 $variation_id = $wpdb->get_var(
816 $wpdb->prepare(
817 "SELECT p.ID FROM $wpdb->posts p
818 JOIN $wpdb->postmeta pm ON pm.post_id = p.ID
819 WHERE pm.meta_key = 'zi_item_id' AND pm.meta_value = %s
820 AND p.post_type = 'product_variation' AND p.post_parent = %d
821 LIMIT 1",
822 $item_id,
823 $group_id
824 )
825 );
826 if ( ! empty( $item->sku ) && empty( $variation_id ) ) {
827 $variation_id = wc_get_product_id_by_sku( $item->sku );
828 }
829 if ( $variation_id ) {
830 // fwrite( $fd, PHP_EOL . 'Variation ID exists : ' . $variation_id );
831 // if product type is not variation then return.
832 $v_product = wc_get_product( $variation_id );
833 // Check if the product object is valid.
834 if ( $v_product && is_a( $v_product, 'WC_Product' ) ) {
835 if ( $v_product->is_type( 'simple' ) ) {
836 wp_delete_post( $variation_id, true );
837 $variation_id = '';
838 // Continue to create new variation below.
839 }
840 }
841 // If variation already exists then update it.
842 $variation = new WC_Product_Variation( $variation_id );
843 // SKU - Imported.
844 if ( ! empty( $item->sku ) ) {
845 $variation->set_sku( $item->sku );
846 }
847 // update purchase price as meta data.
848 if ( ! empty( $item->purchase_rate ) ) {
849 $variation->update_meta_data( 'cogs_total_value', $item->purchase_rate );
850 }
851 // Price - Imported.
852 $zi_disable_price_sync = $this->config['Settings']['disable_price'];
853 if ( ! $zi_disable_price_sync ) {
854 $variation->set_regular_price( $item->rate );
855 }
856 // Set Tax Class.
857 if ( $item->tax_id && $is_tax_enabled ) {
858 $zi_common_class = new CMBIRD_Common_Functions();
859 $woo_tax_class = $zi_common_class->get_tax_class_by_zoho_id( $item->tax_id )
860 ?? $zi_common_class->get_tax_class_by_percentage( $item->tax_percentage );
861 $variation->set_tax_status( 'taxable' );
862 $variation->set_tax_class( $woo_tax_class );
863 }
864 // Stock Imported code.
865 if ( ! $zi_disable_stock_sync && is_numeric( $stock_quantity ) ) {
866 // fwrite( $fd, PHP_EOL . 'Stock Quantity : ' . $stock_quantity );
867 $variation->set_manage_stock( true );
868 if ( $stock_quantity > 0 ) {
869 $variation->set_manage_stock( true );
870 $variation->set_stock_quantity( $stock_quantity );
871 $variation->set_stock_status( 'instock' );
872 } elseif ( $stock_quantity <= 0 ) {
873 $variation->set_manage_stock( true );
874 $variation->set_stock_quantity( $stock_quantity );
875 $stock_status = $variation->backorders_allowed() ? 'onbackorder' : 'outofstock';
876 $variation->set_stock_status( $stock_status );
877 }
878 }
879 // Update variation attributes if missing or need updating.
880 $attribute_name1 = $item->attribute_option_name1;
881 $attribute_name2 = $item->attribute_option_name2;
882 $attribute_name3 = $item->attribute_option_name3;
883
884 // Prepare the variation data.
885 $attribute_arr = array();
886 if ( ! empty( $attribute_name1 ) ) {
887 $sanitized_name1 = wc_sanitize_taxonomy_name( $item->attribute_name1 );
888 $attribute_arr[ $sanitized_name1 ] = $attribute_name1;
889 }
890 if ( ! empty( $attribute_name2 ) ) {
891 $sanitized_name2 = wc_sanitize_taxonomy_name( $item->attribute_name2 );
892 $attribute_arr[ $sanitized_name2 ] = $attribute_name2;
893 }
894 if ( ! empty( $attribute_name3 ) ) {
895 $sanitized_name3 = wc_sanitize_taxonomy_name( $item->attribute_name3 );
896 $attribute_arr[ $sanitized_name3 ] = $attribute_name3;
897 }
898
899 // Update attributes for the variation.
900 $variation_attributes = array();
901 foreach ( $attribute_arr as $attribute => $term_name ) {
902 $taxonomy = 'pa_' . $attribute;
903
904 // If taxonomy doesn't exist, create it.
905 if ( ! taxonomy_exists( $taxonomy ) ) {
906 register_taxonomy(
907 $taxonomy,
908 'product_variation',
909 array(
910 'hierarchical' => false,
911 'label' => ucfirst( $attribute ),
912 'query_var' => true,
913 'rewrite' => array( 'slug' => sanitize_title( $attribute ) ),
914 ),
915 );
916 }
917
918 // Check if the term exists and create if needed.
919 if ( ! term_exists( $term_name, $taxonomy ) ) {
920 wp_insert_term( $term_name, $taxonomy );
921 }
922
923 // Get the term slug.
924 $term_object = get_term_by( 'name', $term_name, $taxonomy );
925 $term_slug = $term_object ? $term_object->slug : sanitize_title( $term_name );
926
927 // Get the post terms from the parent variable product.
928 $post_term_names = wp_get_post_terms( $group_id, $taxonomy, array( 'fields' => 'names' ) );
929
930 // Set the term on the parent product if not already set.
931 if ( ! in_array( $term_name, $post_term_names, true ) ) {
932 wp_set_post_terms( $group_id, $term_name, $taxonomy, true );
933 }
934
935 // Build variation attributes array for set_attributes().
936 $variation_attributes[ $taxonomy ] = $term_slug;
937 }
938
939 // Set all attributes at once using WooCommerce 10+ API.
940 if ( ! empty( $variation_attributes ) ) {
941 $variation->set_attributes( $variation_attributes );
942 }
943
944 // Featured Image of variation.
945 if ( ! empty( $item->image_document_id ) && ! $disable_image_sync ) {
946 $image_class = new CMBIRD_Image_ZI();
947 $variation_image_id = $image_class->cmbird_zi_get_image( $item->item_id, $item->name, $item->image_name, $variation_id );
948 if ( ! has_post_thumbnail( $group_id ) ) {
949 if ( $variation_image_id ) {
950 set_post_thumbnail( $group_id, $variation_image_id );
951 }
952 }
953 }
954 // enable or disable based on status from Zoho.
955 $status = ( 'active' === $item->status ) ? 'publish' : 'draft';
956 $variation->set_status( $status );
957 $variation->save();
958 // clear cache.
959 wc_delete_product_transients( $variation_id );
960 } else {
961 // create new variation.
962 // if status is not active then return.
963 if ( 'active' !== $item->status ) {
964 return;
965 }
966
967 $attribute_name1 = $item->attribute_option_name1;
968 $attribute_name2 = $item->attribute_option_name2;
969 $attribute_name3 = $item->attribute_option_name3;
970 // Prepare the variation data.
971 $attribute_arr = array();
972 if ( ! empty( $attribute_name1 ) ) {
973 $sanitized_name1 = wc_sanitize_taxonomy_name( $item->attribute_name1 );
974 $attribute_arr[ $sanitized_name1 ] = $attribute_name1;
975 }
976 if ( ! empty( $attribute_name2 ) ) {
977 $sanitized_name2 = wc_sanitize_taxonomy_name( $item->attribute_name2 );
978 $attribute_arr[ $sanitized_name2 ] = $attribute_name2;
979 }
980 if ( ! empty( $attribute_name3 ) ) {
981 $sanitized_name3 = wc_sanitize_taxonomy_name( $item->attribute_name3 );
982 $attribute_arr[ $sanitized_name3 ] = $attribute_name3;
983 }
984 // fwrite( $fd, PHP_EOL . 'Attributes_arr: ' . print_r( $attribute_arr, true ) );
985
986 // here actually create new variation because sku not found.
987 $variation = new WC_Product_Variation();
988 $variation->set_parent_id( $group_id );
989 $variation->set_status( 'publish' );
990 $variation->set_regular_price( $item->rate );
991 $variation->set_sku( $item->sku );
992 if ( ! $zi_disable_stock_sync ) {
993 $variation->set_stock_quantity( $stock_quantity );
994 $variation->set_manage_stock( true );
995 $variation->set_stock_status( '' );
996 } else {
997 $variation->set_manage_stock( false );
998 }
999 $variation->add_meta_data( 'zi_item_id', $item->item_id );
1000 $variation_id = $variation->save();
1001
1002 // Get the variation attributes with correct attribute values.
1003 $variation_attributes = array();
1004 foreach ( $attribute_arr as $attribute => $term_name ) {
1005 $taxonomy = 'pa_' . $attribute;
1006
1007 // If taxonomy doesn't exist, create it.
1008 if ( ! taxonomy_exists( $taxonomy ) ) {
1009 register_taxonomy(
1010 $taxonomy,
1011 'product_variation',
1012 array(
1013 'hierarchical' => false,
1014 'label' => ucfirst( $attribute ),
1015 'query_var' => true,
1016 'rewrite' => array( 'slug' => sanitize_title( $attribute ) ),
1017 ),
1018 );
1019 }
1020
1021 // Check if the term exists and create if needed.
1022 if ( ! term_exists( $term_name, $taxonomy ) ) {
1023 wp_insert_term( $term_name, $taxonomy );
1024 }
1025
1026 // Get the term slug.
1027 $term_object = get_term_by( 'name', $term_name, $taxonomy );
1028 $term_slug = $term_object ? $term_object->slug : sanitize_title( $term_name );
1029
1030 // Get the post terms from the parent variable product.
1031 $post_term_names = wp_get_post_terms( $group_id, $taxonomy, array( 'fields' => 'names' ) );
1032
1033 // Set the term on the parent product if not already set.
1034 if ( ! in_array( $term_name, $post_term_names, true ) ) {
1035 wp_set_post_terms( $group_id, $term_name, $taxonomy, true );
1036 }
1037
1038 // Build variation attributes array for set_attributes().
1039 $variation_attributes[ $taxonomy ] = $term_slug;
1040 }
1041
1042 // Set all attributes at once using WooCommerce 10+ API.
1043 if ( ! empty( $variation_attributes ) ) {
1044 $variation->set_attributes( $variation_attributes );
1045 $variation->save();
1046 }
1047
1048 // update purchase price as meta data.
1049 if ( ! empty( $item->purchase_rate ) ) {
1050 $variation->update_meta_data( 'cogs_total_value', $item->purchase_rate );
1051 }
1052 // Stock.
1053 if ( ! empty( $stock ) && ! $zi_disable_stock_sync ) {
1054 update_post_meta( $variation_id, 'manage_stock', true );
1055 if ( $stock > 0 ) {
1056 update_post_meta( $variation_id, '_stock', $stock );
1057 update_post_meta( $variation_id, '_stock_status', 'instock' );
1058 } else {
1059 $backorder_status = get_post_meta( $group_id, '_backorders', true );
1060 update_post_meta( $variation_id, '_stock', $stock );
1061 if ( 'yes' === $backorder_status ) {
1062 update_post_meta( $variation_id, '_stock_status', 'onbackorder' );
1063 } else {
1064 update_post_meta( $variation_id, '_stock_status', 'outofstock' );
1065 }
1066 }
1067 }
1068 // Featured Image of variation.
1069 if ( ! empty( $item->image_document_id ) && ! $disable_image_sync ) {
1070 $image_class = new CMBIRD_Image_ZI();
1071 $variation_image_id = $image_class->cmbird_zi_get_image( $item->item_id, $item->name, $item->image_name, $variation_id );
1072 if ( ! has_post_thumbnail( $group_id ) ) {
1073 if ( $variation_image_id ) {
1074 set_post_thumbnail( $group_id, $variation_image_id );
1075 }
1076 }
1077 }
1078 update_post_meta( $variation_id, 'zi_item_id', $item_id );
1079 // WC_Product_Variable::sync( $group_id );
1080 // Regenerate lookup table for attributes.
1081 $parent_product = wc_get_product( $group_id );
1082 if ( $parent_product ) {
1083 $lookup_data_store = new LookupDataStore();
1084 $lookup_data_store->create_data_for_product( $parent_product );
1085 }
1086 // End group item add process.
1087 unset( $attribute_arr );
1088 }
1089 // end of grouped item updating.
1090 }
1091 }
1092
1093 /**
1094 * Helper Function to check if child of composite items already synced or not
1095 *
1096 * @param string $composite_zoho_id - zoho composite item id to check if it's child are already synced.
1097 * @param string $zi_url - zoho api url.
1098 * @param string $zi_org_id - zoho organization token.
1099 * @param string $prod_id - woocommerce product id.
1100 * @return array | bool of child id and metadata if child item already synced else will return false.
1101 */
1102 public function zi_check_if_child_synced_already( $composite_zoho_id, $zi_url, $zi_org_id, $prod_id ) {
1103 if ( $prod_id ) {
1104 $bundle_childs = WC_PB_DB::query_bundled_items(
1105 array(
1106 'return' => 'id=>product_id',
1107 'bundle_id' => array( $prod_id ),
1108 )
1109 );
1110 }
1111 global $wpdb;
1112
1113 $url = $zi_url . 'inventory/v1/compositeitems/' . $composite_zoho_id . '?organization_id=' . $zi_org_id;
1114
1115 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1116 $json = $execute_curl_call->execute_curl_call_get( $url );
1117 $code = $json->code;
1118 // Flag to allow sync of parent composite item.
1119 $allow_sync = false;
1120 // Array of child object metadata.
1121 $product_array = array(); // [{prod_id:'',metadata:{key:'',value:''}},...].
1122 if ( '0' === $code || 0 === $code ) {
1123 foreach ( $json->composite_item->mapped_items as $child_item ) {
1124 $prod_meta = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->postmeta WHERE meta_key = 'zi_item_id' AND meta_value = %s", $child_item->item_id ) );
1125 // If any child will not have zoho id in meta field then process will return false and syncing will be skipped for given item.
1126 if ( ! empty( $prod_meta->post_id ) ) {
1127
1128 $allow_sync = true;
1129 $product = wc_get_product( $prod_meta->post_id );
1130 $stock_status = 'out_of_stock';
1131 if ( $child_item->stock_on_hand > 0 ) {
1132 $stock_status = 'in_stock';
1133 } elseif ( $product && $product->backorders_allowed() ) {
1134 $stock_status = 'onbackorder';
1135 }
1136 $prod_obj = (object) array(
1137 'prod_id' => $prod_meta->post_id,
1138 'metadata' => (object) array(
1139 'quantity_min' => max( 1, $child_item->quantity ),
1140 'quantity_max' => max( 1, $child_item->quantity ),
1141 'stock_status' => $stock_status,
1142 'max_stock' => $child_item->stock_on_hand,
1143 ),
1144 );
1145 if ( is_array( $bundle_childs ) && ! empty( $bundle_childs ) ) {
1146 $index = array_search( $prod_meta->post_id, $bundle_childs );
1147 unset( $bundle_childs[ $index ] );
1148 }
1149 array_push( $product_array, $prod_obj );
1150 } else {
1151 continue;
1152 }
1153 }
1154 }
1155 if ( is_array( $bundle_childs ) && ! empty( $bundle_childs ) ) {
1156 foreach ( $bundle_childs as $item_id => $val ) {
1157 WC_PB_DB::delete_bundled_item( $item_id );
1158 }
1159 }
1160 if ( $allow_sync ) {
1161 return $product_array;
1162 }
1163 return false;
1164 }
1165 /**
1166 * Mapping of bundled product
1167 *
1168 * @param number $product_id - Product id of child item of bundle product.
1169 * @param number $bundle_id - Bundle id of product.
1170 * @param number $menu_order - Listing order of child product ($menu_order will useful at composite product details page).
1171 * @return number - bundle item id.
1172 */
1173 public function add_bundle_product( $product_id, $bundle_id, $menu_order = 0 ) {
1174 $bundle_items = WC_PB_DB::query_bundled_items(
1175 array(
1176 'return' => 'id=>product_id',
1177 'bundle_id' => array( $bundle_id ),
1178 'product_id' => array( $product_id ),
1179 )
1180 );
1181 $data = array(
1182 'menu_order' => $menu_order,
1183 );
1184
1185 if ( count( $bundle_items ) > 0 ) {
1186 $result = WC_PB_DB::update_bundled_item( $bundle_id, $data );
1187 return $result;
1188 } else {
1189 // create data array of bundle item.
1190 $data = array(
1191 'product_id' => $product_id,
1192 'bundle_id' => $bundle_id,
1193 'menu_order' => $menu_order,
1194 );
1195 $bundle_id = WC_PB_DB::add_bundled_item( $data );
1196 return $bundle_id;
1197 }
1198 }
1199
1200 /**
1201 * Create or update bundle item metadata
1202 *
1203 * @param number $bundle_item_id bundle item id.
1204 * @param string $meta_key - metadata key.
1205 * @param string $meta_value - metadata value.
1206 * @return float|int $result - metadata id.
1207 */
1208 public function zi_update_bundle_meta( $bundle_item_id, $meta_key, $meta_value ) {
1209 // first get metadata from db.
1210 $metadata = WC_PB_DB::get_bundled_item_meta( $bundle_item_id, $meta_key );
1211 if ( $metadata ) {
1212 $result = WC_PB_DB::update_bundled_item_meta( $bundle_item_id, $meta_key, $meta_value );
1213 } else {
1214 $result = WC_PB_DB::add_bundled_item_meta( $bundle_item_id, $meta_key, $meta_value );
1215 }
1216 return $result;
1217 }
1218
1219 /**
1220 * Function to sync composite item from zoho to woocommerce
1221 *
1222 * @param integer $page - Page number of composite item data.
1223 * @param string $category - Category id of composite data.
1224 * @return mixed - mostly array of response message.
1225 */
1226 public function recursively_sync_composite_item_from_zoho( $page, $category ) {
1227 // Start logging.
1228 // $fd = fopen( __DIR__ . '/recursively_sync_composite_item_from_zoho.txt', 'a+' );
1229
1230 global $wpdb;
1231 $zi_org_id = $this->config['ProductZI']['OID'];
1232 $zi_url = $this->config['ProductZI']['APIURL'];
1233
1234 $current_user = wp_get_current_user();
1235 $admin_author_id = $current_user->ID;
1236 if ( ! $admin_author_id ) {
1237 $admin_author_id = 1;
1238 }
1239
1240 $url = $zi_url . 'inventory/v1/compositeitems/?organization_id=' . $zi_org_id . '&filter_by=Status.Active&category_id=' . $category . '&page=' . $page;
1241
1242 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1243 $json = $execute_curl_call->execute_curl_call_get( $url );
1244 $code = $json->code;
1245 // $message = $json->message;
1246 // fwrite($fd, PHP_EOL . '$json : ' . print_r($json, true));
1247 // Response for item sync with sync button. For cron sync blank array will return.
1248 $response_msg = array();
1249 if ( '0' === $code || 0 === $code ) {
1250 if ( empty( $json->composite_items ) ) {
1251 array_push( $response_msg, $this->zi_response_message( 'ERROR', 'No composite item to sync for category : ' . $category ) );
1252 return $response_msg;
1253 }
1254 // Accounting stock mode check.
1255 $accounting_stock = $this->config['Settings']['enable_accounting_stock'];
1256 foreach ( $json->composite_items as $comp_item ) {
1257 // fwrite( $fd, PHP_EOL . 'Composite Item : ' . print_r( $comp_item, true ) );
1258 // Sync stock from specific location check.
1259 $zi_enable_locationstock = $this->config['Settings']['enable_locationstock'];
1260 $location_id = $this->config['Settings']['location_id'];
1261 $locations = $comp_item->locations;
1262
1263 if ( true === $zi_enable_locationstock ) {
1264 foreach ( $locations as $location ) {
1265 if ( $location->location_id === $location_id ) {
1266 if ( $accounting_stock ) {
1267 $stock = $location->location_available_for_sale_stock;
1268 } else {
1269 $stock = $location->location_actual_available_for_sale_stock;
1270 }
1271 }
1272 }
1273 } elseif ( $accounting_stock ) {
1274 $stock = $comp_item->available_for_sale_stock;
1275 } else {
1276 $stock = $comp_item->actual_available_for_sale_stock;
1277 }
1278
1279 // ----------------- Create composite item in woocommerce--------------.
1280 // Code to skip sync with item already exists with same sku.
1281 $prod_id = wc_get_product_id_by_sku( $comp_item->sku );
1282 // Flag to enable or disable sync.
1283 $allow_to_import = false;
1284 // Check if product exists with same sku.
1285 if ( $prod_id ) {
1286 $zi_item_id = get_post_meta( $prod_id, 'zi_item_id', true );
1287 if ( $zi_item_id === $comp_item->composite_item_id ) {
1288 // If product is with same sku and zi_item_id mapped.
1289 // Do not import ...
1290 $allow_to_import = false;
1291 } else {
1292 // Map existing item with zoho id.
1293 update_post_meta( $prod_id, 'zi_item_id', $comp_item->composite_item_id );
1294 $allow_to_import = false;
1295 }
1296 } else {
1297 // If product not exists normal bahaviour of item sync.
1298 $allow_to_import = true;
1299 }
1300 $zoho_comp_item_id = $comp_item->composite_item_id;
1301 if ( $comp_item->composite_item_id ) {
1302 $child_items = $this->zi_check_if_child_synced_already( $zoho_comp_item_id, $zi_url, $zi_org_id, $prod_id );
1303 // Check if child items already synced with zoho.
1304 if ( ! $child_items ) {
1305 array_push( $response_msg, $this->zi_response_message( 'ERROR', 'Child not synced for composite item : ' . $zoho_comp_item_id ) );
1306 continue;
1307 }
1308 $product_res = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->postmeta} WHERE meta_key = 'zi_item_id' AND meta_value = %s", $zoho_comp_item_id ) );
1309 if ( ! empty( $product_res->post_id ) ) {
1310 $com_prod_id = $product_res->post_id;
1311 }
1312 // Check if item is allowed to import or not.
1313 if ( $allow_to_import ) {
1314 $product_class = new CMBIRD_Products_ZI_Export();
1315 $item_array = json_decode( wp_json_encode( $comp_item ), true );
1316 $com_prod_id = $product_class->cmbird_zi_product_to_woocommerce( $item_array, $stock, 'composite' );
1317 update_post_meta( $com_prod_id, 'zi_item_id', $zoho_comp_item_id );
1318 }
1319 }
1320 // Map composite items to database.
1321 if ( ! empty( $com_prod_id ) ) {
1322 wp_set_object_terms( $com_prod_id, 'bundle', 'product_type' );
1323 foreach ( $child_items as $child_prod ) {
1324 // Adding product to bundle.
1325 $child_bundle_id = $this->add_bundle_product( $child_prod->prod_id, $com_prod_id );
1326 if ( $child_bundle_id ) {
1327 foreach ( $child_prod->metadata as $bundle_meta_key => $bundle_meta_val ) {
1328 $this->zi_update_bundle_meta( $child_bundle_id, $bundle_meta_key, $bundle_meta_val );
1329 }
1330 }
1331 }
1332 }
1333 // --------------------------------------------------------------------.
1334
1335 $is_synced_flag = false; // loggin purpose only .
1336
1337 $product = wc_get_product( $com_prod_id );
1338 foreach ( $comp_item as $key => $value ) {
1339 if ( 'status' === $key ) {
1340 if ( ! empty( $com_prod_id ) ) {
1341 $status = 'active' === $value ? 'publish' : 'draft';
1342 $product->set_status( $status );
1343 }
1344 }
1345 if ( 'description' === $key ) {
1346 if ( ! empty( $com_prod_id ) && ! empty( $value ) ) {
1347 $product->set_short_description( $value );
1348 }
1349 }
1350 if ( 'name' === $key ) {
1351 if ( ! empty( $com_prod_id ) ) {
1352 $product->set_name( $value );
1353 }
1354 }
1355 if ( 'sku' === $key ) {
1356 if ( ! empty( $com_prod_id ) ) {
1357 $product->set_sku( $value );
1358 }
1359 }
1360 // Check if stock sync allowed by plugin.
1361 if ( 'available_stock' === $key || 'actual_available_stock' === $key ) {
1362 $zi_disable_stock_sync = $this->config['Settings']['disable_stock'];
1363 if ( ! $zi_disable_stock_sync ) {
1364 if ( $stock ) {
1365 if ( ! empty( $com_prod_id ) ) {
1366 // If value is less than 0 default 1.
1367 $stock_quantity = $stock < 0 ? 0 : $stock;
1368 $product->set_manage_stock( true );
1369 $product->set_stock_quantity( $stock_quantity );
1370 if ( $stock_quantity > 0 ) {
1371 $status = 'instock';
1372 } else {
1373 $backorder_status = $product->backorders_allowed();
1374 $status = $backorder_status ? 'onbackorder' : 'outofstock';
1375 }
1376 $product->set_stock_status( $status );
1377 update_post_meta( $com_prod_id, '_wc_pb_bundled_items_stock_status', $status );
1378 }
1379 }
1380 }
1381 }
1382 if ( 'rate' === $key ) {
1383 $zi_disable_price_sync = $this->config['Settings']['disable_price'];
1384 if ( ! empty( $com_prod_id ) && ! $zi_disable_price_sync ) {
1385 $sale_price = $product->get_sale_price();
1386 if ( empty( $sale_price ) ) {
1387 $product->set_regular_price( $value );
1388 $product->set_price( $value );
1389 update_post_meta( $com_prod_id, '_wc_pb_base_price', $value );
1390 update_post_meta( $com_prod_id, '_wc_pb_base_regular_price', $value );
1391 update_post_meta( $com_prod_id, '_wc_sw_max_regular_price', $value );
1392 } else {
1393 $product->set_regular_price( $value );
1394 update_post_meta( $com_prod_id, '_wc_pb_base_price', $value );
1395 update_post_meta( $com_prod_id, '_wc_pb_base_regular_price', $value );
1396 update_post_meta( $com_prod_id, '_wc_sw_max_regular_price', $value );
1397 }
1398 }
1399 }
1400 $product->save();
1401
1402 if ( 'image_document_id' === $key ) {
1403 if ( ! empty( $com_prod_id ) && ! empty( $value ) ) {
1404 $image_class = new CMBIRD_Image_ZI();
1405 $image_class->cmbird_zi_get_image( $zoho_comp_item_id, $comp_item->name, $comp_item->image_name, $com_prod_id );
1406 }
1407 }
1408 if ( 'category_name' === $key ) {
1409 if ( ! empty( $com_prod_id ) && $comp_item->category_name != '' ) {
1410 $term = get_term_by( 'name', $comp_item->category_name, 'product_cat' );
1411 $term_id = $term->term_id;
1412 if ( empty( $term_id ) ) {
1413 $term = wp_insert_term(
1414 $comp_item->category_name,
1415 'product_cat',
1416 array(
1417 'parent' => 0,
1418 )
1419 );
1420 $term_id = $term['term_id'];
1421 }
1422 if ( $term_id ) {
1423 $existing_terms = wp_get_object_terms( $com_prod_id, 'product_cat' );
1424 if ( $existing_terms && count( $existing_terms ) > 0 ) {
1425 $is_terms_exist = $this->zi_check_terms_exists( $existing_terms, $term_id );
1426 if ( ! $is_terms_exist ) {
1427 update_post_meta( $com_prod_id, 'zi_category_id', $category );
1428 wp_add_object_terms( $com_prod_id, $term_id, 'product_cat' );
1429 }
1430 } else {
1431 update_post_meta( $com_prod_id, 'zi_category_id', $category );
1432 wp_set_object_terms( $com_prod_id, $term_id, 'product_cat' );
1433 }
1434 }
1435 // Remove "uncategorized" category if assigned.
1436 $uncategorized_term = get_term_by( 'slug', 'uncategorized', 'product_cat' );
1437 if ( $uncategorized_term && has_term( $uncategorized_term->term_id, 'product_cat', $com_prod_id ) ) {
1438 wp_remove_object_terms( $com_prod_id, $uncategorized_term->term_id, 'product_cat' );
1439 }
1440 }
1441 }
1442 }
1443
1444 // sync dimensions and weight.
1445 $item_url = "{$zi_url}inventory/v1/compositeitems/{$zoho_comp_item_id}?organization_id={$zi_org_id}";
1446 $this->zi_item_dimension_weight( $item_url, $com_prod_id, true );
1447
1448 // If item synced append to log : logging purpose only.
1449 if ( $is_synced_flag ) {
1450 array_push( $response_msg, $this->zi_response_message( 'SUCCESS', 'Composite item synced for id : ' . $comp_item->composite_item_id, $com_prod_id ) );
1451 }
1452 }
1453
1454 if ( $json->page_context->has_more_page ) {
1455 ++$page;
1456 $this->recursively_sync_composite_item_from_zoho( $page, $category );
1457 }
1458 } else {
1459 array_push( $response_msg, $this->zi_response_message( $code, $json->message ) );
1460 }
1461 // fclose( $fd ); // End of logging.
1462
1463 return $response_msg;
1464 }
1465
1466 /**
1467 * Function to retrieve item details, update weight and dimensions.
1468 *
1469 * @param string $url - URL to ge details.
1470 * @return mixed return true if data false if error.
1471 */
1472 public function zi_item_dimension_weight( $url, $product_id, $is_composite = false ) {
1473 // $fd = fopen(__DIR__ . '/zi_item_dimension_weight.txt', 'a+');
1474 // Check if item is for syncing purpose.
1475 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1476 $json = $execute_curl_call->execute_curl_call_get( $url );
1477 $code = $json->code;
1478 $message = $json->message;
1479 if ( 0 === $code || '0' === $code ) {
1480 if ( $is_composite ) {
1481 // fwrite($fd, PHP_EOL . '$json : ' . print_r($json, true));
1482 $details = $json->composite_item->package_details;
1483 } else {
1484 $details = $json->item->package_details;
1485 }
1486 $product = wc_get_product( $product_id );
1487 $product->set_weight( floatval( $details->weight ) );
1488 $product->set_length( floatval( $details->length ) );
1489 $product->set_width( floatval( $details->width ) );
1490 $product->set_height( floatval( $details->height ) );
1491 $product->save();
1492 } else {
1493 false;
1494 }
1495 // fclose($fd);
1496 }
1497
1498 /**
1499 * Create response object based on data.
1500 *
1501 * @param mixed $index_col - Index value error message.
1502 * @param string $message - Response message.
1503 * @return object
1504 */
1505 public function zi_response_message( $index_col, $message, $woo_id = '' ) {
1506 return (object) array(
1507 'resp_id' => $index_col,
1508 'message' => $message,
1509 'woo_prod_id' => $woo_id,
1510 );
1511 }
1512
1513 /**
1514 * Helper Function to check if terms already exists.
1515 */
1516 public function zi_check_terms_exists( $existing_terms, $term_id ) {
1517 foreach ( $existing_terms as $woo_existing_term ) {
1518 if ( $woo_existing_term->term_id === $term_id ) {
1519 return true;
1520 } else {
1521 return false;
1522 }
1523 }
1524 }
1525
1526 /**
1527 * Refactored batch sync method using WooCommerce REST API batch endpoint.
1528 *
1529 * Fetches items from Zoho API, optionally fetches itemdetails for location stock,
1530 * and processes items in batches through the WooCommerce REST API.
1531 *
1532 * @param array $args Array containing 'page' and 'category' parameters.
1533 * @return array|bool Success or failure status.
1534 */
1535 public function sync_items_batch( $args ) {
1536 // $fd = fopen( __DIR__ . '/sync_items_batch.txt', 'a+' );
1537 $args = func_get_args();
1538 $config = $this->config['Settings'];
1539 if ( is_array( $args ) ) {
1540 if ( isset( $args['page'] ) && isset( $args['category'] ) ) {
1541 $page = $args['page'];
1542 $category = $args['category'];
1543 } elseif ( isset( $args[0] ) && isset( $args[1] ) ) {
1544 $page = $args[0];
1545 $category = $args[1];
1546 } elseif ( isset( $args[0] ) && ! isset( $args[1] ) ) {
1547 $page = $args[0]['page'];
1548 $category = $args[0]['category'];
1549 } else {
1550 return;
1551 }
1552 } else {
1553 return;
1554 }
1555
1556 // Fetch items from Zoho items endpoint.
1557 $zoho_inventory_oid = $this->config['ProductZI']['OID'];
1558 $zoho_inventory_url = $this->config['ProductZI']['APIURL'];
1559 $timeout = ini_get( 'max_execution_time' ) ? ini_get( 'max_execution_time' ) : 60;
1560
1561 // Dynamically set per_page based on timeout.
1562 if ( $timeout <= 30 ) {
1563 $per_page = 20;
1564 } elseif ( $timeout <= 60 ) {
1565 $per_page = 50;
1566 } else {
1567 $per_page = 100;
1568 }
1569
1570 $url = sprintf(
1571 '%sinventory/v1/items?organization_id=%s&category_id=%s&page=%d&per_page=%d&sort_column=last_modified_time',
1572 $zoho_inventory_url,
1573 $zoho_inventory_oid,
1574 $category,
1575 $page,
1576 $per_page
1577 );
1578 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1579 $json = $execute_curl_call->execute_curl_call_get( $url );
1580 $code = (int) property_exists( $json, 'code' ) ? $json->code : '0';
1581
1582 if ( 0 !== $code && '0' !== $code ) {
1583 // Log the error in WC logger.
1584 $logger = wc_get_logger();
1585 $logger->error( sprintf( 'Zoho API error during item sync: Code: %s, Message: %s', $code, $json->message ) );
1586 return false;
1587 }
1588
1589 if ( ! isset( $json->items ) || empty( $json->items ) ) {
1590 return false;
1591 }
1592
1593 // Separate items based on location stock requirement.
1594 $items_ready = array();
1595 $item_ids = array();
1596 $items_without_attributes = array();
1597
1598 foreach ( $json->items as $item ) {
1599 // Skip combo products.
1600 if ( isset( $item->is_combo_product ) && $item->is_combo_product ) {
1601 continue;
1602 }
1603
1604 // Skip variations/groups.
1605 if ( isset( $item->group_id ) && ! empty( $item->group_id ) ) {
1606 if ( empty( $item->attribute_name1 ) ) {
1607 $items_without_attributes[] = $item;
1608 continue;
1609 }
1610 $this->sync_variation_of_group( $item );
1611 continue;
1612 }
1613
1614 // Collect item IDs if location stock is enabled.
1615 if ( $config['enable_location_stock'] ) {
1616 $item_ids[] = $item->item_id;
1617 } else {
1618 $items_ready[] = $item;
1619 }
1620 }
1621
1622 // Notify admin of group items that have no attributes and cannot be synced.
1623 if ( ! empty( $items_without_attributes ) ) {
1624 $rows = '';
1625 foreach ( $items_without_attributes as $no_attr_item ) {
1626 $rows .= '<tr><td>' . esc_html( $no_attr_item->item_id ) . '</td><td>' . esc_html( $no_attr_item->name ) . '</td><td>' . esc_html( $no_attr_item->sku ?? '' ) . '</td><td>' . esc_html( $no_attr_item->group_name ?? '' ) . '</td></tr>';
1627 }
1628 $message = '<p>The following Group Items have no attributes set in Zoho Inventory and cannot be synced as WooCommerce product variations:</p>'
1629 . '<table border="1" cellpadding="5" cellspacing="0"><thead><tr><th>Item ID</th><th>Name</th><th>SKU</th><th>Group Name</th></tr></thead><tbody>'
1630 . $rows
1631 . '</tbody></table>';
1632 $common = new CMBIRD_Common_Functions();
1633 $common->send_email( 'CommerceBird: Group Items Without Attributes', $message );
1634 }
1635
1636 // Fetch itemdetails for location stock if needed.
1637 if ( ! empty( $item_ids ) ) {
1638 $item_details_url_base = "{$zoho_inventory_url}inventory/v1/itemdetails?organization_id={$zoho_inventory_oid}&item_ids=";
1639 $item_id_str = implode( ',', $item_ids );
1640 $item_details_url = $item_details_url_base . $item_id_str;
1641 $json_details = $execute_curl_call->execute_curl_call_get( $item_details_url );
1642 $details_code = (int) property_exists( $json_details, 'code' ) ? $json_details->code : '0';
1643
1644 if ( 0 === $details_code || '0' === $details_code ) {
1645 if ( isset( $json_details->items ) && is_array( $json_details->items ) ) {
1646 foreach ( $json_details->items as $item_detail ) {
1647 $items_ready[] = $item_detail;
1648 }
1649 }
1650 }
1651 }
1652
1653 // Process items through batch endpoint.
1654 if ( ! empty( $items_ready ) ) {
1655 $this->process_items_batch( $items_ready );
1656 }
1657
1658 // Handle pagination.
1659 if ( isset( $json->page_context->has_more_page ) && $json->page_context->has_more_page && $page < 1000 ) {
1660 as_schedule_single_action(
1661 time() + 5,
1662 'import_simple_items_cron',
1663 array(
1664 'page' => $page + 1,
1665 'category' => $category,
1666 ),
1667 'commercebird'
1668 );
1669 }
1670
1671 return array(
1672 'success' => true,
1673 'message' => 'Batch processed successfully',
1674 );
1675 }
1676
1677 /**
1678 * Process items batch for create/update via WooCommerce REST API.
1679 *
1680 * Handles product matching (by SKU or zi_item_id), duplicate removal,
1681 * and builds create/update arrays for batch endpoint.
1682 *
1683 * @param array $items Array of Zoho items to process.
1684 * @return void
1685 */
1686 private function process_items_batch( $items ) {
1687 // $fd = fopen( __DIR__ . '/process_items_batch.txt', 'a+' );
1688 global $wpdb;
1689
1690 $to_create = array();
1691 $to_update = array();
1692
1693 foreach ( $items as $item ) {
1694 // 1. Try to find product by SKU.
1695 $product_id = ( isset( $item->sku ) && ! empty( $item->sku ) )
1696 ? wc_get_product_id_by_sku( $item->sku )
1697 : 0;
1698
1699 // 2. If product exists by SKU, check if mapped to Zoho.
1700 if ( $product_id ) {
1701 $zi_item_id = get_post_meta( $product_id, 'zi_item_id', true );
1702 if ( empty( $zi_item_id ) ) {
1703 // Map existing product with Zoho item ID.
1704 update_post_meta( $product_id, 'zi_item_id', $item->item_id );
1705 }
1706 }
1707
1708 // 3. Remove duplicates based on SKU.
1709 if ( $product_id && ! empty( $item->sku ) ) {
1710 $duplicate_product = $wpdb->get_row(
1711 $wpdb->prepare(
1712 "SELECT post_id FROM {$wpdb->prefix}postmeta WHERE meta_key = '_sku' AND meta_value = %s AND post_id != %d LIMIT 1",
1713 $item->sku,
1714 $product_id
1715 )
1716 );
1717 if ( $duplicate_product ) {
1718 wp_delete_post( $duplicate_product->post_id, true );
1719 $product_id = 0;
1720 }
1721 }
1722
1723 // 4. If no product by SKU, try to find by zi_item_id.
1724 if ( empty( $product_id ) ) {
1725 $existing = $wpdb->get_row(
1726 $wpdb->prepare(
1727 "SELECT post_id FROM {$wpdb->prefix}postmeta WHERE meta_key = 'zi_item_id' AND meta_value = %s LIMIT 1",
1728 $item->item_id
1729 )
1730 );
1731 $product_id = $existing ? $existing->post_id : 0;
1732 }
1733
1734 // 5. Create or update based on what we found.
1735 if ( empty( $product_id ) && isset( $item->status ) && 'active' === $item->status ) {
1736 // Create new product.
1737 $to_create[] = $this->map_item_to_array( $item, null );
1738 } elseif ( ! empty( $product_id ) ) {
1739 // Update existing product.
1740 $to_update[] = $this->map_item_to_array( $item, $product_id );
1741 }
1742 }
1743
1744 // Use batch endpoint.
1745 if ( ! empty( $to_create ) ) {
1746 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import( 'product', $to_create, false, '/wc/v3/products/batch' );
1747 }
1748
1749 if ( ! empty( $to_update ) ) {
1750 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import( 'product', $to_update, true, '/wc/v3/products/batch' );
1751 }
1752 }
1753
1754 /**
1755 * Map Zoho item to WooCommerce batch endpoint format.
1756 *
1757 * Transforms Zoho item data into WooCommerce REST API product format
1758 * for batch create/update operations.
1759 *
1760 * @param object $item Zoho item object.
1761 * @param int $product_id WooCommerce product ID (null for create).
1762 * @return array WooCommerce product data for batch endpoint.
1763 */
1764 private function map_item_to_array( $item, $product_id = null ) {
1765 $config = $this->config['Settings'];
1766 $data = array(
1767 'name' => isset( $item->name ) ? $item->name : '',
1768 'sku' => isset( $item->sku ) ? $item->sku : '',
1769 'status' => ( isset( $item->status ) && 'active' === $item->status ) ? 'publish' : 'draft',
1770 );
1771
1772 // Add product ID for updates.
1773 if ( $product_id ) {
1774 $data['id'] = $product_id;
1775 }
1776
1777 // Add rate if price sync is not disabled.
1778 if ( ! $config['disable_price'] && isset( $item->rate ) ) {
1779 $data['regular_price'] = (string) $item->rate;
1780 }
1781
1782 // Add description if not disabled.
1783 if ( ! $config['disable_description'] && isset( $item->description ) ) {
1784 $data['short_description'] = $item->description;
1785 }
1786
1787 // Add images if not disabled.
1788 if ( ! $config['disable_image'] && isset( $item->image_document_id ) && ! empty( $item->image_document_id ) ) {
1789 $image_class = new CMBIRD_Image_ZI();
1790 $attachment_id = $image_class->cmbird_zi_get_image(
1791 $item->item_id,
1792 isset( $item->name ) ? $item->name : '',
1793 isset( $item->image_name ) ? $item->image_name : '',
1794 $product_id
1795 );
1796
1797 // Add image to batch data.
1798 if ( $attachment_id ) {
1799 $data['images'] = array(
1800 array( 'id' => $attachment_id ),
1801 );
1802 }
1803 }
1804
1805 // Check if Tax is enabled and set tax status.
1806 if ( ! empty( $item->tax_id ) && $this->is_tax_enabled ) {
1807 $zi_common_class = new CMBIRD_Common_Functions();
1808 $woo_tax_class = $zi_common_class->get_tax_class_by_zoho_id( $item->tax_id )
1809 ?? $zi_common_class->get_tax_class_by_percentage( $item->tax_percentage );
1810 $data['tax_class'] = $woo_tax_class;
1811 $data['tax_status'] = 'taxable';
1812 }
1813
1814 // Handle stock.
1815 if ( ! $config['disable_stock'] ) {
1816 $stock = null;
1817
1818 // Use location stock if available (from itemdetails).
1819 if ( isset( $item->locations ) && is_array( $item->locations ) ) {
1820 foreach ( $item->locations as $location ) {
1821 if ( $location->location_id === $config['zoho_location_id'] ) {
1822 $stock = $config['enable_accounting_stock']
1823 ? $location->location_available_for_sale_stock
1824 : $location->location_actual_available_for_sale_stock;
1825 break;
1826 }
1827 }
1828 } else {
1829 // Use global stock from items endpoint.
1830 $stock = $config['enable_accounting_stock']
1831 ? ( isset( $item->available_for_sale_stock ) ? $item->available_for_sale_stock : null )
1832 : ( isset( $item->actual_available_for_sale_stock ) ? $item->actual_available_for_sale_stock : null );
1833 }
1834 if ( null !== $stock ) {
1835 $data['manage_stock'] = true;
1836 $data['stock_quantity'] = $stock;
1837 }
1838 }
1839 // Add cost_of_goods_sold if available.
1840 if ( isset( $item->purchase_rate ) ) {
1841 $data['cost_of_goods_sold'] = $item->purchase_rate;
1842 }
1843
1844 // Add dimensions directly from items endpoint.
1845 if ( isset( $item->weight ) ) {
1846 $data['weight'] = $item->weight;
1847 }
1848 if ( isset( $item->length ) ) {
1849 $data['length'] = $item->length;
1850 }
1851 if ( isset( $item->width ) ) {
1852 $data['width'] = $item->width;
1853 }
1854 if ( isset( $item->height ) ) {
1855 $data['height'] = $item->height;
1856 }
1857
1858 // Add categories.
1859 if ( isset( $item->category_name ) && ! empty( $item->category_name ) ) {
1860 $categories = self::prepare_categories( $item->category_name );
1861 if ( ! empty( $categories ) ) {
1862 $data['categories'] = $categories;
1863 }
1864 }
1865
1866 // Add brand as category if present.
1867 if ( isset( $item->brand ) && ! empty( $item->brand ) ) {
1868 $brand_terms = self::prepare_categories( $item->brand, 'product_brand' );
1869 if ( ! empty( $brand_terms ) ) {
1870 $data['brands'] = $brand_terms;
1871 }
1872 }
1873
1874 // Add meta data.
1875 $data['meta_data'] = array(
1876 array(
1877 'key' => 'zi_item_id',
1878 'value' => isset( $item->item_id ) ? $item->item_id : '',
1879 ),
1880 array(
1881 'key' => 'zi_category_id',
1882 'value' => isset( $item->category_id ) ? $item->category_id : '',
1883 ),
1884 );
1885
1886 // Add custom fields from items endpoint (flattened format).
1887 foreach ( $item as $key => $value ) {
1888 if ( 0 === strpos( $key, 'cf_' ) && false === strpos( $key, '_unformatted' ) ) {
1889 $data['meta_data'][] = array(
1890 'key' => 'zi_' . $key,
1891 'value' => $value,
1892 );
1893 }
1894 }
1895
1896 return $data;
1897 }
1898
1899 /**
1900 * Prepare category data for WooCommerce batch endpoint.
1901 *
1902 * Creates or retrieves category term and returns in batch format.
1903 *
1904 * @param string $category_name Category name from Zoho.
1905 * @param string $taxonomy Taxonomy to use, default is 'product_cat'.
1906 * @return array Category array for WooCommerce batch endpoint.
1907 */
1908 private static function prepare_categories( $category_name, $taxonomy = 'product_cat' ) {
1909 if ( empty( $category_name ) ) {
1910 return array();
1911 }
1912
1913 $term = get_term_by( 'name', $category_name, $taxonomy );
1914 $term_id = $term ? $term->term_id : 0;
1915
1916 if ( ! $term_id ) {
1917 $term = wp_insert_term( $category_name, $taxonomy, array( 'parent' => 0 ) );
1918 $term_id = isset( $term['term_id'] ) ? $term['term_id'] : 0;
1919 }
1920
1921 return $term_id ? array( array( 'id' => $term_id ) ) : array();
1922 }
1923
1924 /**
1925 * Get sync configuration from class config or WordPress options.
1926 *
1927 * Merges class configuration with WordPress option settings.
1928 *
1929 * @return array Configuration array.
1930 */
1931 }
1932 $cmbird_products_zi = new CMBIRD_Products_ZI();
1933