diff --git a/composer.json b/composer.json index 00a990486c..0f17c6d53b 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ }, "autoload": { "psr-4": { - "WeDevs\\Dokan\\": "includes/" + "WeDevs\\Dokan\\": "includes/", + "WeDevs\\Dokan\\ThirdParty\\Packages\\": "lib/packages/" }, "files": [ "includes/functions-rest-api.php", diff --git a/docs/analytics/reports.md b/docs/analytics/reports.md new file mode 100644 index 0000000000..0b7b07ef2a --- /dev/null +++ b/docs/analytics/reports.md @@ -0,0 +1,19 @@ +- [Introduction](#introduction) +- [Custom Products Stats Datastore](#custom-products-stats-datastore) + +## Introduction +To handle **Dokan Orders**, we followed the [WooCommerce Admin Reports Extension Guidelines](https://github.com/woocommerce/woocommerce/blob/trunk/docs/reporting/extending-woocommerce-admin-reports.md#handle-currency-parameters-on-the-server). + +## Custom Stats Datastore + +We need to customize the default *WooCommerce Analytics Datastore* for some reports. For example, we replaced the [WC Products Stats DataStore](https://github.com/woocommerce/woocommerce/blob/9297409c5a705d1cd0ae65ec9b058271bd90851e/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php#L170) with the [Dokan Product Stats Store](./../../includes/Analytics/Reports/Products/Stats/WcDataStore.php). This modification involves overriding the `$total_query` and `$interval_query` properties by substituting the `Automattic\WooCommerce\Admin\API\Reports\SqlQuery` class with `WeDevs\Dokan\Analytics\Reports\WcSqlQuery`. + +The primary change was to update the `get_sql_clause( $type, $handling = 'unfiltered' )` method to `get_sql_clause( $type, $handling = '' )`, allowing us to apply necessary filters for adding JOIN and WHERE clauses to the `dokan_order_stats` table. + +### Implementation Steps + +- **Step 1:** Create the [WcSqlQuery](./../../includes/Analytics/Reports/DataStoreModifier.php) class to override the `get_sql_clause( $type, $handling = 'unfiltered' )` method from the [WC SqlQuery](https://github.com/woocommerce/woocommerce/blob/9297409c5a705d1cd0ae65ec9b058271bd90851e/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php#L87) class. The new method should use `get_sql_clause( $type, $handling = '' )`. + +- **Step 2:** Implement the [WcDataStore](https://github.com/woocommerce/woocommerce/blob/9297409c5a705d1cd0ae65ec9b058271bd90851e/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php#L170) class to set the `$total_query` and `$interval_query` properties with instance of **WcSqlQuery**. + +- **Step 3:** Use the `woocommerce_data_stores` filter within the [DataStoreModifier](./../../includes/Analytics/Reports/DataStoreModifier.php) class to replace the default WooCommerce Products Stats datastore with the custom Dokan Product Stats Store. \ No newline at end of file diff --git a/docs/tdd/get-started.md b/docs/tdd/get-started.md index 28850f0cd7..32bdd47483 100644 --- a/docs/tdd/get-started.md +++ b/docs/tdd/get-started.md @@ -301,4 +301,4 @@ $array = [ // Use the custom assertion method $this->assertNestedContains( [ 'subkey1' => 'value1' ], $array ); $this->assertNestedContains( [ 'key2' => 'value3' ], $array ); -``` \ No newline at end of file +``` diff --git a/dokan-class.php b/dokan-class.php new file mode 100755 index 0000000000..12392395ad --- /dev/null +++ b/dokan-class.php @@ -0,0 +1,482 @@ +define_constants(); + + register_activation_hook( DOKAN_FILE, [ $this, 'activate' ] ); + register_deactivation_hook( DOKAN_FILE, [ $this, 'deactivate' ] ); + + add_action( 'before_woocommerce_init', [ $this, 'declare_woocommerce_feature_compatibility' ] ); + add_action( 'woocommerce_loaded', [ $this, 'init_plugin' ] ); + add_action( 'woocommerce_flush_rewrite_rules', [ $this, 'flush_rewrite_rules' ] ); + + // Register admin notices to container and load notices + $this->get_container()->get( 'admin_notices' ); + + $this->init_appsero_tracker(); + + add_action( 'plugins_loaded', [ $this, 'woocommerce_not_loaded' ], 11 ); + } + + /** + * Initializes the WeDevs_Dokan() class + * + * Checks for an existing WeDevs_WeDevs_Dokan() instance + * and if it doesn't find one, create it. + */ + public static function init() { + if ( self::$instance === null ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Magic getter to bypass referencing objects + * + * @since 2.6.10 + * + * @param string $prop + * + * @return object Class Instance + */ + public function __get( $prop ) { + if ( $this->get_container()->has( $prop ) ) { + return $this->get_container()->get( $prop ); + } + + if ( array_key_exists( $prop, $this->legacy_container ) ) { + return $this->legacy_container[ $prop ]; + } + } + + /** + * Check if the PHP version is supported + * + * @return bool + */ + public function is_supported_php() { + if ( version_compare( PHP_VERSION, $this->min_php, '<=' ) ) { + return false; + } + + return true; + } + + /** + * Get the plugin path. + * + * @return string + */ + public function plugin_path() { + return untrailingslashit( plugin_dir_path( __FILE__ ) ); + } + + /** + * Get the template path. + * + * @return string + */ + public function template_path() { + return apply_filters( 'dokan_template_path', 'dokan/' ); + } + + /** + * Placeholder for activation function + * + * Nothing being called here yet. + */ + public function activate() { + if ( ! $this->has_woocommerce() ) { + set_transient( 'dokan_wc_missing_notice', true ); + } + + if ( ! $this->is_supported_php() ) { + require_once WC_ABSPATH . 'includes/wc-notice-functions.php'; + + /* translators: 1: Required PHP Version 2: Running php version */ + wc_print_notice( sprintf( __( 'The Minimum PHP Version Requirement for Dokan is %1$s. You are Running PHP %2$s', 'dokan-lite' ), $this->min_php, phpversion() ), 'error' ); + exit; + } + + require_once __DIR__ . '/includes/functions.php'; + require_once __DIR__ . '/includes/functions-compatibility.php'; + + $this->get_container()->get( 'upgrades' ); + $installer = new \WeDevs\Dokan\Install\Installer(); + $installer->do_install(); + + // rewrite rules during dokan activation + if ( $this->has_woocommerce() ) { + $this->flush_rewrite_rules(); + } + } + + /** + * Flush rewrite rules after dokan is activated or woocommerce is activated + * + * @since 3.2.8 + */ + public function flush_rewrite_rules() { + // fix rewrite rules + $this->get_container()->get( 'rewrite' )->register_rule(); + flush_rewrite_rules(); + } + + /** + * Placeholder for deactivation function + * + * Nothing being called here yet. + */ + public function deactivate() { + delete_transient( 'dokan_wc_missing_notice', true ); + } + + /** + * Initialize plugin for localization + * + * @uses load_plugin_textdomain() + */ + public function localization_setup() { + load_plugin_textdomain( 'dokan-lite', false, dirname( plugin_basename( __FILE__ ) ) . '/languages/' ); + } + + /** + * Define all constants + * + * @return void + */ + public function define_constants() { + defined( 'DOKAN_PLUGIN_VERSION' ) || define( 'DOKAN_PLUGIN_VERSION', $this->version ); + defined( 'DOKAN_DIR' ) || define( 'DOKAN_DIR', __DIR__ ); + defined( 'DOKAN_INC_DIR' ) || define( 'DOKAN_INC_DIR', __DIR__ . '/includes' ); + defined( 'DOKAN_LIB_DIR' ) || define( 'DOKAN_LIB_DIR', __DIR__ . '/lib' ); + defined( 'DOKAN_PLUGIN_ASSEST' ) || define( 'DOKAN_PLUGIN_ASSEST', plugins_url( 'assets', __FILE__ ) ); + + // give a way to turn off loading styles and scripts from parent theme + defined( 'DOKAN_LOAD_STYLE' ) || define( 'DOKAN_LOAD_STYLE', true ); + defined( 'DOKAN_LOAD_SCRIPTS' ) || define( 'DOKAN_LOAD_SCRIPTS', true ); + } + + /** + * Add High Performance Order Storage Support + * + * @since 3.8.0 + * + * @return void + */ + public function declare_woocommerce_feature_compatibility() { + if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true ); + } + } + + /** + * Load the plugin after WP User Frontend is loaded + * + * @return void + */ + public function init_plugin() { + $this->includes(); + $this->init_hooks(); + + do_action( 'dokan_loaded' ); + } + + /** + * Initialize the actions + * + * @return void + */ + public function init_hooks() { + // Localize our plugin + add_action( 'init', [ $this, 'localization_setup' ] ); + + // initialize the classes + add_action( 'init', [ $this, 'init_classes' ], 4 ); + add_action( 'init', [ $this, 'wpdb_table_shortcuts' ], 1 ); + + add_action( 'plugins_loaded', [ $this, 'after_plugins_loaded' ] ); + + add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), [ $this, 'plugin_action_links' ] ); + add_action( 'in_plugin_update_message-dokan-lite/dokan.php', [ \WeDevs\Dokan\Install\Installer::class, 'in_plugin_update_message' ] ); + + add_action( 'widgets_init', [ $this, 'register_widgets' ] ); + } + + /** + * Include all the required files + * + * @return void + */ + public function includes() { + require_once DOKAN_DIR . '/deprecated/deprecated-functions.php'; + require_once DOKAN_DIR . '/deprecated/deprecated-hooks.php'; + require_once DOKAN_INC_DIR . '/functions.php'; + + if ( ! function_exists( 'dokan_pro' ) ) { + require_once DOKAN_INC_DIR . '/reports.php'; + } + + require_once DOKAN_INC_DIR . '/Order/functions.php'; + require_once DOKAN_INC_DIR . '/Product/functions.php'; + require_once DOKAN_INC_DIR . '/Withdraw/functions.php'; + require_once DOKAN_INC_DIR . '/functions-compatibility.php'; + require_once DOKAN_INC_DIR . '/wc-functions.php'; + + require_once DOKAN_INC_DIR . '/wc-template.php'; + require_once DOKAN_DIR . '/deprecated/deprecated-classes.php'; + + if ( is_admin() ) { + require_once DOKAN_INC_DIR . '/Admin/functions.php'; + } else { + require_once DOKAN_INC_DIR . '/template-tags.php'; + } + + require_once DOKAN_INC_DIR . '/store-functions.php'; + } + + /** + * Init all the classes + * + * @return void + */ + public function init_classes() { + $common_services = $this->get_container()->get( 'common-service' ); + + if ( is_admin() ) { + $admin_services = $this->get_container()->get( 'admin-service' ); + } else { + $frontend_services = $this->get_container()->get( 'frontend-service' ); + } + + $container_services = $this->get_container()->get( 'container-service' ); + + $this->legacy_container = apply_filters( 'dokan_get_class_container', $this->legacy_container ); + + if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { + $ajax_services = $this->get_container()->get( 'ajax-service' ); + } + } + + /** + * Load table prefix for withdraw and orders table + * + * @since 1.0 + * + * @return void + */ + public function wpdb_table_shortcuts() { + global $wpdb; + + $wpdb->dokan_withdraw = $wpdb->prefix . 'dokan_withdraw'; + $wpdb->dokan_orders = $wpdb->prefix . 'dokan_orders'; + $wpdb->dokan_announcement = $wpdb->prefix . 'dokan_announcement'; + $wpdb->dokan_refund = $wpdb->prefix . 'dokan_refund'; + $wpdb->dokan_vendor_balance = $wpdb->prefix . 'dokan_vendor_balance'; + } + + /** + * Executed after all plugins are loaded + * + * At this point Dokan Pro is loaded + * + * @since 2.8.7 + * + * @return void + */ + public function after_plugins_loaded() { + // Initiate background processes + $processes = get_option( 'dokan_background_processes', [] ); + + if ( ! empty( $processes ) ) { + $update = false; + foreach ( $processes as $processor => $file ) { + if ( file_exists( $file ) ) { + include_once $file; + new $processor(); + } else { + $update = true; + unset( $processes[ $processor ] ); + } + } + if ( $update ) { + update_option( 'dokan_background_processes', $processes ); + } + } + } + + /** + * Register widgets + * + * @since 2.8 + * + * @return void + */ + public function register_widgets() { + $this->get_container()->get( 'widgets' ); + } + + /** + * Returns if the plugin is in PRO version + * + * @since 2.4 + * + * @return bool + */ + public function is_pro_exists() { + return apply_filters( 'dokan_is_pro_exists', false ); + } + + /** + * Plugin action links + * + * @param array $links + * + * @since 2.4 + * + * @return array + */ + public function plugin_action_links( $links ) { + if ( ! $this->is_pro_exists() ) { + $links[] = '' . __( 'Get Pro', 'dokan-lite' ) . ''; + } + + $links[] = '' . __( 'Settings', 'dokan-lite' ) . ''; + $links[] = '' . __( 'Documentation', 'dokan-lite' ) . ''; + + return $links; + } + + /** + * Initialize Appsero Tracker + * + * @return void + */ + public function init_appsero_tracker() { + $this->get_container()->get( 'tracker' ); + } + + /** + * Check whether woocommerce is installed and active + * + * @since 2.9.16 + * + * @return bool + */ + public function has_woocommerce() { + return class_exists( 'WooCommerce' ); + } + + /** + * Check whether woocommerce is installed + * + * @since 3.2.8 + * + * @return bool + */ + public function is_woocommerce_installed() { + return in_array( 'woocommerce/woocommerce.php', array_keys( get_plugins() ), true ); + } + + /** + * Handles scenerios when WooCommerce is not active + * + * @since 2.9.27 + * + * @return void + */ + public function woocommerce_not_loaded() { + if ( did_action( 'woocommerce_loaded' ) || ! is_admin() ) { + return; + } + + require_once DOKAN_INC_DIR . '/functions.php'; + + if ( get_transient( '_dokan_setup_page_redirect' ) ) { + dokan_redirect_to_admin_setup_wizard(); + } + + new \WeDevs\Dokan\Admin\SetupWizardNoWC(); + } + + /** + * Get Dokan db version key + * + * @since 3.0.0 + * + * @return string + */ + public function get_db_version_key() { + return $this->db_version_key; + } + + public function get_container(): Container { + return dokan_get_container(); + } +} diff --git a/dokan.php b/dokan.php index cbf4cc802c..71d5c57270 100755 --- a/dokan.php +++ b/dokan.php @@ -45,551 +45,44 @@ exit; } -/** - * WeDevs_Dokan class - * - * @class WeDevs_Dokan The class that holds the entire WeDevs_Dokan plugin - * - * @property WeDevs\Dokan\Commission $commission Instance of Commission class - * @property WeDevs\Dokan\Order\Manager $order Instance of Order Manager class - * @property WeDevs\Dokan\Product\Manager $product Instance of Order Manager class - * @property WeDevs\Dokan\Vendor\Manager $vendor Instance of Vendor Manager Class - * @property WeDevs\Dokan\BackgroundProcess\Manager $bg_process Instance of WeDevs\Dokan\BackgroundProcess\Manager class - * @property WeDevs\Dokan\Withdraw\Manager $withdraw Instance of WeDevs\Dokan\Withdraw\Manager class - * @property WeDevs\Dokan\Frontend\Frontend $frontend_manager Instance of \WeDevs\Dokan\Frontend\Frontend class - * @property WeDevs\Dokan\Registration $registration Instance of WeDevs\Dokan\Registration class - */ -final class WeDevs_Dokan { - - /** - * Plugin version - * - * @var string - */ - public $version = '3.11.5'; - - /** - * Instance of self - * - * @var WeDevs_Dokan - */ - private static $instance = null; - - /** - * Minimum PHP version required - * - * @var string - */ - private $min_php = '7.4'; - - /** - * Holds various class instances - * - * @since 2.6.10 - * - * @var array - */ - private $container = []; - - /** - * Databse version key - * - * @since 3.0.0 - * - * @var string - */ - private $db_version_key = 'dokan_theme_version'; - - /** - * Constructor for the WeDevs_Dokan class - * - * Sets up all the appropriate hooks and actions - * within our plugin. - */ - private function __construct() { - require_once __DIR__ . '/vendor/autoload.php'; - - $this->define_constants(); - - register_activation_hook( __FILE__, [ $this, 'activate' ] ); - register_deactivation_hook( __FILE__, [ $this, 'deactivate' ] ); - - add_action( 'before_woocommerce_init', [ $this, 'declare_woocommerce_feature_compatibility' ] ); - add_action( 'woocommerce_loaded', [ $this, 'init_plugin' ] ); - add_action( 'woocommerce_flush_rewrite_rules', [ $this, 'flush_rewrite_rules' ] ); - - // Register admin notices to container and load notices - $this->container['admin_notices'] = new \WeDevs\Dokan\Admin\Notices\Manager(); - - $this->init_appsero_tracker(); - - add_action( 'plugins_loaded', [ $this, 'woocommerce_not_loaded' ], 11 ); - } - - /** - * Initializes the WeDevs_Dokan() class - * - * Checks for an existing WeDevs_WeDevs_Dokan() instance - * and if it doesn't find one, create it. - */ - public static function init() { - if ( self::$instance === null ) { - self::$instance = new self(); - } - - return self::$instance; - } - - /** - * Magic getter to bypass referencing objects - * - * @since 2.6.10 - * - * @param string $prop - * - * @return object Class Instance - */ - public function __get( $prop ) { - if ( array_key_exists( $prop, $this->container ) ) { - return $this->container[ $prop ]; - } - } - - /** - * Check if the PHP version is supported - * - * @return bool - */ - public function is_supported_php() { - if ( version_compare( PHP_VERSION, $this->min_php, '<=' ) ) { - return false; - } - - return true; - } - - /** - * Get the plugin path. - * - * @return string - */ - public function plugin_path() { - return untrailingslashit( plugin_dir_path( __FILE__ ) ); - } - - /** - * Get the template path. - * - * @return string - */ - public function template_path() { - return apply_filters( 'dokan_template_path', 'dokan/' ); - } - - /** - * Placeholder for activation function - * - * Nothing being called here yet. - */ - public function activate() { - if ( ! $this->has_woocommerce() ) { - set_transient( 'dokan_wc_missing_notice', true ); - } - - if ( ! $this->is_supported_php() ) { - require_once WC_ABSPATH . 'includes/wc-notice-functions.php'; - - /* translators: 1: Required PHP Version 2: Running php version */ - wc_print_notice( sprintf( __( 'The Minimum PHP Version Requirement for Dokan is %1$s. You are Running PHP %2$s', 'dokan-lite' ), $this->min_php, phpversion() ), 'error' ); - exit; - } - - require_once __DIR__ . '/includes/functions.php'; - require_once __DIR__ . '/includes/functions-compatibility.php'; - - $this->container['upgrades'] = new \WeDevs\Dokan\Upgrade\Manager(); - $installer = new \WeDevs\Dokan\Install\Installer(); - $installer->do_install(); - - // rewrite rules during dokan activation - if ( $this->has_woocommerce() ) { - $this->flush_rewrite_rules(); - } - } - - /** - * Flush rewrite rules after dokan is activated or woocommerce is activated - * - * @since 3.2.8 - */ - public function flush_rewrite_rules() { - // fix rewrite rules - if ( ! isset( $this->container['rewrite'] ) ) { - $this->container['rewrite'] = new \WeDevs\Dokan\Rewrites(); - } - $this->container['rewrite']->register_rule(); - flush_rewrite_rules(); - } - - /** - * Placeholder for deactivation function - * - * Nothing being called here yet. - */ - public function deactivate() { - delete_transient( 'dokan_wc_missing_notice', true ); - } - - /** - * Initialize plugin for localization - * - * @uses load_plugin_textdomain() - */ - public function localization_setup() { - load_plugin_textdomain( 'dokan-lite', false, dirname( plugin_basename( __FILE__ ) ) . '/languages/' ); - } - - /** - * Define all constants - * - * @return void - */ - public function define_constants() { - $this->define( 'DOKAN_PLUGIN_VERSION', $this->version ); - $this->define( 'DOKAN_FILE', __FILE__ ); - $this->define( 'DOKAN_DIR', __DIR__ ); - $this->define( 'DOKAN_INC_DIR', __DIR__ . '/includes' ); - $this->define( 'DOKAN_LIB_DIR', __DIR__ . '/lib' ); - $this->define( 'DOKAN_PLUGIN_ASSEST', plugins_url( 'assets', __FILE__ ) ); - - // give a way to turn off loading styles and scripts from parent theme - $this->define( 'DOKAN_LOAD_STYLE', true ); - $this->define( 'DOKAN_LOAD_SCRIPTS', true ); - } - - /** - * Define constant if not already defined - * - * @since 2.9.16 - * - * @param string $name - * @param string|bool $value - * - * @return void - */ - private function define( $name, $value ) { - if ( ! defined( $name ) ) { - define( $name, $value ); - } - } +require_once __DIR__ . '/vendor/autoload.php'; +// Load files for loading the WeDevs_Dokan class. +require_once __DIR__ . '/dokan-class.php'; - /** - * Add High Performance Order Storage Support - * - * @since 3.8.0 - * - * @return void - */ - public function declare_woocommerce_feature_compatibility() { - if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { - \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); - \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true ); - } - } +// Define constant for the Plugin file. +defined( 'DOKAN_FILE' ) || define( 'DOKAN_FILE', __FILE__ ); - /** - * Load the plugin after WP User Frontend is loaded - * - * @return void - */ - public function init_plugin() { - $this->includes(); - $this->init_hooks(); +// Use the necessary namespace. +use WeDevs\Dokan\DependencyManagement\Container; - do_action( 'dokan_loaded' ); - } +// Declare the $dokan_container as global to access from the inside of the function. +global $dokan_container; - /** - * Initialize the actions - * - * @return void - */ - public function init_hooks() { - // Localize our plugin - add_action( 'init', [ $this, 'localization_setup' ] ); +// Instantiate the container. +$dokan_container = new Container(); - // initialize the classes - add_action( 'init', [ $this, 'init_classes' ], 4 ); - add_action( 'init', [ $this, 'wpdb_table_shortcuts' ], 1 ); +// Register the service providers. +$dokan_container->addServiceProvider( new \WeDevs\Dokan\DependencyManagement\Providers\ServiceProvider() ); - add_action( 'plugins_loaded', [ $this, 'after_plugins_loaded' ] ); - - add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), [ $this, 'plugin_action_links' ] ); - add_action( 'in_plugin_update_message-dokan-lite/dokan.php', [ \WeDevs\Dokan\Install\Installer::class, 'in_plugin_update_message' ] ); - - add_action( 'widgets_init', [ $this, 'register_widgets' ] ); - } - - /** - * Include all the required files - * - * @return void - */ - public function includes() { - require_once DOKAN_DIR . '/deprecated/deprecated-functions.php'; - require_once DOKAN_DIR . '/deprecated/deprecated-hooks.php'; - require_once DOKAN_INC_DIR . '/functions.php'; - - if ( ! function_exists( 'dokan_pro' ) ) { - require_once DOKAN_INC_DIR . '/reports.php'; - } - - require_once DOKAN_INC_DIR . '/Order/functions.php'; - require_once DOKAN_INC_DIR . '/Product/functions.php'; - require_once DOKAN_INC_DIR . '/Withdraw/functions.php'; - require_once DOKAN_INC_DIR . '/functions-compatibility.php'; - require_once DOKAN_INC_DIR . '/wc-functions.php'; - - require_once DOKAN_INC_DIR . '/wc-template.php'; - require_once DOKAN_DIR . '/deprecated/deprecated-classes.php'; - - if ( is_admin() ) { - require_once DOKAN_INC_DIR . '/Admin/functions.php'; - } else { - require_once DOKAN_INC_DIR . '/template-tags.php'; - } - - require_once DOKAN_INC_DIR . '/store-functions.php'; - } - - /** - * Init all the classes - * - * @return void - */ - public function init_classes() { - new \WeDevs\Dokan\Withdraw\Hooks(); - new \WeDevs\Dokan\Product\Hooks(); - new \WeDevs\Dokan\ProductCategory\Hooks(); - new \WeDevs\Dokan\Vendor\Hooks(); - new \WeDevs\Dokan\Upgrade\Hooks(); - new \WeDevs\Dokan\Vendor\UserSwitch(); - new \WeDevs\Dokan\CacheInvalidate(); - new \WeDevs\Dokan\Shipping\Hooks(); - - if ( is_admin() ) { - new \WeDevs\Dokan\Admin\Hooks(); - new \WeDevs\Dokan\Admin\Menu(); - new \WeDevs\Dokan\Admin\AdminBar(); - new \WeDevs\Dokan\Admin\Pointers(); - new \WeDevs\Dokan\Admin\Settings(); - new \WeDevs\Dokan\Admin\UserProfile(); - new \WeDevs\Dokan\Admin\SetupWizard(); - } else { - new \WeDevs\Dokan\Vendor\StoreListsFilter(); - new \WeDevs\Dokan\ThemeSupport\Manager(); - } - - $this->container['product_block'] = new \WeDevs\Dokan\Blocks\ProductBlock(); - $this->container['pageview'] = new \WeDevs\Dokan\PageViews(); - $this->container['seller_wizard'] = new \WeDevs\Dokan\Vendor\SetupWizard(); - $this->container['core'] = new \WeDevs\Dokan\Core(); - $this->container['scripts'] = new \WeDevs\Dokan\Assets(); - $this->container['email'] = new \WeDevs\Dokan\Emails\Manager(); - $this->container['vendor'] = new \WeDevs\Dokan\Vendor\Manager(); - $this->container['product'] = new \WeDevs\Dokan\Product\Manager(); - $this->container['shortcodes'] = new \WeDevs\Dokan\Shortcodes\Shortcodes(); - $this->container['registration'] = new \WeDevs\Dokan\Registration(); - $this->container['order'] = new \WeDevs\Dokan\Order\Manager(); - $this->container['order_controller'] = new \WeDevs\Dokan\Order\Controller(); - $this->container['api'] = new \WeDevs\Dokan\REST\Manager(); - $this->container['withdraw'] = new \WeDevs\Dokan\Withdraw\Manager(); - $this->container['dashboard'] = new \WeDevs\Dokan\Dashboard\Manager(); - $this->container['commission'] = new \WeDevs\Dokan\Commission(); - $this->container['customizer'] = new \WeDevs\Dokan\Customizer(); - $this->container['upgrades'] = new \WeDevs\Dokan\Upgrade\Manager(); - $this->container['product_sections'] = new \WeDevs\Dokan\ProductSections\Manager(); - $this->container['reverse_withdrawal'] = new \WeDevs\Dokan\ReverseWithdrawal\ReverseWithdrawal(); - $this->container['dummy_data_importer'] = new \WeDevs\Dokan\DummyData\Importer(); - $this->container['catalog_mode'] = new \WeDevs\Dokan\CatalogMode\Controller(); - $this->container['bg_process'] = new \WeDevs\Dokan\BackgroundProcess\Manager(); - $this->container['frontend_manager'] = new \WeDevs\Dokan\Frontend\Frontend(); - - //fix rewrite rules - if ( ! isset( $this->container['rewrite'] ) ) { - $this->container['rewrite'] = new \WeDevs\Dokan\Rewrites(); - } - - $this->container = apply_filters( 'dokan_get_class_container', $this->container ); - - if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { - new \WeDevs\Dokan\Ajax(); - } - - new \WeDevs\Dokan\Privacy(); - } - - /** - * Load table prefix for withdraw and orders table - * - * @since 1.0 - * - * @return void - */ - public function wpdb_table_shortcuts() { - global $wpdb; - - $wpdb->dokan_withdraw = $wpdb->prefix . 'dokan_withdraw'; - $wpdb->dokan_orders = $wpdb->prefix . 'dokan_orders'; - $wpdb->dokan_announcement = $wpdb->prefix . 'dokan_announcement'; - $wpdb->dokan_refund = $wpdb->prefix . 'dokan_refund'; - $wpdb->dokan_vendor_balance = $wpdb->prefix . 'dokan_vendor_balance'; - } - - /** - * Executed after all plugins are loaded - * - * At this point Dokan Pro is loaded - * - * @since 2.8.7 - * - * @return void - */ - public function after_plugins_loaded() { - // Initiate background processes - $processes = get_option( 'dokan_background_processes', [] ); - - if ( ! empty( $processes ) ) { - $update = false; - foreach ( $processes as $processor => $file ) { - if ( file_exists( $file ) ) { - include_once $file; - new $processor(); - } else { - $update = true; - unset( $processes[ $processor ] ); - } - } - if ( $update ) { - update_option( 'dokan_background_processes', $processes ); - } - } - } - - /** - * Register widgets - * - * @since 2.8 - * - * @return void - */ - public function register_widgets() { - $this->container['widgets'] = new \WeDevs\Dokan\Widgets\Manager(); - } - - /** - * Returns if the plugin is in PRO version - * - * @since 2.4 - * - * @return bool - */ - public function is_pro_exists() { - return apply_filters( 'dokan_is_pro_exists', false ); - } - - /** - * Plugin action links - * - * @param array $links - * - * @since 2.4 - * - * @return array - */ - public function plugin_action_links( $links ) { - if ( ! $this->is_pro_exists() ) { - $links[] = '' . __( 'Get Pro', 'dokan-lite' ) . ''; - } - - $links[] = '' . __( 'Settings', 'dokan-lite' ) . ''; - $links[] = '' . __( 'Documentation', 'dokan-lite' ) . ''; - - return $links; - } - - /** - * Initialize Appsero Tracker - * - * @return void - */ - public function init_appsero_tracker() { - $this->container['tracker'] = new \WeDevs\Dokan\Tracker(); - } - - /** - * Check whether woocommerce is installed and active - * - * @since 2.9.16 - * - * @return bool - */ - public function has_woocommerce() { - return class_exists( 'WooCommerce' ); - } - - /** - * Check whether woocommerce is installed - * - * @since 3.2.8 - * - * @return bool - */ - public function is_woocommerce_installed() { - return in_array( 'woocommerce/woocommerce.php', array_keys( get_plugins() ), true ); - } - - /** - * Handles scenerios when WooCommerce is not active - * - * @since 2.9.27 - * - * @return void - */ - public function woocommerce_not_loaded() { - if ( did_action( 'woocommerce_loaded' ) || ! is_admin() ) { - return; - } - - require_once DOKAN_INC_DIR . '/functions.php'; - - if ( get_transient( '_dokan_setup_page_redirect' ) ) { - dokan_redirect_to_admin_setup_wizard(); - } - - new \WeDevs\Dokan\Admin\SetupWizardNoWC(); - } +/** + * Get the container. + * + * @return Container The global container instance. + */ +function dokan_get_container(): Container { + global $dokan_container; - /** - * Get Dokan db version key - * - * @since 3.0.0 - * - * @return string - */ - public function get_db_version_key() { - return $this->db_version_key; - } + return $dokan_container; } /** - * Load Dokan Plugin when all plugins loaded + * Load Dokan Plugin when all plugins loaded. * - * @return WeDevs_Dokan + * @return WeDevs_Dokan The singleton instance of WeDevs_Dokan. */ -function dokan() { // phpcs:ignore +function dokan() { return WeDevs_Dokan::init(); } -// Lets Go.... +// Let's go... dokan(); diff --git a/includes/Analytics/Reports/BaseQueryFilter.php b/includes/Analytics/Reports/BaseQueryFilter.php new file mode 100644 index 0000000000..dbb7de1292 --- /dev/null +++ b/includes/Analytics/Reports/BaseQueryFilter.php @@ -0,0 +1,179 @@ +register_hooks(); + } + + /** + * Add join clause for Dokan order state table in WooCommerce analytics queries. + * + * @param array $clauses The existing join clauses. + * + * @return array The modified join clauses. + */ + public function add_join_subquery( array $clauses ): array { + global $wpdb; + + $dokan_order_state_table = $this->get_dokan_table(); + + $clauses[] = "JOIN {$dokan_order_state_table} ON {$wpdb->prefix}{$this->wc_table}.order_id = {$dokan_order_state_table}.order_id"; + + return array_unique( $clauses ); + } + + /** + * Add where clause for Dokan order state in WooCommerce analytics queries. + * + * @param array $clauses The existing where clauses. + * + * @return array The modified where clauses. + */ + public function add_where_subquery( array $clauses ): array { + $dokan_order_state_table = $this->get_dokan_table(); + $order_types = $this->get_order_and_refund_types_to_include(); + + $clauses[] = "AND {$dokan_order_state_table}.order_type in ( $order_types ) "; + + $clauses = $this->add_where_subquery_for_refund( $clauses ); + $clauses = $this->add_where_subquery_for_seller_filter( $clauses ); + + return array_unique( $clauses ); + } + + /** + * Add where clause for refunds in WooCommerce analytics queries. + * + * @param array $clauses The existing where clauses. + * + * @return array The modified where clauses. + */ + protected function add_where_subquery_for_refund( array $clauses ): array { + if ( ! isset( $_GET['refunds'] ) ) { + return $clauses; + } + + $dokan_order_state_table = $this->get_dokan_table(); + $order_types = $this->get_refund_types_to_include(); + + $clauses[] = "AND {$dokan_order_state_table}.order_type in ( $order_types ) "; + + return $clauses; + } + + /** + * Determine if the query should be filtered by seller ID. + * + * @return bool True if the query should be filtered by seller ID, false otherwise. + */ + public function should_filter_by_seller_id(): bool { + return true; + } + + /** + * Get the order types to include in WooCommerce analytics queries. + * + * @return string The order types to include. + */ + protected function get_order_and_refund_types_to_include(): string { + $order_type = new OrderType(); + + if ( $this->should_filter_by_seller_id() ) { + return implode( ',', $order_type->get_seller_order_types() ); + } + + return implode( ',', $order_type->get_admin_order_types() ); + } + + /** + * Get the refund types to include in WooCommerce analytics queries. + * + * @return string The refund types to include. + */ + protected function get_refund_types_to_include(): string { + $order_type = new OrderType(); + + if ( $this->should_filter_by_seller_id() ) { + return implode( ',', $order_type->get_seller_refund_types() ); + } + + return implode( ',', $order_type->get_admin_refund_types() ); + } + + protected function get_dokan_table(): string { + return DataStore::get_db_table_name(); + } + + /** + * Get the non refund order types to include in WooCommerce analytics queries. + * + * @return string The refund types to include. + */ + protected function get_non_refund_order_types_to_include(): string { + $order_type = new OrderType(); + + if ( $this->should_filter_by_seller_id() ) { + return implode( ',', $order_type->get_seller_non_refund_order_types() ); + } + + return implode( ',', $order_type->get_admin_non_refund_order_types() ); + } + + /** + * Add where clause for seller query filter in WooCommerce analytics queries. + * + * @param array $clauses The existing where clauses. + * + * @return array The modified where clauses. + */ + protected function add_where_subquery_for_seller_filter( array $clauses ): array { + $seller_id = $this->get_seller_id(); + + if ( ! $seller_id ) { + return $clauses; + } + + $dokan_order_state_table = $this->get_dokan_table(); + + global $wpdb; + + $clauses[] = $wpdb->prepare( "AND {$dokan_order_state_table}.seller_id = %s", $seller_id ); //phpcs:ignore + + return $clauses; + } + + /** + * Get seller id from Query param for Admin and currently logged in user as Vendor + * + * @return int + */ + public function get_seller_id() { + if ( ! is_user_logged_in() ) { + return 0; + } + + if ( ! current_user_can( 'manage_options' ) ) { + return dokan_get_current_user_id(); + } + + return (int) ( wp_unslash( $_GET['sellers'] ?? 0 ) ); // phpcs:ignore + } +} diff --git a/includes/Analytics/Reports/Categories/QueryFilter.php b/includes/Analytics/Reports/Categories/QueryFilter.php new file mode 100644 index 0000000000..d5428315b9 --- /dev/null +++ b/includes/Analytics/Reports/Categories/QueryFilter.php @@ -0,0 +1,33 @@ +context ) { + return $column; + } + + $order_type = new OrderType(); + $seller_types = implode( ',', $order_type->get_seller_order_types() ); + $admin_types = implode( ',', $order_type->get_admin_order_types() ); + $table_name = $this->get_dokan_table(); + + /** + * Parent order ID need to be set to 0 for Dokan suborder. + * Because, WC orders analytics generates order details link against the parent order + * assuming the order having parent ID as a refund order. + */ + // $column['parent_id'] = "(CASE WHEN {$table_name}.order_type NOT IN ($refund_types) THEN 0 ELSE {$wc_table_name}.parent_id END ) as parent_id"; + $column['amount'] = "SUM(CASE WHEN {$table_name}.order_type IN ($admin_types) THEN discount_amount ELSE 0 END ) as amount"; + $column['orders_count'] = "COUNT( DISTINCT (CASE WHEN {$table_name}.order_type IN ($seller_types) THEN {$table_name}.order_id END) ) as orders_count"; + + return $column; + } +} diff --git a/includes/Analytics/Reports/Coupons/Stats/QueryFilter.php b/includes/Analytics/Reports/Coupons/Stats/QueryFilter.php new file mode 100644 index 0000000000..9c1e33c560 --- /dev/null +++ b/includes/Analytics/Reports/Coupons/Stats/QueryFilter.php @@ -0,0 +1,40 @@ +clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new WcSqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new WcSqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } +} diff --git a/includes/Analytics/Reports/Customers/QueryFilter.php b/includes/Analytics/Reports/Customers/QueryFilter.php new file mode 100644 index 0000000000..7fcdf72694 --- /dev/null +++ b/includes/Analytics/Reports/Customers/QueryFilter.php @@ -0,0 +1,65 @@ +context ) { + return $column; + } + + $types = $this->get_order_and_refund_types_to_include(); + $table_name = $this->get_dokan_table(); + + $orders_count = "COUNT( DISTINCT (CASE WHEN {$table_name}.order_type IN ($types) THEN {$table_name}.order_id END) ) "; //'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END )'; + $total_spend = "SUM(CASE WHEN {$table_name}.order_type IN ($types) THEN total_sales ELSE 0 END )"; //'SUM( total_sales )'; + + /** + * Parent order ID need to be set to 0 for Dokan suborder. + * Because, WC orders analytics generates order details link against the parent order + * assuming the order having parent ID as a refund order. + */ + $column['orders_count'] = "{$orders_count} as orders_count"; + $column['total_spend'] = "{$total_spend} as total_spend"; + $column['avg_order_value'] = "CASE WHEN {$orders_count} = 0 THEN NULL ELSE {$total_spend} / {$orders_count} END AS avg_order_value"; + + return $column; + } +} diff --git a/includes/Analytics/Reports/Customers/Stats/QueryFilter.php b/includes/Analytics/Reports/Customers/Stats/QueryFilter.php new file mode 100644 index 0000000000..3a10e7b1ca --- /dev/null +++ b/includes/Analytics/Reports/Customers/Stats/QueryFilter.php @@ -0,0 +1,81 @@ +modify_select_field( $clause ); + } + + return $modified_clauses; + } + + protected function modify_select_field( string $field ): string { + $parts = explode( ' as ', strtolower( $field ) ); + $renamed_field = trim( $parts[1] ?? '' ); + + // The following fields need be modified. + // [0] => SUM( total_sales ) AS total_spend, + // [1] => SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count, + // [2] => CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value + + $table_name = $this->get_dokan_table(); + + $types = $this->get_order_and_refund_types_to_include(); + + switch ( str_replace( ',', '', $renamed_field ) ) { + case 'total_spend': + $field = "SUM(CASE WHEN {$table_name}.order_type IN ($types) THEN total_sales ELSE 0 END ) as total_spend"; + break; + case 'orders_count': + $field = "COUNT( DISTINCT (CASE WHEN {$table_name}.order_type IN ($types) THEN {$table_name}.order_id END) ) as orders_count"; + break; + case 'avg_order_value': + $order_types_conditions = "CASE WHEN {$table_name}.order_type IN ($types) THEN 1 ELSE 0 END"; + + $field = "CASE WHEN SUM( $order_types_conditions ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( $order_types_conditions ) END AS avg_order_value"; + break; + default: + break; + } + + // Append comma if the given field ends with comma[","]. + if ( str_ends_with( $renamed_field, ',' ) ) { + $field = $field . ','; + } + + return $field; + } +} diff --git a/includes/Analytics/Reports/DataStoreModifier.php b/includes/Analytics/Reports/DataStoreModifier.php new file mode 100644 index 0000000000..2048614054 --- /dev/null +++ b/includes/Analytics/Reports/DataStoreModifier.php @@ -0,0 +1,58 @@ +register_hooks(); + } + + public function register_hooks(): void { + add_filter( 'woocommerce_data_stores', [ $this, 'modify_wc_products_stats_datastore' ], 20 ); + } + + /** + * Customize the WooCommerce products stats datastore to override the $total_query and $interval_query properties. + * This modification replaces the Automattic\WooCommerce\Admin\API\Reports\SqlQuery class with WeDevs\Dokan\Analytics\Reports\WcSqlQuery + * to apply specific filters to queries. + * The reason for this change is that the "get_sql_clause" method's second parameter defaults to "unfiltered," which blocks the filters we need + * to add JOIN and WHERE clauses for the dokan_order_stats table. + * + * @see https://github.com/woocommerce/woocommerce/blob/9297409c5a705d1cd0ae65ec9b058271bd90851e/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php#L170 + * + * @param array $wc_stores An array of WooCommerce datastores. + * @return array Modified array of WooCommerce datastores. + */ + public function modify_wc_products_stats_datastore( $wc_stores ) { + if ( isset( $wc_stores['report-products-stats'] ) ) { + $wc_stores['report-products-stats'] = \WeDevs\Dokan\Analytics\Reports\Products\Stats\WcDataStore::class; + } + + if ( isset( $wc_stores['report-taxes-stats'] ) ) { + $wc_stores['report-taxes-stats'] = \WeDevs\Dokan\Analytics\Reports\Taxes\Stats\WcDataStore::class; + } + + if ( isset( $wc_stores['report-orders-stats'] ) ) { + $wc_stores['report-orders-stats'] = \WeDevs\Dokan\Analytics\Reports\Orders\Stats\WcDataStore::class; + } + + if ( isset( $wc_stores['report-coupons-stats'] ) ) { + $wc_stores['report-coupons-stats'] = \WeDevs\Dokan\Analytics\Reports\Coupons\Stats\WcDataStore::class; + } + + if ( isset( $wc_stores['report-stock-stats'] ) ) { + $wc_stores['report-stock-stats'] = \WeDevs\Dokan\Analytics\Reports\Stock\Stats\WcDataStore::class; + } + + return $wc_stores; + } +} diff --git a/includes/Analytics/Reports/OrderType.php b/includes/Analytics/Reports/OrderType.php new file mode 100644 index 0000000000..fbc359ae91 --- /dev/null +++ b/includes/Analytics/Reports/OrderType.php @@ -0,0 +1,193 @@ +get_parent_id() ) { + return false; + } + + if ( $order instanceof \WC_Order ) { + return true; + } + + $parent_order = wc_get_order( $order->get_parent_id() ); + + return $this->is_dokan_suborder_related( $parent_order ); + } + + /** + * Determines the type of the given order based on its relation to Dokan suborders and refunds. + * + * @param \WC_Abstract_Order $order The order object to classify. + * + * @return int The order type constant. + */ + public function get_type( \WC_Abstract_Order $order ): int { + $is_suborder_related = $this->is_dokan_suborder_related( $order ); + + if ( $is_suborder_related ) { + // Refund of Dokan suborder. + if ( $order instanceof WC_Order_Refund ) { + return self::DOKAN_SUBORDER_REFUND; + } + + // Dokan Suborder + return self::DOKAN_SUBORDER; + } + + if ( ! $is_suborder_related ) { + // Refund of WC order. + if ( $order instanceof WC_Order_Refund ) { + $suborder_ids = array_filter( + (array) dokan_get_suborder_ids_by( $order->get_parent_id() ) + ); + + if ( count( $suborder_ids ) ) { + return self::DOKAN_PARENT_ORDER_REFUND; + } + + return self::DOKAN_SINGLE_ORDER_REFUND; + } + + $suborder_ids = dokan_get_suborder_ids_by( $order->get_id() ); + + // Dokan Single Vendor Order + if ( $suborder_ids === null || ( is_array( $suborder_ids ) && count( $suborder_ids ) === 0 ) ) { + return self::DOKAN_SINGLE_ORDER; + } + } + + return self::DOKAN_PARENT_ORDER; + } + + /** + * Gets the list of order types relevant to admin users. + * + * @return array List of admin order type constants. + */ + public function get_admin_order_types(): array { + return [ + self::DOKAN_PARENT_ORDER, + self::DOKAN_SINGLE_ORDER, + self::DOKAN_PARENT_ORDER_REFUND, + self::DOKAN_SINGLE_ORDER_REFUND, + ]; + } + + /** + * Gets the list of order types relevant to sellers. + * + * @return array List of seller order type constants. + */ + public function get_seller_order_types(): array { + return [ + self::DOKAN_SINGLE_ORDER, + self::DOKAN_SUBORDER, + self::DOKAN_SUBORDER_REFUND, + self::DOKAN_SINGLE_ORDER_REFUND, + ]; + } + + /** + * Gets the list of order types (excluding refunds) relevant to admin users. + * + * @return array List of admin order type constants (non-refund). + */ + public function get_admin_non_refund_order_types(): array { + return [ + self::DOKAN_PARENT_ORDER, + self::DOKAN_SINGLE_ORDER, + ]; + } + + /** + * Gets the list of order types (excluding refunds) relevant to sellers. + * + * @return array List of seller order type constants (non-refund). + */ + public function get_seller_non_refund_order_types(): array { + return [ + self::DOKAN_SINGLE_ORDER, + self::DOKAN_SUBORDER, + ]; + } + + /** + * Gets the list of refund types relevant to all users. + * + * @return array List of refund type constants. + */ + public function get_refund_types(): array { + return [ + self::DOKAN_PARENT_ORDER_REFUND, + self::DOKAN_SUBORDER_REFUND, + self::DOKAN_SINGLE_ORDER_REFUND, + ]; + } + + /** + * Gets the list of refund types relevant to sellers. + * + * @return array List of seller refund type constants. + */ + public function get_seller_refund_types(): array { + return [ + self::DOKAN_SUBORDER_REFUND, + self::DOKAN_SINGLE_ORDER_REFUND, + ]; + } + + /** + * Gets the list of refund types relevant to admin users. + * + * @return array List of admin refund type constants. + */ + public function get_admin_refund_types(): array { + return [ + self::DOKAN_PARENT_ORDER_REFUND, + self::DOKAN_SINGLE_ORDER_REFUND, + ]; + } + + /** + * Gets the list of refund types relevant to admin users. + * + * @return array List of admin refund type constants. + */ + public function get_all_order_types(): array { + return [ + self::DOKAN_PARENT_ORDER, + self::DOKAN_SINGLE_ORDER, + self::DOKAN_SUBORDER, + self::DOKAN_PARENT_ORDER_REFUND, + self::DOKAN_SUBORDER_REFUND, + self::DOKAN_SINGLE_ORDER_REFUND, + ]; + } +} diff --git a/includes/Analytics/Reports/Orders/QueryFilter.php b/includes/Analytics/Reports/Orders/QueryFilter.php new file mode 100644 index 0000000000..922c1a8c8e --- /dev/null +++ b/includes/Analytics/Reports/Orders/QueryFilter.php @@ -0,0 +1,91 @@ +get_refund_types() ); + $table_name = Stats\DataStore::get_db_table_name(); + + /** + * Parent order ID need to be set to 0 for Dokan suborder. + * Because, WC orders analytics generates order details link against the parent order + * assuming the order having parent ID as a refund order. + */ + $column['parent_id'] = "(CASE WHEN {$table_name}.order_type NOT IN ($refund_types) THEN 0 ELSE {$wc_table_name}.parent_id END ) as parent_id"; + + return $column; + } + + /** + * Exclude order IDs from WooCommerce analytics queries based on seller or admin context. + * + * @param array $ids The existing excluded order IDs. + * @param array $query_args The query arguments. + * @param string $field The field being queried. + * @param string $context The context of the query. + * + * @return array The modified excluded order IDs. + */ + public function exclude_order_ids( array $ids, array $query_args, string $field, $context ): array { + if ( $context !== 'orders' || ! $this->should_filter_by_seller_id() ) { + return $ids; + } + + return []; + } + + /** + * Add custom columns to the select clause of WooCommerce analytics queries. + * + * @param array $clauses The existing select clauses. + * + * @return array The modified select clauses. + */ + public function add_select_subquery( array $clauses ): array { + $clauses[] = ', seller_earning, seller_gateway_fee, seller_discount, admin_commission, admin_gateway_fee, admin_discount, admin_subsidy'; + + return array_unique( $clauses ); + } +} diff --git a/includes/Analytics/Reports/Orders/Stats/DataStore.php b/includes/Analytics/Reports/Orders/Stats/DataStore.php new file mode 100644 index 0000000000..0048d96d6a --- /dev/null +++ b/includes/Analytics/Reports/Orders/Stats/DataStore.php @@ -0,0 +1,215 @@ +date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); + parent::__construct(); + } + + /** + * Get the data based on args. + * + * @param array $args Query parameters. + * @return stdClass|WP_Error + */ + public function get_data( $args ) { + throw new Exception( 'Not supported by Dokan' ); + } + + /** + * Add order information to the lookup table when orders are created or modified. + * + * @param int $post_id Post ID. + * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. + */ + public static function sync_order( $post_id ) { + if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { + return -1; + } + + $order = wc_get_order( $post_id ); + if ( ! $order ) { + return -1; + } + + return self::update( $order ); + } + + /** + * Update the database with stats data. + * + * @param \WC_Order|\WC_Order_Refund $order Order or refund to update row for. + * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. + */ + public static function update( $order ) { + global $wpdb; + $table_name = self::get_db_table_name(); + + if ( ! $order->get_id() || ! $order->get_date_created() ) { + return -1; + } + + $vendor_earning = (float) dokan()->commission->get_earning_by_order( $order ); + $admin_earning = (float) dokan()->commission->get_earning_by_order( $order, 'admin' ); + + $gateway_fee = $order->get_meta( 'dokan_gateway_fee' ); + $gateway_fee_provider = $order->get_meta( 'dokan_gateway_fee_paid_by' ); + + $shipping_fee = $order->get_shipping_total(); + $shipping_fee_recipient = $order->get_meta( 'shipping_fee_recipient' ); + + /** + * Filters order stats data. + * + * @param array $data Data written to order stats lookup table. + * @param WC_Order $order Order object. + * + * @since 4.0.0 + */ + $data = apply_filters( + 'dokan_analytics_update_order_stats_data', + array( + 'order_id' => $order->get_id(), + 'seller_id' => (int) $order->get_meta( '_dokan_vendor_id' ), + 'order_type' => (int) ( ( new OrderType() )->get_type( $order ) ), + // Seller Data + 'seller_earning' => $vendor_earning, + 'seller_gateway_fee' => $gateway_fee_provider === 'seller' ? $gateway_fee : '0', + 'seller_shipping_fee' => $shipping_fee_recipient === 'seller' ? $shipping_fee : '0', + 'seller_discount' => $order->get_meta( '_seller_discount' ), + // Admin Data + 'admin_commission' => $admin_earning, + 'admin_gateway_fee' => $gateway_fee_provider !== 'seller' ? $gateway_fee : '0', + 'admin_shipping_fee' => $shipping_fee_recipient !== 'seller' ? $shipping_fee : '0', + 'admin_discount' => $order->get_meta( '_admin_discount' ), + 'admin_subsidy' => $order->get_meta( '_admin_subsidy' ), + ), + $order, + ); + + $format = array( + '%d', + '%d', + '%d', + // Seller data + '%f', + '%f', + '%f', + '%f', + // Admin data + '%f', + '%f', + '%f', + '%f', + '%f', + ); + + // Update or add the information to the DB. + $result = $wpdb->replace( $table_name, $data, $format ); + + /** + * Fires when Dokan order's stats reports are updated. + * + * @param int $order_id Order ID. + * + * @since DOKAN_SINCE + */ + do_action( 'dokan_analytics_update_order_stats', $order->get_id() ); + + // Check the rows affected for success. Using REPLACE can affect 2 rows if the row already exists. + return ( 1 === $result || 2 === $result ); + } + + /** + * Deletes the order stats when an order is deleted. + * + * @param int $post_id Post ID. + */ + public static function delete_order( $post_id ) { + global $wpdb; + $order_id = (int) $post_id; + + if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { + return; + } + + // Retrieve customer details before the order is deleted. + $order = wc_get_order( $order_id ); + $customer_id = absint( CustomersDataStore::get_existing_customer_id_from_order( $order ) ); + + // Delete the order. + $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); + + /** + * Fires when orders stats are deleted. + * + * @param int $order_id Order ID. + * @param int $customer_id Customer ID. + * + * @since 4.0.0 + */ + do_action( 'dokan_analytics_delete_order_stats', $order_id, $customer_id ); + + ReportsCache::invalidate(); + } +} diff --git a/includes/Analytics/Reports/Orders/Stats/QueryFilter.php b/includes/Analytics/Reports/Orders/Stats/QueryFilter.php new file mode 100644 index 0000000000..3d867f7a0e --- /dev/null +++ b/includes/Analytics/Reports/Orders/Stats/QueryFilter.php @@ -0,0 +1,88 @@ +context ) { + return $column; + } + + $table_name = $this->get_dokan_table(); + $types = $this->get_non_refund_order_types_to_include(); + + $column['orders_count'] = "SUM( CASE WHEN {$table_name}.order_type IN ($types) THEN 1 ELSE 0 END ) as orders_count"; + $column['avg_items_per_order'] = "SUM( {$wc_table_name}.num_items_sold ) / SUM( CASE WHEN {$table_name}.order_type IN($types) THEN 1 ELSE 0 END ) AS avg_items_per_order"; + $column['avg_order_value'] = "SUM( {$wc_table_name}.net_total ) / SUM( CASE WHEN {$table_name}.order_type IN($types) THEN 1 ELSE 0 END ) AS avg_order_value"; + $column['avg_admin_commission'] = "SUM( {$table_name}.admin_commission ) / SUM( CASE WHEN {$table_name}.order_type IN($types) THEN 1 ELSE 0 END ) AS avg_admin_commission"; + $column['avg_seller_earning'] = "SUM( {$table_name}.seller_earning ) / SUM( CASE WHEN {$table_name}.order_type IN($types) THEN 1 ELSE 0 END ) AS avg_seller_earning"; + + return $column; + } + + /** + * Adds custom select subqueries for calculating Dokan-specific totals in the analytics reports. + * + * @param array $clauses The existing SQL select clauses. + * + * @return array Modified SQL select clauses. + */ + public function add_select_subquery_for_total( $clauses ) { + $table_name = $this->get_dokan_table(); + $types = $this->get_order_and_refund_types_to_include(); + + $clauses[] = ', sum(seller_earning) as total_seller_earning, sum(seller_gateway_fee) as total_seller_gateway_fee, sum(seller_discount) as total_seller_discount, sum(admin_commission) as total_admin_commission, sum(admin_gateway_fee) as total_admin_gateway_fee, sum(admin_discount) as total_admin_discount, sum(admin_subsidy) as total_admin_subsidy'; + $clauses[] = ", SUM( {$table_name}.admin_commission ) / SUM( CASE WHEN {$table_name}.order_type IN($types) THEN 1 ELSE 0 END ) AS avg_admin_commission"; + $clauses[] = ", SUM( {$table_name}.seller_earning ) / SUM( CASE WHEN {$table_name}.order_type IN($types) THEN 1 ELSE 0 END ) AS avg_seller_earning"; + + return $clauses; + } +} diff --git a/includes/Analytics/Reports/Orders/Stats/ScheduleListener.php b/includes/Analytics/Reports/Orders/Stats/ScheduleListener.php new file mode 100644 index 0000000000..31b7a58a6c --- /dev/null +++ b/includes/Analytics/Reports/Orders/Stats/ScheduleListener.php @@ -0,0 +1,53 @@ +register_hooks(); + } + + /** + * Register hooks for WooCommerce analytics order events. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'woocommerce_analytics_update_order_stats', [ $this, 'sync_dokan_order' ] ); + add_action( 'woocommerce_before_delete_order', [ $this, 'delete_order' ] ); + add_action( 'delete_post', [ $this, 'delete_order' ] ); + } + + /** + * Sync Dokan order data when WooCommerce analytics updates order stats. + * + * @param int $order_id The ID of the order being updated. + * + * @return void + */ + public function sync_dokan_order( $order_id ) { + return \WeDevs\Dokan\Analytics\Reports\Orders\Stats\DataStore::sync_order( $order_id ); + } + + /** + * Delete Dokan order data when WooCommerce deletes an order. + * + * @param int $order_id The ID of the order being deleted. + * + * @return void + */ + public function delete_order( $order_id ) { + return \WeDevs\Dokan\Analytics\Reports\Orders\Stats\DataStore::delete_order( $order_id ); + } +} diff --git a/includes/Analytics/Reports/Orders/Stats/WcDataStore.php b/includes/Analytics/Reports/Orders/Stats/WcDataStore.php new file mode 100644 index 0000000000..a098f4264d --- /dev/null +++ b/includes/Analytics/Reports/Orders/Stats/WcDataStore.php @@ -0,0 +1,28 @@ +clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new WcSqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new WcSqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } +} diff --git a/includes/Analytics/Reports/Products/QueryFilter.php b/includes/Analytics/Reports/Products/QueryFilter.php new file mode 100644 index 0000000000..8548f1a20a --- /dev/null +++ b/includes/Analytics/Reports/Products/QueryFilter.php @@ -0,0 +1,33 @@ +clear_all_clauses(); + $this->total_query = new WcSqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new WcSqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } +} diff --git a/includes/Analytics/Reports/Stock/QueryFilter.php b/includes/Analytics/Reports/Stock/QueryFilter.php new file mode 100644 index 0000000000..3cdbae665a --- /dev/null +++ b/includes/Analytics/Reports/Stock/QueryFilter.php @@ -0,0 +1,53 @@ +get_route() === '/wc-analytics/reports/stock' ) { + add_filter( 'posts_clauses', array( $this, 'add_author_clause' ), 10, 2 ); + } + + return $result; + } + + /** + * Apply seller ID query param to where SQL Clause. + * + * @param WP_Query $wp_query + * @return array + */ + public function add_author_clause( $args, $wp_query ) { + global $wpdb; + + $seller_id = $this->get_seller_id(); + + if ( $seller_id ) { + $args['where'] = $args['where'] . $wpdb->prepare( + " AND {$wpdb->posts}.post_author = %d ", + $seller_id + ); + } + + return $args; + } +} diff --git a/includes/Analytics/Reports/Stock/Stats/WcDataStore.php b/includes/Analytics/Reports/Stock/Stats/WcDataStore.php new file mode 100644 index 0000000000..a9920e63f1 --- /dev/null +++ b/includes/Analytics/Reports/Stock/Stats/WcDataStore.php @@ -0,0 +1,166 @@ +get_seller_id(); + + $report_data = array(); + $cache_expire = DAY_IN_SECONDS * 30; + // Set seller specific key. + $low_stock_transient_name = 'wc_admin_stock_count_lowstock' . $seller_id; + $low_stock_count = get_transient( $low_stock_transient_name ); + + if ( false === $low_stock_count ) { + $low_stock_count = $this->get_low_stock_count(); + set_transient( $low_stock_transient_name, $low_stock_count, $cache_expire ); + } else { + $low_stock_count = intval( $low_stock_count ); + } + + $report_data['lowstock'] = $low_stock_count; + + $status_options = wc_get_product_stock_status_options(); + foreach ( $status_options as $status => $label ) { + // Set seller specific key. + $transient_name = 'wc_admin_stock_count_' . $status . $seller_id; + $count = get_transient( $transient_name ); + if ( false === $count ) { + $count = $this->get_count( $status ); + set_transient( $transient_name, $count, $cache_expire ); + } else { + $count = intval( $count ); + } + $report_data[ $status ] = $count; + } + + // Set seller specific key. + $product_count_transient_name = 'wc_admin_product_count' . $seller_id; + $product_count = get_transient( $product_count_transient_name ); + if ( false === $product_count ) { + $product_count = $this->get_product_count(); + set_transient( $product_count_transient_name, $product_count, $cache_expire ); + } else { + $product_count = intval( $product_count ); + } + $report_data['products'] = $product_count; + return $report_data; + } + + /** + * Get low stock count (products with stock < low stock amount, but greater than no stock amount). + * + * @return int Low stock count. + */ + protected function get_low_stock_count() { + global $wpdb; + + $no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) ); + $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); + $seller_where = $this->get_seller_where_query(); + + return (int) $wpdb->get_var( + $wpdb->prepare( + " + SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts + LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id + LEFT JOIN {$wpdb->postmeta} low_stock_amount_meta ON posts.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' + WHERE posts.post_type IN ( 'product', 'product_variation' ) + AND wc_product_meta_lookup.stock_quantity IS NOT NULL + AND wc_product_meta_lookup.stock_status = 'instock' + AND ( + ( + low_stock_amount_meta.meta_value > '' + AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) + AND wc_product_meta_lookup.stock_quantity > %d + ) + OR ( + ( + low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' + ) + AND wc_product_meta_lookup.stock_quantity <= %d + AND wc_product_meta_lookup.stock_quantity > %d + ) + ) + {$seller_where} + ", + $no_stock_amount, + $low_stock_amount, + $no_stock_amount + ) + ); + } + + /** + * Get count for the passed in stock status. + * + * @param string $status Status slug. + * @return int Count. + */ + protected function get_count( $status ) { + global $wpdb; + + $seller_where = $this->get_seller_where_query(); + + return (int) $wpdb->get_var( + $wpdb->prepare(// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + " + SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts + LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id + WHERE posts.post_type IN ( 'product', 'product_variation' ) + AND wc_product_meta_lookup.stock_status = %s {$seller_where} + ", + $status + ) + ); + } + + /** + * Get product count for the store. + * + * @return int Product count. + */ + protected function get_product_count() { + $query_args = array(); + $query_args['post_type'] = array( 'product', 'product_variation' ); + $seller_id = $this->get_seller_id(); + + if ( $seller_id ) { + $query_args['author'] = $seller_id; + } + + $query = new \WP_Query(); + $query->query( $query_args ); + + return intval( $query->found_posts ); + } + + protected function get_seller_id(): int { + return (int) dokan()->get_container()->get( \WeDevs\Dokan\Analytics\Reports\Stock\QueryFilter::class )->get_seller_id(); + } + + protected function get_seller_where_query() { + $seller_id = $this->get_seller_id(); + $where = ''; + + if ( $seller_id ) { + global $wpdb; + + $where = $wpdb->prepare( + ' AND posts.post_author = %d ', + $seller_id + ); + } + + return $where; + } +} diff --git a/includes/Analytics/Reports/Taxes/QueryFilter.php b/includes/Analytics/Reports/Taxes/QueryFilter.php new file mode 100644 index 0000000000..8dac5502da --- /dev/null +++ b/includes/Analytics/Reports/Taxes/QueryFilter.php @@ -0,0 +1,33 @@ +context ) { + return $column; + } + + $table_name = $this->get_dokan_table(); + $types = $this->get_non_refund_order_types_to_include(); + + $column['orders_count'] = "SUM( CASE WHEN {$table_name}.order_type IN ($types) THEN 1 ELSE 0 END ) as orders_count"; + + return $column; + } +} diff --git a/includes/Analytics/Reports/Taxes/Stats/WcDataStore.php b/includes/Analytics/Reports/Taxes/Stats/WcDataStore.php new file mode 100644 index 0000000000..357efba06a --- /dev/null +++ b/includes/Analytics/Reports/Taxes/Stats/WcDataStore.php @@ -0,0 +1,28 @@ +clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new WcSqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new WcSqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } +} diff --git a/includes/Analytics/Reports/Variations/QueryFilter.php b/includes/Analytics/Reports/Variations/QueryFilter.php new file mode 100644 index 0000000000..16615bb88d --- /dev/null +++ b/includes/Analytics/Reports/Variations/QueryFilter.php @@ -0,0 +1,33 @@ +services as $class ) { + $implements_more = class_implements( $class ); + if ( $implements_more ) { + $implements = array_merge( $implements, $implements_more ); + } + } + + $implements = array_unique( $implements ); + + return array_key_exists( $alias, $implements ); + } + + /** + * Register a class in the container and add tags for all the interfaces it implements. + * + * This also updates the `$this->provides` property with the interfaces provided by the class, and ensures + * that the property doesn't contain duplicates. + * + * @param string $id Entry ID (typically a class or interface name). + * @param mixed|null $concrete Concrete entity to register under that ID, null for automatic creation. + * @param bool|null $shared Whether to register the class as shared (`get` always returns the same instance) + * or not. + * + * @return DefinitionInterface + */ + protected function add_with_implements_tags( string $id, $concrete = null, bool $shared = null ): DefinitionInterface { + $definition = $this->getContainer()->add( $id, $concrete, $shared ); + + foreach ( class_implements( $id ) as $interface ) { + $definition->addTag( $interface ); + } + + return $definition; + } + + /** + * Register a shared class in the container and add tags for all the interfaces it implements. + * + * @param string $id Entry ID (typically a class or interface name). + * @param mixed|null $concrete Concrete entity to register under that ID, null for automatic creation. + * + * @return DefinitionInterface + */ + protected function share_with_implements_tags( string $id, $concrete = null ): DefinitionInterface { + return $this->add_with_implements_tags( $id, $concrete, true ); + } +} diff --git a/includes/DependencyManagement/BootableServiceProvider.php b/includes/DependencyManagement/BootableServiceProvider.php new file mode 100644 index 0000000000..b07bfadd98 --- /dev/null +++ b/includes/DependencyManagement/BootableServiceProvider.php @@ -0,0 +1,19 @@ +invokeInit( $instance ); + return $instance; + } + + /** + * Invoke methods on resolved instance, including 'init'. + * + * @param object $instance The concrete to invoke methods on. + * + * @return object + */ + protected function invokeMethods( $instance ): object { + $this->invokeInit( $instance ); + parent::invokeMethods( $instance ); + return $instance; + } + + /** + * Invoke the 'init' method on a resolved object. + * + * Constructor injection causes backwards compatibility problems + * so we will rely on method injection via an internal method. + * + * @param object $instance The resolved object. + * @return void + */ + private function invokeInit( $instance ) { + $resolved = $this->resolveArguments( $this->arguments ); + + if ( method_exists( $instance, static::INJECTION_METHOD ) ) { + call_user_func_array( array( $instance, static::INJECTION_METHOD ), $resolved ); + } + } + + /** + * Forget the cached resolved object, so the next time it's requested + * it will be resolved again. + */ + public function forgetResolved() { + $this->resolved = null; + } +} diff --git a/includes/DependencyManagement/Providers/AdminServiceProvider.php b/includes/DependencyManagement/Providers/AdminServiceProvider.php new file mode 100644 index 0000000000..34c48ab984 --- /dev/null +++ b/includes/DependencyManagement/Providers/AdminServiceProvider.php @@ -0,0 +1,58 @@ +services, true ); + } + + /** + * Register the classes. + */ + public function register(): void { + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\Hooks::class, \WeDevs\Dokan\Admin\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\Menu::class, \WeDevs\Dokan\Admin\Menu::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\AdminBar::class, \WeDevs\Dokan\Admin\AdminBar::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\Pointers::class, \WeDevs\Dokan\Admin\Pointers::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\Settings::class, \WeDevs\Dokan\Admin\Settings::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\UserProfile::class, \WeDevs\Dokan\Admin\UserProfile::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Admin\SetupWizard::class, \WeDevs\Dokan\Admin\SetupWizard::class ) + ->addTag( self::TAG ); + } +} diff --git a/includes/DependencyManagement/Providers/AjaxServiceProvider.php b/includes/DependencyManagement/Providers/AjaxServiceProvider.php new file mode 100644 index 0000000000..e76b59337f --- /dev/null +++ b/includes/DependencyManagement/Providers/AjaxServiceProvider.php @@ -0,0 +1,34 @@ +services, true ); + } + + /** + * Register the classes. + */ + public function register(): void { + $this->getContainer() + ->addShared( \WeDevs\Dokan\Ajax::class, \WeDevs\Dokan\Ajax::class ) + ->addTag( self::TAG ); + } +} diff --git a/includes/DependencyManagement/Providers/AnalyticsServiceProvider.php b/includes/DependencyManagement/Providers/AnalyticsServiceProvider.php new file mode 100644 index 0000000000..70d9c9ae66 --- /dev/null +++ b/includes/DependencyManagement/Providers/AnalyticsServiceProvider.php @@ -0,0 +1,65 @@ +services, true ) || in_array( $alias, self::TAGS, true ); + } + + /** + * Register the classes. + */ + public function register(): void { + foreach ( $this->services as $service ) { + $definition = $this->getContainer() + ->addShared( + $service, function () use ( $service ) { + return new $service(); + } + ); + $this->add_tags( $definition, self::TAGS ); + } + } + + private function add_tags( DefinitionInterface $definition, $tags ) { + foreach ( $tags as $tag ) { + $definition = $definition->addTag( $tag ); + } + } +} diff --git a/includes/DependencyManagement/Providers/CommonServiceProvider.php b/includes/DependencyManagement/Providers/CommonServiceProvider.php new file mode 100644 index 0000000000..4d59f0ca24 --- /dev/null +++ b/includes/DependencyManagement/Providers/CommonServiceProvider.php @@ -0,0 +1,66 @@ +services, true ); + } + + /** + * Register the classes. + */ + public function register(): void { + $this->getContainer() + ->addShared( \WeDevs\Dokan\Withdraw\Hooks::class, \WeDevs\Dokan\Withdraw\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Product\Hooks::class, \WeDevs\Dokan\Product\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\ProductCategory\Hooks::class, \WeDevs\Dokan\ProductCategory\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Upgrade\Hooks::class, \WeDevs\Dokan\Upgrade\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Vendor\Hooks::class, \WeDevs\Dokan\Vendor\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Vendor\UserSwitch::class, \WeDevs\Dokan\Vendor\UserSwitch::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\CacheInvalidate::class, \WeDevs\Dokan\CacheInvalidate::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Shipping\Hooks::class, \WeDevs\Dokan\Shipping\Hooks::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\Privacy::class, \WeDevs\Dokan\Privacy::class ) + ->addTag( self::TAG ); + } +} diff --git a/includes/DependencyManagement/Providers/FrontendServiceProvider.php b/includes/DependencyManagement/Providers/FrontendServiceProvider.php new file mode 100644 index 0000000000..5eb6681f15 --- /dev/null +++ b/includes/DependencyManagement/Providers/FrontendServiceProvider.php @@ -0,0 +1,38 @@ +services, true ); + } + + /** + * Register the classes. + */ + public function register(): void { + $this->getContainer() + ->addShared( \WeDevs\Dokan\Vendor\StoreListsFilter::class, \WeDevs\Dokan\Vendor\StoreListsFilter::class ) + ->addTag( self::TAG ); + + $this->getContainer() + ->addShared( \WeDevs\Dokan\ThemeSupport\Manager::class, \WeDevs\Dokan\ThemeSupport\Manager::class ) + ->addTag( self::TAG ); + } +} diff --git a/includes/DependencyManagement/Providers/ServiceProvider.php b/includes/DependencyManagement/Providers/ServiceProvider.php new file mode 100644 index 0000000000..1a465b5f64 --- /dev/null +++ b/includes/DependencyManagement/Providers/ServiceProvider.php @@ -0,0 +1,78 @@ + \WeDevs\Dokan\Blocks\ProductBlock::class, + 'pageview' => \WeDevs\Dokan\PageViews::class, + 'seller_wizard' => \WeDevs\Dokan\Vendor\SetupWizard::class, + 'core' => \WeDevs\Dokan\Core::class, + 'scripts' => \WeDevs\Dokan\Assets::class, + 'email' => \WeDevs\Dokan\Emails\Manager::class, + 'vendor' => \WeDevs\Dokan\Vendor\Manager::class, + 'product' => \WeDevs\Dokan\Product\Manager::class, + 'shortcodes' => \WeDevs\Dokan\Shortcodes\Shortcodes::class, + 'registration' => \WeDevs\Dokan\Registration::class, + 'order' => \WeDevs\Dokan\Order\Manager::class, + 'order_controller' => \WeDevs\Dokan\Order\Controller::class, + 'api' => \WeDevs\Dokan\REST\Manager::class, + 'withdraw' => \WeDevs\Dokan\Withdraw\Manager::class, + 'dashboard' => \WeDevs\Dokan\Dashboard\Manager::class, + 'commission' => \WeDevs\Dokan\Commission::class, + 'customizer' => \WeDevs\Dokan\Customizer::class, + 'upgrades' => \WeDevs\Dokan\Upgrade\Manager::class, + 'product_sections' => \WeDevs\Dokan\ProductSections\Manager::class, + 'reverse_withdrawal' => \WeDevs\Dokan\ReverseWithdrawal\ReverseWithdrawal::class, + 'dummy_data_importer' => \WeDevs\Dokan\DummyData\Importer::class, + 'catalog_mode' => \WeDevs\Dokan\CatalogMode\Controller::class, + 'bg_process' => \WeDevs\Dokan\BackgroundProcess\Manager::class, + 'frontend_manager' => \WeDevs\Dokan\Frontend\Frontend::class, + 'rewrite' => \WeDevs\Dokan\Rewrites::class, + 'widgets' => \WeDevs\Dokan\Widgets\Manager::class, + 'admin_notices' => \WeDevs\Dokan\Admin\Notices\Manager::class, + 'tracker' => \WeDevs\Dokan\Tracker::class, + ]; + + /** + * @inheritDoc + * + * @return void + */ + public function boot(): void { + $this->getContainer()->addServiceProvider( new AdminServiceProvider() ); + $this->getContainer()->addServiceProvider( new CommonServiceProvider() ); + $this->getContainer()->addServiceProvider( new FrontendServiceProvider() ); + $this->getContainer()->addServiceProvider( new AjaxServiceProvider() ); + $this->getContainer()->addServiceProvider( new AnalyticsServiceProvider() ); + } + + /** + * {@inheritDoc} + * + * Check if the service provider can provide the given service alias. + * + * @param string $alias The service alias to check. + * @return bool True if the service provider can provide the service, false otherwise. + */ + public function provides( string $alias ): bool { + if ( isset( $this->services[ $alias ] ) ) { + return true; + } + + return parent::provides( $alias ); + } + + /** + * Register the classes. + */ + public function register(): void { + foreach ( $this->services as $key => $class_name ) { + $this->getContainer()->addShared( $key, $class_name )->addTag( self::TAG ); + } + } +} diff --git a/includes/Install/Installer.php b/includes/Install/Installer.php index ee3ceeb14c..52104da3d2 100755 --- a/includes/Install/Installer.php +++ b/includes/Install/Installer.php @@ -327,6 +327,7 @@ public function create_tables() { $this->create_refund_table(); $this->create_vendor_balance_table(); $this->create_reverse_withdrawal_table(); + $this->create_dokan_order_stats_table(); } /** @@ -536,4 +537,31 @@ private static function parse_update_notice( $content, $new_version ) { return wp_kses_post( $upgrade_notice ); } + + public function create_dokan_order_stats_table() { + // Following imported here because this method could be called from the others file. + include_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + global $wpdb; + + $sql = "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}dokan_order_stats` ( + `order_id` bigint UNSIGNED NOT NULL, + `seller_id` bigint UNSIGNED NOT NULL DEFAULT '0', + `order_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0 = Dokan Parent Order, 1 = Dokan Single Vendor Order, 2 = Dokan Suborder, 3 = Refund of Dokan Parent Order, 4 = Refund of Dokan Suborder, 5 = Refund of Dokan Single Order', + `seller_earning` double NOT NULL DEFAULT '0', + `seller_gateway_fee` double NOT NULL DEFAULT '0', + `seller_shipping_fee` double NOT NULL DEFAULT '0', + `seller_discount` double NOT NULL DEFAULT '0', + `admin_commission` double NOT NULL DEFAULT '0', + `admin_gateway_fee` double NOT NULL DEFAULT '0', + `admin_shipping_fee` double NOT NULL DEFAULT '0', + `admin_discount` double NOT NULL DEFAULT '0', + `admin_subsidy` double NOT NULL DEFAULT '0', + PRIMARY KEY (order_id), + KEY seller_id (seller_id), + KEY order_type (order_type) + ) ENGINE=InnoDB {$wpdb->get_charset_collate()};"; + + dbDelta( $sql ); + } } diff --git a/includes/Order/MiscHooks.php b/includes/Order/MiscHooks.php index a4b897530b..60cd36abc0 100644 --- a/includes/Order/MiscHooks.php +++ b/includes/Order/MiscHooks.php @@ -22,13 +22,6 @@ class MiscHooks { * @since 3.8.0 */ public function __construct() { - //Wc remove child order from wc_order_product_lookup & trim child order from posts for analytics - add_action( 'wc-admin_import_orders', [ $this, 'delete_child_order_from_wc_order_product' ] ); - - // Exclude suborders in woocommerce analytics. - add_filter( 'woocommerce_analytics_orders_select_query', [ $this, 'trim_child_order_for_analytics_order' ] ); - add_filter( 'woocommerce_analytics_update_order_stats_data', [ $this, 'trim_child_order_for_analytics_order_stats' ], 10, 2 ); - // remove customer info from order export based on setting add_filter( 'dokan_csv_export_headers', [ $this, 'hide_customer_info_from_vendor_order_export' ], 20, 1 ); @@ -36,46 +29,6 @@ public function __construct() { add_filter( 'wp_count_posts', [ $this, 'modify_vendor_order_counts' ], 10, 1 ); // no need to add hpos support for this filter } - /** - * Delete_child_order_from_wc_order_product - * - * @since 3.8.0 Moved this method from Order/Hooks.php file - * - * @param \ActionScheduler_Action $args - * - * @return void - */ - public function delete_child_order_from_wc_order_product( $args ) { - $order = wc_get_order( $args ); - - if ( $order->get_parent_id() ) { - global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->delete( $wpdb->prefix . 'wc_order_product_lookup', [ 'order_id' => $order->get_id() ] ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->delete( $wpdb->prefix . 'wc_order_stats', [ 'order_id' => $order->get_id() ] ); - } - } - - /** - * Trim child order if parent exist from wc_order_product_lookup for analytics order - * - * @since 3.8.0 Moved this method from Order/Hooks.php file - * - * @param WC_Order $orders - * - * @return WC_Order - */ - public function trim_child_order_for_analytics_order( $orders ) { - foreach ( $orders->data as $key => $order ) { - if ( $order['parent_id'] ) { - unset( $orders->data[ $key ] ); - } - } - - return $orders; - } - /** * Remove customer sensitive information while exporting order * @@ -195,28 +148,4 @@ public function modify_vendor_order_counts( $counts ) { return $counts; } - - /** - * Exclude suborders and include dokan subscription product orders when generate woocommerce analytics data. - * - * @see https://github.com/getdokan/dokan-pro/issues/2735 - * - * @param array $data - * @param \WC_Order $order - * - * @return array - */ - public function trim_child_order_for_analytics_order_stats( $data, $order ) { - if ( ! $order->get_parent_id() || - ( - dokan()->is_pro_exists() - && dokan_pro()->module->is_active( 'product_subscription' ) - && \DokanPro\Modules\Subscription\Helper::is_vendor_subscription_order( $order ) - ) - ) { - return $data; - } - - return []; - } } diff --git a/includes/Upgrade/Manager.php b/includes/Upgrade/Manager.php index cb622d8efa..eae16fd326 100644 --- a/includes/Upgrade/Manager.php +++ b/includes/Upgrade/Manager.php @@ -32,7 +32,7 @@ public function is_upgrade_required() { * @return bool */ public function has_ongoing_process() { - return ! ! get_option( $this->is_upgrading_db_key, false ); + return (bool) get_option( $this->is_upgrading_db_key, false ); } /** @@ -112,4 +112,3 @@ public function do_upgrade() { do_action( 'dokan_upgrade_finished' ); } } - diff --git a/includes/Upgrade/Upgrades.php b/includes/Upgrade/Upgrades.php index f12d269472..a81110997f 100644 --- a/includes/Upgrade/Upgrades.php +++ b/includes/Upgrade/Upgrades.php @@ -43,6 +43,7 @@ class Upgrades { '3.6.5' => Upgrades\V_3_6_5::class, '3.7.10' => Upgrades\V_3_7_10::class, '3.7.19' => Upgrades\V_3_7_19::class, + '3.13.0' => Upgrades\V_3_13_0::class, ]; /** diff --git a/includes/Upgrade/Upgrades/V_3_13_0.php b/includes/Upgrade/Upgrades/V_3_13_0.php new file mode 100644 index 0000000000..3620a0c75a --- /dev/null +++ b/includes/Upgrade/Upgrades/V_3_13_0.php @@ -0,0 +1,17 @@ +create_dokan_order_stats_table(); + + // Sync the WC order stats. + $import = ReportsSync::regenerate_report_data( null, false ); + } +} diff --git a/lib/packages/League/Container/Argument/ArgumentInterface.php b/lib/packages/League/Container/Argument/ArgumentInterface.php new file mode 100644 index 0000000000..bc3c864e35 --- /dev/null +++ b/lib/packages/League/Container/Argument/ArgumentInterface.php @@ -0,0 +1,13 @@ +getContainer(); + } catch (ContainerException $e) { + $container = ($this instanceof ReflectionContainer) ? $this : null; + } + + foreach ($arguments as &$arg) { + // if we have a literal, we don't want to do anything more with it + if ($arg instanceof LiteralArgumentInterface) { + $arg = $arg->getValue(); + continue; + } + + if ($arg instanceof ArgumentInterface) { + $argValue = $arg->getValue(); + } else { + $argValue = $arg; + } + + if (!is_string($argValue)) { + continue; + } + + // resolve the argument from the container, if it happens to be another + // argument wrapper, use that value + if ($container instanceof ContainerInterface && $container->has($argValue)) { + try { + $arg = $container->get($argValue); + + if ($arg instanceof ArgumentInterface) { + $arg = $arg->getValue(); + } + + continue; + } catch (NotFoundException $e) { + } + } + + // if we have a default value, we use that, no more resolution as + // we expect a default/optional argument value to be literal + if ($arg instanceof DefaultValueInterface) { + $arg = $arg->getDefaultValue(); + } + } + + return $arguments; + } + + public function reflectArguments(ReflectionFunctionAbstract $method, array $args = []): array + { + $params = $method->getParameters(); + $arguments = []; + + foreach ($params as $param) { + $name = $param->getName(); + + // if we've been given a value for the argument, treat as literal + if (array_key_exists($name, $args)) { + $arguments[] = new LiteralArgument($args[$name]); + continue; + } + + $type = $param->getType(); + + if ($type instanceof ReflectionNamedType) { + // in PHP 8, nullable arguments have "?" prefix + $typeHint = ltrim($type->getName(), '?'); + + if ($param->isDefaultValueAvailable()) { + $arguments[] = new DefaultValueArgument($typeHint, $param->getDefaultValue()); + continue; + } + + $arguments[] = new ResolvableArgument($typeHint); + continue; + } + + if ($param->isDefaultValueAvailable()) { + $arguments[] = new LiteralArgument($param->getDefaultValue()); + continue; + } + + throw new NotFoundException(sprintf( + 'Unable to resolve a value for parameter (%s) in the function/method (%s)', + $name, + $method->getName() + )); + } + + return $this->resolveArguments($arguments); + } + + abstract public function getContainer(): DefinitionContainerInterface; +} diff --git a/lib/packages/League/Container/Argument/DefaultValueArgument.php b/lib/packages/League/Container/Argument/DefaultValueArgument.php new file mode 100644 index 0000000000..cf3c436e43 --- /dev/null +++ b/lib/packages/League/Container/Argument/DefaultValueArgument.php @@ -0,0 +1,24 @@ +defaultValue = $defaultValue; + parent::__construct($value); + } + + /** + * @return mixed|null + */ + public function getDefaultValue() + { + return $this->defaultValue; + } +} diff --git a/lib/packages/League/Container/Argument/DefaultValueInterface.php b/lib/packages/League/Container/Argument/DefaultValueInterface.php new file mode 100644 index 0000000000..879fe903a4 --- /dev/null +++ b/lib/packages/League/Container/Argument/DefaultValueInterface.php @@ -0,0 +1,13 @@ +value = $value; + } else { + throw new InvalidArgumentException('Incorrect type for value.'); + } + } + + /** + * {@inheritdoc} + */ + public function getValue() + { + return $this->value; + } +} diff --git a/lib/packages/League/Container/Argument/LiteralArgumentInterface.php b/lib/packages/League/Container/Argument/LiteralArgumentInterface.php new file mode 100644 index 0000000000..4cb06ff980 --- /dev/null +++ b/lib/packages/League/Container/Argument/LiteralArgumentInterface.php @@ -0,0 +1,9 @@ +value = $value; + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/lib/packages/League/Container/Argument/ResolvableArgumentInterface.php b/lib/packages/League/Container/Argument/ResolvableArgumentInterface.php new file mode 100644 index 0000000000..e7136e6c4b --- /dev/null +++ b/lib/packages/League/Container/Argument/ResolvableArgumentInterface.php @@ -0,0 +1,10 @@ +definitions = $definitions ?? new DefinitionAggregate(); + $this->providers = $providers ?? new ServiceProviderAggregate(); + $this->inflectors = $inflectors ?? new InflectorAggregate(); + + if ($this->definitions instanceof ContainerAwareInterface) { + $this->definitions->setContainer($this); + } + + if ($this->providers instanceof ContainerAwareInterface) { + $this->providers->setContainer($this); + } + + if ($this->inflectors instanceof ContainerAwareInterface) { + $this->inflectors->setContainer($this); + } + } + + public function add(string $id, $concrete = null): DefinitionInterface + { + $concrete = $concrete ?? $id; + + if (true === $this->defaultToShared) { + return $this->addShared($id, $concrete); + } + + return $this->definitions->add($id, $concrete); + } + + public function addShared(string $id, $concrete = null): DefinitionInterface + { + $concrete = $concrete ?? $id; + return $this->definitions->addShared($id, $concrete); + } + + public function defaultToShared(bool $shared = true): ContainerInterface + { + $this->defaultToShared = $shared; + return $this; + } + + public function extend(string $id): DefinitionInterface + { + if ($this->providers->provides($id)) { + $this->providers->register($id); + } + + if ($this->definitions->has($id)) { + return $this->definitions->getDefinition($id); + } + + throw new NotFoundException(sprintf( + 'Unable to extend alias (%s) as it is not being managed as a definition', + $id + )); + } + + public function addServiceProvider(ServiceProviderInterface $provider): DefinitionContainerInterface + { + $this->providers->add($provider); + return $this; + } + + /** + * @template RequestedType + * + * @param class-string|string $id + * + * @return RequestedType|mixed + */ + public function get($id) + { + return $this->resolve($id); + } + + /** + * @template RequestedType + * + * @param class-string|string $id + * + * @return RequestedType|mixed + */ + public function getNew($id) + { + return $this->resolve($id, true); + } + + public function has($id): bool + { + if ($this->definitions->has($id)) { + return true; + } + + if ($this->definitions->hasTag($id)) { + return true; + } + + if ($this->providers->provides($id)) { + return true; + } + + foreach ($this->delegates as $delegate) { + if ($delegate->has($id)) { + return true; + } + } + + return false; + } + + public function inflector(string $type, callable $callback = null): InflectorInterface + { + return $this->inflectors->add($type, $callback); + } + + public function delegate(ContainerInterface $container): self + { + $this->delegates[] = $container; + + if ($container instanceof ContainerAwareInterface) { + $container->setContainer($this); + } + + return $this; + } + + protected function resolve($id, bool $new = false) + { + if ($this->definitions->has($id)) { + $resolved = (true === $new) ? $this->definitions->resolveNew($id) : $this->definitions->resolve($id); + return $this->inflectors->inflect($resolved); + } + + if ($this->definitions->hasTag($id)) { + $arrayOf = (true === $new) + ? $this->definitions->resolveTaggedNew($id) + : $this->definitions->resolveTagged($id); + + array_walk($arrayOf, function (&$resolved) { + $resolved = $this->inflectors->inflect($resolved); + }); + + return $arrayOf; + } + + if ($this->providers->provides($id)) { + $this->providers->register($id); + + if (!$this->definitions->has($id) && !$this->definitions->hasTag($id)) { + throw new ContainerException(sprintf('Service provider lied about providing (%s) service', $id)); + } + + return $this->resolve($id, $new); + } + + foreach ($this->delegates as $delegate) { + if ($delegate->has($id)) { + $resolved = $delegate->get($id); + return $this->inflectors->inflect($resolved); + } + } + + throw new NotFoundException(sprintf('Alias (%s) is not being managed by the container or delegates', $id)); + } +} diff --git a/lib/packages/League/Container/ContainerAwareInterface.php b/lib/packages/League/Container/ContainerAwareInterface.php new file mode 100644 index 0000000000..494ff2b244 --- /dev/null +++ b/lib/packages/League/Container/ContainerAwareInterface.php @@ -0,0 +1,11 @@ +container = $container; + + if ($this instanceof ContainerAwareInterface) { + return $this; + } + + throw new BadMethodCallException(sprintf( + 'Attempt to use (%s) while not implementing (%s)', + ContainerAwareTrait::class, + ContainerAwareInterface::class + )); + } + + public function getContainer(): DefinitionContainerInterface + { + if ($this->container instanceof DefinitionContainerInterface) { + return $this->container; + } + + throw new ContainerException('No container implementation has been set.'); + } +} diff --git a/lib/packages/League/Container/Definition/Definition.php b/lib/packages/League/Container/Definition/Definition.php new file mode 100644 index 0000000000..df0fb488e6 --- /dev/null +++ b/lib/packages/League/Container/Definition/Definition.php @@ -0,0 +1,238 @@ +alias = $id; + $this->concrete = $concrete; + } + + public function addTag(string $tag): DefinitionInterface + { + $this->tags[$tag] = true; + return $this; + } + + public function hasTag(string $tag): bool + { + return isset($this->tags[$tag]); + } + + public function setAlias(string $id): DefinitionInterface + { + $this->alias = $id; + return $this; + } + + public function getAlias(): string + { + return $this->alias; + } + + public function setShared(bool $shared = true): DefinitionInterface + { + $this->shared = $shared; + return $this; + } + + public function isShared(): bool + { + return $this->shared; + } + + public function getConcrete() + { + return $this->concrete; + } + + public function setConcrete($concrete): DefinitionInterface + { + $this->concrete = $concrete; + $this->resolved = null; + return $this; + } + + public function addArgument($arg): DefinitionInterface + { + $this->arguments[] = $arg; + return $this; + } + + public function addArguments(array $args): DefinitionInterface + { + foreach ($args as $arg) { + $this->addArgument($arg); + } + + return $this; + } + + public function addMethodCall(string $method, array $args = []): DefinitionInterface + { + $this->methods[] = [ + 'method' => $method, + 'arguments' => $args + ]; + + return $this; + } + + public function addMethodCalls(array $methods = []): DefinitionInterface + { + foreach ($methods as $method => $args) { + $this->addMethodCall($method, $args); + } + + return $this; + } + + public function resolve() + { + if (null !== $this->resolved && $this->isShared()) { + return $this->resolved; + } + + return $this->resolveNew(); + } + + public function resolveNew() + { + $concrete = $this->concrete; + + if (is_callable($concrete)) { + $concrete = $this->resolveCallable($concrete); + } + + if ($concrete instanceof LiteralArgumentInterface) { + $this->resolved = $concrete->getValue(); + return $concrete->getValue(); + } + + if ($concrete instanceof ArgumentInterface) { + $concrete = $concrete->getValue(); + } + + if (is_string($concrete) && class_exists($concrete)) { + $concrete = $this->resolveClass($concrete); + } + + if (is_object($concrete)) { + $concrete = $this->invokeMethods($concrete); + } + + try { + $container = $this->getContainer(); + } catch (ContainerException $e) { + $container = null; + } + + // stop recursive resolving + if (is_string($concrete) && in_array($concrete, $this->recursiveCheck)) { + $this->resolved = $concrete; + return $concrete; + } + + // if we still have a string, try to pull it from the container + // this allows for `alias -> alias -> ... -> concrete + if (is_string($concrete) && $container instanceof ContainerInterface && $container->has($concrete)) { + $this->recursiveCheck[] = $concrete; + $concrete = $container->get($concrete); + } + + $this->resolved = $concrete; + return $concrete; + } + + /** + * @param callable $concrete + * @return mixed + */ + protected function resolveCallable(callable $concrete) + { + $resolved = $this->resolveArguments($this->arguments); + return call_user_func_array($concrete, $resolved); + } + + protected function resolveClass(string $concrete): object + { + $resolved = $this->resolveArguments($this->arguments); + $reflection = new ReflectionClass($concrete); + return $reflection->newInstanceArgs($resolved); + } + + protected function invokeMethods(object $instance): object + { + foreach ($this->methods as $method) { + $args = $this->resolveArguments($method['arguments']); + $callable = [$instance, $method['method']]; + call_user_func_array($callable, $args); + } + + return $instance; + } +} diff --git a/lib/packages/League/Container/Definition/DefinitionAggregate.php b/lib/packages/League/Container/Definition/DefinitionAggregate.php new file mode 100644 index 0000000000..96976f6990 --- /dev/null +++ b/lib/packages/League/Container/Definition/DefinitionAggregate.php @@ -0,0 +1,117 @@ +definitions = array_filter($definitions, static function ($definition) { + return ($definition instanceof DefinitionInterface); + }); + } + + public function add(string $id, $definition): DefinitionInterface + { + if (false === ($definition instanceof DefinitionInterface)) { + $definition = new Definition($id, $definition); + } + + $this->definitions[] = $definition->setAlias($id); + + return $definition; + } + + public function addShared(string $id, $definition): DefinitionInterface + { + $definition = $this->add($id, $definition); + return $definition->setShared(true); + } + + public function has(string $id): bool + { + foreach ($this->getIterator() as $definition) { + if ($id === $definition->getAlias()) { + return true; + } + } + + return false; + } + + public function hasTag(string $tag): bool + { + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + return true; + } + } + + return false; + } + + public function getDefinition(string $id): DefinitionInterface + { + foreach ($this->getIterator() as $definition) { + if ($id === $definition->getAlias()) { + return $definition->setContainer($this->getContainer()); + } + } + + throw new NotFoundException(sprintf('Alias (%s) is not being handled as a definition.', $id)); + } + + public function resolve(string $id) + { + return $this->getDefinition($id)->resolve(); + } + + public function resolveNew(string $id) + { + return $this->getDefinition($id)->resolveNew(); + } + + public function resolveTagged(string $tag): array + { + $arrayOf = []; + + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + $arrayOf[] = $definition->setContainer($this->getContainer())->resolve(); + } + } + + return $arrayOf; + } + + public function resolveTaggedNew(string $tag): array + { + $arrayOf = []; + + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + $arrayOf[] = $definition->setContainer($this->getContainer())->resolveNew(); + } + } + + return $arrayOf; + } + + public function getIterator(): Generator + { + yield from $this->definitions; + } +} diff --git a/lib/packages/League/Container/Definition/DefinitionAggregateInterface.php b/lib/packages/League/Container/Definition/DefinitionAggregateInterface.php new file mode 100644 index 0000000000..13eca48995 --- /dev/null +++ b/lib/packages/League/Container/Definition/DefinitionAggregateInterface.php @@ -0,0 +1,21 @@ +type = $type; + $this->callback = $callback; + } + + public function getType(): string + { + return $this->type; + } + + public function invokeMethod(string $name, array $args): InflectorInterface + { + $this->methods[$name] = $args; + return $this; + } + + public function invokeMethods(array $methods): InflectorInterface + { + foreach ($methods as $name => $args) { + $this->invokeMethod($name, $args); + } + + return $this; + } + + public function setProperty(string $property, $value): InflectorInterface + { + $this->properties[$property] = $this->resolveArguments([$value])[0]; + return $this; + } + + public function setProperties(array $properties): InflectorInterface + { + foreach ($properties as $property => $value) { + $this->setProperty($property, $value); + } + + return $this; + } + + public function inflect(object $object): void + { + $properties = $this->resolveArguments(array_values($this->properties)); + $properties = array_combine(array_keys($this->properties), $properties); + + // array_combine() can technically return false + foreach ($properties ?: [] as $property => $value) { + $object->{$property} = $value; + } + + foreach ($this->methods as $method => $args) { + $args = $this->resolveArguments($args); + $callable = [$object, $method]; + call_user_func_array($callable, $args); + } + + if ($this->callback !== null) { + call_user_func($this->callback, $object); + } + } +} diff --git a/lib/packages/League/Container/Inflector/InflectorAggregate.php b/lib/packages/League/Container/Inflector/InflectorAggregate.php new file mode 100644 index 0000000000..2a20fe5707 --- /dev/null +++ b/lib/packages/League/Container/Inflector/InflectorAggregate.php @@ -0,0 +1,44 @@ +inflectors[] = $inflector; + return $inflector; + } + + public function inflect($object) + { + foreach ($this->getIterator() as $inflector) { + $type = $inflector->getType(); + + if ($object instanceof $type) { + $inflector->setContainer($this->getContainer()); + $inflector->inflect($object); + } + } + + return $object; + } + + public function getIterator(): Generator + { + yield from $this->inflectors; + } +} diff --git a/lib/packages/League/Container/Inflector/InflectorAggregateInterface.php b/lib/packages/League/Container/Inflector/InflectorAggregateInterface.php new file mode 100644 index 0000000000..9395850bf0 --- /dev/null +++ b/lib/packages/League/Container/Inflector/InflectorAggregateInterface.php @@ -0,0 +1,14 @@ +cacheResolutions = $cacheResolutions; + } + + public function get($id, array $args = []) + { + if ($this->cacheResolutions === true && array_key_exists($id, $this->cache)) { + return $this->cache[$id]; + } + + if (!$this->has($id)) { + throw new NotFoundException( + sprintf('Alias (%s) is not an existing class and therefore cannot be resolved', $id) + ); + } + + $reflector = new ReflectionClass($id); + $construct = $reflector->getConstructor(); + + if ($construct && !$construct->isPublic()) { + throw new NotFoundException( + sprintf('Alias (%s) has a non-public constructor and therefore cannot be instantiated', $id) + ); + } + + $resolution = $construct === null + ? new $id() + : $reflector->newInstanceArgs($this->reflectArguments($construct, $args)) + ; + + if ($this->cacheResolutions === true) { + $this->cache[$id] = $resolution; + } + + return $resolution; + } + + public function has($id): bool + { + return class_exists($id); + } + + public function call(callable $callable, array $args = []) + { + if (is_string($callable) && strpos($callable, '::') !== false) { + $callable = explode('::', $callable); + } + + if (is_array($callable)) { + if (is_string($callable[0])) { + // if we have a definition container, try that first, otherwise, reflect + try { + $callable[0] = $this->getContainer()->get($callable[0]); + } catch (ContainerException $e) { + $callable[0] = $this->get($callable[0]); + } + } + + $reflection = new ReflectionMethod($callable[0], $callable[1]); + + if ($reflection->isStatic()) { + $callable[0] = null; + } + + return $reflection->invokeArgs($callable[0], $this->reflectArguments($reflection, $args)); + } + + if (is_object($callable)) { + $reflection = new ReflectionMethod($callable, '__invoke'); + return $reflection->invokeArgs($callable, $this->reflectArguments($reflection, $args)); + } + + $reflection = new ReflectionFunction(\Closure::fromCallable($callable)); + + return $reflection->invokeArgs($this->reflectArguments($reflection, $args)); + } +} diff --git a/lib/packages/League/Container/ServiceProvider/AbstractServiceProvider.php b/lib/packages/League/Container/ServiceProvider/AbstractServiceProvider.php new file mode 100644 index 0000000000..6b69913147 --- /dev/null +++ b/lib/packages/League/Container/ServiceProvider/AbstractServiceProvider.php @@ -0,0 +1,28 @@ +identifier ?? get_class($this); + } + + public function setIdentifier(string $id): ServiceProviderInterface + { + $this->identifier = $id; + return $this; + } +} diff --git a/lib/packages/League/Container/ServiceProvider/BootableServiceProviderInterface.php b/lib/packages/League/Container/ServiceProvider/BootableServiceProviderInterface.php new file mode 100644 index 0000000000..986091e2c0 --- /dev/null +++ b/lib/packages/League/Container/ServiceProvider/BootableServiceProviderInterface.php @@ -0,0 +1,16 @@ +providers, true)) { + return $this; + } + + $provider->setContainer($this->getContainer()); + + if ($provider instanceof BootableServiceProviderInterface) { + $provider->boot(); + } + + $this->providers[] = $provider; + return $this; + } + + public function provides(string $service): bool + { + foreach ($this->getIterator() as $provider) { + if ($provider->provides($service)) { + return true; + } + } + + return false; + } + + public function getIterator(): Generator + { + yield from $this->providers; + } + + public function register(string $service): void + { + if (false === $this->provides($service)) { + throw new ContainerException( + sprintf('(%s) is not provided by a service provider', $service) + ); + } + + foreach ($this->getIterator() as $provider) { + if (in_array($provider->getIdentifier(), $this->registered, true)) { + continue; + } + + if ($provider->provides($service)) { + $provider->register(); + $this->registered[] = $provider->getIdentifier(); + } + } + } +} diff --git a/lib/packages/League/Container/ServiceProvider/ServiceProviderAggregateInterface.php b/lib/packages/League/Container/ServiceProvider/ServiceProviderAggregateInterface.php new file mode 100644 index 0000000000..c66a3b8362 --- /dev/null +++ b/lib/packages/League/Container/ServiceProvider/ServiceProviderAggregateInterface.php @@ -0,0 +1,15 @@ +sut = new OrderType(); + } + + public function test_wc_order_is_dokan_suborder_related_method() { + $parent_id = $this->create_multi_vendor_order(); + $parent_order = wc_get_order( $parent_id ); + + $this->assertFalse( $this->sut->is_dokan_suborder_related( $parent_order ) ); + + $sub_order_ids = dokan_get_suborder_ids_by( $parent_id ); + + foreach ( $sub_order_ids as $sub_id ) { + $sub_order = wc_get_order( $sub_id ); + + $this->assertTrue( $this->sut->is_dokan_suborder_related( $sub_order ) ); + } + } + + public function test_order_type_method_for_multi_vendor() { + $parent_id = $this->create_multi_vendor_order(); + $parent_order = wc_get_order( $parent_id ); + + $this->assertEquals( $this->sut::DOKAN_PARENT_ORDER, $this->sut->get_type( $parent_order ) ); + + $sub_order_ids = dokan_get_suborder_ids_by( $parent_id ); + + foreach ( $sub_order_ids as $sub_id ) { + $sub_order = wc_get_order( $sub_id ); + + $this->assertEquals( $this->sut::DOKAN_SUBORDER, $this->sut->get_type( $sub_order ) ); + } + } + + public function test_order_type_method_for_single_vendor() { + $this->seller_id2 = $this->seller_id1; + + $parent_id = $this->create_multi_vendor_order(); + $parent_order = wc_get_order( $parent_id ); + + $this->assertEquals( $this->sut::DOKAN_SINGLE_ORDER, $this->sut->get_type( $parent_order ) ); + + $sub_order_ids = dokan_get_suborder_ids_by( $parent_id ); + + $this->assertNull( $sub_order_ids ); + } + + public function test_order_type_for_dokan_sub_refund() { + $parent_id = $this->create_multi_vendor_order(); + + $sub_order_ids = dokan_get_suborder_ids_by( $parent_id ); + $sub_id = $sub_order_ids[0]; + + $refund = $this->create_refund( $sub_id ); + + $this->assertEquals( $this->sut::DOKAN_SUBORDER_REFUND, $this->sut->get_type( $refund ) ); + } + + public function test_order_type_for_wc_refund() { + $parent_id = $this->create_multi_vendor_order(); + + $refund = $this->create_refund( $parent_id ); + + $this->assertEquals( $this->sut::DOKAN_PARENT_ORDER_REFUND, $this->sut->get_type( $refund ) ); + } + + public function test_order_type_for_dokan_single_order_refund() { + $this->seller_id2 = $this->seller_id1; + + $parent_id = $this->create_multi_vendor_order(); // Create single vendor order. + + $refund = $this->create_refund( $parent_id ); + + $this->assertEquals( $this->sut::DOKAN_SINGLE_ORDER_REFUND, $this->sut->get_type( $refund ) ); + } +} diff --git a/tests/php/src/Analytics/Reports/Orders/QueryFilterTest.php b/tests/php/src/Analytics/Reports/Orders/QueryFilterTest.php new file mode 100644 index 0000000000..77754d3fea --- /dev/null +++ b/tests/php/src/Analytics/Reports/Orders/QueryFilterTest.php @@ -0,0 +1,310 @@ +sut = dokan_get_container()->get( QueryFilter::class ); + } + + protected function tearDown(): void { + parent::tearDown(); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $this->sut ); + } + /** + * Test that the order statistics hooks are registered correctly. + * + * @see https://giuseppe-mazzapica.gitbook.io/brain-monkey/wordpress-specific-tools/wordpress-hooks-added + * + * @return void + */ + public function test_orders_hook_registered() { + $order_stats_query_filter = dokan_get_container()->get( QueryFilter::class ); + // Assert the Join Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_join_orders_subquery', [ $order_stats_query_filter, 'add_join_subquery' ] ) ); + // Assert the Where Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $order_stats_query_filter, 'add_where_subquery' ] ) ); + } + + /** + * Test dokan_order_stats JOIN and WHERE clauses are applied on order_stats select Query. + * + * Method(partial) mocking @see http://docs.mockery.io/en/latest/reference/partial_mocks.html + * + * @param int $order_id The order ID. + * @param int $seller_id1 The first seller ID. + * @param int $seller_id2 The second seller ID. + * @return void + */ + public function test_filter_hooks_are_applied_for_orders_query() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $mocking_methods = [ + 'add_join_subquery', + 'add_where_subquery', + 'add_select_subquery', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + foreach ( $mocking_methods as $method ) { + $service->shouldReceive( $method ) + ->atLeast() + ->once() + ->andReturnUsing( + function ( $clauses ) { + return $clauses; + } + ); + } + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query(); + + $wc_stats_query->get_data(); + } + + /** + * @dataProvider get_dokan_stats_data + * + * @return void + */ + public function test_dokan_order_stats_fields_are_selected_for_seller( $expected_data ) { + $order_id = $this->create_multi_vendor_order(); + + $this->set_order_meta_for_dokan( $order_id, $expected_data ); + + $this->run_all_pending(); + + $mocking_methods = [ + 'should_filter_by_seller_id', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + remove_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnTrue(); + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query(); + + $data = $wc_stats_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $report_data = $data->data; + + $this->assertEquals( count( $sub_ids ), count( $report_data ) ); + + foreach ( $sub_ids as $index => $s_id ) { + $sub_order = wc_get_order( $s_id ); + $order_data = $report_data[ $index ]; + + $this->assertEquals( $s_id, $order_data['order_id'] ); + $this->assertEquals( floatval( $sub_order->get_total() ), $order_data['total_sales'] ); + + foreach ( $expected_data as $key => $val ) { + $this->assertEquals( $val, $order_data[ $key ] ); + } + } + } + + /** + * @dataProvider get_dokan_stats_data + * + * @return void + */ + public function test_dokan_order_stats_fields_are_selected_for_admin( $expected_data ) { + $order_id = $this->create_multi_vendor_order(); + + $this->set_order_meta_for_dokan( $order_id, $expected_data ); + + $this->run_all_pending(); + + remove_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnUsing( + function () { + return false; + } + ); + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query(); + + $data = $wc_stats_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $report_data = $data->data; + + $this->assertEquals( 1, count( $report_data ) ); + + $report_data = $report_data[0]; + + foreach ( $expected_data as $key => $val ) { + $this->assertArrayHasKey( $key, $report_data ); + } + } + + public function test_orders_for_dokan_suborder_refund() { + $order_id = $this->create_multi_vendor_order(); + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $refund = $this->create_refund( $sub_ids[0] ); + + $this->run_all_pending(); + + remove_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnTrue(); + + $_GET['refunds'] = 'all'; + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( [ 'refunds' => 'all' ] ); + $data = $wc_stats_query->get_data(); + + $report_data = $data->data; + + $this->assertEquals( 1, count( $report_data ) ); + + $report_data = $report_data[0]; + + $this->assertEquals( $refund->get_id(), $report_data['order_id'] ); + } + + public function test_orders_for_dokan_parent_order_refund() { + $order_id = $this->create_multi_vendor_order(); + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $parent_refund = $this->create_refund( $sub_ids[0], true, true ); + + $this->run_all_pending(); + + remove_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnFalse(); + + $_GET['refunds'] = 'all'; + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( [ 'refunds' => 'all' ] ); + $data = $wc_stats_query->get_data(); + + $report_data = $data->data; + + $this->assertEquals( 1, count( $report_data ) ); + + $report_data = $report_data[0]; + + $this->assertEquals( $parent_refund->get_id(), $report_data['order_id'] ); + } + + public function test_orders_for_dokan_single_order_refund() { + $this->seller_id2 = $this->seller_id1; + + $order_id = $this->create_multi_vendor_order(); + + $refund = $this->create_refund( $order_id ); + + $this->run_all_pending(); + + remove_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnFalse(); + + $_GET['refunds'] = 'all'; + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( [ 'refunds' => 'all' ] ); + $data = $wc_stats_query->get_data(); + + $report_data = $data->data; + + $this->assertEquals( 1, count( $report_data ) ); + + $report_data = $report_data[0]; + + $this->assertEquals( $refund->get_id(), $report_data['order_id'] ); + } + + public function test_orders_analytics_for_seller_filter_as_a_admin() { + $order_id = $this->create_single_vendor_order( $this->seller_id1 ); + $order_id2 = $this->create_single_vendor_order( $this->seller_id2 ); + + $this->run_all_pending(); + + $_GET['sellers'] = $this->seller_id1; + + wp_set_current_user( $this->admin_id ); + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query(); + + $data = $wc_stats_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $report_data = $data->data; + + $this->assertEquals( 1, count( $report_data ) ); + $this->assertEquals( $order_id, $report_data[0]['order_id'] ); + } + + public function test_orders_analytics_for_seller_filter_as_a_vendor() { + $order_id = $this->create_single_vendor_order( $this->seller_id1 ); + $order_id2 = $this->create_single_vendor_order( $this->seller_id2 ); + + $this->run_all_pending(); + + // Ignore seller filter if a seller pass another seller ID as filter . + $_GET['seller'] = $this->seller_id1; + + wp_set_current_user( $this->seller_id2 ); + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query(); + + $data = $wc_stats_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $report_data = $data->data; + + $this->assertEquals( 1, count( $report_data ) ); + $this->assertEquals( $order_id2, $report_data[0]['order_id'] ); + } +} diff --git a/tests/php/src/Analytics/Reports/Orders/Stats/QueryFilterTest.php b/tests/php/src/Analytics/Reports/Orders/Stats/QueryFilterTest.php new file mode 100644 index 0000000000..b08052d396 --- /dev/null +++ b/tests/php/src/Analytics/Reports/Orders/Stats/QueryFilterTest.php @@ -0,0 +1,166 @@ +sut = dokan_get_container()->get( QueryFilter::class ); + } + + protected function tearDown(): void { + parent::tearDown(); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $this->sut ); + } + + /** + * Test that the order statistics hooks are registered correctly. + * + * @see https://giuseppe-mazzapica.gitbook.io/brain-monkey/wordpress-specific-tools/wordpress-hooks-added + * + * @return void + */ + public function test_order_stats_hook_registered() { + $order_stats_query_filter = dokan_get_container()->get( QueryFilter::class ); + // Assert the Join Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_join_orders_stats_total', [ $order_stats_query_filter, 'add_join_subquery' ] ) ); + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_join_orders_stats_interval', [ $order_stats_query_filter, 'add_join_subquery' ] ) ); + // Assert the Where Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_where_orders_stats_total', [ $order_stats_query_filter, 'add_where_subquery' ] ) ); + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_where_orders_stats_interval', [ $order_stats_query_filter, 'add_where_subquery' ] ) ); + } + + + /** + * Test dokan_order_stats JOIN and WHERE clauses are applied on order_stats select Query. + * + * Method(partial) mocking @see http://docs.mockery.io/en/latest/reference/partial_mocks.html + * + * @param int $order_id The order ID. + * @param int $seller_id1 The first seller ID. + * @param int $seller_id2 The second seller ID. + * @return void + */ + public function test_dokan_order_states_query_filter_hooks_are_order_stats_update() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $mocking_methods = [ + 'add_join_subquery', + 'add_where_subquery', + 'add_select_subquery_for_total', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + foreach ( $mocking_methods as $method ) { + $service->shouldReceive( $method ) + ->atLeast() + ->once() + ->andReturnUsing( + function ( $clauses ) { + return $clauses; + } + ); + } + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query(); + + $wc_stats_query->get_data(); + } + + /** + * @dataProvider get_dokan_stats_data + * + * @return void + */ + public function test_dokan_order_stats_added_to_wc_select_query_for_seller( array $data ) { + $parent_id = $this->create_multi_vendor_order(); + + $this->set_order_meta_for_dokan( $parent_id, $data ); + + $this->run_all_pending(); + + $filter = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $filter ); + + $filter->shouldReceive( 'should_filter_by_seller_id' ) + ->atLeast() + ->once() + ->andReturnTrue(); + + $orders_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query( [] ); + + $report_data = $orders_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $parent_id ); + + $this->assertCount( $report_data->totals->orders_count, $sub_ids ); + + $sub_ord_count = count( $sub_ids ); + + // Assert dokan order stats totals. + foreach ( $data as $key => $val ) { + $this->assertEquals( floatval( $val * $sub_ord_count ), $report_data->totals->{"total_$key"} ); + } + } + + /** + * @dataProvider get_dokan_stats_data + * + * @return void + */ + public function test_dokan_order_stats_added_to_wc_select_query_for_admin( array $data ) { + $parent_id = $this->create_multi_vendor_order(); + $this->set_order_meta_for_dokan( $parent_id, $data ); + + $this->run_all_pending(); + + $filter = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + + remove_filter( 'woocommerce_analytics_clauses_where_orders_stats_total', [ $this->sut, 'add_where_subquery' ], 30 ); + remove_filter( 'woocommerce_analytics_clauses_where_orders_stats_total', [ $this->sut, 'add_where_subquery' ], 30 ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $filter ); + + $filter->shouldReceive( 'should_filter_by_seller_id' ) + ->atLeast() + ->once() + ->andReturnFalse(); + + $orders_query = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query( [] ); + + $report_data = $orders_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $parent_id ); + + $this->assertEquals( 1, $report_data->totals->orders_count ); + + $sub_ord_count = count( $sub_ids ); + + // Assert dokan order stats totals. + foreach ( $data as $key => $val ) { + $this->assertEquals( floatval( $val * $sub_ord_count ), $report_data->totals->{"total_$key"} ); + } + } +} diff --git a/tests/php/src/Analytics/Reports/Orders/Stats/ScheduleListenerTest.php b/tests/php/src/Analytics/Reports/Orders/Stats/ScheduleListenerTest.php new file mode 100644 index 0000000000..1f2f892b72 --- /dev/null +++ b/tests/php/src/Analytics/Reports/Orders/Stats/ScheduleListenerTest.php @@ -0,0 +1,83 @@ +get( ScheduleListener::class ); + self::assertNotFalse( has_action( 'woocommerce_analytics_update_order_stats', [ $order_stats_table_listener, 'sync_dokan_order' ] ) ); + } + + /** + * Test the dokan_order_stats table update hooks are executed by ScheduleListener class. + * + * Method(partial) mocking @see http://docs.mockery.io/en/latest/reference/partial_mocks.html + * + * @param int $order_id The order ID. + * @param int $seller_id1 The first seller ID. + * @param int $seller_id2 The second seller ID. + * @return void + */ + public function test_dokan_order_states_update_hook_execute_on_order_stats_update() { + $order_id = $this->create_multi_vendor_order(); + $service = Mockery::mock( ScheduleListener::class . '[sync_dokan_order]' ); + dokan_get_container()->extend( ScheduleListener::class )->setConcrete( $service ); + + $service->shouldReceive( 'sync_dokan_order' ) + ->atLeast() + ->once() + ->andReturn( 1 ); + + $this->run_all_pending(); + } + + /** + * Test the mock class for multi-vendor order statistics. + * + * @param int $order_id The order ID. + * @param int $seller_id1 The first seller ID. + * @param int $seller_id2 The second seller ID. + * @return void + */ + public function test_data_is_inserted_in_dokan_order_stats_table() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $wc_order_stats_table = \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore::get_db_table_name(); + + $dokan_order_stats_table = \WeDevs\Dokan\Analytics\Reports\Orders\Stats\DataStore::get_db_table_name(); + + $this->assertDatabaseCount( + $wc_order_stats_table, 1, [ + 'order_id' => $order_id, + ] + ); + + $this->assertDatabaseCount( + $dokan_order_stats_table, 1, [ + 'order_id' => $order_id, + 'order_type' => OrderType::DOKAN_PARENT_ORDER, + ] + ); + + $sub_order_ids = dokan_get_suborder_ids_by( $order_id ); + + foreach ( $sub_order_ids as $sub_id ) { + $this->assertDatabaseCount( + $dokan_order_stats_table, 1, [ + 'order_id' => $sub_id, + 'order_type' => OrderType::DOKAN_SUBORDER, + ] + ); + } + } +} diff --git a/tests/php/src/Analytics/Reports/Products/QueryFilterTest.php b/tests/php/src/Analytics/Reports/Products/QueryFilterTest.php new file mode 100644 index 0000000000..0e3cb0678f --- /dev/null +++ b/tests/php/src/Analytics/Reports/Products/QueryFilterTest.php @@ -0,0 +1,173 @@ +sut = dokan_get_container()->get( QueryFilter::class ); + } + + protected function tearDown(): void { + parent::tearDown(); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $this->sut ); + } + /** + * Test that the order statistics hooks are registered correctly. + * + * @see https://giuseppe-mazzapica.gitbook.io/brain-monkey/wordpress-specific-tools/wordpress-hooks-added + * + * @return void + */ + public function test_products_hook_registered() { + $order_stats_query_filter = dokan_get_container()->get( QueryFilter::class ); + // Assert the Join Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_join_products_subquery', [ $order_stats_query_filter, 'add_join_subquery' ] ) ); + // Assert the Where Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_where_products_subquery', [ $order_stats_query_filter, 'add_where_subquery' ] ) ); + } + + /** + * Test dokan_order_stats JOIN and WHERE clauses are applied on order_stats select Query. + * + * Method(partial) mocking @see http://docs.mockery.io/en/latest/reference/partial_mocks.html + * + * @param int $order_id The order ID. + * @param int $seller_id1 The first seller ID. + * @param int $seller_id2 The second seller ID. + * @return void + */ + public function test_filter_hooks_are_applied_for_products_query() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $mocking_methods = [ + 'add_join_subquery', + 'add_where_subquery', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + foreach ( $mocking_methods as $method ) { + $service->shouldReceive( $method ) + ->atLeast() + ->once() + ->andReturnUsing( + function ( $clauses ) { + return $clauses; + } + ); + } + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query(); + + $wc_stats_query->get_data(); + } + + /** + * + * @return void + */ + public function test_dokan_products_fields_are_selected_for_seller() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $mocking_methods = [ + 'should_filter_by_seller_id', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + remove_filter( 'woocommerce_analytics_clauses_where_products_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnTrue(); + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query(); + + $data = $wc_stats_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $order_id ); + + $report_data = $data->data; + + $this->assertCount( 2, $report_data ); + + // Assert that sub order items are fetched. + foreach ( $sub_ids as $s_id ) { + $s_order = wc_get_order( $s_id ); + + foreach ( $s_order->get_items() as $item ) { + $this->assertNestedContains( + [ + 'product_id' => $item->get_product_id(), + 'net_revenue' => floatval( $item->get_total() ), + 'items_sold' => $item->get_quantity(), + 'orders_count' => 1, + ], $report_data + ); + } + } + } + + public function test_dokan_products_fields_are_selected_for_admin() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $mocking_methods = [ + 'should_filter_by_seller_id', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + remove_filter( 'woocommerce_analytics_clauses_where_products_subquery', [ $this->sut, 'add_where_subquery' ], 30 ); + + $service->shouldReceive( 'should_filter_by_seller_id' ) + ->andReturnFalse(); + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query(); + + $data = $wc_stats_query->get_data(); + + $report_data = $data->data; + + $this->assertCount( 2, $report_data ); + + // Assert that parent order items are fetched. + $s_order = wc_get_order( $order_id ); + + foreach ( $s_order->get_items() as $item ) { + $this->assertNestedContains( + [ + 'product_id' => $item->get_product_id(), + 'net_revenue' => floatval( $item->get_total() ), + 'items_sold' => $item->get_quantity(), + 'orders_count' => 1, + ], $report_data + ); + } + } +} diff --git a/tests/php/src/Analytics/Reports/Products/Stats/QueryFilterTest.php b/tests/php/src/Analytics/Reports/Products/Stats/QueryFilterTest.php new file mode 100644 index 0000000000..b7a1459cd7 --- /dev/null +++ b/tests/php/src/Analytics/Reports/Products/Stats/QueryFilterTest.php @@ -0,0 +1,169 @@ +sut = dokan_get_container()->get( QueryFilter::class ); + } + + protected function tearDown(): void { + parent::tearDown(); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $this->sut ); + } + + /** + * Test that the order statistics hooks are registered correctly. + * + * @see https://giuseppe-mazzapica.gitbook.io/brain-monkey/wordpress-specific-tools/wordpress-hooks-added + * + * @return void + */ + public function test_products_stats_hook_registered() { + $order_stats_query_filter = dokan_get_container()->get( QueryFilter::class ); + // Assert the Join Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_join_products_stats_total', [ $order_stats_query_filter, 'add_join_subquery' ] ) ); + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_join_products_stats_interval', [ $order_stats_query_filter, 'add_join_subquery' ] ) ); + // Assert the Where Clause filters are registered + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_where_products_stats_total', [ $order_stats_query_filter, 'add_where_subquery' ] ) ); + self::assertNotFalse( has_filter( 'woocommerce_analytics_clauses_where_products_stats_interval', [ $order_stats_query_filter, 'add_where_subquery' ] ) ); + } + + /** + * Test dokan_order_stats JOIN and WHERE clauses are applied on order_stats select Query. + * + * Method(partial) mocking @see http://docs.mockery.io/en/latest/reference/partial_mocks.html + * + * @return void + */ + public function test_dokan_products_states_query_filter_hooks_are_order_stats_update() { + $order_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $mocking_methods = [ + 'add_join_subquery', + 'add_where_subquery', + ]; + + $service = Mockery::mock( QueryFilter::class . '[' . implode( ',', $mocking_methods ) . ']' ); + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $service ); + + foreach ( $mocking_methods as $method ) { + $service->shouldReceive( $method ) + ->atLeast() + ->once() + ->andReturnUsing( + function ( $clauses ) { + return $clauses; + } + ); + } + + $wc_stats_query = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query(); + + $wc_stats_query->get_data(); + } + + /** + * + * @return void + */ + public function test_dokan_products_stats_added_to_wc_select_query_for_seller() { + $parent_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $filter = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $filter ); + + $filter->shouldReceive( 'should_filter_by_seller_id' ) + ->atLeast() + ->once() + ->andReturnTrue(); + + $orders_query = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query( [] ); + + $report_data = $orders_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $parent_id ); + + $this->assertCount( $report_data->totals->orders_count, $sub_ids ); + + $order = wc_get_order( $parent_id ); + + // Initialize a variable to hold the total + $line_items_total = 0; + + // Loop through each line item in the order + foreach ( $order->get_items() as $item_id => $item ) { + // Add the line item total to the cumulative total + $line_items_total += $item->get_total(); + } + + $this->assertCount( $report_data->totals->orders_count, $sub_ids ); + $this->assertEquals( $order->get_item_count(), $report_data->totals->items_sold ); + $this->assertEquals( $line_items_total, $report_data->totals->net_revenue ); + } + + /** + * + * @return void + */ + public function test_dokan_products_stats_added_to_wc_select_query_for_admin() { + $parent_id = $this->create_multi_vendor_order(); + + $this->run_all_pending(); + + $filter = Mockery::mock( QueryFilter::class . '[should_filter_by_seller_id]' ); + + remove_filter( 'woocommerce_analytics_clauses_where_products_stats_total', [ $this->sut, 'add_where_subquery' ], 30 ); + remove_filter( 'woocommerce_analytics_clauses_where_products_stats_total', [ $this->sut, 'add_where_subquery' ], 30 ); + + dokan_get_container()->extend( QueryFilter::class )->setConcrete( $filter ); + + $filter->shouldReceive( 'should_filter_by_seller_id' ) + ->atLeast() + ->once() + ->andReturnFalse(); + + $orders_query = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query( [] ); + + $report_data = $orders_query->get_data(); + + $sub_ids = dokan_get_suborder_ids_by( $parent_id ); + + $this->assertEquals( 1, $report_data->totals->orders_count ); + + $order = wc_get_order( $parent_id ); + + // Initialize a variable to hold the total + $line_items_total = 0; + + // Loop through each line item in the order + foreach ( $order->get_items() as $item_id => $item ) { + // Add the line item total to the cumulative total + $line_items_total += $item->get_total(); + } + + $this->assertEquals( $order->get_item_count(), $report_data->totals->items_sold ); + $this->assertEquals( $line_items_total, $report_data->totals->net_revenue ); + } +} diff --git a/tests/php/src/Analytics/Reports/ReportTestCase.php b/tests/php/src/Analytics/Reports/ReportTestCase.php new file mode 100644 index 0000000000..30ba44f5e2 --- /dev/null +++ b/tests/php/src/Analytics/Reports/ReportTestCase.php @@ -0,0 +1,113 @@ +get_items(); + $product_id = ''; + $line_item = []; + $line_item_id = ''; + + foreach ( $order_items as $key => $item ) { + $line_item_id = $item->get_id(); + + $qty = $item->get_quantity() - 1; + $amount = $item->get_total(); + $product_id = $item->get_product_id(); + $line_item = array( + 'qty' => $qty, + 'refund_total' => $amount, + 'refund_tax' => array(), + ); + + break; + } + + // Create the refund object. + $refund = wc_create_refund( + array( + 'reason' => 'Testing Refund', + 'order_id' => $order_id, + 'line_items' => [ $line_item_id => $line_item ], + ) + ); + + if ( $parent_refund && $order->get_parent_id() ) { + $parent_id = $order->get_parent_id(); + $parent_order = wc_get_order( $parent_id ); + $order_items = $parent_order->get_items(); + foreach ( $order_items as $key => $item ) { + if ( $product_id === $item->get_product_id() ) { + $line_item_id = $item->get_id(); + break; + } + } + + // Create the parent order refund object. + $parent_refund = wc_create_refund( + array( + 'reason' => 'Testing Parent Refund', + 'order_id' => $parent_id, + 'line_items' => [ $line_item_id => $line_item ], + ) + ); + + if ( $return_parent_refund ) { + return $parent_refund; + } + } + + return $refund; + } + + protected function set_order_meta_for_dokan( $parent_id, array $data ) { + // Filter null when order is single vendor order. + $sub_order_ids = array_filter( (array) dokan_get_suborder_ids_by( $parent_id ) ); + + // Fill sub orders meta data. + foreach ( $sub_order_ids as $sub_id ) { + $sub_order = wc_get_order( $sub_id ); + + foreach ( $data as $key => $val ) { + $sub_order->add_meta_data( '_' . $key, $val, true ); + } + + $sub_order->save_meta_data(); + $sub_order->save(); + } + + // Fill parent order meta data + $order = wc_get_order( $parent_id ); + $count = count( $sub_order_ids ) ? count( $sub_order_ids ) : 1; + + foreach ( $data as $key => $val ) { + $order->add_meta_data( '_' . $key, $val * $count, true ); + } + + $order->save_meta_data(); + $order->save(); + } + + public static function get_dokan_stats_data() { + return [ + [ + [ + 'seller_earning' => random_int( 5, 10 ), + 'seller_gateway_fee' => random_int( 5, 10 ), + 'seller_discount' => random_int( 5, 10 ), + 'admin_commission' => random_int( 5, 10 ), + 'admin_gateway_fee' => random_int( 5, 10 ), + 'admin_discount' => random_int( 5, 10 ), + 'admin_subsidy' => random_int( 5, 10 ), + ], + ], + ]; + } +} diff --git a/tests/php/src/Analytics/Reports/Stock/ProductQueryFilterTest.php b/tests/php/src/Analytics/Reports/Stock/ProductQueryFilterTest.php new file mode 100644 index 0000000000..d670dcbf82 --- /dev/null +++ b/tests/php/src/Analytics/Reports/Stock/ProductQueryFilterTest.php @@ -0,0 +1,54 @@ +factory()->product->set_seller_id( $this->seller_id1 ) + ->create_many( 5 ); + + $seller2_prod_ids = $this->factory()->product->set_seller_id( $this->seller_id2 ) + ->create_many( 5 ); + + wp_set_current_user( $this->admin_id ); + + $_GET['sellers'] = $this->seller_id1; + + $response = $this->get_request( 'stock', [ 'sellers' => $this->seller_id1 ] ); + + $data = $response->get_data(); + + foreach ( $data as $item ) { + $prod = wc_get_product( $item['id'] ); + } + + $this->assertCount( count( $seller1_prod_ids ), $data ); + } + + public function tests_stock_reports_are_fetched_without_seller_filter() { + $seller1_prod_ids = $this->factory()->product->set_seller_id( $this->seller_id1 ) + ->create_many( 5 ); + + $seller2_prod_ids = $this->factory()->product->set_seller_id( $this->seller_id2 ) + ->create_many( 5 ); + + wp_set_current_user( $this->admin_id ); + + $response = $this->get_request( 'stock' ); + + $data = $response->get_data(); + + foreach ( $data as $item ) { + $prod = wc_get_product( $item['id'] ); + } + + $this->assertCount( count( $seller1_prod_ids ) + count( $seller2_prod_ids ), $data ); + } +} diff --git a/tests/php/src/Analytics/Reports/Stock/Stats/QueryFilterTest.php b/tests/php/src/Analytics/Reports/Stock/Stats/QueryFilterTest.php new file mode 100644 index 0000000000..c6f7df4d5b --- /dev/null +++ b/tests/php/src/Analytics/Reports/Stock/Stats/QueryFilterTest.php @@ -0,0 +1,53 @@ +factory()->product + ->set_seller_id( $this->seller_id1 ) + ->create_many( 5 ); + + $seller2_prod_ids = $this->factory()->product + ->set_seller_id( $this->seller_id2 ) + ->create_many( 5 ); + + wp_set_current_user( $this->admin_id ); + + $query = new \Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Query(); + + $data = $query->get_data(); + $total = count( $seller1_prod_ids ) + count( $seller2_prod_ids ); + + $this->assertEquals( $total, $data['instock'] ); + $this->assertEquals( $total, $data['products'] ); + } + + public function tests_stock_stats_report_by_seller_filter() { + $seller1_prod_ids = $this->factory()->product + ->set_seller_id( $this->seller_id1 ) + ->create_many( 5 ); + + $seller2_prod_ids = $this->factory()->product + ->set_seller_id( $this->seller_id2 ) + ->create_many( 5 ); + + wp_set_current_user( $this->admin_id ); + + $_GET['sellers'] = $this->seller_id1; + + $query = new \Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Query(); + + $data = $query->get_data(); + $total = count( $seller1_prod_ids ); + + $this->assertEquals( $total, $data['instock'] ); + $this->assertEquals( $total, $data['products'] ); + } +} diff --git a/tests/php/src/DBAssertionTrait.php b/tests/php/src/DBAssertionTrait.php new file mode 100644 index 0000000000..34416b8a7d --- /dev/null +++ b/tests/php/src/DBAssertionTrait.php @@ -0,0 +1,77 @@ + 'val1', 'field1' => 'val1']; + // $placeholders = "field1='%s' AND field2='%s' "; + $placeholders = implode( + ' AND ', array_map( + function ( $key ) { + return "{$key} = %s"; + }, array_keys( $data ) + ) + ); + } else { + $data = [ 1 ]; + } + + if ( ! str_starts_with( $table, $wpdb->prefix ) ) { + $table = $wpdb->prefix . $table; + } + + $sql = $wpdb->prepare( + "SELECT COUNT(*) FROM $table WHERE $placeholders ", + array_values( $data ) + ); + + $rows_count = $wpdb->get_var( $sql ); + + return $rows_count; + } + + /** + * Assert that a table contains at least one row matching the specified criteria. + * + * @param string $table The name of the table (without the prefix). + * @param array $data An associative array of field-value pairs to match. + * @return void + */ + public function assertDatabaseHas( string $table, array $data = [] ): void { + $rows_count = $this->getDatabaseCount( $table, $data ); + + $this->assertGreaterThanOrEqual( 1, $rows_count, "No rows found in `$table` for given data " . json_encode( $data ) ); + } + + /** + * Assert that a table contains the specified number of rows matching the criteria. + * + * @param string $table The name of the table (without the prefix). + * @param int $count The expected number of matching rows. + * @param array $data An associative array of field-value pairs to match. + * @return void + */ + public function assertDatabaseCount( string $table, int $count, array $data = [] ): void { + $rows_count = $this->getDatabaseCount( $table, $data ); + + $this->assertEquals( $count, $rows_count, "No rows found in `$table` for given data " . json_encode( $data ) ); + } +}