PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.9.0
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.9.0
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 / admin / includes / Actions / Sync / ExactOnlineSync.php
commercebird / admin / includes / Actions / Sync Last commit date
ExactOnlineSync.php 3 months ago ZohoCRMSync.php 5 months ago ZohoInventorySync.php 2 months ago index.php 1 year ago
ExactOnlineSync.php
650 lines
1 <?php
2
3 namespace CommerceBird\Admin\Actions\Sync;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 use CommerceBird\Admin\Actions\Ajax\ExactOnlineAjax;
10 use CommerceBird\Admin\Connectors\Connector;
11
12 /**
13 * Handles synchronization of products, customers, and orders between WooCommerce and Exact Online.
14 */
15 class ExactOnlineSync {
16
17 /**
18 * Whether to enable stock synchronization.
19 *
20 * @var bool Whether to enable stock synchronization.
21 * @since 2.6.5
22 */
23 public static bool $enable_eo_stock;
24
25 /**
26 * Construct to load options from wp_options
27 */
28 public function __construct() {
29 // Load any required options or settings here if needed.
30 self::$enable_eo_stock = get_option( 'cmbird_enable_eo_stock', false );
31 }
32
33 /**
34 * Sync data from Exact Online.
35 *
36 * @param string $type product|customer.
37 * @param array $data to sync from Exact Online.
38 * @param bool $import import or update.
39 * @return mixed
40 */
41 public function sync( string $type, array $data, bool $import = false ) {
42 if ( empty( $type ) ) {
43 return false;
44 }
45 if ( $import ) {
46 $this->import( $type, $data );
47 } else {
48 $this->update( $type, $data );
49 }
50 }
51 /**
52 * Import data from Exact Online.
53 *
54 * For product data will be like,
55 * {
56 * "Code":string,
57 * "Description": string,
58 * "ID": string,
59 * "IsSalesItem": bool,
60 * "PictureName": null|string,
61 * "PictureUrl": string,
62 * "StandardSalesPrice": float,
63 * "Stock": int
64 * },
65 *
66 * for Order data will be like,
67 * {
68 * "MainContact": null|string,
69 * "Email": null|string,
70 * "ID": string,
71 * "Name": string,
72 * "AddressLine1": null|string,
73 * "AddressLine2": null|string,
74 * "City": string,
75 * "Country": string,
76 * "Phone": null|string,
77 * "Postcode": string
78 * }
79 *
80 * @param string $type of provided data.
81 * @param array $data of import.
82 * @return mixed
83 */
84 public static function import( string $type, array $data ) {
85 // add logging.
86 // $fd = fopen( __DIR__ . '/import.txt', 'a+' );
87 $endpoint = '';
88 $payload = array();
89
90 switch ( $type ) {
91 case 'product':
92 $endpoint = '/wc/v3/products/batch';
93 $filtered_data = array_filter(
94 $data,
95 function ( $item ) {
96 // Exclude item if product already exists via SKU.
97 return ! wc_get_product_id_by_sku( $item['Code'] );
98 }
99 );
100 $payload = array(
101 'create' => array_map(
102 function ( $item ) {
103 // Skip image import if PictureName is 'placeholder_item'.
104 $images = array();
105 if ( isset( $item['PictureName'] ) && 'placeholder_item' !== $item['PictureName'] ) {
106 $image_id = self::get_existing_image_id( $item['PictureName'] );
107 // If image exists, add it to the images array, else upload the image and add it to the images array.
108 if ( $image_id ) {
109 $images[] = array( 'id' => $image_id );
110 } else {
111 $image_id = self::upload_image( $item['PictureUrl'], $item['PictureName'] );
112 // If image upload is successful, add it to the images array.
113 if ( $image_id ) {
114 $images[] = array( 'id' => $image_id );
115 }
116 }
117 }
118 // Check if category exists with $item['ItemGroupDescription'], else create it and get the ID.
119 if ( isset( $item['ItemGroupDescription'] ) ) {
120 $term = get_term_by( 'name', $item['ItemGroupDescription'], 'product_cat' );
121 $term_id = $term->term_id;
122 if ( empty( $term_id ) ) {
123 $term = wp_insert_term(
124 $item['ItemGroupDescription'],
125 'product_cat',
126 array(
127 'parent' => 0,
128 )
129 );
130 $term_id = $term['term_id'];
131 }
132 } else {
133 $term_id = 0;
134 }
135 // if stock exists then set manage_stock to true && if stock is enabled in settings.
136 $manage_stock = ( isset( $item['Stock'] ) && self::$enable_eo_stock ) ? true : false;
137 $item_stock = isset( $item['Stock'] ) ? $item['Stock'] : null;
138 return array(
139 'name' => $item['Description'],
140 'sku' => $item['Code'],
141 'status' => 'publish',
142 'type' => 'simple',
143 'regular_price' => (string) $item['StandardSalesPrice'],
144 'images' => $images,
145 'stock_quantity' => $item_stock,
146 'manage_stock' => $manage_stock,
147 'categories' => array(
148 array(
149 'id' => $term_id,
150 ),
151 ),
152 'meta_data' => array(
153 array(
154 'key' => 'eo_item_id',
155 'value' => $item['ID'],
156 ),
157 array(
158 'key' => '_cogs_total_value',
159 'value' => $item['CostPriceStandard'],
160 ),
161 array(
162 'key' => 'eo_unit',
163 'value' => $item['Unit'],
164 ),
165 ),
166 );
167 },
168 $filtered_data
169 ),
170 );
171 break;
172
173 case 'customer':
174 $endpoint = '/wc/v3/customers/batch';
175 $filtered_data = array_filter(
176 $data,
177 function ( $item ) {
178 // Exclude item if customer already exists via email.
179 return ! get_user_by( 'email', $item['Email'] );
180 }
181 );
182 $payload = array(
183 'create' => array_map(
184 function ( $item ) {
185 if ( empty( $item['Email'] ) ) {
186 return null;
187 }
188 if ( ! empty( $item['VATNumber'] ) ) {
189 $first_name = '';
190 $last_name = '';
191 $company = $item['Name'];
192 } else {
193 $names = explode( ' ', $item['Name'] );
194 $first_name = array_shift( $names ); // Take the first word as first name.
195 $last_name = implode( ' ', $names ); // Join the rest as last name.
196 }
197 // generate username based on email.
198 $username = explode( '@', $item['Email'] )[0];
199 // add random number to username if it already exists.
200 $existing_user = get_user_by( 'login', $username );
201 if ( $existing_user ) {
202 $username .= '_' . wp_rand( 1000, 9999 );
203 }
204 $address = array(
205 'first_name' => $first_name,
206 'last_name' => $last_name,
207 'company' => $company ?? '',
208 'address_1' => $item['AddressLine1'] ?? '',
209 'address_2' => $item['AddressLine2'] ?? '',
210 'city' => $item['City'] ?? '',
211 'country' => $item['Country'] ?? '',
212 'postcode' => $item['Postcode'] ?? '',
213 'phone' => $item['Phone'] ?? '',
214 'email' => $item['Email'],
215 );
216 return array(
217 'email' => $item['Email'],
218 'first_name' => $first_name,
219 'last_name' => $last_name,
220 'username' => $username,
221 'billing' => $address,
222 'shipping' => $address,
223 'meta_data' => array(
224 array(
225 'key' => 'eo_account_id',
226 'value' => $item['ID'],
227 ),
228 array(
229 'key' => 'eo_contact_id',
230 'value' => $item['MainContact'] ?? '',
231 ),
232 ),
233 );
234 },
235 array_filter( $filtered_data, fn( $item ) => ! empty( $item['Email'] ) )
236 ),
237 );
238 break;
239
240 default:
241 return false;
242 }
243
244 if ( empty( $payload['create'] ) ) {
245 return false;
246 }
247 // log the payload.
248 // fwrite( $fd, print_r( $payload, true ) );
249 add_filter( 'woocommerce_rest_check_permissions', '__return_true' );
250 $request = new \WP_REST_Request( 'POST', $endpoint );
251 $request->set_body_params( $payload );
252 $response = rest_do_request( $request );
253 remove_filter( 'woocommerce_rest_check_permissions', '__return_true' );
254 // log the response.
255 // fwrite( $fd, print_r( $response, true ) );
256 // fclose( stream: $fd );
257 return $response;
258 }
259
260 /**
261 * Update data based on Exact Online.
262 *
263 * @param string $type of provided data.
264 * @param array $data to update in Exact Online.
265 * @return mixed
266 */
267 public function update( string $type, array $data ) {
268 // $fd = fopen( __DIR__ . '/update.txt', 'w+' );
269 $endpoint = '';
270 $payload = array();
271 // log data.
272 // fwrite( $fd, print_r( $data, true ) );
273
274 switch ( $type ) {
275 case 'product':
276 // Track all SKUs from Exact Online for this batch.
277 $exact_skus = array_column( $data, 'Code' );
278 $existing_synced_skus = get_option( 'cmbird_eo_synced_skus', array() );
279 if ( ! is_array( $existing_synced_skus ) ) {
280 $existing_synced_skus = array();
281 }
282 $existing_synced_skus = array_merge( $existing_synced_skus, $exact_skus );
283 update_option( 'cmbird_eo_synced_skus', array_values( array_unique( $existing_synced_skus ) ), false );
284
285 $filtered_data = array_filter(
286 $data,
287 function ( $item ) {
288 // Exclude item if product does not exists via SKU.
289 return wc_get_product_id_by_sku( $item['Code'] );
290 }
291 );
292 $endpoint = '/wc/v3/products/batch';
293 $payload = array(
294 'update' => array_map(
295 function ( $item ) {
296 // Check if product exists via SKU, else get the product ID by title.
297 $product_id = wc_get_product_id_by_sku( $item['Code'] ) ? wc_get_product_id_by_sku( $item['Code'] ) : self::get_product_id_by_title( $item['Description'] );
298 // Mark product as synced with current timestamp.
299 update_post_meta( $product_id, '_eo_last_synced', time() );
300 // Featured image.
301 if ( isset( $item['PictureName'] ) && 'placeholder_item' !== $item['PictureName'] ) {
302 $image_id = self::get_existing_image_id( $item['PictureName'] );
303 // If image exists, add it to the images array, else upload the image and add it to the images array.
304 if ( $image_id ) {
305 set_post_thumbnail( $product_id, $image_id );
306 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
307 update_post_meta( $product_id, '_thumbnail_id', $image_id );
308 wp_update_image_subsizes( $image_id );
309 } else {
310 $image_id = self::upload_image( $item['PictureUrl'], $item['PictureName'] );
311 // If image upload is successful, add it to the images array.
312 if ( $image_id ) {
313 set_post_thumbnail( $product_id, $image_id );
314 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
315 update_post_meta( $product_id, '_thumbnail_id', $image_id );
316 wp_update_image_subsizes( $image_id );
317 }
318 }
319 }
320 // update product category.
321 if ( isset( $item['ItemGroupDescription'] ) ) {
322 // Check if term exists by name.
323 $term = get_term_by( 'name', $item['ItemGroupDescription'], 'product_cat' );
324 $term_id = $term->term_id;
325 if ( empty( $term_id ) ) {
326 $term = wp_insert_term(
327 $item['ItemGroupDescription'],
328 'product_cat',
329 array(
330 'parent' => 0,
331 )
332 );
333 $term_id = $term['term_id'];
334 }
335 // update product category directly.
336 wp_set_object_terms( $product_id, $term_id, 'product_cat' );
337 }
338 $stock_status = '';
339 $backorders_allowed = get_post_meta( $product_id, '_backorders', true ) === 'yes';
340 if ( isset( $item['Stock'] ) ) {
341 if ( self::$enable_eo_stock ) {
342 if ( $item['Stock'] <= 0 ) {
343 $stock_status = $backorders_allowed ? 'outofstock' : 'instock';
344 } else {
345 $stock_status = 'instock';
346 }
347 }
348 }
349 return array(
350 'id' => $product_id,
351 'regular_price' => (string) $item['StandardSalesPrice'],
352 'stock_quantity' => ( isset( $item['Stock'] ) && self::$enable_eo_stock ) ? (int) $item['Stock'] : null,
353 'manage_stock' => ( isset( $item['Stock'] ) && self::$enable_eo_stock ) ? true : false,
354 'stock_status' => $stock_status,
355 'name' => $item['Description'],
356 'slug' => sanitize_title( $item['Description'] ),
357 'meta_data' => array(
358 array(
359 'key' => 'eo_item_id',
360 'value' => $item['ID'],
361 ),
362 array(
363 'key' => '_cogs_total_value',
364 'value' => $item['CostPriceStandard'],
365 ),
366 array(
367 'key' => 'eo_unit',
368 'value' => $item['Unit'],
369 ),
370 ),
371
372 );
373 },
374 $filtered_data
375 ),
376 );
377 break;
378
379 case 'customer':
380 $endpoint = '/wc/v3/customers/batch';
381 $payload = array(
382 'update' => array_map(
383 function ( $item ) {
384 $user = get_user_by( 'email', $item['Email'] );
385 return $user ? array(
386 'id' => $user->ID,
387 'meta_data' => array(
388 array(
389 'key' => 'eo_account_id',
390 'value' => $item['ID'],
391 ),
392 array(
393 'key' => 'eo_contact_id',
394 'value' => $item['MainContact'] ?? '',
395 ),
396 ),
397 // if $item['VATNumber'] is not empty then update the billing company name.
398 'billing' => ! empty( $item['VATNumber'] ) ? array(
399 'company' => $item['Name'],
400 ) : null,
401 ) : null;
402 },
403 array_filter( $data, fn( $item ) => get_user_by( 'email', $item['Email'] ) )
404 ),
405 );
406 break;
407
408 case 'orders':
409 $endpoint = '/wc/v3/orders/batch';
410 $payload = array(
411 'update' => array_map(
412 function ( $item ) {
413 return array(
414 'id' => $item['Description'],
415 'meta_data' => array(
416 array(
417 'key' => 'eo_order_id',
418 'value' => $item['OrderID'],
419 ),
420 array(
421 'key' => 'eo_order_number',
422 'value' => $item['OrderNumber'],
423 ),
424 ),
425 );
426 },
427 $data
428 ),
429 );
430 break;
431
432 default:
433 return false;
434 }
435 // fwrite( $fd, print_r( $payload, true ) );
436 if ( empty( $payload['update'] ) ) {
437 return false;
438 }
439 add_filter( 'woocommerce_rest_check_permissions', '__return_true' );
440 $request = new \WP_REST_Request( 'POST', $endpoint );
441 $request->set_body_params( $payload );
442 $response = rest_do_request( $request );
443 remove_filter( 'woocommerce_rest_check_permissions', '__return_true' );
444 // fwrite( $fd, 'response: ' . print_r( $response, true ) );
445 // fclose( $fd );
446 return $response;
447 }
448
449 private static function get_product_id_by_title( string $product_title ) {
450 // Set up the query arguments.
451 $args = array(
452 'post_type' => 'product',
453 'posts_per_page' => 1,
454 'fields' => 'ids',
455 's' => $product_title, // Search by product title.
456 );
457
458 // Run the query.
459 $query = new \WP_Query( $args );
460
461 // Get the product ID from the query results.
462 $product_id = $query->post_count > 0 ? $query->posts[0] : 0;
463
464 // Reset post data.
465 wp_reset_postdata();
466
467 return $product_id;
468 }
469
470 public static function get_payment_status_via_cron() {
471 // execute get_payment_status of ExactOnlineAjax class.
472 $ajax = new ExactOnlineAjax();
473 $ajax->get_payment_status();
474 }
475
476 /**
477 * Process the payment status of the order via Exact Online.
478 *
479 * @param array $
480 * @return void
481 */
482 public static function cmbird_payment_status() {
483 $args = func_get_args();
484 $order_id = $args[0];
485 if ( empty( $order_id ) ) {
486 return;
487 }
488 $order = wc_get_order( $order_id );
489 $object = array();
490 $order_id = $order->get_id();
491 $object['OrderID'] = $order_id;
492 $customer_id = $order->get_customer_id();
493 // get the eo_account_id from the user meta.
494 $object['AccountID'] = get_user_meta( $customer_id, 'eo_account_id', true );
495 $response = ( new Connector() )->payment_status( $object );
496 // check response contains "Payment_Status" key.
497 if ( ! isset( $response['Payment_Status'] ) ) {
498 return;
499 }
500 // if response is Paid then update the order status to completed.
501 if ( 'Paid' === $response['Payment_Status'] ) {
502 // set order as paid.
503 if ( $order->get_status() === 'completed' ) {
504 return;
505 }
506 $order->payment_complete();
507 $order->update_status( 'completed', __( 'Payment processed in Exact Online', 'commercebird' ) );
508 $order->save();
509 } elseif ( 'Unpaid' === $response['Payment_Status'] ) {
510 if ( $order->get_status() === 'on-hold' ) {
511 return;
512 }
513 $order->update_status( 'on-hold', __( 'Payment not processed in Exact Online', 'commercebird' ) );
514 $order->save();
515 }
516 }
517
518 /**
519 * Check if the image exists in the media library.
520 *
521 * @param string $picture_name
522 * @return int|false Attachment ID if exists, false otherwise
523 */
524 private static function get_existing_image_id( $picture_name ) {
525 global $wpdb;
526
527 // Strip extension from picture_name.
528 $picture_title = pathinfo( $picture_name, PATHINFO_FILENAME );
529
530 // Prepare a LIKE match to catch sanitized versions.
531 $like = '%' . $wpdb->esc_like( $picture_title ) . '%';
532
533 $attachment_id = $wpdb->get_var(
534 $wpdb->prepare(
535 "
536 SELECT ID FROM {$wpdb->posts}
537 WHERE post_type = 'attachment'
538 AND post_title LIKE %s
539 ORDER BY ID DESC
540 LIMIT 1
541 ",
542 $like
543 )
544 );
545
546 return $attachment_id ? (int) $attachment_id : false;
547 }
548
549 /**
550 * Upload the product image from Exact Online.
551 *
552 * @param string $picture_url URL of the image to upload.
553 * @param string $picture_name Name of the image file (used for naming the attachment).
554 * @return int|false Attachment ID of the uploaded image if successful, otherwise false
555 */
556 private static function upload_image( $picture_url, $picture_name ) {
557 require_once ABSPATH . 'wp-admin/includes/media.php';
558 require_once ABSPATH . 'wp-admin/includes/file.php';
559 require_once ABSPATH . 'wp-admin/includes/image.php';
560
561 // Fetch image content.
562 $response = wp_safe_remote_get( $picture_url, array( 'timeout' => 10 ) );
563
564 if ( is_wp_error( $response ) ) {
565 // error_log( 'Error fetching image: ' . $response->get_error_message() );
566 return false;
567 }
568
569 $image_data = wp_remote_retrieve_body( $response );
570 if ( empty( $image_data ) ) {
571 return false;
572 }
573
574 // Generate filename.
575 $upload_dir = wp_upload_dir();
576 $filename = sanitize_file_name( $picture_name );
577 $file_path = $upload_dir['path'] . '/' . $filename;
578
579 // Save the file.
580 file_put_contents( $file_path, $image_data );
581
582 // Check if file was saved correctly.
583 if ( ! file_exists( $file_path ) ) {
584 return false;
585 }
586
587 // Prepare file array for WordPress.
588 $file = array(
589 'name' => $filename,
590 'type' => mime_content_type( $file_path ),
591 'tmp_name' => $file_path,
592 'size' => filesize( $file_path ),
593 );
594
595 // Upload to WordPress Media Library.
596 $attachment_id = media_handle_sideload( $file, 0 );
597
598 // Check for errors.
599 if ( is_wp_error( $attachment_id ) ) {
600 // error_log( 'Error attaching image: ' . $attachment_id->get_error_message() );
601 return false;
602 }
603
604 return $attachment_id;
605 }
606
607 /**
608 * Sync orders via cron job.
609 * This function will get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key
610 * and send them to the send_orders function in CommerceBird class.
611 */
612 public static function sync_orders_via_cron() {
613 $logger = wc_get_logger();
614 $context = array( 'source' => 'cmbird_exact_online_orders' );
615 // get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key.
616 $start_date = gmdate( 'Y-m-d H:i:s', strtotime( '-15 days' ) );
617 $end_date = time();
618 $orders = wc_get_orders(
619 array(
620 'status' => array_diff( array_keys( wc_get_order_statuses() ), array( 'wc-failed', 'wc-checkout-draft', 'wc-pending', 'wc-on-hold', 'wc-cancelled' ) ),
621 'limit' => -1,
622 'date_created' => $start_date . '...' . $end_date,
623 'return' => 'ids',
624 'meta_query' => array(
625 array(
626 'key' => 'eo_order_id',
627 'compare' => 'NOT EXISTS',
628 ),
629 ),
630 )
631 );
632 if ( empty( $orders ) ) {
633 return;
634 }
635 // send the orders to the send_orders function in CommerceBird class.
636 $response = ( new Connector() )->send_orders( array( 'orderIds' => $orders ) );
637 if ( is_string( $response ) || 200 !== $response['code'] ) {
638 // log the error.
639 $logger->error(
640 'Error syncing Exact Online orders',
641 $context + array(
642 'response' => $response,
643 'orders' => $orders,
644 )
645 );
646 return;
647 }
648 }
649 }
650