From 0ccddcc356370b442def487d327f8702abdda5d9 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 21 Jan 2024 21:48:38 +0100 Subject: [PATCH] fix product duplication bug --- includes/API/Traits/Uuid_Handler.php | 148 ++++++++++++++++-- includes/Admin/Products/List_Products.php | 57 +++++-- includes/Templates/Frontend.php | 2 +- package.json | 2 +- readme.txt | 5 +- templates/pos.php | 5 +- templates/receipt.php | 6 - .../API/Test_Customers_Controller.php | 35 +++++ .../Test_Product_Categories_Controller.php | 26 +++ .../includes/API/Test_Products_Controller.php | 35 +++++ woocommerce-pos.php | 4 +- 11 files changed, 282 insertions(+), 43 deletions(-) diff --git a/includes/API/Traits/Uuid_Handler.php b/includes/API/Traits/Uuid_Handler.php index 72726eb..0adfab8 100644 --- a/includes/API/Traits/Uuid_Handler.php +++ b/includes/API/Traits/Uuid_Handler.php @@ -9,6 +9,10 @@ use WC_Order_Item; use WCPOS\WooCommercePOS\Logger; use WP_User; +use WC_Product; +use WC_Product_Variation; +use WC_Abstract_Order; +use Automattic\WooCommerce\Utilities\OrderUtil; use function get_user_meta; use function update_user_meta; @@ -33,21 +37,25 @@ function ( WC_Meta_Data $meta ) { $uuids ); - // If there is no uuid, add one, i.e., new product - if ( empty( $uuid_values ) ) { - $object->update_meta_data( '_woocommerce_pos_uuid', $this->create_uuid() ); - } - - // Check if there's more than one uuid, if so, delete and regenerate + // Check if there's more than one uuid, if so, keep the first and delete the rest. if ( \count( $uuid_values ) > 1 ) { - foreach ( $uuids as $uuid_meta ) { - $object->delete_meta_data( $uuid_meta->key ); + // Keep the first UUID and remove the rest. + for ( $i = 1; $i < count( $uuid_values ); $i++ ) { + $object->delete_meta_data_by_mid( $uuids[ $i ]->id ); } + $uuid_values = array( reset( $uuid_values ) ); // Keep only the first UUID in the array. + } + + // Check conditions for updating the UUID. + $should_update_uuid = empty( $uuid_values ) + || ( isset( $uuid_values[0] ) && ! Uuid::isValid( $uuid_values[0] ) ) + || ( isset( $uuid_values[0] ) && $this->uuid_postmeta_exists( $uuid_values[0], $object ) ); + + if ( $should_update_uuid ) { $object->update_meta_data( '_woocommerce_pos_uuid', $this->create_uuid() ); } } - /** * @param WP_User $user * @@ -56,7 +64,21 @@ function ( WC_Meta_Data $meta ) { private function maybe_add_user_uuid( WP_User $user ): void { $uuids = get_user_meta( $user->ID, '_woocommerce_pos_uuid', false ); - if ( empty( $uuids ) || empty( $uuids[0] ) ) { + // Check if there's more than one uuid, if so, keep the first and delete the rest. + if ( count( $uuids ) > 1 ) { + // Keep the first UUID and remove the rest. + for ( $i = 1; $i < count( $uuids ); $i++ ) { + delete_user_meta( $user->ID, '_woocommerce_pos_uuid', $uuids[ $i ] ); + } + $uuids = array( $uuids[0] ); + } + + // Check conditions for updating the UUID. + $should_update_uuid = empty( $uuids ) + || ( isset( $uuids[0] ) && ! Uuid::isValid( $uuids[0] ) ) + || ( isset( $uuids[0] ) && $this->uuid_usermeta_exists( $uuids[0], $user->ID ) ); + + if ( $should_update_uuid ) { update_user_meta( $user->ID, '_woocommerce_pos_uuid', $this->create_uuid() ); } } @@ -84,14 +106,30 @@ private function maybe_add_order_item_uuid( WC_Order_Item $item ): void { * * @return string */ - private function get_term_uuid( object $item ): string { - $uuid = get_term_meta( $item->term_id, '_woocommerce_pos_uuid', true ); - if ( ! $uuid ) { - $uuid = Uuid::uuid4()->toString(); - add_term_meta( $item->term_id, '_woocommerce_pos_uuid', $uuid, true ); + private function get_term_uuid( $term ): string { + $uuids = get_term_meta( $term->term_id, '_woocommerce_pos_uuid', false ); + + // Check if there's more than one uuid, if so, keep the first and delete the rest. + if ( count( $uuids ) > 1 ) { + // Keep the first UUID and remove the rest. + for ( $i = 1; $i < count( $uuids ); $i++ ) { + delete_term_meta( $term->term_id, '_woocommerce_pos_uuid', $uuids[ $i ] ); + } + $uuids = array( $uuids[0] ); + } + + // Check conditions for updating the UUID. + $should_update_uuid = empty( $uuids ) + || ( isset( $uuids[0] ) && ! Uuid::isValid( $uuids[0] ) ) + || ( isset( $uuids[0] ) && $this->uuid_termmeta_exists( $uuids[0], $term->term_id ) ); + + if ( $should_update_uuid ) { + $uuid = $this->create_uuid(); + add_term_meta( $term->term_id, '_woocommerce_pos_uuid', $uuid, true ); + return $uuid; } - return $uuid; + return $uuids[0]; } /** @@ -108,4 +146,82 @@ private function create_uuid(): string { return 'fallback-uuid-' . time(); } } + + /** + * Check if the given UUID is unique. + * + * @param string $uuid The UUID to check. + * @param WC_Data $object The WooCommerce data object. + * @return bool True if unique, false otherwise. + */ + private function uuid_postmeta_exists( string $uuid, WC_Data $object ): bool { + global $wpdb; + + if ( $object instanceof WC_Abstract_Order && OrderUtil::custom_orders_table_usage_is_enabled() ) { + // Check the orders meta table. + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM {$wpdb->prefix}wc_ordermeta WHERE meta_key = '_woocommerce_pos_uuid' AND meta_value = %s AND order_id != %d LIMIT 1", + $uuid, + $object->get_id() + ) + ); + } else { + // Check the postmeta table. + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key = '_woocommerce_pos_uuid' AND meta_value = %s AND post_id != %d LIMIT 1", + $uuid, + $object->get_id() + ) + ); + } + + // Convert the result to a boolean. + return (bool) $result; + } + + /** + * Check if the given UUID already exists for any user. + * + * @param string $uuid The UUID to check. + * @param int $exclude_id The user ID to exclude from the check. + * @return bool True if unique, false otherwise. + */ + private function uuid_usermeta_exists( string $uuid, int $exclude_id ): bool { + global $wpdb; + + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM {$wpdb->usermeta} WHERE meta_key = '_woocommerce_pos_uuid' AND meta_value = %s AND user_id != %d LIMIT 1", + $uuid, + $exclude_id + ) + ); + + // Convert the result to a boolean. + return (bool) $result; + } + + /** + * Check if the given UUID already exists for any term. + * + * @param string $uuid The UUID to check. + * @param int $exclude_term_id The term ID to exclude from the check. + * @return bool True if unique, false otherwise. + */ + private function uuid_termmeta_exists( string $uuid, int $exclude_term_id ): bool { + global $wpdb; + + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM {$wpdb->termmeta} WHERE meta_key = '_woocommerce_pos_uuid' AND meta_value = %s AND term_id != %d LIMIT 1", + $uuid, + $exclude_term_id + ) + ); + + // Convert the result to a boolean. + return (bool) $result; + } } diff --git a/includes/Admin/Products/List_Products.php b/includes/Admin/Products/List_Products.php index 019fb3c..1e4a15a 100644 --- a/includes/Admin/Products/List_Products.php +++ b/includes/Admin/Products/List_Products.php @@ -27,7 +27,7 @@ class List_Products { private $options; - + public function __construct() { $this->barcode_field = woocommerce_pos_get_settings( 'general', 'barcode_field' ); @@ -43,10 +43,15 @@ public function __construct() { add_action( 'woocommerce_product_options_sku', array( $this, 'woocommerce_product_options_sku' ) ); add_action( 'woocommerce_process_product_meta', array( $this, 'woocommerce_process_product_meta' ) ); // variations - add_action('woocommerce_product_after_variable_attributes', array( - $this, - 'after_variable_attributes_barcode_field', - ), 10, 3); + add_action( + 'woocommerce_product_after_variable_attributes', + array( + $this, + 'after_variable_attributes_barcode_field', + ), + 10, + 3 + ); add_action( 'woocommerce_save_product_variation', array( $this, 'save_product_variation_barcode_field' ) ); } @@ -57,17 +62,30 @@ public function __construct() { add_action( 'woocommerce_product_bulk_edit_save', array( $this, 'bulk_edit_save' ) ); add_action( 'quick_edit_custom_box', array( $this, 'quick_edit' ), 10, 2 ); add_action( 'manage_product_posts_custom_column', array( $this, 'custom_product_column' ), 10, 2 ); - add_action('woocommerce_product_after_variable_attributes', array( - $this, - 'after_variable_attributes_pos_only_products', - ), 10, 3); - add_action('woocommerce_save_product_variation', array( - $this, - 'save_product_variation_pos_only_products', - )); + add_action( + 'woocommerce_product_after_variable_attributes', + array( + $this, + 'after_variable_attributes_pos_only_products', + ), + 10, + 3 + ); + add_action( + 'woocommerce_save_product_variation', + array( + $this, + 'save_product_variation_pos_only_products', + ) + ); } + + add_filter( 'woocommerce_duplicate_product_exclude_meta', array( $this, 'exclude_uuid_meta_on_product_duplicate' ) ); } + /** + * + */ public function woocommerce_product_options_sku(): void { woocommerce_wp_text_input( array( @@ -228,4 +246,17 @@ public function custom_product_column( $column, $post_id ): void { echo ''; } } + + /** + * Filter to allow us to exclude meta keys from product duplication.. + * + * @param array $exclude_meta The keys to exclude from the duplicate. + * @param array $existing_meta_keys The meta keys that the product already has. + * + * @return array + */ + public function exclude_uuid_meta_on_product_duplicate( array $meta_keys ) { + $meta_keys[] = '_woocommerce_pos_uuid'; + return $meta_keys; + } } diff --git a/includes/Templates/Frontend.php b/includes/Templates/Frontend.php index 785c84c..f3bcf06 100644 --- a/includes/Templates/Frontend.php +++ b/includes/Templates/Frontend.php @@ -86,7 +86,7 @@ public function head(): void { public function footer(): void { $development = isset( $_ENV['DEVELOPMENT'] ) && $_ENV['DEVELOPMENT']; $user = wp_get_current_user(); - $github_url = 'https://cdn.jsdelivr.net/gh/wcpos/web-bundle@latest/'; + $github_url = 'https://cdn.jsdelivr.net/gh/wcpos/web-bundle@1.4/'; $auth_service = Auth::instance(); $stores = array_map( function ( $store ) { diff --git a/package.json b/package.json index d0ea916..1159120 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wcpos/woocommerce-pos", - "version": "1.4.7", + "version": "1.4.8", "description": "A simple front-end for taking WooCommerce orders at the Point of Sale.", "main": "index.js", "workspaces": { diff --git a/readme.txt b/readme.txt index 21a162e..a0a9a2f 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: kilbot Tags: cart, e-commerce, ecommerce, inventory, point-of-sale, pos, sales, sell, shop, shopify, store, vend, woocommerce, wordpress-ecommerce Requires at least: 5.6 & WooCommerce 5.3 Tested up to: 6.4 -Stable tag: 1.4.7 +Stable tag: 1.4.8 License: GPL-3.0 License URI: http://www.gnu.org/licenses/gpl-3.0.html @@ -63,6 +63,9 @@ There is more information on our website at [https://wcpos.com](https://wcpos.co == Changelog == += 1.4.8 - 2024/01/21 = +* Fix: duplicating Products in WC Admin also duplicated POS UUID, which casued problems + = 1.4.7 - 2024/01/18 = * Bump: web application to version 1.4.1 * Fix: scroll-to-top issue when scrolling data tables diff --git a/templates/pos.php b/templates/pos.php index d0bb371..da6acf9 100644 --- a/templates/pos.php +++ b/templates/pos.php @@ -14,7 +14,7 @@ <?php esc_attr_e( 'Point of Sale', 'woocommerce-pos' ); ?> - <?php esc_html( bloginfo( 'name' ) ); ?> - + @@ -54,7 +54,7 @@ * Matches Expo build * Extend the react-native-web reset: - * https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/exports/StyleSheet/initialRules.js + * https://necolas.github.io/react-native-web/docs/setup/#root-element */ html, body, @@ -115,4 +115,3 @@ - diff --git a/templates/receipt.php b/templates/receipt.php index 881041d..fd9cac6 100644 --- a/templates/receipt.php +++ b/templates/receipt.php @@ -320,8 +320,6 @@ - - @@ -334,10 +332,6 @@ - diff --git a/tests/includes/API/Test_Customers_Controller.php b/tests/includes/API/Test_Customers_Controller.php index eb0c6a2..b13a076 100644 --- a/tests/includes/API/Test_Customers_Controller.php +++ b/tests/includes/API/Test_Customers_Controller.php @@ -524,4 +524,39 @@ public function test_customer_search_with_excludes(): void { $this->assertEquals( 1, \count( $data ) ); $this->assertEquals( $customer1->get_id(), $data[0]['id'] ); } + + /** + * + */ + public function test_customer_uuid_is_unique(): void { + $uuid = UUID::uuid4()->toString(); + $customer1 = CustomerHelper::create_customer(); + $customer1->update_meta_data( '_woocommerce_pos_uuid', $uuid ); + $customer1->save_meta_data(); + $customer2 = CustomerHelper::create_customer(); + $customer2->update_meta_data( '_woocommerce_pos_uuid', $uuid ); + $customer2->save_meta_data(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/customers' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 2, \count( $data ) ); + + // pluck uuids from meta_data + $uuids = array(); + foreach ( $data as $customer ) { + foreach ( $customer['meta_data'] as $meta ) { + if ( '_woocommerce_pos_uuid' === $meta['key'] ) { + $uuids[] = $meta['value']; + } + } + } + + $this->assertEquals( 2, \count( $uuids ) ); + $this->assertContains( $uuid, $uuids ); + $this->assertEquals( 2, \count( array_unique( $uuids ) ) ); + } } diff --git a/tests/includes/API/Test_Product_Categories_Controller.php b/tests/includes/API/Test_Product_Categories_Controller.php index fb7dc73..b9ce291 100644 --- a/tests/includes/API/Test_Product_Categories_Controller.php +++ b/tests/includes/API/Test_Product_Categories_Controller.php @@ -184,4 +184,30 @@ public function test_product_category_search_with_excludes() { $this->assertEquals( $cat2['term_id'], $data[0]['id'] ); } + + /** + * + */ + public function test_unique_product_category_uuid() { + $uuid = UUID::uuid4()->toString(); + $cat1 = ProductHelper::create_product_category( 'Music1' ); + add_term_meta( $cat1['term_id'], '_woocommerce_pos_uuid', $uuid ); + + $cat2 = ProductHelper::create_product_category( 'Music2' ); + add_term_meta( $cat2['term_id'], '_woocommerce_pos_uuid', $uuid ); + $request = $this->wp_rest_get_request( '/wcpos/v1/products/categories' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 3, \count( $data ) ); + + // pluck uuids + $uuids = wp_list_pluck( $data, 'uuid' ); + + $this->assertEquals( 3, \count( $uuids ) ); + $this->assertContains( $uuid, $uuids ); + $this->assertEquals( 3, \count( array_unique( $uuids ) ) ); + } } diff --git a/tests/includes/API/Test_Products_Controller.php b/tests/includes/API/Test_Products_Controller.php index 6395f74..6e9959c 100644 --- a/tests/includes/API/Test_Products_Controller.php +++ b/tests/includes/API/Test_Products_Controller.php @@ -787,4 +787,39 @@ public function test_filter_on_sale_with_excludes() { $ids = wp_list_pluck( $data, 'id' ); $this->assertEquals( array( $product3->get_id() ), $ids ); } + + /** + * + */ + public function test_uuid_is_unique() { + $uuid = UUID::uuid4()->toString(); + $product1 = ProductHelper::create_simple_product(); + $product1->update_meta_data( '_woocommerce_pos_uuid', $uuid ); + $product1->save_meta_data(); + $product2 = ProductHelper::create_simple_product(); + $product2->update_meta_data( '_woocommerce_pos_uuid', $uuid ); + $product2->save_meta_data(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/products' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 2, \count( $data ) ); + + // pluck uuids from meta_data + $uuids = array(); + foreach ( $data as $product ) { + foreach ( $product['meta_data'] as $meta ) { + if ( '_woocommerce_pos_uuid' === $meta['key'] ) { + $uuids[] = $meta['value']; + } + } + } + + $this->assertEquals( 2, \count( $uuids ) ); + $this->assertContains( $uuid, $uuids ); + $this->assertEquals( 2, \count( array_unique( $uuids ) ) ); + } } diff --git a/woocommerce-pos.php b/woocommerce-pos.php index c78cfa4..2743772 100644 --- a/woocommerce-pos.php +++ b/woocommerce-pos.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce POS * Plugin URI: https://wordpress.org/plugins/woocommerce-pos/ * Description: A simple front-end for taking WooCommerce orders at the Point of Sale. Requires WooCommerce. - * Version: 1.4.7 + * Version: 1.4.8 * Author: kilbot * Author URI: http://wcpos.com * Text Domain: woocommerce-pos @@ -22,7 +22,7 @@ namespace WCPOS\WooCommercePOS; // Define plugin constants. -const VERSION = '1.4.7'; +const VERSION = '1.4.8'; const PLUGIN_NAME = 'woocommerce-pos'; const SHORT_NAME = 'wcpos'; \define( __NAMESPACE__ . '\PLUGIN_FILE', plugin_basename( __FILE__ ) ); // 'woocommerce-pos/woocommerce-pos.php'