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