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