From f2634ca5c8dd0b949c79a773ca9bb84ca0ecab8c Mon Sep 17 00:00:00 2001 From: Peter Fabian Date: Fri, 18 Jan 2019 17:11:46 +0100 Subject: [PATCH] Added support for segmenting to coupons/stats. --- ...-rest-reports-coupons-stats-controller.php | 62 +++- .../includes/class-wc-admin-api-init.php | 1 + ...admin-reports-coupons-stats-segmenting.php | 312 ++++++++++++++++++ ...admin-reports-coupons-stats-data-store.php | 5 +- 4 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 plugins/woocommerce-admin/includes/class-wc-admin-reports-coupons-stats-segmenting.php diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-coupons-stats-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-coupons-stats-controller.php index ee963f7eef3..744b476ed88 100644 --- a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-coupons-stats-controller.php +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-coupons-stats-controller.php @@ -39,15 +39,16 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con * @return array */ protected function prepare_reports_query( $request ) { - $args = array(); - $args['before'] = $request['before']; - $args['after'] = $request['after']; - $args['interval'] = $request['interval']; - $args['page'] = $request['page']; - $args['per_page'] = $request['per_page']; - $args['orderby'] = $request['orderby']; - $args['order'] = $request['order']; - $args['coupons'] = (array) $request['coupons']; + $args = array(); + $args['before'] = $request['before']; + $args['after'] = $request['after']; + $args['interval'] = $request['interval']; + $args['page'] = $request['page']; + $args['per_page'] = $request['per_page']; + $args['orderby'] = $request['orderby']; + $args['order'] = $request['order']; + $args['coupons'] = (array) $request['coupons']; + $args['segmentby'] = $request['segmentby']; return $args; } @@ -132,7 +133,7 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con * @return array */ public function get_item_schema() { - $totals = array( + $data_values = array( 'amount' => array( 'description' => __( 'Net discount amount.', 'wc-admin' ), 'type' => 'number', @@ -153,6 +154,36 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con ), ); + $segments = array( + 'segments' => array( + 'description' => __( 'Reports data grouped by segment condition.', 'wc-admin' ), + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'segment_id' => array( + 'description' => __( 'Segment identificator.', 'wc-admin' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'enum' => array( 'day', 'week', 'month', 'year' ), + ), + 'subtotals' => array( + 'description' => __( 'Interval subtotals.', 'wc-admin' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'properties' => $data_values, + ), + ), + ), + ), + ); + + $totals = array_merge( $data_values, $segments ); + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_coupons_stats', @@ -299,6 +330,17 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con 'type' => 'integer', ), ); + $params['segmentby'] = array( + 'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ), + 'type' => 'string', + 'enum' => array( + 'product', + 'variation', + 'category', // will we support this? + 'coupon', + ), + 'validate_callback' => 'rest_validate_request_arg', + ); return $params; } diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php b/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php index f11950008ba..907f3603737 100644 --- a/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php +++ b/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php @@ -50,6 +50,7 @@ class WC_Admin_Api_Init { require_once dirname( __FILE__ ) . '/class-wc-admin-reports-segmenting.php'; require_once dirname( __FILE__ ) . '/class-wc-admin-reports-orders-stats-segmenting.php'; require_once dirname( __FILE__ ) . '/class-wc-admin-reports-products-stats-segmenting.php'; + require_once dirname( __FILE__ ) . '/class-wc-admin-reports-coupons-stats-segmenting.php'; // Query classes for reports. require_once dirname( __FILE__ ) . '/class-wc-admin-reports-revenue-query.php'; diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-reports-coupons-stats-segmenting.php b/plugins/woocommerce-admin/includes/class-wc-admin-reports-coupons-stats-segmenting.php new file mode 100644 index 00000000000..cbb898e9534 --- /dev/null +++ b/plugins/woocommerce-admin/includes/class-wc-admin-reports-coupons-stats-segmenting.php @@ -0,0 +1,312 @@ + "SUM($products_table.coupon_amount) as amount", + ); + + return $this->prepare_selections( $columns_mapping ); + } + + /** + * Returns SELECT clause statements to be used for order-related product-level segmenting query (e.g. orders_count when segmented by category). + * + * @param string $coupons_lookup_table Name of SQL table containing the order-level segmenting info. + * + * @return string SELECT clause statements. + */ + protected function get_segment_selections_order_level( $coupons_lookup_table ) { + $columns_mapping = array( + 'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count", + 'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count", + ); + + return $this->prepare_selections( $columns_mapping ); + } + + /** + * Returns SELECT clause statements to be used for order-level segmenting query (e.g. discount amount when segmented by coupons). + * + * @param string $coupons_lookup_table Name of SQL table containing the order-level info. + * @param array $overrides Array of overrides for default column calculations. + * + * @return string + */ + protected function segment_selections_orders( $coupons_lookup_table, $overrides = array() ) { + $columns_mapping = array( + 'amount' => "SUM($coupons_lookup_table.discount_amount) as amount", + 'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count", + 'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count", + ); + + if ( $overrides ) { + $columns_mapping = array_merge( $columns_mapping, $overrides ); + } + + return $this->prepare_selections( $columns_mapping ); + } + + /** + * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). + * + * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. + * @param string $segmenting_from FROM part of segmenting SQL query. + * @param string $segmenting_where WHERE part of segmenting SQL query. + * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. + * @param string $segmenting_dimension_name Name of the segmenting dimension. + * @param string $table_name Name of SQL table which is the stats table for orders. + * @param array $totals_query Array of SQL clauses for totals query. + * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. + * + * @return array + */ + protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { + global $wpdb; + + // Product-level numbers and order-level numbers can be fetched by the same query. + $segments_products = $wpdb->get_results( + "SELECT + $segmenting_groupby AS $segmenting_dimension_name + {$segmenting_selections['product_level']} + {$segmenting_selections['order_level']} + FROM + $table_name + $segmenting_from + {$totals_query['from_clause']} + WHERE + 1=1 + {$totals_query['where_time_clause']} + {$totals_query['where_clause']} + $segmenting_where + GROUP BY + $segmenting_groupby", + ARRAY_A + ); // WPCS: cache ok, DB call ok, unprepared SQL ok. + + $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); + return $totals_segments; + } + + /** + * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). + * + * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. + * @param string $segmenting_from FROM part of segmenting SQL query. + * @param string $segmenting_where WHERE part of segmenting SQL query. + * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. + * @param string $segmenting_dimension_name Name of the segmenting dimension. + * @param string $table_name Name of SQL table which is the stats table for orders. + * @param array $intervals_query Array of SQL clauses for intervals query. + * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. + * + * @return array + */ + protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { + global $wpdb; + + // LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments. + $limit_parts = explode( ',', $intervals_query['limit'] ); + $orig_rowcount = intval( $limit_parts[1] ); + $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); + + // Product-level numbers and order-level numbers can be fetched by the same query. + $segments_products = $wpdb->get_results( + "SELECT + {$intervals_query['select_clause']} AS time_interval, + $segmenting_groupby AS $segmenting_dimension_name + {$segmenting_selections['product_level']} + {$segmenting_selections['order_level']} + FROM + $table_name + $segmenting_from + {$intervals_query['from_clause']} + WHERE + 1=1 + {$intervals_query['where_time_clause']} + {$intervals_query['where_clause']} + $segmenting_where + GROUP BY + time_interval, $segmenting_groupby + $segmenting_limit", + ARRAY_A + ); // WPCS: cache ok, DB call ok, unprepared SQL ok. + + $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); + return $intervals_segments; + } + + /** + * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). + * + * @param string $segmenting_select SELECT part of segmenting SQL query. + * @param string $segmenting_from FROM part of segmenting SQL query. + * @param string $segmenting_where WHERE part of segmenting SQL query. + * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. + * @param string $table_name Name of SQL table which is the stats table for orders. + * @param array $totals_query Array of SQL clauses for intervals query. + * + * @return array + */ + protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { + global $wpdb; + + $totals_segments = $wpdb->get_results( + "SELECT + $segmenting_groupby + $segmenting_select + FROM + $table_name + $segmenting_from + {$totals_query['from_clause']} + WHERE + 1=1 + {$totals_query['where_time_clause']} + {$totals_query['where_clause']} + $segmenting_where + GROUP BY + $segmenting_groupby", + ARRAY_A + ); // WPCS: cache ok, DB call ok, unprepared SQL ok. + + // Reformat result. + $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); + return $totals_segments; + } + + /** + * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). + * + * @param string $segmenting_select SELECT part of segmenting SQL query. + * @param string $segmenting_from FROM part of segmenting SQL query. + * @param string $segmenting_where WHERE part of segmenting SQL query. + * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. + * @param string $table_name Name of SQL table which is the stats table for orders. + * @param array $intervals_query Array of SQL clauses for intervals query. + * + * @return array + */ + protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { + global $wpdb; + $limit_parts = explode( ',', $intervals_query['limit'] ); + $orig_rowcount = intval( $limit_parts[1] ); + $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); + + $intervals_segments = $wpdb->get_results( + "SELECT + MAX($table_name.date_created) AS datetime_anchor, + {$intervals_query['select_clause']} AS time_interval, + $segmenting_groupby + $segmenting_select + FROM + $table_name + $segmenting_from + {$intervals_query['from_clause']} + WHERE + 1=1 + {$intervals_query['where_time_clause']} + {$intervals_query['where_clause']} + $segmenting_where + GROUP BY + time_interval, $segmenting_groupby + $segmenting_limit", + ARRAY_A + ); // WPCS: cache ok, DB call ok, unprepared SQL ok. + + // Reformat result. + $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); + return $intervals_segments; + } + + /** + * Return array of segments formatted for REST response. + * + * @param string $type Type of segments to return--'totals' or 'intervals'. + * @param array $query_params SQL query parameter array. + * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). + * + * @return array + * @throws WC_REST_Exception In case of segmenting by variations, when no parent product is specified. + */ + protected function get_segments( $type, $query_params, $table_name ) { + global $wpdb; + if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { + return array(); + } + + $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; + $unique_orders_table = ''; + $segmenting_where = ''; + + // Product, variation, and category are bound to product, so here product segmenting table is required, + // while coupon and customer are bound to order, so we don't need the extra JOIN for those. + // This also means that segment selections need to be calculated differently. + if ( 'product' === $this->query_args['segmentby'] ) { + $segmenting_selections = array( + 'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ), + 'order_level' => $this->get_segment_selections_order_level( $table_name ), + ); + $segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; + $segmenting_groupby = $product_segmenting_table . '.product_id'; + $segmenting_dimension_name = 'product_id'; + + $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); + } elseif ( 'variation' === $this->query_args['segmentby'] ) { + if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { + throw new WC_REST_Exception( 'woocommerce_rest_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'wc-admin' ), 400 ); + } + + $segmenting_selections = array( + 'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ), + 'order_level' => $this->get_segment_selections_order_level( $table_name ), + ); + $segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; + $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; + $segmenting_groupby = $product_segmenting_table . '.variation_id'; + $segmenting_dimension_name = 'variation_id'; + + $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); + } elseif ( 'category' === $this->query_args['segmentby'] ) { + $segmenting_selections = array( + 'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ), + 'order_level' => $this->get_segment_selections_order_level( $table_name ), + ); + $segmenting_from = " + INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id) + LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id + RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id + "; + $segmenting_where = " AND taxonomy = 'product_cat'"; + $segmenting_groupby = 'wp_term_taxonomy.term_taxonomy_id'; + $segmenting_dimension_name = 'category_id'; + + $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); + } elseif ( 'coupon' === $this->query_args['segmentby'] ) { + $segmenting_selections = $this->segment_selections_orders( $table_name ); + $segmenting_from = ''; + $segmenting_groupby = "$table_name.coupon_id"; + + $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); + } + + return $segments; + } +} diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-coupons-stats-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-coupons-stats-data-store.php index 395eca39504..ea591f68390 100644 --- a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-coupons-stats-data-store.php +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-coupons-stats-data-store.php @@ -165,7 +165,9 @@ class WC_Admin_Reports_Coupons_Stats_Data_Store extends WC_Admin_Reports_Coupons if ( null === $totals ) { return $data; } - $totals = (object) $this->cast_numbers( $totals[0] ); + $segmenter = new WC_Admin_Reports_Coupons_Stats_Segmenting( $query_args, $this->report_columns ); + $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); + $totals = (object) $this->cast_numbers( $totals[0] ); // Intervals. $this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name ); @@ -213,6 +215,7 @@ class WC_Admin_Reports_Coupons_Stats_Data_Store extends WC_Admin_Reports_Coupons } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } + $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); $this->create_interval_subtotals( $data->intervals ); wp_cache_set( $cache_key, $data, $this->cache_group );