->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' ); } }