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