PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.6.3
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.6.3
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / 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
609 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 $request = new \WP_REST_Request( 'POST', $endpoint );
234 $request->set_body_params( $payload );
235 $response = rest_do_request( $request );
236 // fwrite( $fd, print_r( $response, true ) );
237 // fclose( stream: $fd );
238 return $response;
239 }
240
241 /**
242 * Update data based on Exact Online.
243 *
244 * @param string $type of provided data.
245 * @param array $data to update in Exact Online.
246 * @return mixed
247 */
248 public static function update( string $type, array $data ) {
249 // $fd = fopen( __DIR__ . '/update.txt', 'w+' );
250 $endpoint = '';
251 $payload = array();
252 // log data.
253 // fwrite( $fd, print_r( $data, true ) );
254
255 switch ( $type ) {
256 case 'product':
257 // Track all SKUs from Exact Online for this batch.
258 $exact_skus = array_column( $data, 'Code' );
259 $existing_synced_skus = get_transient( 'cmbird_eo_synced_skus' ) ?: array();
260 $existing_synced_skus = array_merge( $existing_synced_skus, $exact_skus );
261 set_transient( 'cmbird_eo_synced_skus', array_unique( $existing_synced_skus ), DAY_IN_SECONDS );
262
263 $filtered_data = array_filter(
264 $data,
265 function ( $item ) {
266 // Exclude item if product does not exists via SKU.
267 return wc_get_product_id_by_sku( $item['Code'] );
268 }
269 );
270 $endpoint = '/wc/v3/products/batch';
271 $payload = array(
272 'update' => array_map(
273 function ( $item ) {
274 // Check if product exists via SKU, else get the product ID by title.
275 $product_id = wc_get_product_id_by_sku( $item['Code'] ) ?: self::get_product_id_by_title( $item['Description'] );
276 // Mark product as synced with current timestamp.
277 update_post_meta( $product_id, '_eo_last_synced', time() );
278 // Featured image.
279 if ( isset( $item['PictureName'] ) && 'placeholder_item' !== $item['PictureName'] ) {
280 $image_id = self::get_existing_image_id( $item['PictureName'] );
281 // If image exists, add it to the images array, else upload the image and add it to the images array.
282 if ( $image_id ) {
283 set_post_thumbnail( $product_id, $image_id );
284 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
285 update_post_meta( $product_id, '_thumbnail_id', $image_id );
286 wp_update_image_subsizes( $image_id );
287 } else {
288 $image_id = self::upload_image( $item['PictureUrl'], $item['PictureName'] );
289 // If image upload is successful, add it to the images array.
290 if ( $image_id ) {
291 set_post_thumbnail( $product_id, $image_id );
292 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
293 update_post_meta( $product_id, '_thumbnail_id', $image_id );
294 wp_update_image_subsizes( $image_id );
295 }
296 }
297 }
298 // update product category.
299 if ( isset( $item['ItemGroupDescription'] ) ) {
300 // Check if term exists by name.
301 $term = get_term_by( 'name', $item['ItemGroupDescription'], 'product_cat' );
302 $term_id = $term->term_id;
303 if ( empty( $term_id ) ) {
304 $term = wp_insert_term(
305 $item['ItemGroupDescription'],
306 'product_cat',
307 array(
308 'parent' => 0,
309 )
310 );
311 $term_id = $term['term_id'];
312 }
313 // update product category directly.
314 wp_set_object_terms( $product_id, $term_id, 'product_cat' );
315 }
316 // update product name and slug if its different from current one.
317 $product = wc_get_product( $product_id );
318 if ( $product->get_name() !== $item['Description'] ) {
319 $product->set_name( $item['Description'] );
320 $product->set_slug( sanitize_title( $item['Description'] ) );
321 $product->save();
322 }
323 // create meta_data array if product does not contain eo_item_id meta.
324 $meta_data = get_post_meta( $product_id, 'eo_item_id', true );
325 if ( empty( $meta_data ) ) {
326 update_post_meta( $product_id, 'eo_item_id', $item['ID'] );
327 update_post_meta( $product_id, '_cost_price', $item['CostPriceStandard'] );
328 update_post_meta( $product_id, 'eo_unit', $item['Unit'] );
329 }
330 return array(
331 'id' => $product_id,
332 'regular_price' => (string) $item['StandardSalesPrice'],
333 );
334 },
335 $filtered_data
336 ),
337 );
338 break;
339
340 case 'customer':
341 $endpoint = '/wc/v3/customers/batch';
342 $payload = array(
343 'update' => array_map(
344 function ( $item ) {
345 $user = get_user_by( 'email', $item['Email'] );
346 return $user ? array(
347 'id' => $user->ID,
348 'meta_data' => array(
349 array(
350 'key' => 'eo_account_id',
351 'value' => $item['ID'],
352 ),
353 array(
354 'key' => 'eo_contact_id',
355 'value' => $item['MainContact'] ?? '',
356 ),
357 ),
358 // if $item['VATNumber'] is not empty then update the billing company name.
359 'billing' => ! empty( $item['VATNumber'] ) ? array(
360 'company' => $item['Name'],
361 ) : null,
362 ) : null;
363 },
364 array_filter( $data, fn( $item ) => get_user_by( 'email', $item['Email'] ) )
365 ),
366 );
367 break;
368
369 case 'orders':
370 $endpoint = '/wc/v3/orders/batch';
371 $payload = array(
372 'update' => array_map(
373 function ( $item ) {
374 return array(
375 'id' => $item['Description'],
376 'meta_data' => array(
377 array(
378 'key' => 'eo_order_id',
379 'value' => $item['OrderID'],
380 ),
381 array(
382 'key' => 'eo_order_number',
383 'value' => $item['OrderNumber'],
384 ),
385 ),
386 );
387 },
388 $data
389 ),
390 );
391 break;
392
393 default:
394 return false;
395 }
396 // fwrite( $fd, print_r( $payload, true ) );
397 if ( empty( $payload['update'] ) ) {
398 return false;
399 }
400 $request = new \WP_REST_Request( 'POST', $endpoint );
401 $request->set_body_params( $payload );
402 $response = rest_do_request( $request );
403 // fwrite( $fd, 'response: ' . print_r( $response, true ) );
404 // fclose( $fd );
405 return $response;
406 }
407
408 private static function get_product_id_by_title( string $product_title ) {
409 // Set up the query arguments.
410 $args = array(
411 'post_type' => 'product',
412 'posts_per_page' => 1,
413 'fields' => 'ids',
414 's' => $product_title, // Search by product title.
415 );
416
417 // Run the query.
418 $query = new \WP_Query( $args );
419
420 // Get the product ID from the query results.
421 $product_id = $query->post_count > 0 ? $query->posts[0] : 0;
422
423 // Reset post data.
424 wp_reset_postdata();
425
426 return $product_id;
427 }
428
429 public static function get_payment_status_via_cron() {
430 // execute get_payment_status of ExactOnlineAjax class.
431 $ajax = new ExactOnlineAjax();
432 $ajax->get_payment_status();
433 }
434
435 /**
436 * Process the payment status of the order via Exact Online.
437 *
438 * @param array $
439 * @return void
440 */
441 public static function cmbird_payment_status() {
442 $args = func_get_args();
443 $order_id = $args[0];
444 if ( empty( $order_id ) ) {
445 return;
446 }
447 $order = wc_get_order( $order_id );
448 $object = array();
449 $order_id = $order->get_id();
450 $object['OrderID'] = $order_id;
451 $customer_id = $order->get_customer_id();
452 // get the eo_account_id from the user meta.
453 $object['AccountID'] = get_user_meta( $customer_id, 'eo_account_id', true );
454 $response = ( new Connector() )->payment_status( $object );
455 // check response contains "Payment_Status" key.
456 if ( ! isset( $response['Payment_Status'] ) ) {
457 return;
458 }
459 // if response is Paid then update the order status to completed.
460 if ( 'Paid' === $response['Payment_Status'] ) {
461 // set order as paid.
462 if ( $order->get_status() === 'completed' ) {
463 return;
464 }
465 $order->payment_complete();
466 $order->update_status( 'completed', __( 'Payment processed in Exact Online', 'commercebird' ) );
467 $order->save();
468 } elseif ( 'Unpaid' === $response['Payment_Status'] ) {
469 if ( $order->get_status() === 'on-hold' ) {
470 return;
471 }
472 $order->update_status( 'on-hold', __( 'Payment not processed in Exact Online', 'commercebird' ) );
473 $order->save();
474 }
475 }
476
477 /**
478 * Check if the image exists in the media library.
479 *
480 * @param string $picture_name
481 * @return int|false Attachment ID if exists, false otherwise
482 */
483 private static function get_existing_image_id( $picture_name ) {
484 global $wpdb;
485
486 // Strip extension from picture_name.
487 $picture_title = pathinfo( $picture_name, PATHINFO_FILENAME );
488
489 // Prepare a LIKE match to catch sanitized versions.
490 $like = '%' . $wpdb->esc_like( $picture_title ) . '%';
491
492 $attachment_id = $wpdb->get_var(
493 $wpdb->prepare(
494 "
495 SELECT ID FROM {$wpdb->posts}
496 WHERE post_type = 'attachment'
497 AND post_title LIKE %s
498 ORDER BY ID DESC
499 LIMIT 1
500 ",
501 $like
502 )
503 );
504
505 return $attachment_id ? (int) $attachment_id : false;
506 }
507
508 /**
509 * Upload the product image from Exact Online.
510 *
511 * @param string $product_id
512 * @param string $picture_name
513 * @return $attachment_id of the uploaded image if successful, otherwise false
514 */
515 private static function upload_image( $picture_url, $picture_name ) {
516 require_once ABSPATH . 'wp-admin/includes/media.php';
517 require_once ABSPATH . 'wp-admin/includes/file.php';
518 require_once ABSPATH . 'wp-admin/includes/image.php';
519
520 // Fetch image content.
521 $response = wp_safe_remote_get( $picture_url, array( 'timeout' => 10 ) );
522
523 if ( is_wp_error( $response ) ) {
524 // error_log( 'Error fetching image: ' . $response->get_error_message() );
525 return false;
526 }
527
528 $image_data = wp_remote_retrieve_body( $response );
529 if ( empty( $image_data ) ) {
530 return false;
531 }
532
533 // Generate filename.
534 $upload_dir = wp_upload_dir();
535 $filename = sanitize_file_name( $picture_name );
536 $file_path = $upload_dir['path'] . '/' . $filename;
537
538 // Save the file.
539 file_put_contents( $file_path, $image_data );
540
541 // Check if file was saved correctly.
542 if ( ! file_exists( $file_path ) ) {
543 return false;
544 }
545
546 // Prepare file array for WordPress.
547 $file = array(
548 'name' => $filename,
549 'type' => mime_content_type( $file_path ),
550 'tmp_name' => $file_path,
551 'size' => filesize( $file_path ),
552 );
553
554 // Upload to WordPress Media Library.
555 $attachment_id = media_handle_sideload( $file, 0 );
556
557 // Check for errors.
558 if ( is_wp_error( $attachment_id ) ) {
559 // error_log( 'Error attaching image: ' . $attachment_id->get_error_message() );
560 return false;
561 }
562
563 return $attachment_id;
564 }
565
566 /**
567 * Sync orders via cron job.
568 * This function will get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key
569 * and send them to the send_orders function in CommerceBird class.
570 */
571 public static function sync_orders_via_cron() {
572 $logger = wc_get_logger();
573 $context = array( 'source' => 'cmbird_exact_online_orders' );
574 // get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key.
575 $start_date = gmdate( 'Y-m-d H:i:s', strtotime( '-15 days' ) );
576 $end_date = time();
577 $orders = wc_get_orders(
578 array(
579 'status' => array_diff( array_keys( wc_get_order_statuses() ), array( 'wc-failed', 'wc-checkout-draft', 'wc-pending', 'wc-on-hold', 'wc-cancelled' ) ),
580 'limit' => -1,
581 'date_created' => $start_date . '...' . $end_date,
582 'return' => 'ids',
583 'meta_query' => array(
584 array(
585 'key' => 'eo_order_id',
586 'compare' => 'NOT EXISTS',
587 ),
588 ),
589 )
590 );
591 if ( empty( $orders ) ) {
592 return;
593 }
594 // send the orders to the send_orders function in CommerceBird class.
595 $response = ( new Connector() )->send_orders( array( 'orderIds' => $orders ) );
596 if ( is_string( $response ) || 200 !== $response['code'] ) {
597 // log the error.
598 $logger->error(
599 'Error syncing Exact Online orders',
600 $context + array(
601 'response' => $response,
602 'orders' => $orders,
603 )
604 );
605 return;
606 }
607 }
608 }
609