PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / trunk
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). vtrunk
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 2 weeks ago class-import-items.php 2 weeks ago class-import-price-list.php 3 months ago class-multi-currency.php 8 months ago class-order-sync.php 2 weeks ago class-product.php 2 months ago class-users-contact.php 3 weeks ago index.php 1 year ago
class-import-items.php
2521 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 $item_field_mappings = $this->get_item_custom_field_mappings();
84 $custom_fields = $this->normalize_custom_fields_payload( $custom_fields );
85
86 if ( empty( $custom_fields ) ) {
87 return;
88 }
89
90 foreach ( $custom_fields as $custom_field ) {
91 // Extract data from custom field.
92 $api_name = (string) $this->get_custom_field_attribute( $custom_field, 'api_name' );
93 $value = $this->get_custom_field_attribute( $custom_field, 'value' );
94
95 if ( $this->is_truthy_custom_field_flag( $custom_field, 'is_rich_text_supported' ) ) {
96 $formatted_value = $this->get_custom_field_attribute( $custom_field, 'value_formatted', '' );
97 if ( ! $this->is_empty_custom_field_value( $formatted_value ) ) {
98 $value = $formatted_value;
99 }
100 }
101
102 // Resolve Woo field key from saved item mapping first, then fallback to api_name.
103 $resolved_key = $this->resolve_item_custom_field_key( $custom_field, $item_field_mappings );
104 if ( empty( $resolved_key ) ) {
105 $resolved_key = $api_name;
106 }
107
108 if ( $this->is_attachment_custom_field( $custom_field ) ) {
109 $value = $this->sync_item_custom_field_attachment( $custom_field, $pdt_id );
110 }
111
112 $value = $this->decode_custom_field_value( $value );
113
114 // Check if both field key and value are present.
115 if ( ! empty( $resolved_key ) && ! $this->is_empty_custom_field_value( $value ) ) {
116 $stored_in_meta = false;
117
118 // Attempt ACF update first when available.
119 if ( function_exists( 'update_field' ) ) {
120 $acf_result = update_field( $resolved_key, $value, $pdt_id );
121
122 // If ACF couldn't resolve/write this selector, persist directly as post meta.
123 if ( false === $acf_result && ! metadata_exists( 'post', $pdt_id, $resolved_key ) ) {
124 update_post_meta( $pdt_id, $resolved_key, $value );
125 $stored_in_meta = true;
126 }
127 }
128
129 if ( ! function_exists( 'update_field' ) ) {
130 update_post_meta( $pdt_id, $resolved_key, $value );
131 $stored_in_meta = true;
132 }
133
134 if ( ! $stored_in_meta && ! metadata_exists( 'post', $pdt_id, $resolved_key ) ) {
135 update_post_meta( $pdt_id, $resolved_key, $value );
136 }
137 }
138 }
139 // Allow snippet code to execute custom logic after all field updates are processed.
140 do_action( 'cmbird_zi_custom_field_updated', $pdt_id );
141 }
142
143 /**
144 * Returns a custom field attribute from either array or object payloads.
145 *
146 * @param array|object $custom_field Incoming Zoho custom field object/array.
147 * @param string $key Attribute name.
148 * @param mixed $default Default value.
149 * @return mixed
150 */
151 private function get_custom_field_attribute( $custom_field, $key, $default = '' ) {
152 if ( is_object( $custom_field ) && isset( $custom_field->{$key} ) ) {
153 return $custom_field->{$key};
154 }
155
156 if ( is_array( $custom_field ) && array_key_exists( $key, $custom_field ) ) {
157 return $custom_field[ $key ];
158 }
159
160 return $default;
161 }
162
163 /**
164 * Normalizes incoming custom fields payload to a list of field objects/arrays.
165 *
166 * @param mixed $custom_fields Incoming payload from Zoho item/webhook.
167 * @return array
168 */
169 private function normalize_custom_fields_payload( $custom_fields ) {
170 if ( is_object( $custom_fields ) && isset( $custom_fields->api_name ) ) {
171 return array( $custom_fields );
172 }
173
174 if ( is_array( $custom_fields ) && isset( $custom_fields['api_name'] ) ) {
175 return array( $custom_fields );
176 }
177
178 if ( is_array( $custom_fields ) ) {
179 return $custom_fields;
180 }
181
182 return array();
183 }
184
185 /**
186 * Returns whether a custom-field flag is truthy.
187 *
188 * @param array|object $custom_field Incoming Zoho custom field object/array.
189 * @param string $key Flag attribute name.
190 * @return bool
191 */
192 private function is_truthy_custom_field_flag( $custom_field, $key ) {
193 $flag = $this->get_custom_field_attribute( $custom_field, $key, false );
194
195 if ( is_bool( $flag ) ) {
196 return $flag;
197 }
198
199 return '1' === (string) $flag || 'true' === strtolower( (string) $flag );
200 }
201
202 /**
203 * Determines whether the incoming custom field is a file attachment.
204 *
205 * @param array|object $custom_field Incoming Zoho custom field object/array.
206 * @return bool
207 */
208 private function is_attachment_custom_field( $custom_field ) {
209 $field_type_candidates = array(
210 $this->get_custom_field_attribute( $custom_field, 'data_type' ),
211 $this->get_custom_field_attribute( $custom_field, 'field_type' ),
212 $this->get_custom_field_attribute( $custom_field, 'customfield_type' ),
213 $this->get_custom_field_attribute( $custom_field, 'type' ),
214 $this->get_custom_field_attribute( $custom_field, 'input_type' ),
215 );
216
217 foreach ( $field_type_candidates as $candidate ) {
218 $candidate = strtolower( trim( (string) $candidate ) );
219 if ( in_array( $candidate, array( 'attachment', 'file', 'file_upload', 'fileupload' ), true ) ) {
220 return true;
221 }
222 }
223
224 return false;
225 }
226
227 /**
228 * Downloads a Zoho attachment custom field into the media library and returns its URL.
229 *
230 * @param array|object $custom_field Incoming Zoho custom field object/array.
231 * @param int $pdt_id Product id.
232 * @return string
233 */
234 private function sync_item_custom_field_attachment( $custom_field, $pdt_id ) {
235 $document_id = $this->get_custom_field_attachment_document_id( $custom_field );
236
237 if ( '' === $document_id ) {
238 return '';
239 }
240
241 $existing_attachment_id = $this->find_attachment_by_zoho_document_id( $document_id );
242 if ( $existing_attachment_id ) {
243 $existing_url = wp_get_attachment_url( $existing_attachment_id );
244 return $existing_url ? $existing_url : '';
245 }
246
247 $file_name = $this->get_custom_field_attachment_file_name( $custom_field, $document_id );
248 $zoho_inventory_oid = (string) $this->config['ProductZI']['OID'];
249 $zoho_inventory_url = (string) $this->config['ProductZI']['APIURL'];
250 $url = $zoho_inventory_url . 'inventory/v1/documents/' . rawurlencode( $document_id ) . '?organization_id=' . rawurlencode( $zoho_inventory_oid );
251
252 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
253 $temp_file_path = $execute_curl_call_handle->execute_curl_call_file_get( $url, $file_name, 'zoho_documents' );
254
255 if ( is_wp_error( $temp_file_path ) || empty( $temp_file_path ) || ! file_exists( $temp_file_path ) ) {
256 return '';
257 }
258
259 require_once ABSPATH . 'wp-admin/includes/media.php';
260 require_once ABSPATH . 'wp-admin/includes/file.php';
261 require_once ABSPATH . 'wp-admin/includes/image.php';
262
263 $file_array = array(
264 'name' => sanitize_file_name( $file_name ),
265 'tmp_name' => $temp_file_path,
266 );
267
268 $attachment_id = media_handle_sideload( $file_array, $pdt_id );
269 if ( is_wp_error( $attachment_id ) ) {
270 if ( file_exists( $temp_file_path ) ) {
271 wp_delete_file( $temp_file_path );
272 }
273 return '';
274 }
275
276 update_post_meta( $attachment_id, 'cmbird_zoho_document_id', $document_id );
277
278 if ( file_exists( $temp_file_path ) ) {
279 wp_delete_file( $temp_file_path );
280 }
281
282 $attachment_url = wp_get_attachment_url( $attachment_id );
283
284 return $attachment_url ? $attachment_url : '';
285 }
286
287 /**
288 * Returns the Zoho document identifier for an attachment custom field.
289 *
290 * @param array|object $custom_field Incoming Zoho custom field object/array.
291 * @return string
292 */
293 private function get_custom_field_attachment_document_id( $custom_field ) {
294 $value = $this->get_custom_field_attribute( $custom_field, 'value' );
295
296 if ( is_scalar( $value ) ) {
297 return trim( (string) $value );
298 }
299
300 if ( is_array( $value ) ) {
301 foreach ( array( 'document_id', 'id', 'value', 0 ) as $key ) {
302 if ( isset( $value[ $key ] ) && is_scalar( $value[ $key ] ) ) {
303 return trim( (string) $value[ $key ] );
304 }
305 }
306 }
307
308 if ( is_object( $value ) ) {
309 foreach ( array( 'document_id', 'id', 'value' ) as $property ) {
310 if ( isset( $value->{$property} ) && is_scalar( $value->{$property} ) ) {
311 return trim( (string) $value->{$property} );
312 }
313 }
314 }
315
316 return '';
317 }
318
319 /**
320 * Returns a best-effort file name for an attachment custom field.
321 *
322 * @param array|object $custom_field Incoming Zoho custom field object/array.
323 * @param string $document_id Zoho document id.
324 * @return string
325 */
326 private function get_custom_field_attachment_file_name( $custom_field, $document_id ) {
327 $file_name_candidates = array(
328 $this->get_custom_field_attribute( $custom_field, 'file_name' ),
329 $this->get_custom_field_attribute( $custom_field, 'document_name' ),
330 $this->get_custom_field_attribute( $custom_field, 'attachment_name' ),
331 $this->get_custom_field_attribute( $custom_field, 'value_formatted' ),
332 $this->get_custom_field_attribute( $custom_field, 'label' ),
333 $document_id,
334 );
335
336 foreach ( $file_name_candidates as $candidate ) {
337 if ( is_scalar( $candidate ) ) {
338 $candidate = sanitize_file_name( (string) $candidate );
339 if ( '' !== $candidate ) {
340 return $candidate;
341 }
342 }
343 }
344
345 return $document_id;
346 }
347
348 /**
349 * Finds an existing WordPress attachment by stored Zoho document id.
350 *
351 * @param string $document_id Zoho document id.
352 * @return int
353 */
354 private function find_attachment_by_zoho_document_id( $document_id ) {
355 $attachments = get_posts(
356 array(
357 'post_type' => 'attachment',
358 'post_status' => 'inherit',
359 'fields' => 'ids',
360 'posts_per_page' => 1,
361 'meta_key' => 'cmbird_zoho_document_id',
362 'meta_value' => $document_id,
363 )
364 );
365
366 if ( empty( $attachments ) ) {
367 return 0;
368 }
369
370 return (int) $attachments[0];
371 }
372
373 /**
374 * Returns saved item custom field mappings as Zoho-field => Woo-field lookup.
375 *
376 * @return array
377 */
378 private function get_item_custom_field_mappings() {
379 $stored_option = get_option( 'cmbird_wootozoho_custom_fields', array() );
380
381 if ( is_string( $stored_option ) ) {
382 $decoded = json_decode( $stored_option, true );
383 if ( is_array( $decoded ) ) {
384 $stored_option = $decoded;
385 }
386 }
387
388 $item_rows = array();
389 if ( is_array( $stored_option ) && isset( $stored_option['item'] ) && is_array( $stored_option['item'] ) ) {
390 $item_rows = $stored_option['item'];
391 }
392
393 $lookup = array();
394 foreach ( $item_rows as $woo_key => $row ) {
395 $woo_field_key = '';
396 $zoho_field_key = '';
397
398 // New shape: associative map { woo_key: zoho_field }.
399 if ( is_string( $woo_key ) && ! is_array( $row ) ) {
400 $woo_field_key = trim( (string) $woo_key );
401 $zoho_field_key = trim( (string) $row );
402 }
403
404 // Legacy/alternate shape: row array with key/value.
405 if ( is_array( $row ) ) {
406 $woo_field_key = isset( $row['key'] ) ? trim( (string) $row['key'] ) : $woo_field_key;
407 $zoho_field_key = isset( $row['value'] ) ? trim( (string) $row['value'] ) : $zoho_field_key;
408 }
409
410 if ( '' === $woo_field_key || '' === $zoho_field_key ) {
411 continue;
412 }
413
414 $lookup[ $this->normalize_custom_field_identifier( $zoho_field_key ) ] = $woo_field_key;
415 }
416
417 return $lookup;
418 }
419
420 /**
421 * Resolves the destination Woo custom field key for an incoming Zoho field.
422 *
423 * @param array|object $custom_field Incoming Zoho custom field object/array.
424 * @param array $lookup Saved mapping lookup.
425 * @return string
426 */
427 private function resolve_item_custom_field_key( $custom_field, $lookup ) {
428 if ( empty( $lookup ) ) {
429 return '';
430 }
431
432 $candidates = array();
433
434 if ( is_object( $custom_field ) ) {
435 if ( isset( $custom_field->field_name_formatted ) ) {
436 $candidates[] = (string) $custom_field->field_name_formatted;
437 }
438 if ( isset( $custom_field->label ) ) {
439 $candidates[] = (string) $custom_field->label;
440 }
441 if ( isset( $custom_field->field_name ) ) {
442 $candidates[] = (string) $custom_field->field_name;
443 }
444 if ( isset( $custom_field->api_name ) ) {
445 $candidates[] = (string) $custom_field->api_name;
446 }
447 } elseif ( is_array( $custom_field ) ) {
448 if ( isset( $custom_field['field_name_formatted'] ) ) {
449 $candidates[] = (string) $custom_field['field_name_formatted'];
450 }
451 if ( isset( $custom_field['label'] ) ) {
452 $candidates[] = (string) $custom_field['label'];
453 }
454 if ( isset( $custom_field['field_name'] ) ) {
455 $candidates[] = (string) $custom_field['field_name'];
456 }
457 if ( isset( $custom_field['api_name'] ) ) {
458 $candidates[] = (string) $custom_field['api_name'];
459 }
460 }
461
462 foreach ( $candidates as $candidate ) {
463 $key = $this->normalize_custom_field_identifier( $candidate );
464 if ( '' !== $key && isset( $lookup[ $key ] ) ) {
465 return $lookup[ $key ];
466 }
467 }
468
469 return '';
470 }
471
472 /**
473 * Normalizes field identifiers to support case-insensitive mapping lookup.
474 *
475 * @param string $identifier Identifier from saved mapping or Zoho payload.
476 * @return string
477 */
478 private function normalize_custom_field_identifier( $identifier ) {
479 return strtolower( trim( (string) $identifier ) );
480 }
481
482 /**
483 * Determines whether a custom field value should be considered empty.
484 *
485 * @param mixed $value Incoming value from Zoho.
486 * @return bool
487 */
488 private function is_empty_custom_field_value( $value ) {
489 return null === $value || '' === $value;
490 }
491
492 /**
493 * Decodes HTML entities for string custom field values.
494 *
495 * @param mixed $value Incoming value from Zoho.
496 * @return mixed
497 */
498 private function decode_custom_field_value( $value ) {
499 if ( ! is_string( $value ) ) {
500 return $value;
501 }
502
503 return html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
504 }
505
506 /**
507 * Filters an incoming Zoho rate before it is saved as a WooCommerce regular price.
508 *
509 * @param mixed $rate Incoming Zoho rate.
510 * @param mixed $item Zoho item payload.
511 * @param int|null $product_id WooCommerce product or variation ID when known.
512 * @return mixed
513 */
514 private function get_filtered_regular_price( $rate, $item, $product_id = null ) {
515 return apply_filters( 'cmbird_zi_item_regular_price', $rate, $item, $product_id );
516 }
517
518
519 /**
520 * Function to add group items recursively by manual sync
521 *
522 * Accepts arguments via func_get_args(): page number and category ID for pagination.
523 *
524 * @return mixed
525 */
526 public function sync_groupitem_recursively() {
527 // $fd = fopen( __DIR__ . '/sync_groupitem_recursively.txt', 'a+' );
528
529 $args = func_get_args();
530 if ( ! empty( $args ) ) {
531 if ( is_array( $args ) ) {
532 if ( isset( $args['page'] ) && isset( $args['category'] ) ) {
533 $page = $args['page'];
534 $category = $args['category'];
535 } elseif ( isset( $args[0] ) && isset( $args[1] ) ) {
536 $page = $args[0];
537 $category = $args[1];
538 } elseif ( isset( $args[0] ) && ! isset( $args[1] ) ) {
539 $page = $args[0]['page'];
540 $category = $args[0]['category'];
541 } else {
542 return;
543 }
544 } else {
545 return;
546 }
547
548 // Memory management: Set memory limit if needed.
549 if ( function_exists( 'wp_raise_memory_limit' ) ) {
550 wp_raise_memory_limit( 'admin' );
551 }
552
553 global $wpdb;
554 $zoho_inventory_oid = $this->config['ProductZI']['OID'];
555 $zoho_inventory_url = $this->config['ProductZI']['APIURL'];
556 $url = $zoho_inventory_url . 'inventory/v1/itemgroups/?organization_id=' . $zoho_inventory_oid . '&category_id=' . $category . '&page=' . $page . '&per_page=20&filter_by=Status.Active';
557 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
558 $json = $execute_curl_call->execute_curl_call_get( $url );
559 $code = $json->code;
560 // $message = $json->message;
561 $response_msg = array();
562
563 if ( '0' === $code || 0 === $code ) {
564 $zi_disable_description_sync = $this->config['Settings']['disable_description'];
565 $zi_disable_name_sync = $this->config['Settings']['disable_name'];
566 // Resolve each group's WooCommerce category from its OWN Zoho category_id,
567 // not the requested $category. The itemgroups endpoint does not reliably
568 // constrain results to the requested category, so trusting $category here
569 // re-tags unrelated parent products with the wrong category.
570 $category_map = new \CommerceBird\Admin\Mappings\ZohoCategoryMap();
571 $term_id_cache = array();
572 $resolve_term = static function ( $zoho_category_id ) use ( $category_map, &$term_id_cache ) {
573 $zoho_category_id = (string) $zoho_category_id;
574 if ( '' === $zoho_category_id ) {
575 return null;
576 }
577 if ( ! array_key_exists( $zoho_category_id, $term_id_cache ) ) {
578 $term_id_cache[ $zoho_category_id ] = $category_map->term_id_for_zoho_id( $zoho_category_id );
579 }
580 return $term_id_cache[ $zoho_category_id ];
581 };
582 $processed_count = 0;
583 foreach ( $json->itemgroups as $gp_arr ) {
584 // Memory cleanup every 10 group items.
585 if ( $processed_count > 0 && $processed_count % 10 === 0 ) {
586 wp_cache_flush();
587 if ( function_exists( 'gc_collect_cycles' ) ) {
588 gc_collect_cycles();
589 }
590 }
591 $zi_group_id = $gp_arr->group_id;
592 $zi_group_name = $gp_arr->group_name;
593 // Resolve the category from this group's own Zoho category_id,
594 // falling back to the requested category only when absent.
595 $group_term_id = $resolve_term( ! empty( $gp_arr->category_id ) ? $gp_arr->category_id : $category );
596 // skip if there is no first attribute.
597 $zi_group_attribute1 = $gp_arr->attribute_id1;
598 if ( empty( $zi_group_attribute1 ) ) {
599 continue;
600 }
601
602 // Get Group ID.
603 $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 ) );
604 if ( empty( $group_id ) ) {
605 // search by zi_group_name if not found by zi_item_id.
606 $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 ) . '%' ) );
607 }
608 // array_push( $response_msg, $this->zi_response_message( 'SUCCESS', 'Zoho Group Item Synced: ' . $zi_group_name, $group_id ) );
609 // end insert group product.
610 // variable items.
611 if ( ! empty( $group_id ) ) {
612 $existing_parent_product = wc_get_product( $group_id );
613 if ( ! empty( $gp_arr->description ) && ! $zi_disable_description_sync ) {
614 $existing_parent_product->set_short_description( $gp_arr->description );
615 }
616 if ( ! empty( $gp_arr->name ) && ! $zi_disable_name_sync ) {
617 $existing_parent_product->set_name( $gp_arr->name );
618 // santize the name for slug and save the slug.
619 $slug = sanitize_title( $gp_arr->name );
620 $existing_parent_product->set_slug( $slug );
621 }
622 // add zi_category_id as meta.
623 $existing_parent_product->update_meta_data( 'zi_category_id', $gp_arr->category_id );
624 // add zi_item_id as meta.
625 $existing_parent_product->update_meta_data( 'zi_item_id', $gp_arr->group_id );
626 // set the product category.
627 if ( ! empty( $group_term_id ) ) {
628 wp_set_object_terms( $group_id, (int) $group_term_id, 'product_cat' );
629 // remove the uncategorized term from the product.
630 wp_remove_object_terms( $group_id, 'uncategorized', 'product_cat' );
631 wp_remove_object_terms( $group_id, 'uncategorised', 'product_cat' );
632 }
633 // create attributes if not exists.
634 $attributes = $existing_parent_product->get_attributes();
635 if ( empty( $attributes ) ) {
636 // Create or Update the Attributes.
637 $attr_created = $this->sync_attributes_of_group( $gp_arr, $group_id );
638 }
639 $variations_check = $existing_parent_product->get_children();
640 if ( empty( $variations_check ) ) {
641 $this->import_variable_product_variations( $gp_arr, $group_id );
642 }
643 $existing_parent_product->save();
644 // ACF Fields.
645 if ( ! empty( $gp_arr->custom_fields ) ) {
646 $this->sync_item_custom_fields( $gp_arr->custom_fields, $group_id );
647 }
648 // update Brand.
649 if ( ! empty( $gp_arr->brand ) ) {
650 // check if the Brand or Brands taxonomy exists and then update the term.
651 if ( taxonomy_exists( 'product_brand' ) ) {
652 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
653 } elseif ( taxonomy_exists( 'product_brand' ) ) {
654 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
655 }
656 }
657 } else {
658 // Create the parent variable product.
659 $parent_product = new WC_Product_Variable();
660 $parent_product->set_name( $zi_group_name );
661 $parent_product->set_status( 'publish' );
662 $parent_product->set_short_description( $gp_arr->description );
663 $parent_product->add_meta_data( 'zi_item_id', $zi_group_id );
664 $parent_product->add_meta_data( 'zi_category_id', ! empty( $gp_arr->category_id ) ? $gp_arr->category_id : $category );
665 $group_id = $parent_product->save();
666 // Sync category by finding it first.
667 if ( $group_term_id && term_exists( $group_term_id, 'product_cat' ) ) {
668 wp_set_object_terms( $group_id, (int) $group_term_id, 'product_cat' );
669 }
670 // update Brand.
671 if ( ! empty( $gp_arr->brand ) ) {
672 // check if the Brand or Brands taxonomy exists and then update the term.
673 if ( taxonomy_exists( 'product_brand' ) ) {
674 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
675 } elseif ( taxonomy_exists( 'product_brand' ) ) {
676 wp_set_object_terms( $group_id, $gp_arr->brand, 'product_brand' );
677 }
678 }
679 // ACF Fields.
680 if ( ! empty( $gp_arr->custom_fields ) ) {
681 $this->sync_item_custom_fields( $gp_arr->custom_fields, $group_id );
682 }
683 // Create or Update the Attributes.
684 $attr_created = $this->sync_attributes_of_group( $gp_arr, $group_id );
685
686 if ( ! empty( $group_id ) && $attr_created ) {
687 $this->import_variable_product_variations( $gp_arr, $group_id );
688 }
689 } // end of create variable product.
690
691 ++$processed_count;
692 } // end foreach group items.
693
694 // Final memory cleanup after processing all group items.
695 wp_cache_flush();
696 if ( function_exists( 'gc_collect_cycles' ) ) {
697 gc_collect_cycles();
698 }
699
700 // Schedule next page if available.
701 if ( isset( $json->page_context ) && $json->page_context->has_more_page ) {
702 $data = array(
703 'page' => $page + 1,
704 'category' => $category,
705 );
706 $existing_schedule = as_has_scheduled_action( 'import_group_items_cron', $data, 'commercebird' );
707 // Check if the scheduled action exists.
708 if ( ! $existing_schedule ) {
709 as_schedule_single_action( time(), 'import_group_items_cron', $data, 'commercebird' );
710 }
711 }
712
713 // Store message before unsetting json.
714 $message = isset( $json->message ) ? $json->message : '';
715 // Clear any temporary variables.
716 unset( $json, $execute_curl_call );
717 array_push( $response_msg, $this->zi_response_message( $code, $message ) );
718 }
719 // End of logging.
720 // fclose( $fd );
721 return $response_msg;
722 } else {
723 return;
724 }
725 }
726
727 /**
728 * Callback function for importing a variable product and its variations.
729 *
730 * @param object $gp_arr - Group item object.
731 * @param int $group_id - Group item id.
732 * @return void
733 */
734 public function import_variable_product_variations( $gp_arr, $group_id ): void {
735 // $fd = fopen( __DIR__ . '/import_variable_product_variations.txt', 'a+' );
736
737 if ( empty( $gp_arr ) || empty( $group_id ) ) {
738 return;
739 }
740
741 global $wpdb;
742 $product = wc_get_product( $group_id );
743
744 if ( $product ) {
745 $item_group = $gp_arr;
746 $items = $item_group->items;
747 $attribute_name1 = $item_group->attribute_name1;
748 $attribute_name2 = $item_group->attribute_name2;
749 $attribute_name3 = $item_group->attribute_name3;
750
751 // fwrite( $fd, PHP_EOL . 'Items : ' . print_r( $items, true ) );
752 // get the options for stock sync.
753 $accounting_stock = $this->config['Settings']['enable_accounting_stock'];
754 $zi_disable_stock_sync = $this->config['Settings']['disable_stock'];
755 $zi_disable_image_sync = $this->config['Settings']['disable_image'];
756
757 $to_create = array();
758 $to_update = array();
759 $update = false;
760 $batch_count = 0;
761 $max_batch_size = 100;
762 $parent_image_set = false;
763
764 foreach ( $items as $item ) {
765 // reset this array.
766 $attribute_arr = array();
767 $variation_id = '';
768 $status = $item->status === 'active' ? 'publish' : 'draft';
769
770 $zi_item_id = $item->item_id;
771 // Only consider existing records that are actual variation posts and belong to this parent.
772 $variation_id = $wpdb->get_var(
773 $wpdb->prepare(
774 "SELECT p.ID FROM $wpdb->posts p
775 JOIN $wpdb->postmeta pm ON pm.post_id = p.ID
776 WHERE pm.meta_key = 'zi_item_id' AND pm.meta_value = %s
777 AND p.post_type = 'product_variation' AND p.post_parent = %d
778 LIMIT 1",
779 $zi_item_id,
780 $group_id
781 )
782 );
783
784 if ( ! empty( $variation_id ) ) {
785 $v_product = wc_get_product( $variation_id );
786 // Check if the product object is valid.
787 if ( $v_product && is_a( $v_product, 'WC_Product' ) ) {
788 if ( $v_product->is_type( 'simple' ) ) {
789 wp_delete_post( $variation_id, true );
790 }
791 }
792 }
793 // SKU check of the variation — if SKU exists on a SIMPLE product, remove that SIMPLE product (not the variation).
794 if ( ! empty( $item->sku ) ) {
795 $sku_prod_id = wc_get_product_id_by_sku( $item->sku );
796 $v_product = wc_get_product( $sku_prod_id );
797 // Check if the product object is valid.
798 if ( $v_product && is_a( $v_product, 'WC_Product' ) ) {
799 if ( $v_product->is_type( 'simple' ) ) {
800 wp_delete_post( $sku_prod_id, true );
801 }
802 }
803 }
804 if ( ! empty( $variation_id ) ) {
805 $update = true;
806 }
807 $stock = 0;
808 if ( $accounting_stock ) {
809 $stock = $item->available_stock;
810 } else {
811 $stock = $item->actual_available_stock;
812 }
813
814 $attribute_name11 = $item->attribute_option_name1;
815 $attribute_name12 = $item->attribute_option_name2;
816 $attribute_name13 = $item->attribute_option_name3;
817 // fwrite( $fd, PHP_EOL . '>>> Item: ' . $item->item_id . ' | SKU: ' . $item->sku . ' | opt1: ' . $attribute_name11 . ' | opt2: ' . $attribute_name12 . ' | opt3: ' . $attribute_name13 );
818 // Prepare the variation data.
819 if ( ! empty( $attribute_name1 ) ) {
820 $sanitized_name1 = wc_sanitize_taxonomy_name( $attribute_name1 );
821 $attribute_arr[ $sanitized_name1 ] = $attribute_name11;
822 }
823 if ( ! empty( $attribute_name2 ) ) {
824 $sanitized_name2 = wc_sanitize_taxonomy_name( $attribute_name2 );
825 $attribute_arr[ $sanitized_name2 ] = $attribute_name12;
826 }
827 if ( ! empty( $attribute_name3 ) ) {
828 $sanitized_name3 = wc_sanitize_taxonomy_name( $attribute_name3 );
829 $attribute_arr[ $sanitized_name3 ] = $attribute_name13;
830 }
831
832 // Process and set variation attributes.
833 $variation_attributes = array();
834 foreach ( $attribute_arr as $attribute => $term_name ) {
835 $taxonomy = 'pa_' . $attribute;
836
837 // If taxonomy doesn't exist, create it.
838 if ( ! taxonomy_exists( $taxonomy ) ) {
839 register_taxonomy(
840 $taxonomy,
841 'product_variation',
842 array(
843 'hierarchical' => false,
844 'label' => ucfirst( $attribute ),
845 'query_var' => true,
846 'rewrite' => array( 'slug' => sanitize_title( $attribute ) ),
847 ),
848 );
849 }
850
851 // Check if the term exists and create if needed.
852 if ( ! term_exists( $term_name, $taxonomy ) ) {
853 wp_insert_term( $term_name, $taxonomy );
854 }
855
856 // Get the term slug.
857 $term_object = get_term_by( 'name', $term_name, $taxonomy );
858 $term_slug = $term_object ? $term_object->slug : sanitize_title( $term_name );
859
860 // Get the post terms from the parent variable product.
861 $post_term_names = wp_get_post_terms( $group_id, $taxonomy, array( 'fields' => 'names' ) );
862
863 // Set the term on the parent product if not already set.
864 if ( is_array( $post_term_names ) && ! in_array( $term_name, $post_term_names, true ) ) {
865 wp_set_post_terms( $group_id, $term_name, $taxonomy, true );
866 }
867
868 // Build variation attributes array for batch API.
869 $variation_attributes[ $taxonomy ] = array(
870 'slug' => $term_slug,
871 'name' => $term_name,
872 );
873 }
874
875 $regular_price = $this->get_filtered_regular_price( $item->rate, $item, ! empty( $variation_id ) ? (int) $variation_id : null );
876
877 // Prepare variation data for batch API.
878 $variation_data = array(
879 'type' => 'variation',
880 'status' => $status,
881 'regular_price' => (string) $regular_price,
882 'sku' => $item->sku,
883 );
884
885 // Set attributes.
886 if ( ! empty( $variation_attributes ) ) {
887 $variation_data['attributes'] = array();
888 foreach ( $variation_attributes as $taxonomy => $term_data ) {
889 $variation_data['attributes'][] = array(
890 'id' => wc_attribute_taxonomy_id_by_name( str_replace( 'pa_', '', $taxonomy ) ),
891 'option' => $term_data['name'],
892 );
893 }
894 }
895
896 // Handle stock.
897 if ( ! $zi_disable_stock_sync && $stock > 0 ) {
898 $variation_data['manage_stock'] = true;
899 $variation_data['stock_quantity'] = $stock;
900 } else {
901 $variation_data['manage_stock'] = false;
902 }
903
904 // Handle image. Check setting first, then confirm the item actually has an image in Zoho.
905 if ( ! $zi_disable_image_sync && ! empty( $item->image_document_id ) && ! empty( $item->image_name ) ) {
906 $image_class = new CMBIRD_Image_ZI();
907 $attachment_id = $image_class->cmbird_zi_get_image( $item->item_id, $item->name, $item->image_name, null, $item->image_document_id );
908 if ( $attachment_id ) {
909 $variation_data['image'] = array( 'id' => $attachment_id );
910 // Set parent product thumbnail if not already set.
911 if ( ! $parent_image_set && ! has_post_thumbnail( $group_id ) ) {
912 set_post_thumbnail( $group_id, $attachment_id );
913 $parent_image_set = true;
914 }
915 }
916 }
917
918 // Add meta data.
919 $variation_data['meta_data'] = array(
920 array(
921 'key' => 'zi_item_id',
922 'value' => $item->item_id,
923 ),
924 );
925
926 // Add purchase price if available.
927 if ( ! empty( $item->purchase_rate ) ) {
928 $variation_data['meta_data'][] = array(
929 'key' => 'cogs_total_value',
930 'value' => $item->purchase_rate,
931 );
932 }
933
934 // Add to batch array.
935 if ( ! $update ) {
936 $to_create[] = $variation_data;
937 } else {
938 $variation_data['id'] = $variation_id;
939 $to_update[] = $variation_data;
940 }
941 ++$batch_count;
942
943 // Process batch when we hit the limit.
944 if ( $batch_count >= $max_batch_size ) {
945 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import(
946 'variations',
947 array(
948 'create' => $to_create,
949 'update' => $to_update,
950 ),
951 false,
952 '/wc/v3/products/' . $group_id . '/variations/batch'
953 );
954 $to_create = array();
955 $to_update = array();
956 $batch_count = 0;
957 }
958 }
959
960 // Process remaining variations in the batch.
961 if ( ! empty( $to_create ) || ! empty( $to_update ) ) {
962 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import(
963 'variations',
964 array(
965 'create' => $to_create,
966 'update' => $to_update,
967 ),
968 false,
969 '/wc/v3/products/' . $group_id . '/variations/batch'
970 );
971
972 // Ensure WooCommerce transient/cache & attribute lookup are up to date so variations appear in admin.
973 wc_delete_product_transients( $group_id );
974 $parent_product = wc_get_product( $group_id );
975 if ( $parent_product ) {
976 $lookup_data_store = new LookupDataStore();
977 $lookup_data_store->create_data_for_product( $parent_product );
978 }
979 }
980 // End group item add process.
981 // array_push($response_msg, $this->zi_response_message('SUCCESS', 'Zoho variable item created for zoho item id ' . $zi_item_id, $variation_id));
982 // End of Logging.
983 // fclose( $fd );
984 }
985 }
986
987 /**
988 * Update or Create the Product Attributes for the Variable Item Sync
989 *
990 * @param object $gp_arr - the group item object from Zoho Inventory.
991 * @param int $group_id - the parent product id in WooCommerce.
992 * @return bool - true if attributes were created successfully, false otherwise
993 */
994 public function sync_attributes_of_group( $gp_arr, $group_id ) {
995 // $fd = fopen( __DIR__ . '/sync_attributes_of_group.txt', 'a+' );
996 // Check if the group item has attributes.
997 if ( empty( $gp_arr->attribute_name1 ) ) {
998 return false;
999 }
1000 // Create attributes.
1001 $success = true; // Track the success of attribute creation.
1002 $attributes_data = array();
1003 $attribute_count = 0;
1004
1005 // Loop through the attribute names.
1006 for ( $i = 1; $i <= 3; $i++ ) {
1007 $attribute_name_key = 'attribute_name' . $i;
1008 $attribute_option_name_key = 'attribute_option_name' . $i;
1009
1010 // Get the attribute name.
1011 $attribute_name = $gp_arr->$attribute_name_key;
1012
1013 if ( ! empty( $attribute_name ) ) {
1014 // Check if the attribute is already added to the attributes array.
1015 if ( ! isset( $attributes_data[ $attribute_name ] ) ) {
1016 // Create the attribute and add it to the attributes array.
1017 $attribute = array(
1018 'name' => $attribute_name,
1019 'position' => $attribute_count,
1020 'visible' => true,
1021 'variation' => true,
1022 'options' => array(),
1023 );
1024
1025 // Loop through the items and retrieve attribute options.
1026 $attribute_options = array();
1027 foreach ( $gp_arr->items as $item ) {
1028 $attribute_option = $item->$attribute_option_name_key;
1029 if ( ! empty( $attribute_option ) && ! in_array( $attribute_option, $attribute_options, true ) ) {
1030 $attribute_options[] = $attribute_option;
1031 }
1032 }
1033
1034 // Set the attribute options.
1035 $attribute['options'] = $attribute_options;
1036
1037 $attributes_data[] = $attribute;
1038 ++$attribute_count;
1039 }
1040 }
1041 }
1042 // fwrite( $fd, PHP_EOL . '$attributes : ' . print_r( $attributes_data, true ) );
1043
1044 // Assign the attributes to the parent product.
1045 if ( count( $attributes_data ) > 0 ) {
1046 $product_attributes = array();
1047
1048 // Loop through defined attribute data.
1049 foreach ( $attributes_data as $key => $attribute_array ) {
1050 if ( isset( $attribute_array['name'] ) && isset( $attribute_array['options'] ) ) {
1051 // Clean attribute name to get the taxonomy.
1052 $taxonomy = 'pa_' . wc_sanitize_taxonomy_name( $attribute_array['name'] );
1053 $option_term_ids = array();
1054 $existing_terms = array();
1055 // Create the attribute if it doesn't exist.
1056 if ( ! taxonomy_exists( $taxonomy ) ) {
1057 // Clean attribute label for better display.
1058 $attribute_label = ucfirst( $attribute_array['name'] );
1059
1060 // Register the new attribute taxonomy.
1061 $attribute_args = array(
1062 'slug' => $taxonomy,
1063 'name' => $attribute_label,
1064 'type' => 'select',
1065 'order_by' => 'menu_order',
1066 'has_archives' => false,
1067 );
1068
1069 $result = wc_create_attribute( $attribute_args );
1070 register_taxonomy( $taxonomy, array( 'product' ), array() );
1071
1072 if ( ! is_wp_error( $result ) ) {
1073 // fwrite($fd, PHP_EOL . 'result : ' . $result);
1074 // Loop through defined attribute data options (terms values).
1075 foreach ( $attribute_array['options'] as $option ) {
1076 // Check if the term exists for the attribute taxonomy.
1077 $term = term_exists( $option, $taxonomy );
1078 // Also check by slug in case option is slug-formatted.
1079 $term_by_slug = get_term_by( 'slug', sanitize_title( $option ), $taxonomy );
1080 if ( $term_by_slug ) {
1081 $term = array( 'term_id' => $term_by_slug->term_id );
1082 }
1083 if ( ! $term ) {
1084 // Term doesn't exist, create a new one.
1085 // Convert slug-formatted names to proper names.
1086 $term_name = str_replace( array( '-', '_' ), ' ', $option );
1087 $term_name = ucwords( $term_name );
1088 $term = wp_insert_term(
1089 $term_name,
1090 $taxonomy,
1091 array(
1092 'slug' => sanitize_title( $term_name ),
1093 )
1094 );
1095 if ( is_wp_error( $term ) ) {
1096 // fwrite( $fd, PHP_EOL . 'Error creating term: ' . $term->get_error_message() );
1097 continue;
1098 }
1099 }
1100 // Add term ID to the array for assignment to parent product.
1101 $term_id = is_array( $term ) ? $term['term_id'] : $term;
1102 $option_term_ids[] = $term_id;
1103 }
1104 } else {
1105 $success = false;
1106 // return error message.
1107 $error_string = $result->get_error_message();
1108 return $success;
1109 }
1110 } else {
1111 // Taxonomy exists, get existing terms.
1112 $existing_terms = get_terms(
1113 array(
1114 'taxonomy' => $taxonomy,
1115 'hide_empty' => false,
1116 )
1117 );
1118 }
1119
1120 if ( $existing_terms ) {
1121 foreach ( $attribute_array['options'] as $option ) {
1122 $match_found = false;
1123 foreach ( $existing_terms as $existing_term ) {
1124 // Check both name and slug to handle cases where Zoho sends slug-formatted names.
1125 if ( $existing_term->name === $option || $existing_term->slug === sanitize_title( $option ) ) {
1126 $option_term_ids[] = $existing_term->term_id;
1127 $match_found = true;
1128 break;
1129 }
1130 }
1131 // fwrite( $fd, PHP_EOL . 'Option "' . $option . '" match found: ' . ( $match_found ? 'Yes' : 'No' ) );
1132 if ( ! $match_found ) {
1133 // Check if the term exists for the attribute taxonomy.
1134 $term = get_term_by( 'slug', sanitize_title( $option ), $taxonomy );
1135 // fwrite( $fd, PHP_EOL . 'Term by slug check for option "' . $option . '": ' . print_r( $term, true ) );
1136 if ( $term ) {
1137 $term = array( 'term_id' => $term->term_id );
1138 }
1139 if ( ! $term ) {
1140 // Term doesn't exist, create it.
1141 // Convert slug-formatted names to proper names.
1142 $term_name = str_replace( array( '-', '_' ), ' ', $option );
1143 $term_name = ucwords( $term_name );
1144 $term = wp_insert_term( $term_name, $taxonomy );
1145 if ( ! is_wp_error( $term ) ) {
1146 // Get the term ID.
1147 $term_id = is_array( $term ) ? $term['term_id'] : $term;
1148 $option_term_ids[] = $term_id;
1149 } else {
1150 $success = false;
1151 }
1152 } else {
1153 // Get the existing term ID.
1154 $term_id = $term['term_id'];
1155 $option_term_ids[] = $term_id;
1156 }
1157 }
1158 }
1159 }
1160 // Set the selected terms for the product.
1161 // fwrite( $fd, PHP_EOL . '[SYNC_ATTR] Setting terms for taxonomy: ' . $taxonomy . ' | Term IDs: ' . print_r( $option_term_ids, true ) );
1162 wp_set_object_terms( $group_id, $option_term_ids, $taxonomy, false );
1163
1164 $attribute_object = new WC_Product_Attribute();
1165 $attribute_object->set_id( wc_attribute_taxonomy_id_by_name( $taxonomy ) );
1166 $attribute_object->set_name( $taxonomy );
1167 $attribute_object->set_options( $option_term_ids );
1168 $attribute_object->set_visible( $attribute_array['visible'] ?? true );
1169 $attribute_object->set_variation( $attribute_array['variation'] ?? true );
1170 $product_attributes[] = $attribute_object;
1171 }
1172 }
1173 }
1174
1175 // Save the attributes to the parent product using WooCommerce 10+ API.
1176 if ( ! empty( $product_attributes ) ) {
1177 $product = wc_get_product( $group_id );
1178 if ( $product ) {
1179 $product->set_attributes( $product_attributes );
1180 $product->save();
1181 }
1182 }
1183 // fclose($fd);
1184 return $success;
1185 }
1186
1187 /**
1188 * Update or create variation in WooCommerce if Group-ID already exists in wpdB
1189 *
1190 * @param object $item - item object coming in from simple item recursive function.
1191 * @return: void
1192 */
1193 public function sync_variation_of_group( $item ) {
1194 // $fd = fopen( __DIR__ . '/sync_variation_of_group.txt', 'a+' );
1195
1196 // log the item.
1197 // fwrite( $fd, PHP_EOL . 'Item : ' . print_r( $item, true ) );
1198
1199 global $wpdb;
1200 // Stock mode check.
1201 $zi_disable_stock_sync = $this->config['Settings']['disable_stock'];
1202 $accounting_stock = $this->config['Settings']['enable_accounting_stock'];
1203 $disable_image_sync = $this->config['Settings']['disable_image'];
1204 $is_tax_enabled = $this->is_tax_enabled;
1205
1206 if ( $accounting_stock ) {
1207 $stock = $item->available_stock;
1208 } else {
1209 $stock = $item->actual_available_stock;
1210 }
1211
1212 $item_id = $item->item_id;
1213 // $item_category = $item->category_name;
1214 $groupid = property_exists( $item, 'group_id' ) ? $item->group_id : 0;
1215 // find parent variable product.
1216 $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 ) );
1217 if ( empty( $group_id ) ) {
1218 // search by group name if not found by group id.
1219 $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 ) . '%' ) );
1220 }
1221 $stock_quantity = $stock < 0 ? 0 : $stock;
1222 // fwrite($fd, PHP_EOL . 'Before group item sync : ' . $group_id);
1223 if ( ! empty( $group_id ) ) {
1224 // fwrite( $fd, PHP_EOL . 'Inside item sync : ' . $item->name );
1225 // Brand.
1226 if ( isset( $item->brand ) && ! empty( $group_id ) ) {
1227 if ( taxonomy_exists( 'product_brand' ) ) {
1228 wp_set_object_terms( $group_id, $item->brand, 'product_brand' );
1229 }
1230 }
1231 // update the zi_item_id of parent variable product if not already set.
1232 $existing_zi_item_id = get_post_meta( $group_id, 'zi_item_id', true );
1233 if ( empty( $existing_zi_item_id ) ) {
1234 update_post_meta( $group_id, 'zi_item_id', $groupid );
1235 }
1236
1237 // Only accept a matching variation that belongs to this parent product.
1238 $variation_id = $wpdb->get_var(
1239 $wpdb->prepare(
1240 "SELECT p.ID FROM $wpdb->posts p
1241 JOIN $wpdb->postmeta pm ON pm.post_id = p.ID
1242 WHERE pm.meta_key = 'zi_item_id' AND pm.meta_value = %s
1243 AND p.post_type = 'product_variation' AND p.post_parent = %d
1244 LIMIT 1",
1245 $item_id,
1246 $group_id
1247 )
1248 );
1249 if ( ! empty( $item->sku ) && empty( $variation_id ) ) {
1250 $variation_id = wc_get_product_id_by_sku( $item->sku );
1251 }
1252 if ( $variation_id ) {
1253 // fwrite( $fd, PHP_EOL . 'Variation ID exists : ' . $variation_id );
1254 // if product type is not variation then return.
1255 $v_product = wc_get_product( $variation_id );
1256 // Check if the product object is valid.
1257 if ( $v_product && is_a( $v_product, 'WC_Product' ) ) {
1258 if ( $v_product->is_type( 'simple' ) ) {
1259 wp_delete_post( $variation_id, true );
1260 $variation_id = '';
1261 // Continue to create new variation below.
1262 }
1263 }
1264 // If variation already exists then update it.
1265 $variation = new WC_Product_Variation( $variation_id );
1266 // SKU - Imported.
1267 if ( ! empty( $item->sku ) ) {
1268 $variation->set_sku( $item->sku );
1269 }
1270 // update purchase price as meta data.
1271 if ( ! empty( $item->purchase_rate ) ) {
1272 $variation->update_meta_data( 'cogs_total_value', $item->purchase_rate );
1273 }
1274 // Price - Imported.
1275 $zi_disable_price_sync = $this->config['Settings']['disable_price'];
1276 if ( ! $zi_disable_price_sync ) {
1277 $variation->set_regular_price( $this->get_filtered_regular_price( $item->rate, $item, (int) $variation_id ) );
1278 }
1279 // Set Tax Class.
1280 if ( $item->tax_id && $is_tax_enabled ) {
1281 $zi_common_class = new CMBIRD_Common_Functions();
1282 $woo_tax_class = $zi_common_class->get_tax_class_by_zoho_id( $item->tax_id )
1283 ?? $zi_common_class->get_tax_class_by_percentage( $item->tax_percentage );
1284 $variation->set_tax_status( 'taxable' );
1285 $variation->set_tax_class( $woo_tax_class );
1286 }
1287 // Stock Imported code.
1288 if ( ! $zi_disable_stock_sync && is_numeric( $stock_quantity ) ) {
1289 // fwrite( $fd, PHP_EOL . 'Stock Quantity : ' . $stock_quantity );
1290 $variation->set_manage_stock( true );
1291 if ( $stock_quantity > 0 ) {
1292 $variation->set_manage_stock( true );
1293 $variation->set_stock_quantity( $stock_quantity );
1294 $variation->set_stock_status( 'instock' );
1295 } elseif ( $stock_quantity <= 0 ) {
1296 $variation->set_manage_stock( true );
1297 $variation->set_stock_quantity( $stock_quantity );
1298 $stock_status = $variation->backorders_allowed() ? 'onbackorder' : 'outofstock';
1299 $variation->set_stock_status( $stock_status );
1300 }
1301 }
1302 // Update variation attributes if missing or need updating.
1303 $attribute_name1 = $item->attribute_option_name1;
1304 $attribute_name2 = $item->attribute_option_name2;
1305 $attribute_name3 = $item->attribute_option_name3;
1306
1307 // Prepare the variation data.
1308 $attribute_arr = array();
1309 if ( ! empty( $attribute_name1 ) ) {
1310 $sanitized_name1 = wc_sanitize_taxonomy_name( $item->attribute_name1 );
1311 $attribute_arr[ $sanitized_name1 ] = $attribute_name1;
1312 }
1313 if ( ! empty( $attribute_name2 ) ) {
1314 $sanitized_name2 = wc_sanitize_taxonomy_name( $item->attribute_name2 );
1315 $attribute_arr[ $sanitized_name2 ] = $attribute_name2;
1316 }
1317 if ( ! empty( $attribute_name3 ) ) {
1318 $sanitized_name3 = wc_sanitize_taxonomy_name( $item->attribute_name3 );
1319 $attribute_arr[ $sanitized_name3 ] = $attribute_name3;
1320 }
1321
1322 // Update attributes for the variation.
1323 $variation_attributes = array();
1324 foreach ( $attribute_arr as $attribute => $term_name ) {
1325 $taxonomy = 'pa_' . $attribute;
1326
1327 // If taxonomy doesn't exist, create it.
1328 if ( ! taxonomy_exists( $taxonomy ) ) {
1329 register_taxonomy(
1330 $taxonomy,
1331 'product_variation',
1332 array(
1333 'hierarchical' => false,
1334 'label' => ucfirst( $attribute ),
1335 'query_var' => true,
1336 'rewrite' => array( 'slug' => sanitize_title( $attribute ) ),
1337 ),
1338 );
1339 }
1340
1341 // Check if the term exists and create if needed.
1342 if ( ! term_exists( $term_name, $taxonomy ) ) {
1343 wp_insert_term( $term_name, $taxonomy );
1344 }
1345
1346 // Get the term slug.
1347 $term_object = get_term_by( 'name', $term_name, $taxonomy );
1348 $term_slug = $term_object ? $term_object->slug : sanitize_title( $term_name );
1349
1350 // Get the post terms from the parent variable product.
1351 $post_term_names = wp_get_post_terms( $group_id, $taxonomy, array( 'fields' => 'names' ) );
1352
1353 // Set the term on the parent product if not already set.
1354 if ( is_array( $post_term_names ) && ! in_array( $term_name, $post_term_names, true ) ) {
1355 wp_set_post_terms( $group_id, $term_name, $taxonomy, true );
1356 }
1357
1358 // Build variation attributes array for set_attributes().
1359 $variation_attributes[ $taxonomy ] = $term_slug;
1360 }
1361
1362 // Set all attributes at once using WooCommerce 10+ API.
1363 if ( ! empty( $variation_attributes ) ) {
1364 $variation->set_attributes( $variation_attributes );
1365 }
1366
1367 // Featured Image of variation.
1368 if ( ! empty( $item->image_document_id ) && ! $disable_image_sync ) {
1369 $image_class = new CMBIRD_Image_ZI();
1370 $variation_image_id = $image_class->cmbird_zi_get_image( $item->item_id, $item->name, $item->image_name, $variation_id, $item->image_document_id );
1371 if ( $variation_image_id ) {
1372 // Set the image on the variation object so the save() below persists
1373 // it. cmbird_zi_get_image() already wrote _thumbnail_id directly, but
1374 // this stale object (loaded before the image existed) would otherwise
1375 // overwrite it with an empty image_id on save.
1376 $variation->set_image_id( $variation_image_id );
1377 // Fall back to using it as the parent's featured image only if it has none.
1378 if ( ! has_post_thumbnail( $group_id ) ) {
1379 set_post_thumbnail( $group_id, $variation_image_id );
1380 }
1381 }
1382 }
1383 // enable or disable based on status from Zoho.
1384 $status = ( 'active' === $item->status ) ? 'publish' : 'draft';
1385 $variation->set_status( $status );
1386 $variation->save();
1387 // clear cache.
1388 wc_delete_product_transients( $variation_id );
1389 } else {
1390 // create new variation.
1391 // if status is not active then return.
1392 if ( 'active' !== $item->status ) {
1393 return;
1394 }
1395
1396 $attribute_name1 = $item->attribute_option_name1;
1397 $attribute_name2 = $item->attribute_option_name2;
1398 $attribute_name3 = $item->attribute_option_name3;
1399 // Prepare the variation data.
1400 $attribute_arr = array();
1401 if ( ! empty( $attribute_name1 ) ) {
1402 $sanitized_name1 = wc_sanitize_taxonomy_name( $item->attribute_name1 );
1403 $attribute_arr[ $sanitized_name1 ] = $attribute_name1;
1404 }
1405 if ( ! empty( $attribute_name2 ) ) {
1406 $sanitized_name2 = wc_sanitize_taxonomy_name( $item->attribute_name2 );
1407 $attribute_arr[ $sanitized_name2 ] = $attribute_name2;
1408 }
1409 if ( ! empty( $attribute_name3 ) ) {
1410 $sanitized_name3 = wc_sanitize_taxonomy_name( $item->attribute_name3 );
1411 $attribute_arr[ $sanitized_name3 ] = $attribute_name3;
1412 }
1413 // fwrite( $fd, PHP_EOL . 'Attributes_arr: ' . print_r( $attribute_arr, true ) );
1414
1415 // here actually create new variation because sku not found.
1416 $variation = new WC_Product_Variation();
1417 $variation->set_parent_id( $group_id );
1418 $variation->set_status( 'publish' );
1419 $variation->set_regular_price( $this->get_filtered_regular_price( $item->rate, $item, null ) );
1420 $variation->set_sku( $item->sku );
1421 if ( ! $zi_disable_stock_sync ) {
1422 $variation->set_stock_quantity( $stock_quantity );
1423 $variation->set_manage_stock( true );
1424 $variation->set_stock_status( '' );
1425 } else {
1426 $variation->set_manage_stock( false );
1427 }
1428 $variation->add_meta_data( 'zi_item_id', $item->item_id );
1429 $variation_id = $variation->save();
1430
1431 // Get the variation attributes with correct attribute values.
1432 $variation_attributes = array();
1433 foreach ( $attribute_arr as $attribute => $term_name ) {
1434 $taxonomy = 'pa_' . $attribute;
1435
1436 // If taxonomy doesn't exist, create it.
1437 if ( ! taxonomy_exists( $taxonomy ) ) {
1438 register_taxonomy(
1439 $taxonomy,
1440 'product_variation',
1441 array(
1442 'hierarchical' => false,
1443 'label' => ucfirst( $attribute ),
1444 'query_var' => true,
1445 'rewrite' => array( 'slug' => sanitize_title( $attribute ) ),
1446 ),
1447 );
1448 }
1449
1450 // Check if the term exists and create if needed.
1451 if ( ! term_exists( $term_name, $taxonomy ) ) {
1452 wp_insert_term( $term_name, $taxonomy );
1453 }
1454
1455 // Get the term slug.
1456 $term_object = get_term_by( 'name', $term_name, $taxonomy );
1457 $term_slug = $term_object ? $term_object->slug : sanitize_title( $term_name );
1458
1459 // Get the post terms from the parent variable product.
1460 $post_term_names = wp_get_post_terms( $group_id, $taxonomy, array( 'fields' => 'names' ) );
1461
1462 // Set the term on the parent product if not already set.
1463 if ( is_array( $post_term_names ) && ! in_array( $term_name, $post_term_names, true ) ) {
1464 wp_set_post_terms( $group_id, $term_name, $taxonomy, true );
1465 }
1466
1467 // Build variation attributes array for set_attributes().
1468 $variation_attributes[ $taxonomy ] = $term_slug;
1469 }
1470
1471 // Set all attributes at once using WooCommerce 10+ API.
1472 if ( ! empty( $variation_attributes ) ) {
1473 $variation->set_attributes( $variation_attributes );
1474 $variation->save();
1475 }
1476
1477 // update purchase price as meta data.
1478 if ( ! empty( $item->purchase_rate ) ) {
1479 $variation->update_meta_data( 'cogs_total_value', $item->purchase_rate );
1480 }
1481 // Stock.
1482 if ( ! empty( $stock ) && ! $zi_disable_stock_sync ) {
1483 update_post_meta( $variation_id, 'manage_stock', true );
1484 if ( $stock > 0 ) {
1485 update_post_meta( $variation_id, '_stock', $stock );
1486 update_post_meta( $variation_id, '_stock_status', 'instock' );
1487 } else {
1488 $backorder_status = get_post_meta( $group_id, '_backorders', true );
1489 update_post_meta( $variation_id, '_stock', $stock );
1490 if ( 'yes' === $backorder_status ) {
1491 update_post_meta( $variation_id, '_stock_status', 'onbackorder' );
1492 } else {
1493 update_post_meta( $variation_id, '_stock_status', 'outofstock' );
1494 }
1495 }
1496 }
1497 // Featured Image of variation.
1498 if ( ! empty( $item->image_document_id ) && ! $disable_image_sync ) {
1499 $image_class = new CMBIRD_Image_ZI();
1500 $variation_image_id = $image_class->cmbird_zi_get_image( $item->item_id, $item->name, $item->image_name, $variation_id, $item->image_document_id );
1501 if ( $variation_image_id ) {
1502 // Link the image to the variation itself and persist it.
1503 $variation->set_image_id( $variation_image_id );
1504 $variation->save();
1505 // Fall back to using it as the parent's featured image only if it has none.
1506 if ( ! has_post_thumbnail( $group_id ) ) {
1507 set_post_thumbnail( $group_id, $variation_image_id );
1508 }
1509 }
1510 }
1511 update_post_meta( $variation_id, 'zi_item_id', $item_id );
1512 // WC_Product_Variable::sync( $group_id );
1513 // Regenerate lookup table for attributes.
1514 $parent_product = wc_get_product( $group_id );
1515 if ( $parent_product ) {
1516 $lookup_data_store = new LookupDataStore();
1517 $lookup_data_store->create_data_for_product( $parent_product );
1518 }
1519 // End group item add process.
1520 unset( $attribute_arr );
1521 }
1522 // end of grouped item updating.
1523 }
1524 unset( $item );
1525 }
1526
1527 /**
1528 * Helper Function to check if child of composite items already synced or not
1529 *
1530 * @param string $composite_zoho_id - zoho composite item id to check if it's child are already synced.
1531 * @param string $zi_url - zoho api url.
1532 * @param string $zi_org_id - zoho organization token.
1533 * @param string $prod_id - woocommerce product id.
1534 * @return array | bool of child id and metadata if child item already synced else will return false.
1535 */
1536 public function zi_check_if_child_synced_already( $composite_zoho_id, $zi_url, $zi_org_id, $prod_id ) {
1537 if ( $prod_id ) {
1538 $bundle_childs = WC_PB_DB::query_bundled_items(
1539 array(
1540 'return' => 'id=>product_id',
1541 'bundle_id' => array( $prod_id ),
1542 )
1543 );
1544 }
1545 global $wpdb;
1546
1547 $url = $zi_url . 'inventory/v1/compositeitems/' . $composite_zoho_id . '?organization_id=' . $zi_org_id;
1548
1549 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1550 $json = $execute_curl_call->execute_curl_call_get( $url );
1551 $code = $json->code;
1552 // Flag to allow sync of parent composite item.
1553 $allow_sync = false;
1554 // Array of child object metadata.
1555 $product_array = array(); // [{prod_id:'',metadata:{key:'',value:''}},...].
1556 if ( '0' === $code || 0 === $code ) {
1557 foreach ( $json->composite_item->mapped_items as $child_item ) {
1558 $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 ) );
1559 // If any child will not have zoho id in meta field then process will return false and syncing will be skipped for given item.
1560 if ( ! empty( $prod_meta->post_id ) ) {
1561
1562 $allow_sync = true;
1563 $product = wc_get_product( $prod_meta->post_id );
1564 $stock_status = 'out_of_stock';
1565 if ( $child_item->stock_on_hand > 0 ) {
1566 $stock_status = 'in_stock';
1567 } elseif ( $product && $product->backorders_allowed() ) {
1568 $stock_status = 'onbackorder';
1569 }
1570 $prod_obj = (object) array(
1571 'prod_id' => $prod_meta->post_id,
1572 'metadata' => (object) array(
1573 'quantity_min' => max( 1, $child_item->quantity ),
1574 'quantity_max' => max( 1, $child_item->quantity ),
1575 'stock_status' => $stock_status,
1576 'max_stock' => $child_item->stock_on_hand,
1577 ),
1578 );
1579 if ( is_array( $bundle_childs ) && ! empty( $bundle_childs ) ) {
1580 $index = array_search( $prod_meta->post_id, $bundle_childs );
1581 unset( $bundle_childs[ $index ] );
1582 }
1583 array_push( $product_array, $prod_obj );
1584 } else {
1585 continue;
1586 }
1587 }
1588 }
1589 if ( is_array( $bundle_childs ) && ! empty( $bundle_childs ) ) {
1590 foreach ( $bundle_childs as $item_id => $val ) {
1591 WC_PB_DB::delete_bundled_item( $item_id );
1592 }
1593 }
1594 if ( $allow_sync ) {
1595 return $product_array;
1596 }
1597 return false;
1598 }
1599 /**
1600 * Mapping of bundled product
1601 *
1602 * @param number $product_id - Product id of child item of bundle product.
1603 * @param number $bundle_id - Bundle id of product.
1604 * @param number $menu_order - Listing order of child product ($menu_order will useful at composite product details page).
1605 * @return number - bundle item id.
1606 */
1607 public function add_bundle_product( $product_id, $bundle_id, $menu_order = 0 ) {
1608 $bundle_items = WC_PB_DB::query_bundled_items(
1609 array(
1610 'return' => 'id=>product_id',
1611 'bundle_id' => array( $bundle_id ),
1612 'product_id' => array( $product_id ),
1613 )
1614 );
1615 $data = array(
1616 'menu_order' => $menu_order,
1617 );
1618
1619 if ( count( $bundle_items ) > 0 ) {
1620 $result = WC_PB_DB::update_bundled_item( $bundle_id, $data );
1621 return $result;
1622 } else {
1623 // create data array of bundle item.
1624 $data = array(
1625 'product_id' => $product_id,
1626 'bundle_id' => $bundle_id,
1627 'menu_order' => $menu_order,
1628 );
1629 $bundle_id = WC_PB_DB::add_bundled_item( $data );
1630 return $bundle_id;
1631 }
1632 }
1633
1634 /**
1635 * Create or update bundle item metadata
1636 *
1637 * @param number $bundle_item_id bundle item id.
1638 * @param string $meta_key - metadata key.
1639 * @param string $meta_value - metadata value.
1640 * @return float|int $result - metadata id.
1641 */
1642 public function zi_update_bundle_meta( $bundle_item_id, $meta_key, $meta_value ) {
1643 // first get metadata from db.
1644 $metadata = WC_PB_DB::get_bundled_item_meta( $bundle_item_id, $meta_key );
1645 if ( $metadata ) {
1646 $result = WC_PB_DB::update_bundled_item_meta( $bundle_item_id, $meta_key, $meta_value );
1647 } else {
1648 $result = WC_PB_DB::add_bundled_item_meta( $bundle_item_id, $meta_key, $meta_value );
1649 }
1650 return $result;
1651 }
1652
1653 /**
1654 * Function to sync composite item from zoho to woocommerce
1655 *
1656 * @param integer $page - Page number of composite item data.
1657 * @param string $category - Category id of composite data.
1658 * @return mixed - mostly array of response message.
1659 */
1660 public function recursively_sync_composite_item_from_zoho( $page, $category ) {
1661 // Start logging.
1662 // $fd = fopen( __DIR__ . '/recursively_sync_composite_item_from_zoho.txt', 'a+' );
1663
1664 global $wpdb;
1665 $zi_org_id = $this->config['ProductZI']['OID'];
1666 $zi_url = $this->config['ProductZI']['APIURL'];
1667
1668 $current_user = wp_get_current_user();
1669 $admin_author_id = $current_user->ID;
1670 if ( ! $admin_author_id ) {
1671 $admin_author_id = 1;
1672 }
1673
1674 $url = $zi_url . 'inventory/v1/compositeitems/?organization_id=' . $zi_org_id . '&filter_by=Status.Active&category_id=' . $category . '&page=' . $page;
1675
1676 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1677 $json = $execute_curl_call->execute_curl_call_get( $url );
1678 $code = $json->code;
1679 // $message = $json->message;
1680 // fwrite($fd, PHP_EOL . '$json : ' . print_r($json, true));
1681 // Response for item sync with sync button. For cron sync blank array will return.
1682 $response_msg = array();
1683 if ( '0' === $code || 0 === $code ) {
1684 if ( empty( $json->composite_items ) ) {
1685 array_push( $response_msg, $this->zi_response_message( 'ERROR', 'No composite item to sync for category : ' . $category ) );
1686 return $response_msg;
1687 }
1688 // Accounting stock mode check.
1689 $accounting_stock = $this->config['Settings']['enable_accounting_stock'];
1690 foreach ( $json->composite_items as $comp_item ) {
1691 // fwrite( $fd, PHP_EOL . 'Composite Item : ' . print_r( $comp_item, true ) );
1692 // Sync stock from specific location check.
1693 $zi_enable_locationstock = $this->config['Settings']['enable_locationstock'];
1694 $location_id = $this->config['Settings']['location_id'];
1695 $locations = $comp_item->locations;
1696
1697 if ( true === $zi_enable_locationstock ) {
1698 foreach ( $locations as $location ) {
1699 if ( $location->location_id === $location_id ) {
1700 if ( $accounting_stock ) {
1701 $stock = $location->location_available_for_sale_stock;
1702 } else {
1703 $stock = $location->location_actual_available_for_sale_stock;
1704 }
1705 }
1706 }
1707 } elseif ( $accounting_stock ) {
1708 $stock = $comp_item->available_for_sale_stock;
1709 } else {
1710 $stock = $comp_item->actual_available_for_sale_stock;
1711 }
1712
1713 // ----------------- Create composite item in woocommerce--------------.
1714 // Code to skip sync with item already exists with same sku.
1715 $prod_id = wc_get_product_id_by_sku( $comp_item->sku );
1716 // Flag to enable or disable sync.
1717 $allow_to_import = false;
1718 // Check if product exists with same sku.
1719 if ( $prod_id ) {
1720 $zi_item_id = get_post_meta( $prod_id, 'zi_item_id', true );
1721 if ( $zi_item_id === $comp_item->composite_item_id ) {
1722 // If product is with same sku and zi_item_id mapped.
1723 // Do not import ...
1724 $allow_to_import = false;
1725 } else {
1726 // Map existing item with zoho id.
1727 update_post_meta( $prod_id, 'zi_item_id', $comp_item->composite_item_id );
1728 $allow_to_import = false;
1729 }
1730 } else {
1731 // If product not exists normal bahaviour of item sync.
1732 $allow_to_import = true;
1733 }
1734 $zoho_comp_item_id = $comp_item->composite_item_id;
1735 if ( $comp_item->composite_item_id ) {
1736 $child_items = $this->zi_check_if_child_synced_already( $zoho_comp_item_id, $zi_url, $zi_org_id, $prod_id );
1737 // Check if child items already synced with zoho.
1738 if ( ! $child_items ) {
1739 array_push( $response_msg, $this->zi_response_message( 'ERROR', 'Child not synced for composite item : ' . $zoho_comp_item_id ) );
1740 continue;
1741 }
1742 $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 ) );
1743 if ( ! empty( $product_res->post_id ) ) {
1744 $com_prod_id = $product_res->post_id;
1745 }
1746 // Check if item is allowed to import or not.
1747 if ( $allow_to_import ) {
1748 $product_class = new CMBIRD_Products_ZI_Export();
1749 $item_array = json_decode( wp_json_encode( $comp_item ), true );
1750 $com_prod_id = $product_class->cmbird_zi_product_to_woocommerce( $item_array, $stock, 'composite' );
1751 update_post_meta( $com_prod_id, 'zi_item_id', $zoho_comp_item_id );
1752 }
1753 }
1754 // Map composite items to database.
1755 if ( ! empty( $com_prod_id ) ) {
1756 wp_set_object_terms( $com_prod_id, 'bundle', 'product_type' );
1757 foreach ( $child_items as $child_prod ) {
1758 // Adding product to bundle.
1759 $child_bundle_id = $this->add_bundle_product( $child_prod->prod_id, $com_prod_id );
1760 if ( $child_bundle_id ) {
1761 foreach ( $child_prod->metadata as $bundle_meta_key => $bundle_meta_val ) {
1762 $this->zi_update_bundle_meta( $child_bundle_id, $bundle_meta_key, $bundle_meta_val );
1763 }
1764 }
1765 }
1766 }
1767 // --------------------------------------------------------------------.
1768
1769 $is_synced_flag = false; // loggin purpose only .
1770
1771 $product = wc_get_product( $com_prod_id );
1772 foreach ( $comp_item as $key => $value ) {
1773 if ( 'status' === $key ) {
1774 if ( ! empty( $com_prod_id ) ) {
1775 $status = 'active' === $value ? 'publish' : 'draft';
1776 $product->set_status( $status );
1777 }
1778 }
1779 if ( 'description' === $key ) {
1780 if ( ! empty( $com_prod_id ) && ! empty( $value ) ) {
1781 $product->set_short_description( $value );
1782 }
1783 }
1784 if ( 'name' === $key ) {
1785 if ( ! empty( $com_prod_id ) ) {
1786 $product->set_name( $value );
1787 }
1788 }
1789 if ( 'sku' === $key ) {
1790 if ( ! empty( $com_prod_id ) ) {
1791 $product->set_sku( $value );
1792 }
1793 }
1794 // Check if stock sync allowed by plugin.
1795 if ( 'available_stock' === $key || 'actual_available_stock' === $key ) {
1796 $zi_disable_stock_sync = $this->config['Settings']['disable_stock'];
1797 if ( ! $zi_disable_stock_sync ) {
1798 if ( $stock ) {
1799 if ( ! empty( $com_prod_id ) ) {
1800 // If value is less than 0 default 1.
1801 $stock_quantity = $stock < 0 ? 0 : $stock;
1802 $product->set_manage_stock( true );
1803 $product->set_stock_quantity( $stock_quantity );
1804 if ( $stock_quantity > 0 ) {
1805 $status = 'instock';
1806 } else {
1807 $backorder_status = $product->backorders_allowed();
1808 $status = $backorder_status ? 'onbackorder' : 'outofstock';
1809 }
1810 $product->set_stock_status( $status );
1811 update_post_meta( $com_prod_id, '_wc_pb_bundled_items_stock_status', $status );
1812 }
1813 }
1814 }
1815 }
1816 if ( 'rate' === $key ) {
1817 $zi_disable_price_sync = $this->config['Settings']['disable_price'];
1818 if ( ! empty( $com_prod_id ) && ! $zi_disable_price_sync ) {
1819 $filtered_rate = $this->get_filtered_regular_price( $value, $comp_item, (int) $com_prod_id );
1820 $sale_price = $product->get_sale_price();
1821 if ( empty( $sale_price ) ) {
1822 $product->set_regular_price( $filtered_rate );
1823 $product->set_price( $filtered_rate );
1824 update_post_meta( $com_prod_id, '_wc_pb_base_price', $filtered_rate );
1825 update_post_meta( $com_prod_id, '_wc_pb_base_regular_price', $filtered_rate );
1826 update_post_meta( $com_prod_id, '_wc_sw_max_regular_price', $filtered_rate );
1827 } else {
1828 $product->set_regular_price( $filtered_rate );
1829 update_post_meta( $com_prod_id, '_wc_pb_base_price', $filtered_rate );
1830 update_post_meta( $com_prod_id, '_wc_pb_base_regular_price', $filtered_rate );
1831 update_post_meta( $com_prod_id, '_wc_sw_max_regular_price', $filtered_rate );
1832 }
1833 }
1834 }
1835 $product->save();
1836
1837 if ( 'image_document_id' === $key ) {
1838 if ( ! empty( $com_prod_id ) && ! empty( $value ) ) {
1839 $image_class = new CMBIRD_Image_ZI();
1840 $image_class->cmbird_zi_get_image( $zoho_comp_item_id, $comp_item->name, $comp_item->image_name, $com_prod_id, $value );
1841 }
1842 }
1843 if ( 'category_name' === $key ) {
1844 if ( ! empty( $com_prod_id ) && $comp_item->category_name != '' ) {
1845 $term = get_term_by( 'name', $comp_item->category_name, 'product_cat' );
1846 $term_id = $term->term_id;
1847 if ( empty( $term_id ) ) {
1848 $term = wp_insert_term(
1849 $comp_item->category_name,
1850 'product_cat',
1851 array(
1852 'parent' => 0,
1853 )
1854 );
1855 $term_id = $term['term_id'];
1856 }
1857 if ( $term_id ) {
1858 $existing_terms = wp_get_object_terms( $com_prod_id, 'product_cat' );
1859 if ( $existing_terms && count( $existing_terms ) > 0 ) {
1860 $is_terms_exist = $this->zi_check_terms_exists( $existing_terms, $term_id );
1861 if ( ! $is_terms_exist ) {
1862 update_post_meta( $com_prod_id, 'zi_category_id', $category );
1863 wp_add_object_terms( $com_prod_id, $term_id, 'product_cat' );
1864 }
1865 } else {
1866 update_post_meta( $com_prod_id, 'zi_category_id', $category );
1867 wp_set_object_terms( $com_prod_id, $term_id, 'product_cat' );
1868 }
1869 }
1870 // Remove "uncategorized" category if assigned.
1871 $uncategorized_term = get_term_by( 'slug', 'uncategorized', 'product_cat' );
1872 if ( $uncategorized_term && has_term( $uncategorized_term->term_id, 'product_cat', $com_prod_id ) ) {
1873 wp_remove_object_terms( $com_prod_id, $uncategorized_term->term_id, 'product_cat' );
1874 }
1875 }
1876 }
1877 }
1878
1879 // sync dimensions and weight.
1880 $item_url = "{$zi_url}inventory/v1/compositeitems/{$zoho_comp_item_id}?organization_id={$zi_org_id}";
1881 $this->zi_item_dimension_weight( $item_url, $com_prod_id, true );
1882
1883 // If item synced append to log : logging purpose only.
1884 if ( $is_synced_flag ) {
1885 array_push( $response_msg, $this->zi_response_message( 'SUCCESS', 'Composite item synced for id : ' . $comp_item->composite_item_id, $com_prod_id ) );
1886 }
1887 }
1888
1889 if ( $json->page_context->has_more_page ) {
1890 ++$page;
1891 $this->recursively_sync_composite_item_from_zoho( $page, $category );
1892 }
1893 } else {
1894 array_push( $response_msg, $this->zi_response_message( $code, $json->message ) );
1895 }
1896 // fclose( $fd ); // End of logging.
1897
1898 return $response_msg;
1899 }
1900
1901 /**
1902 * Function to retrieve item details, update weight and dimensions.
1903 *
1904 * @param string $url - URL to ge details.
1905 * @return mixed return true if data false if error.
1906 */
1907 public function zi_item_dimension_weight( $url, $product_id, $is_composite = false ) {
1908 // $fd = fopen(__DIR__ . '/zi_item_dimension_weight.txt', 'a+');
1909 // Check if item is for syncing purpose.
1910 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
1911 $json = $execute_curl_call->execute_curl_call_get( $url );
1912 $code = $json->code;
1913 $message = $json->message;
1914 if ( 0 === $code || '0' === $code ) {
1915 if ( $is_composite ) {
1916 // fwrite($fd, PHP_EOL . '$json : ' . print_r($json, true));
1917 $details = $json->composite_item->package_details;
1918 } else {
1919 $details = $json->item->package_details;
1920 }
1921 $product = wc_get_product( $product_id );
1922 $product->set_weight( floatval( $details->weight ) );
1923 $product->set_length( floatval( $details->length ) );
1924 $product->set_width( floatval( $details->width ) );
1925 $product->set_height( floatval( $details->height ) );
1926 $product->save();
1927 } else {
1928 false;
1929 }
1930 // fclose($fd);
1931 }
1932
1933 /**
1934 * Create response object based on data.
1935 *
1936 * @param mixed $index_col - Index value error message.
1937 * @param string $message - Response message.
1938 * @return object
1939 */
1940 public function zi_response_message( $index_col, $message, $woo_id = '' ) {
1941 return (object) array(
1942 'resp_id' => $index_col,
1943 'message' => $message,
1944 'woo_prod_id' => $woo_id,
1945 );
1946 }
1947
1948 /**
1949 * Helper Function to check if terms already exists.
1950 */
1951 public function zi_check_terms_exists( $existing_terms, $term_id ) {
1952 foreach ( $existing_terms as $woo_existing_term ) {
1953 if ( $woo_existing_term->term_id === $term_id ) {
1954 return true;
1955 } else {
1956 return false;
1957 }
1958 }
1959 }
1960
1961 /**
1962 * Refactored batch sync method using WooCommerce REST API batch endpoint.
1963 *
1964 * Fetches items from Zoho API, optionally fetches itemdetails for location stock,
1965 * and processes items in batches through the WooCommerce REST API.
1966 *
1967 * @param array $args Array containing 'page' and 'category' parameters.
1968 * @return array|bool Success or failure status.
1969 */
1970 public function sync_items_batch( $args ) {
1971 // $fd = fopen( __DIR__ . '/sync_items_batch.txt', 'a+' );
1972 $args = func_get_args();
1973 $config = $this->config['Settings'];
1974 if ( is_array( $args ) ) {
1975 if ( isset( $args['page'] ) && isset( $args['category'] ) ) {
1976 $page = $args['page'];
1977 $category = $args['category'];
1978 } elseif ( isset( $args[0] ) && isset( $args[1] ) ) {
1979 $page = $args[0];
1980 $category = $args[1];
1981 } elseif ( isset( $args[0] ) && ! isset( $args[1] ) ) {
1982 $page = $args[0]['page'];
1983 $category = $args[0]['category'];
1984 } else {
1985 return;
1986 }
1987 } else {
1988 return;
1989 }
1990
1991 // Fetch items from Zoho items endpoint.
1992 $zoho_inventory_oid = $this->config['ProductZI']['OID'];
1993 $zoho_inventory_url = $this->config['ProductZI']['APIURL'];
1994 $timeout = ini_get( 'max_execution_time' ) ? ini_get( 'max_execution_time' ) : 60;
1995
1996 // Dynamically set per_page based on timeout.
1997 if ( $timeout <= 30 ) {
1998 $per_page = 20;
1999 } elseif ( $timeout <= 60 ) {
2000 $per_page = 50;
2001 } else {
2002 $per_page = 100;
2003 }
2004
2005 $url = sprintf(
2006 '%sinventory/v1/items?organization_id=%s&category_id=%s&page=%d&per_page=%d&sort_column=last_modified_time',
2007 $zoho_inventory_url,
2008 $zoho_inventory_oid,
2009 $category,
2010 $page,
2011 $per_page
2012 );
2013 $execute_curl_call = new CMBIRD_API_Handler_Zoho();
2014 $json = $execute_curl_call->execute_curl_call_get( $url );
2015 $code = (int) property_exists( $json, 'code' ) ? $json->code : '0';
2016
2017 if ( 0 !== $code && '0' !== $code ) {
2018 // Log the error in WC logger.
2019 $logger = wc_get_logger();
2020 $logger->error( sprintf( 'Zoho API error during item sync: Code: %s, Message: %s', $code, $json->message ) );
2021 return false;
2022 }
2023
2024 if ( ! isset( $json->items ) || empty( $json->items ) ) {
2025 return false;
2026 }
2027
2028 // Separate items based on location stock requirement.
2029 $items_ready = array();
2030 $item_ids = array();
2031 $item_summaries_by_id = array();
2032 $items_without_attributes = array();
2033
2034 foreach ( $json->items as $item ) {
2035 if ( ! empty( $item->item_id ) ) {
2036 $item_summaries_by_id[ (string) $item->item_id ] = $item;
2037 }
2038
2039 // Skip combo products.
2040 if ( isset( $item->is_combo_product ) && $item->is_combo_product ) {
2041 continue;
2042 }
2043
2044 // Skip variations/groups.
2045 if ( isset( $item->group_id ) && ! empty( $item->group_id ) ) {
2046 if ( ! empty( $item->attribute_name1 ) ) {
2047 $this->sync_variation_of_group( $item );
2048 continue;
2049 } else {
2050 $items_without_attributes[] = $item;
2051 }
2052 }
2053
2054 // Collect item IDs if location stock is enabled.
2055 if ( $config['enable_location_stock'] ) {
2056 $item_ids[] = $item->item_id;
2057 } else {
2058 $items_ready[] = $item;
2059 }
2060 }
2061
2062 // Notify admin of group items that have no attributes and will be synced as simple items.
2063 if ( ! empty( $items_without_attributes ) ) {
2064 $rows = '';
2065 foreach ( $items_without_attributes as $no_attr_item ) {
2066 $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>';
2067 }
2068 $message = '<p>The following Group Items have no attributes set in Zoho Inventory and will be synced as simple items:</p>'
2069 . '<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>'
2070 . $rows
2071 . '</tbody></table>';
2072 $common = new CMBIRD_Common_Functions();
2073 $common->send_email( 'CommerceBird: Group Items Without Attributes', $message );
2074 }
2075
2076 // Fetch itemdetails for location stock if needed.
2077 if ( ! empty( $item_ids ) ) {
2078 $item_details_url_base = "{$zoho_inventory_url}inventory/v1/itemdetails?organization_id={$zoho_inventory_oid}&item_ids=";
2079 $item_id_str = implode( ',', $item_ids );
2080 $item_details_url = $item_details_url_base . $item_id_str;
2081 $json_details = $execute_curl_call->execute_curl_call_get( $item_details_url );
2082 $details_code = (int) property_exists( $json_details, 'code' ) ? $json_details->code : '0';
2083
2084 if ( 0 === $details_code || '0' === $details_code ) {
2085 if ( isset( $json_details->items ) && is_array( $json_details->items ) ) {
2086 foreach ( $json_details->items as $item_detail ) {
2087 // if ( ! empty( $item_detail->item_id ) ) {
2088 // $item_detail = $this->merge_item_summary_fields(
2089 // $item_detail,
2090 // $item_summaries_by_id[ (int) $item_detail->item_id ] ?? null
2091 // );
2092 // }
2093 $items_ready[] = $item_detail;
2094 }
2095 // log items ready.
2096 // fwrite( $fd, PHP_EOL . 'Items ready for batch processing: ' . print_r( $items_ready, true ) );
2097 }
2098 }
2099 }
2100
2101 // Process items through batch endpoint.
2102 if ( ! empty( $items_ready ) ) {
2103 $this->process_items_batch( $items_ready );
2104 }
2105
2106 // Handle pagination.
2107 if ( isset( $json->page_context->has_more_page ) && $json->page_context->has_more_page && $page < 1000 ) {
2108 as_schedule_single_action(
2109 time() + 5,
2110 'import_simple_items_cron',
2111 array(
2112 'page' => $page + 1,
2113 'category' => $category,
2114 ),
2115 'commercebird'
2116 );
2117 }
2118
2119 return array(
2120 'success' => true,
2121 'message' => 'Batch processed successfully',
2122 );
2123 }
2124
2125 /**
2126 * Preserves summary fields from the items endpoint when itemdetails omits them.
2127 *
2128 * @param object $item_detail Detailed item payload.
2129 * @param object|null $item_summary Summary item payload from the items endpoint.
2130 * @return object
2131 */
2132 private function merge_item_summary_fields( $item_detail, $item_summary ) {
2133 if ( ! is_object( $item_detail ) || ! is_object( $item_summary ) ) {
2134 return $item_detail;
2135 }
2136
2137 $fields_to_preserve = array(
2138 'category_id',
2139 'category_name',
2140 'image_document_id',
2141 'image_name',
2142 'brand',
2143 'group_id',
2144 'group_name',
2145 'attribute_id1',
2146 'attribute_id2',
2147 'attribute_id3',
2148 'attribute_name1',
2149 'attribute_name2',
2150 'attribute_name3',
2151 'attribute_option_id1',
2152 'attribute_option_id2',
2153 'attribute_option_id3',
2154 'attribute_option_name1',
2155 'attribute_option_name2',
2156 'attribute_option_name3',
2157 );
2158
2159 foreach ( $fields_to_preserve as $field_name ) {
2160 if (
2161 ( ! isset( $item_detail->$field_name ) || '' === $item_detail->$field_name )
2162 && isset( $item_summary->$field_name )
2163 && '' !== $item_summary->$field_name
2164 ) {
2165 $item_detail->$field_name = $item_summary->$field_name;
2166 }
2167 }
2168
2169 return $item_detail;
2170 }
2171
2172 /**
2173 * Process items batch for create/update via WooCommerce REST API.
2174 *
2175 * Handles product matching (by SKU or zi_item_id), duplicate removal,
2176 * and builds create/update arrays for batch endpoint.
2177 *
2178 * @param array $items Array of Zoho items to process.
2179 * @return void
2180 */
2181 private function process_items_batch( $items ) {
2182 // $fd = fopen( __DIR__ . '/process_items_batch.txt', 'a+' );
2183 global $wpdb;
2184
2185 $to_create = array();
2186 $to_update = array();
2187
2188 foreach ( $items as $item ) {
2189 // 1. Try to find product by SKU.
2190 $product_id = ( isset( $item->sku ) && ! empty( $item->sku ) )
2191 ? wc_get_product_id_by_sku( $item->sku )
2192 : 0;
2193
2194 // 2. If product exists by SKU, check if mapped to Zoho.
2195 if ( $product_id ) {
2196 $zi_item_id = get_post_meta( $product_id, 'zi_item_id', true );
2197 if ( empty( $zi_item_id ) ) {
2198 // Map existing product with Zoho item ID.
2199 update_post_meta( $product_id, 'zi_item_id', $item->item_id );
2200 }
2201 }
2202
2203 // 3. Remove duplicates based on SKU.
2204 if ( $product_id && ! empty( $item->sku ) ) {
2205 $duplicate_product = $wpdb->get_row(
2206 $wpdb->prepare(
2207 "SELECT post_id FROM {$wpdb->prefix}postmeta WHERE meta_key = '_sku' AND meta_value = %s AND post_id != %d LIMIT 1",
2208 $item->sku,
2209 $product_id
2210 )
2211 );
2212 if ( $duplicate_product ) {
2213 wp_delete_post( $duplicate_product->post_id, true );
2214 $product_id = 0;
2215 }
2216 }
2217
2218 // 4. If no product by SKU, try to find by zi_item_id.
2219 if ( empty( $product_id ) ) {
2220 $existing = $wpdb->get_row(
2221 $wpdb->prepare(
2222 "SELECT post_id FROM {$wpdb->prefix}postmeta WHERE meta_key = 'zi_item_id' AND meta_value = %s LIMIT 1",
2223 $item->item_id
2224 )
2225 );
2226 $product_id = $existing ? $existing->post_id : 0;
2227 }
2228
2229 // 5. Create or update based on what we found.
2230 if ( empty( $product_id ) && isset( $item->status ) && 'active' === $item->status ) {
2231 // Create new product.
2232 $to_create[] = $this->map_item_to_array( $item, null );
2233 } elseif ( ! empty( $product_id ) ) {
2234 // Update existing product.
2235 $to_update[] = $this->map_item_to_array( $item, $product_id );
2236 }
2237 }
2238
2239 // Use batch endpoint.
2240 if ( ! empty( $to_create ) ) {
2241 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import( 'product', $to_create, false, '/wc/v3/products/batch' );
2242 }
2243
2244 if ( ! empty( $to_update ) ) {
2245 \CommerceBird\Admin\Actions\Sync\ZohoInventorySync::import( 'product', $to_update, true, '/wc/v3/products/batch' );
2246 }
2247 }
2248
2249 /**
2250 * Map Zoho item to WooCommerce batch endpoint format.
2251 *
2252 * Transforms Zoho item data into WooCommerce REST API product format
2253 * for batch create/update operations.
2254 *
2255 * @param object $item Zoho item object.
2256 * @param int $product_id WooCommerce product ID (null for create).
2257 * @return array WooCommerce product data for batch endpoint.
2258 */
2259 private function map_item_to_array( $item, $product_id = null ) {
2260 $config = $this->config['Settings'];
2261 $rate = isset( $item->rate )
2262 ? $this->get_filtered_regular_price( $item->rate, $item, $product_id )
2263 : null;
2264 $data = array(
2265 'name' => isset( $item->name ) ? $item->name : '',
2266 'sku' => isset( $item->sku ) ? $item->sku : '',
2267 'status' => ( isset( $item->status ) && 'active' === $item->status ) ? 'publish' : 'draft',
2268 );
2269
2270 // Add product ID for updates.
2271 if ( $product_id ) {
2272 $data['id'] = $product_id;
2273 // Remove "uncategorized" category if assigned.
2274 $uncategorized_term = get_term_by( 'slug', 'uncategorized', 'product_cat' );
2275 if ( $uncategorized_term && has_term( $uncategorized_term->term_id, 'product_cat', $product_id ) ) {
2276 wp_remove_object_terms( $product_id, $uncategorized_term->term_id, 'product_cat' );
2277 }
2278 }
2279
2280 // Add rate if price sync is not disabled.
2281 if ( ! $config['disable_price'] && null !== $rate ) {
2282 $data['regular_price'] = (string) $rate;
2283 }
2284
2285 // Add description if not disabled.
2286 if ( ! $config['disable_description'] && isset( $item->description ) ) {
2287 $data['short_description'] = $item->description;
2288 }
2289
2290 // Add images if not disabled.
2291 if ( ! $config['disable_image'] && isset( $item->image_document_id ) && ! empty( $item->image_document_id ) ) {
2292 $image_class = new CMBIRD_Image_ZI();
2293 $attachment_id = $image_class->cmbird_zi_get_image(
2294 $item->item_id,
2295 isset( $item->name ) ? $item->name : '',
2296 isset( $item->image_name ) ? $item->image_name : '',
2297 $product_id,
2298 $item->image_document_id
2299 );
2300
2301 // Add image to batch data.
2302 if ( $attachment_id ) {
2303 $data['images'] = array(
2304 array( 'id' => $attachment_id ),
2305 );
2306
2307 // Any other document attached to the item (attachment_order 1 is the
2308 // featured image handled above) becomes a gallery image. The WooCommerce
2309 // REST API treats the first `images` entry as the featured image and
2310 // every entry after it as the gallery, so order matters here.
2311 if ( isset( $item->documents ) && is_array( $item->documents ) && count( $item->documents ) > 1 ) {
2312 $gallery_documents = array_filter(
2313 $item->documents,
2314 static function ( $document ) {
2315 return isset( $document->document_id )
2316 && ( ! isset( $document->attachment_order ) || 1 !== (int) $document->attachment_order );
2317 }
2318 );
2319 usort(
2320 $gallery_documents,
2321 static function ( $a, $b ) {
2322 $order_a = isset( $a->attachment_order ) ? (int) $a->attachment_order : 0;
2323 $order_b = isset( $b->attachment_order ) ? (int) $b->attachment_order : 0;
2324 return $order_a <=> $order_b;
2325 }
2326 );
2327
2328 foreach ( $gallery_documents as $document ) {
2329 // Passing a null post id makes cmbird_zi_get_image() import/dedupe the
2330 // image without calling set_post_thumbnail(), so it doesn't overwrite
2331 // the featured image just set above; passing the document's own id
2332 // (rather than the item's default image endpoint) is what actually
2333 // selects this specific gallery image instead of the featured one.
2334 $gallery_attachment_id = $image_class->cmbird_zi_get_image(
2335 $item->item_id,
2336 isset( $item->name ) ? $item->name : '',
2337 isset( $document->file_name ) ? $document->file_name : '',
2338 null,
2339 $document->document_id
2340 );
2341 if ( $gallery_attachment_id ) {
2342 $data['images'][] = array( 'id' => $gallery_attachment_id );
2343 } else {
2344 wc_get_logger()->error(
2345 sprintf(
2346 'CommerceBird: gallery image import failed for item %1$s, document %2$s (%3$s).',
2347 isset( $item->item_id ) ? $item->item_id : '',
2348 isset( $document->document_id ) ? $document->document_id : '',
2349 isset( $document->file_name ) ? $document->file_name : ''
2350 ),
2351 array( 'source' => 'commercebird' )
2352 );
2353 }
2354 }
2355 }
2356 }
2357 }
2358
2359 // Check if Tax is enabled and set tax status.
2360 if ( ! empty( $item->tax_id ) && $this->is_tax_enabled ) {
2361 $zi_common_class = new CMBIRD_Common_Functions();
2362 $woo_tax_class = $zi_common_class->get_tax_class_by_zoho_id( $item->tax_id )
2363 ?? $zi_common_class->get_tax_class_by_percentage( $item->tax_percentage );
2364 $data['tax_class'] = $woo_tax_class;
2365 $data['tax_status'] = 'taxable';
2366 }
2367
2368 // Handle stock.
2369 if ( ! $config['disable_stock'] ) {
2370 $stock = null;
2371
2372 // Use location stock if available (from itemdetails).
2373 if ( isset( $item->locations ) && is_array( $item->locations ) ) {
2374 foreach ( $item->locations as $location ) {
2375 if ( $location->location_id === $config['zoho_location_id'] ) {
2376 $stock = $config['enable_accounting_stock']
2377 ? $location->location_available_for_sale_stock
2378 : $location->location_actual_available_for_sale_stock;
2379 break;
2380 }
2381 }
2382 } else {
2383 // Use global stock from items endpoint.
2384 $stock = $config['enable_accounting_stock']
2385 ? ( isset( $item->available_stock ) ? $item->available_stock : null )
2386 : ( isset( $item->actual_available_stock ) ? $item->actual_available_stock : null );
2387 }
2388 if ( null !== $stock ) {
2389 $data['manage_stock'] = true;
2390 $data['stock_quantity'] = $stock;
2391 }
2392 }
2393 // Add cost_of_goods_sold if available.
2394 if ( isset( $item->purchase_rate ) ) {
2395 $data['cost_of_goods_sold'] = $item->purchase_rate;
2396 }
2397
2398 // Add dimensions directly from items endpoint.
2399 if ( isset( $item->weight ) ) {
2400 $data['weight'] = $item->weight;
2401 }
2402 if ( isset( $item->length ) ) {
2403 $data['length'] = $item->length;
2404 }
2405 if ( isset( $item->width ) ) {
2406 $data['width'] = $item->width;
2407 }
2408 if ( isset( $item->height ) ) {
2409 $data['height'] = $item->height;
2410 }
2411
2412 // Add categories, preferring a manual/category map match by Zoho category ID.
2413 $category_term_id = null;
2414 if ( isset( $item->category_id ) && '' !== $item->category_id ) {
2415 $category_term_id = ( new \CommerceBird\Admin\Mappings\ZohoCategoryMap() )->term_id_for_zoho_id( (string) $item->category_id );
2416 }
2417 if ( null !== $category_term_id ) {
2418 $data['categories'] = array( array( 'id' => $category_term_id ) );
2419 } elseif ( isset( $item->category_name ) && ! empty( $item->category_name ) ) {
2420 $categories = self::prepare_categories( $item->category_name );
2421 if ( ! empty( $categories ) ) {
2422 $data['categories'] = $categories;
2423 }
2424 }
2425
2426 // Add brand as category if present.
2427 if ( isset( $item->brand ) && ! empty( $item->brand ) ) {
2428 $brand_terms = self::prepare_categories( $item->brand, 'product_brand' );
2429 if ( ! empty( $brand_terms ) ) {
2430 $data['brands'] = $brand_terms;
2431 }
2432 }
2433
2434 // Add meta data.
2435 $data['meta_data'] = array(
2436 array(
2437 'key' => 'zi_item_id',
2438 'value' => isset( $item->item_id ) ? $item->item_id : '',
2439 ),
2440 array(
2441 'key' => 'zi_category_id',
2442 'value' => isset( $item->category_id ) ? $item->category_id : '',
2443 ),
2444 );
2445
2446 // Add custom fields from items endpoint (flattened format).
2447 foreach ( $item as $key => $value ) {
2448 if ( 0 === strpos( $key, 'cf_' ) && false === strpos( $key, '_unformatted' ) ) {
2449 $data['meta_data'][] = array(
2450 'key' => 'zi_' . $key,
2451 'value' => $value,
2452 );
2453 }
2454 }
2455
2456 return $data;
2457 }
2458
2459 /**
2460 * Prepare category data for WooCommerce batch endpoint.
2461 *
2462 * Creates or retrieves category term and returns in batch format.
2463 *
2464 * @param string $category_name Category name from Zoho.
2465 * @param string $taxonomy Taxonomy to use, default is 'product_cat'.
2466 * @return array Category array for WooCommerce batch endpoint.
2467 */
2468 private static function prepare_categories( $category_name, $taxonomy = 'product_cat' ) {
2469 if ( empty( $category_name ) ) {
2470 return array();
2471 }
2472
2473 $category_name = trim( (string) $category_name );
2474 $term_id = 0;
2475
2476 $term = get_term_by( 'name', $category_name, $taxonomy );
2477 if ( $term instanceof WP_Term ) {
2478 $term_id = (int) $term->term_id;
2479 }
2480
2481 if ( ! $term_id ) {
2482 $term = get_term_by( 'slug', sanitize_title( $category_name ), $taxonomy );
2483 if ( $term instanceof WP_Term ) {
2484 $term_id = (int) $term->term_id;
2485 }
2486 }
2487
2488 if ( ! $term_id ) {
2489 $term_exists = term_exists( $category_name, $taxonomy );
2490 if ( is_array( $term_exists ) && isset( $term_exists['term_id'] ) ) {
2491 $term_id = (int) $term_exists['term_id'];
2492 } elseif ( is_int( $term_exists ) ) {
2493 $term_id = $term_exists;
2494 }
2495 }
2496
2497 if ( ! $term_id ) {
2498 $term = wp_insert_term( $category_name, $taxonomy, array( 'parent' => 0 ) );
2499
2500 if ( is_wp_error( $term ) ) {
2501 if ( 'term_exists' === $term->get_error_code() ) {
2502 $term_id = (int) $term->get_error_data();
2503 }
2504 } else {
2505 $term_id = isset( $term['term_id'] ) ? (int) $term['term_id'] : 0;
2506 }
2507 }
2508
2509 return $term_id ? array( array( 'id' => $term_id ) ) : array();
2510 }
2511
2512 /**
2513 * Get sync configuration from class config or WordPress options.
2514 *
2515 * Merges class configuration with WordPress option settings.
2516 *
2517 * @return array Configuration array.
2518 */
2519 }
2520 $cmbird_products_zi = new CMBIRD_Products_ZI();
2521