PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.7.7
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.7.7
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 5 months ago ZohoCRMSync.php 5 months ago ZohoInventorySync.php 4 months ago index.php 1 year ago
ExactOnlineSync.php
649 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_transient( 'cmbird_eo_synced_skus' ) ?: array();
279 $existing_synced_skus = array_merge( $existing_synced_skus, $exact_skus );
280 set_transient( 'cmbird_eo_synced_skus', array_unique( $existing_synced_skus ), DAY_IN_SECONDS );
281
282 $filtered_data = array_filter(
283 $data,
284 function ( $item ) {
285 // Exclude item if product does not exists via SKU.
286 return wc_get_product_id_by_sku( $item['Code'] );
287 }
288 );
289 $endpoint = '/wc/v3/products/batch';
290 $payload = array(
291 'update' => array_map(
292 function ( $item ) {
293 // Check if product exists via SKU, else get the product ID by title.
294 $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'] );
295 // Mark product as synced with current timestamp.
296 update_post_meta( $product_id, '_eo_last_synced', time() );
297 // Featured image.
298 if ( isset( $item['PictureName'] ) && 'placeholder_item' !== $item['PictureName'] ) {
299 $image_id = self::get_existing_image_id( $item['PictureName'] );
300 // If image exists, add it to the images array, else upload the image and add it to the images array.
301 if ( $image_id ) {
302 set_post_thumbnail( $product_id, $image_id );
303 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
304 update_post_meta( $product_id, '_thumbnail_id', $image_id );
305 wp_update_image_subsizes( $image_id );
306 } else {
307 $image_id = self::upload_image( $item['PictureUrl'], $item['PictureName'] );
308 // If image upload is successful, add it to the images array.
309 if ( $image_id ) {
310 set_post_thumbnail( $product_id, $image_id );
311 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
312 update_post_meta( $product_id, '_thumbnail_id', $image_id );
313 wp_update_image_subsizes( $image_id );
314 }
315 }
316 }
317 // update product category.
318 if ( isset( $item['ItemGroupDescription'] ) ) {
319 // Check if term exists by name.
320 $term = get_term_by( 'name', $item['ItemGroupDescription'], 'product_cat' );
321 $term_id = $term->term_id;
322 if ( empty( $term_id ) ) {
323 $term = wp_insert_term(
324 $item['ItemGroupDescription'],
325 'product_cat',
326 array(
327 'parent' => 0,
328 )
329 );
330 $term_id = $term['term_id'];
331 }
332 // update product category directly.
333 wp_set_object_terms( $product_id, $term_id, 'product_cat' );
334 }
335 // update product name and slug if its different from current one.
336 $product = wc_get_product( $product_id );
337 // if product type is variation. update it first.
338 if ( 'variation' === $product->get_type() ) {
339 $variation = wc_get_product_object( 'variation', $product_id );
340 $variation->set_props(
341 array(
342 'regular_price' => (string) $item['StandardSalesPrice'],
343 'stock_quantity' => ( isset( $item['Stock'] ) && self::$enable_eo_stock ) ? (int) $item['Stock'] : null,
344 'manage_stock' => ( isset( $item['Stock'] ) && self::$enable_eo_stock ) ? true : false,
345 )
346 );
347 $variation->save();
348 } else {
349 if ( $product->get_name() !== $item['Description'] ) {
350 $product->set_name( $item['Description'] );
351 $product->set_slug( sanitize_title( $item['Description'] ) );
352 }
353 // update the stock quantity if exists.
354 if ( isset( $item['Stock'] ) && self::$enable_eo_stock ) {
355 $product->set_stock_quantity( (int) $item['Stock'] );
356 $product->set_manage_stock( true );
357 }
358 $product->save();
359 }
360
361 // create meta_data array if product does not contain eo_item_id meta.
362 $meta_data = get_post_meta( $product_id, 'eo_item_id', true );
363 if ( empty( $meta_data ) ) {
364 update_post_meta( $product_id, 'eo_item_id', $item['ID'] );
365 update_post_meta( $product_id, '_cost_price', $item['CostPriceStandard'] );
366 update_post_meta( $product_id, 'eo_unit', $item['Unit'] );
367 }
368 return array(
369 'id' => $product_id,
370 'regular_price' => (string) $item['StandardSalesPrice'],
371 );
372 },
373 $filtered_data
374 ),
375 );
376 break;
377
378 case 'customer':
379 $endpoint = '/wc/v3/customers/batch';
380 $payload = array(
381 'update' => array_map(
382 function ( $item ) {
383 $user = get_user_by( 'email', $item['Email'] );
384 return $user ? array(
385 'id' => $user->ID,
386 'meta_data' => array(
387 array(
388 'key' => 'eo_account_id',
389 'value' => $item['ID'],
390 ),
391 array(
392 'key' => 'eo_contact_id',
393 'value' => $item['MainContact'] ?? '',
394 ),
395 ),
396 // if $item['VATNumber'] is not empty then update the billing company name.
397 'billing' => ! empty( $item['VATNumber'] ) ? array(
398 'company' => $item['Name'],
399 ) : null,
400 ) : null;
401 },
402 array_filter( $data, fn( $item ) => get_user_by( 'email', $item['Email'] ) )
403 ),
404 );
405 break;
406
407 case 'orders':
408 $endpoint = '/wc/v3/orders/batch';
409 $payload = array(
410 'update' => array_map(
411 function ( $item ) {
412 return array(
413 'id' => $item['Description'],
414 'meta_data' => array(
415 array(
416 'key' => 'eo_order_id',
417 'value' => $item['OrderID'],
418 ),
419 array(
420 'key' => 'eo_order_number',
421 'value' => $item['OrderNumber'],
422 ),
423 ),
424 );
425 },
426 $data
427 ),
428 );
429 break;
430
431 default:
432 return false;
433 }
434 // fwrite( $fd, print_r( $payload, true ) );
435 if ( empty( $payload['update'] ) ) {
436 return false;
437 }
438 add_filter( 'woocommerce_rest_check_permissions', '__return_true' );
439 $request = new \WP_REST_Request( 'POST', $endpoint );
440 $request->set_body_params( $payload );
441 $response = rest_do_request( $request );
442 remove_filter( 'woocommerce_rest_check_permissions', '__return_true' );
443 // fwrite( $fd, 'response: ' . print_r( $response, true ) );
444 // fclose( $fd );
445 return $response;
446 }
447
448 private static function get_product_id_by_title( string $product_title ) {
449 // Set up the query arguments.
450 $args = array(
451 'post_type' => 'product',
452 'posts_per_page' => 1,
453 'fields' => 'ids',
454 's' => $product_title, // Search by product title.
455 );
456
457 // Run the query.
458 $query = new \WP_Query( $args );
459
460 // Get the product ID from the query results.
461 $product_id = $query->post_count > 0 ? $query->posts[0] : 0;
462
463 // Reset post data.
464 wp_reset_postdata();
465
466 return $product_id;
467 }
468
469 public static function get_payment_status_via_cron() {
470 // execute get_payment_status of ExactOnlineAjax class.
471 $ajax = new ExactOnlineAjax();
472 $ajax->get_payment_status();
473 }
474
475 /**
476 * Process the payment status of the order via Exact Online.
477 *
478 * @param array $
479 * @return void
480 */
481 public static function cmbird_payment_status() {
482 $args = func_get_args();
483 $order_id = $args[0];
484 if ( empty( $order_id ) ) {
485 return;
486 }
487 $order = wc_get_order( $order_id );
488 $object = array();
489 $order_id = $order->get_id();
490 $object['OrderID'] = $order_id;
491 $customer_id = $order->get_customer_id();
492 // get the eo_account_id from the user meta.
493 $object['AccountID'] = get_user_meta( $customer_id, 'eo_account_id', true );
494 $response = ( new Connector() )->payment_status( $object );
495 // check response contains "Payment_Status" key.
496 if ( ! isset( $response['Payment_Status'] ) ) {
497 return;
498 }
499 // if response is Paid then update the order status to completed.
500 if ( 'Paid' === $response['Payment_Status'] ) {
501 // set order as paid.
502 if ( $order->get_status() === 'completed' ) {
503 return;
504 }
505 $order->payment_complete();
506 $order->update_status( 'completed', __( 'Payment processed in Exact Online', 'commercebird' ) );
507 $order->save();
508 } elseif ( 'Unpaid' === $response['Payment_Status'] ) {
509 if ( $order->get_status() === 'on-hold' ) {
510 return;
511 }
512 $order->update_status( 'on-hold', __( 'Payment not processed in Exact Online', 'commercebird' ) );
513 $order->save();
514 }
515 }
516
517 /**
518 * Check if the image exists in the media library.
519 *
520 * @param string $picture_name
521 * @return int|false Attachment ID if exists, false otherwise
522 */
523 private static function get_existing_image_id( $picture_name ) {
524 global $wpdb;
525
526 // Strip extension from picture_name.
527 $picture_title = pathinfo( $picture_name, PATHINFO_FILENAME );
528
529 // Prepare a LIKE match to catch sanitized versions.
530 $like = '%' . $wpdb->esc_like( $picture_title ) . '%';
531
532 $attachment_id = $wpdb->get_var(
533 $wpdb->prepare(
534 "
535 SELECT ID FROM {$wpdb->posts}
536 WHERE post_type = 'attachment'
537 AND post_title LIKE %s
538 ORDER BY ID DESC
539 LIMIT 1
540 ",
541 $like
542 )
543 );
544
545 return $attachment_id ? (int) $attachment_id : false;
546 }
547
548 /**
549 * Upload the product image from Exact Online.
550 *
551 * @param string $product_id
552 * @param string $picture_name
553 * @return $attachment_id of the uploaded image if successful, otherwise false
554 */
555 private static function upload_image( $picture_url, $picture_name ) {
556 require_once ABSPATH . 'wp-admin/includes/media.php';
557 require_once ABSPATH . 'wp-admin/includes/file.php';
558 require_once ABSPATH . 'wp-admin/includes/image.php';
559
560 // Fetch image content.
561 $response = wp_safe_remote_get( $picture_url, array( 'timeout' => 10 ) );
562
563 if ( is_wp_error( $response ) ) {
564 // error_log( 'Error fetching image: ' . $response->get_error_message() );
565 return false;
566 }
567
568 $image_data = wp_remote_retrieve_body( $response );
569 if ( empty( $image_data ) ) {
570 return false;
571 }
572
573 // Generate filename.
574 $upload_dir = wp_upload_dir();
575 $filename = sanitize_file_name( $picture_name );
576 $file_path = $upload_dir['path'] . '/' . $filename;
577
578 // Save the file.
579 file_put_contents( $file_path, $image_data );
580
581 // Check if file was saved correctly.
582 if ( ! file_exists( $file_path ) ) {
583 return false;
584 }
585
586 // Prepare file array for WordPress.
587 $file = array(
588 'name' => $filename,
589 'type' => mime_content_type( $file_path ),
590 'tmp_name' => $file_path,
591 'size' => filesize( $file_path ),
592 );
593
594 // Upload to WordPress Media Library.
595 $attachment_id = media_handle_sideload( $file, 0 );
596
597 // Check for errors.
598 if ( is_wp_error( $attachment_id ) ) {
599 // error_log( 'Error attaching image: ' . $attachment_id->get_error_message() );
600 return false;
601 }
602
603 return $attachment_id;
604 }
605
606 /**
607 * Sync orders via cron job.
608 * This function will get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key
609 * and send them to the send_orders function in CommerceBird class.
610 */
611 public static function sync_orders_via_cron() {
612 $logger = wc_get_logger();
613 $context = array( 'source' => 'cmbird_exact_online_orders' );
614 // get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key.
615 $start_date = gmdate( 'Y-m-d H:i:s', strtotime( '-15 days' ) );
616 $end_date = time();
617 $orders = wc_get_orders(
618 array(
619 'status' => array_diff( array_keys( wc_get_order_statuses() ), array( 'wc-failed', 'wc-checkout-draft', 'wc-pending', 'wc-on-hold', 'wc-cancelled' ) ),
620 'limit' => -1,
621 'date_created' => $start_date . '...' . $end_date,
622 'return' => 'ids',
623 'meta_query' => array(
624 array(
625 'key' => 'eo_order_id',
626 'compare' => 'NOT EXISTS',
627 ),
628 ),
629 )
630 );
631 if ( empty( $orders ) ) {
632 return;
633 }
634 // send the orders to the send_orders function in CommerceBird class.
635 $response = ( new Connector() )->send_orders( array( 'orderIds' => $orders ) );
636 if ( is_string( $response ) || 200 !== $response['code'] ) {
637 // log the error.
638 $logger->error(
639 'Error syncing Exact Online orders',
640 $context + array(
641 'response' => $response,
642 'orders' => $orders,
643 )
644 );
645 return;
646 }
647 }
648 }
649