DataStore.php
<?php
/**
* API\Reports\Products\DataStore class file.
*/
namespace Automattic\WooCommerce\Admin\API\Reports\Products;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Enums\ProductType;
/**
* API\Reports\Products\DataStore.
*/
class DataStore extends ReportsDataStore implements DataStoreInterface {
/**
* Table used to get the data.
*
* @override ReportsDataStore::$table_name
*
* @var string
*/
protected static $table_name = 'wc_order_product_lookup';
/**
* Cache identifier.
*
* @override ReportsDataStore::$cache_key
*
* @var string
*/
protected $cache_key = 'products';
/**
* Mapping columns to data type to return correct response types.
*
* @override ReportsDataStore::$column_types
*
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
// Extended attributes.
'name' => 'strval',
'price' => 'floatval',
'image' => 'strval',
'permalink' => 'strval',
'stock_status' => 'strval',
'stock_quantity' => 'intval',
'low_stock_amount' => 'intval',
'category_ids' => 'array_values',
'variations' => 'array_values',
'sku' => 'strval',
);
/**
* Extended product attributes to include in the data.
*
* @var array
*/
protected $extended_attributes = array(
'name',
'price',
'image',
'permalink',
'stock_status',
'stock_quantity',
'manage_stock',
'low_stock_amount',
'category_ids',
'variations',
'sku',
);
/**
* Data store context used to pass to filters.
*
* @override ReportsDataStore::$context
*
* @var string
*/
protected $context = 'products';
/**
* Assign report columns once full table name has been assigned.
*
* @override ReportsDataStore::assign_report_columns()
*/
protected function assign_report_columns() {
$table_name = self::get_db_table_name();
$this->report_columns = array(
'product_id' => 'product_id',
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count",
);
}
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 10 );
add_action( 'woocommerce_order_partially_refunded', array( __CLASS__, 'add_partial_refund_type_meta' ), 10, 2 );
add_action( 'woocommerce_order_fully_refunded', array( __CLASS__, 'add_full_refund_type_meta' ), 10, 2 );
}
/**
* Add a partial refund type meta to the order.
*
* @param int $order_id Order ID.
* @param int $refund_id Refund ID.
*/
public static function add_partial_refund_type_meta( $order_id, $refund_id ) {
self::add_refund_type_meta( $refund_id, 'partial' );
}
/**
* Add a full refund type meta to the order.
*
* @param int $order_id Order ID.
* @param int $refund_id Refund ID.
*/
public static function add_full_refund_type_meta( $order_id, $refund_id ) {
self::add_refund_type_meta( $refund_id, 'full' );
}
/**
* Add a refund type meta to the order.
*
* @param int $refund_id Refund ID.
* @param string $type Refund type.
*/
public static function add_refund_type_meta( $refund_id, $type ) {
$order = wc_get_order( $refund_id );
$order->update_meta_data( '_refund_type', $type );
$order->save_meta_data();
}
/**
* Fills FROM clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @param string $arg_name Target of the JOIN sql param.
* @param string $id_cell ID cell identifier, like `table_name.id_column_name`.
*/
protected function add_from_sql_params( $query_args, $arg_name, $id_cell ) {
global $wpdb;
$type = 'join';
// Order by product name requires extra JOIN.
switch ( $query_args['orderby'] ) {
case 'product_name':
$join = " JOIN {$wpdb->posts} AS _products ON {$id_cell} = _products.ID";
break;
case 'sku':
$join = " LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'";
break;
case 'variations':
$type = 'left_join';
$join = "LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->posts} WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent";
break;
default:
$join = '';
break;
}
if ( $join ) {
if ( 'inner' === $arg_name ) {
$this->subquery->add_sql_clause( $type, $join );
} else {
$this->add_sql_clause( $type, $join );
}
}
}
/**
* Updates the database query with parameters used for Products report: categories and order status.
*
* @param array $query_args Query arguments supplied by the user.
*/
protected function add_sql_query_params( $query_args ) {
global $wpdb;
$order_product_lookup_table = self::get_db_table_name();
$this->add_time_period_sql_params( $query_args, $order_product_lookup_table );
$this->get_limit_sql_params( $query_args );
$this->add_order_by_sql_params( $query_args );
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$this->add_from_sql_params( $query_args, 'outer', 'default_results.product_id' );
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
} else {
$this->add_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" );
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" );
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
}
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @override ReportsDataStore::normalize_order_by()
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return self::get_db_table_name() . '.date_created';
}
if ( 'product_name' === $order_by ) {
return 'post_title';
}
if ( 'sku' === $order_by ) {
return 'meta_value';
}
return $order_by;
}
/**
* Enriches the product data with attributes specified by the extended_attributes.
*
* @param array $products_data Product data.
* @param array $query_args Query parameters.
*/
protected function include_extended_info( &$products_data, $query_args ) {
global $wpdb;
$product_names = array();
foreach ( $products_data as $key => $product_data ) {
$extended_info = new \ArrayObject();
if ( $query_args['extended_info'] ) {
$product_id = $product_data['product_id'];
$product = wc_get_product( $product_id );
// Product was deleted.
if ( ! $product ) {
if ( ! isset( $product_names[ $product_id ] ) ) {
$product_names[ $product_id ] = $wpdb->get_var(
$wpdb->prepare(
"SELECT i.order_item_name
FROM {$wpdb->prefix}wc_order_product_lookup l
JOIN {$wpdb->prefix}woocommerce_order_items i ON i.order_item_id = l.order_item_id
WHERE l.product_id = %d
ORDER BY l.order_item_id DESC
LIMIT 1",
$product_id
)
);
}
/* translators: %s is product name */
$products_data[ $key ]['extended_info']['name'] = $product_names[ $product_id ] ? sprintf( __( '%s (Deleted)', 'woocommerce' ), $product_names[ $product_id ] ) : __( '(Deleted)', 'woocommerce' );
continue;
}
$extended_attributes = apply_filters( 'woocommerce_rest_reports_products_extended_attributes', $this->extended_attributes, $product_data );
foreach ( $extended_attributes as $extended_attribute ) {
if ( 'variations' === $extended_attribute ) {
if ( ! $product->is_type( ProductType::VARIABLE ) ) {
continue;
}
$function = 'get_children';
} else {
$function = 'get_' . $extended_attribute;
}
if ( is_callable( array( $product, $function ) ) ) {
$value = $product->{$function}();
$extended_info[ $extended_attribute ] = $value;
}
}
// If there is no set low_stock_amount, use the one in user settings.
if ( '' === $extended_info['low_stock_amount'] ) {
$extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
}
$extended_info = $this->cast_numbers( $extended_info );
}
$products_data[ $key ]['extended_info'] = $extended_info;
}
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @override ReportsDataStore::get_data()
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
$data = parent::get_data( $query_args );
/*
* Do not cache extended info -- this is required to get the latest stock data.
* `include_extended_info` checks only `extended_info` key,
* so we don't need to bother about normalizing timestamps.
*/
$defaults = $this->get_default_query_vars();
$query_args = wp_parse_args( $query_args, $defaults );
$this->include_extended_info( $data->data, $query_args );
return $data;
}
/**
* Get the default query arguments to be used by get_data().
* These defaults are only partially applied when used via REST API, as that has its own defaults.
*
* @override ReportsDataStore::get_default_query_vars()
*
* @return array Query parameters.
*/
public function get_default_query_vars() {
$defaults = parent::get_default_query_vars();
$defaults['category_includes'] = array();
$defaults['product_includes'] = array();
$defaults['extended_info'] = false;
return $defaults;
}
/**
* Returns the report data based on normalized parameters.
* Will be called by `get_data` if there is no data in cache.
*
* @override ReportsDataStore::get_noncached_data()
*
* @see get_data
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error.
*/
public function get_noncached_data( $query_args ) {
global $wpdb;
$table_name = self::get_db_table_name();
$this->initialize_queries();
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$included_products = $this->get_included_products_array( $query_args );
$params = $this->get_limit_params( $query_args );
$this->add_sql_query_params( $query_args );
if ( count( $included_products ) > 0 ) {
$filtered_products = array_diff( $included_products, array( '-1' ) );
$total_results = count( $filtered_products );
$total_pages = (int) ceil( $total_results / $params['per_page'] );
if ( 'date' === $query_args['orderby'] ) {
$selections .= ", {$table_name}.date_created";
}
$fields = $this->get_fields( $query_args );
$join_selections = $this->format_join_selections( $fields, array( 'product_id' ) );
$ids_table = $this->get_ids_table( $included_products, 'product_id' );
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
$this->add_sql_clause( 'select', $join_selections );
$this->add_sql_clause( 'from', '(' );
$this->add_sql_clause( 'from', $this->subquery->get_query_statement() );
$this->add_sql_clause( 'from', ") AS {$table_name}" );
$this->add_sql_clause(
'right_join',
"RIGHT JOIN ( {$ids_table} ) AS default_results
ON default_results.product_id = {$table_name}.product_id"
);
$this->add_sql_clause( 'where', 'AND default_results.product_id != -1' );
$products_query = $this->get_query_statement();
} else {
$count_query = "SELECT COUNT(*) FROM (
{$this->subquery->get_query_statement()}
) AS tt";
$db_records_count = (int) $wpdb->get_var(
$count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$total_results = $db_records_count;
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) {
return $data;
}
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
if ( in_array( $query_args['orderby'], array( 'items_sold', 'net_revenue', 'orders_count', 'variations' ), true ) ) {
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', product_id' );
} else {
$this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
}
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
$products_query = $this->subquery->get_query_statement();
}
$product_data = $wpdb->get_results(
$products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
ARRAY_A
);
if ( null === $product_data ) {
return $data;
}
$product_data = array_map( array( $this, 'cast_numbers' ), $product_data );
$data = (object) array(
'data' => $product_data,
'total' => $total_results,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
return $data;
}
/**
* Create or update an entry in the wc_admin_order_product_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success.
*/
public static function sync_order_products( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return -1;
}
$table_name = self::get_db_table_name();
$existing_items = $wpdb->get_col(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT order_item_id FROM {$table_name} WHERE order_id = %d",
$order_id
)
);
$existing_items = array_flip( $existing_items );
$order_items = $order->get_items();
$num_updated = 0;
$decimals = wc_get_price_decimals();
$round_tax = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );
$is_full_refund_without_line_items = false;
$partial_refund_product_revenue = array();
$refund_type = $order->get_meta( '_refund_type' );
$uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data();
$parent_order = null;
// When changing the order status to "Refunded", the refund order's type will be full refund, and the order items will be empty.
// We need to get the parent order items, and exclude the items that are already being partially refunded.
if (
'shop_order_refund' === $order->get_type() &&
'full' === $refund_type &&
empty( $order_items ) &&
$uses_new_full_refund_data
) {
$is_full_refund_without_line_items = true;
$parent_order_id = $order->get_parent_id();
$parent_order = wc_get_order( $parent_order_id );
$order_items = $parent_order->get_items();
// Get the partially refunded product and variation IDs along with their sum of product_net_revenue from the parent order.
$partial_refund_products = $wpdb->get_results(
$wpdb->prepare(
"
SELECT
product_lookup.product_id,
product_lookup.variation_id,
SUM( product_lookup.product_net_revenue ) AS product_net_revenue
FROM %i AS product_lookup
INNER JOIN {$wpdb->prefix}wc_order_stats AS order_stats
ON order_stats.order_id = product_lookup.order_id
WHERE 1 = 1
AND order_stats.parent_id = %d
AND product_lookup.product_net_revenue < 0
GROUP BY product_lookup.product_id, product_lookup.variation_id
",
$table_name,
$parent_order_id
)
);
/**
* Create a lookup table for partially refunded products.
* E.g. [
* '1' => -20,
* '2' => -40,
* '51' => -10,
* '52' => -30,
* ]
*/
foreach ( $partial_refund_products as $product ) {
$id = $product->variation_id ? $product->variation_id : $product->product_id;
$partial_refund_product_revenue[ $id ] = (float) $product->product_net_revenue;
}
}
foreach ( $order_items as $order_item ) {
$order_item_id = $order_item->get_id();
unset( $existing_items[ $order_item_id ] );
$product_qty = $order_item->get_quantity( 'edit' );
$product_id = $order_item->get_product_id( 'edit' );
$variation_id = $order_item->get_variation_id( 'edit' );
$shipping_amount = $order->get_item_shipping_amount( $order_item );
$shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item );
$coupon_amount = $order->get_item_coupon_amount( $order_item );
$tax_amount = $order->get_item_cart_tax_amount( $order_item );
$net_revenue = round( $order_item->get_total( 'edit' ), $decimals );
// If the order is a full refund and there is no order items. The order item here is the parent order item.
if ( $is_full_refund_without_line_items ) {
$id = $variation_id ? $variation_id : $product_id;
$partial_refund = $partial_refund_product_revenue[ $id ] ?? 0;
// If a single line item was refunded 60% then fully refunded after, we need store the difference in the product lookup table.
// E.g. A product costs $100, it was previously partially refunded $60, then fully refunded $40.
// So it will be -abs( 100 + (-60) ) = -40.
$net_revenue = -abs( $net_revenue + $partial_refund );
// Skip items that have already been fully refunded (single or multiple partial refunds).
if ( 0.0 === $net_revenue ) {
continue;
}
$product_qty = -abs( $product_qty );
// Set coupon amount to 0 for full refunds without line items.
$coupon_amount = 0;
if ( $parent_order ) {
$remaining_refund_items = $parent_order->get_remaining_refund_items();
// Calculate the shipping amount to refund from the parent order.
$total_shipping_refunded = $parent_order->get_total_shipping_refunded();
$shipping_total = (float) $parent_order->get_shipping_total();
$total_shipping_to_refund = $shipping_total - $total_shipping_refunded;
if ( $total_shipping_to_refund > 0 ) {
$shipping_amount = -abs( $parent_order->get_item_shipping_amount( $order_item, $remaining_refund_items, $total_shipping_to_refund ) );
}
// Calculate the shipping tax amount to refund from the parent order.
$shipping_tax = (float) $parent_order->get_shipping_tax();
$total_shipping_tax_refunded = $parent_order->get_total_shipping_tax_refunded();
$total_shipping_tax_to_refund = $shipping_tax - $total_shipping_tax_refunded;
if ( $total_shipping_tax_to_refund > 0 ) {
$shipping_tax_amount = -abs( $parent_order->get_item_shipping_tax_amount( $order_item, $remaining_refund_items, $total_shipping_tax_to_refund ) );
}
// Calculate cart tax amount of the item from the parent order.
$tax_amount = -abs( $parent_order->get_item_cart_tax_amount( $order_item ) );
}
}
$is_refund = $net_revenue < 0;
// Skip line items without changes to product quantity.
if ( ! $product_qty && ! $is_refund ) {
++$num_updated;
continue;
}
if ( $round_tax ) {
$tax_amount = round( $tax_amount, $decimals );
}
$result = $wpdb->replace(
self::get_db_table_name(),
array(
'order_item_id' => $order_item_id,
'order_id' => $order->get_id(),
'product_id' => $product_id,
'variation_id' => $variation_id,
'customer_id' => $order->get_report_customer_id(),
'product_qty' => $product_qty,
'product_net_revenue' => $net_revenue,
'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ),
'coupon_amount' => $coupon_amount,
'tax_amount' => $tax_amount,
'shipping_amount' => $shipping_amount,
'shipping_tax_amount' => $shipping_tax_amount,
// @todo Can this be incorrect if modified by filters?
'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
),
array(
'%d', // order_item_id.
'%d', // order_id.
'%d', // product_id.
'%d', // variation_id.
'%d', // customer_id.
'%d', // product_qty.
'%f', // product_net_revenue.
'%s', // date_created.
'%f', // coupon_amount.
'%f', // tax_amount.
'%f', // shipping_amount.
'%f', // shipping_tax_amount.
'%f', // product_gross_revenue.
)
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
/**
* Fires when product's reports are updated.
*
* @param int $order_item_id Order Item ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_update_product', $order_item_id, $order->get_id() );
// Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists.
$num_updated += 2 === intval( $result ) ? 1 : intval( $result );
}
if ( ! empty( $existing_items ) ) {
$existing_items = array_flip( $existing_items );
$format = array_fill( 0, count( $existing_items ), '%d' );
$format = implode( ',', $format );
array_unshift( $existing_items, $order_id );
$wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"DELETE FROM {$table_name} WHERE order_id = %d AND order_item_id in ({$format})",
$existing_items
)
);
}
return ( count( $order_items ) === $num_updated );
}
/**
* Clean products data when an order is deleted.
*
* @param int $order_id Order ID.
*/
public static function sync_on_order_delete( $order_id ) {
global $wpdb;
$wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) );
/**
* Fires when product's reports are removed from database.
*
* @param int $product_id Product ID.
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_analytics_delete_product', 0, $order_id );
ReportsCache::invalidate();
}
/**
* Initialize query objects.
*/
protected function initialize_queries() {
$this->clear_all_clauses();
$this->subquery = new SqlQuery( $this->context . '_subquery' );
$this->subquery->add_sql_clause( 'select', 'product_id' );
$this->subquery->add_sql_clause( 'from', self::get_db_table_name() );
$this->subquery->add_sql_clause( 'group_by', 'product_id' );
}
}