in-depth: also checked in OrdersScheduler::import(), but // update() can be called directly outside of the import flow. if ( OrdersScheduler::is_test_order( $order ) ) { return -1; } $format = array( '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%f', '%f', '%f', '%f', '%s', '%d', '%d', ); $data = array( 'order_id' => $order->get_id(), 'parent_id' => $order->get_parent_id(), 'date_created' => $order->get_date_created()->date( 'Y-m-d H:i:s' ), 'date_paid' => $order->get_date_paid() ? $order->get_date_paid()->date( 'Y-m-d H:i:s' ) : null, 'date_completed' => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d H:i:s' ) : null, 'date_created_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ), 'num_items_sold' => self::get_num_items_sold( $order ), 'total_sales' => $order->get_total(), 'tax_total' => $order->get_total_tax(), 'shipping_total' => $order->get_shipping_total(), 'net_total' => self::get_net_total( $order ), 'status' => self::normalize_order_status( $order->get_status() ), 'customer_id' => $order->get_report_customer_id(), 'returning_customer' => $order->is_returning_customer(), ); $order_fulfillment_status = ''; if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) && true === self::has_fulfillment_status_column() && $order instanceof WC_Order ) { $order_fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order ); $data['fulfillment_status'] = ( 'no_fulfillments' !== $order_fulfillment_status ) ? $order_fulfillment_status : null; $format[] = '%s'; } /** * Filters order stats data. * * @param array $data Data written to order stats lookup table. * @param Order|OrderRefund $order Order object. * * @since 4.0.0 */ $data = apply_filters( 'woocommerce_analytics_update_order_stats_data', $data, $order ); if ( 'shop_order_refund' === $order->get_type() ) { $parent_order = wc_get_order( $order->get_parent_id() ); // Refunds attach to the original order. Skip if the parent is another refund. if ( $parent_order && ! $parent_order instanceof WC_Order_Refund ) { $data['parent_id'] = $parent_order->get_id(); $data['status'] = self::normalize_order_status( $parent_order->get_status() ); $refund_type = $order->get_meta( '_refund_type' ); $uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data(); $use_parent_refund_amounts = $uses_new_full_refund_data && ( 'full' === $refund_type || self::should_split_full_refund_using_parent_order( $order, $parent_order ) ); if ( $use_parent_refund_amounts ) { $data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order ); $data['tax_total'] = -1 * $parent_order->get_total_tax(); $data['net_total'] = -1 * self::get_net_total( $parent_order ); $data['shipping_total'] = -1 * $parent_order->get_shipping_total(); } } /** * Set date_completed and date_paid the same as date_created to avoid problems * when they are being used to sort the data, as refunds don't have them filled */ $data['date_completed'] = $data['date_created']; $data['date_paid'] = $data['date_created']; } // Update or add the information to the DB. $result = $wpdb->replace( $table_name, $data, $format ); /** * Fires when order's stats reports are updated. * * @param int $order_id Order ID. * * @since 4.0.0. */ do_action( 'woocommerce_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( 'woocommerce_analytics_delete_order_stats', $order_id, $customer_id ); ReportsCache::invalidate(); } /** * Calculation methods. */ /** * Get number of items sold among all orders. * * @param WC_Order|WC_Order_Refund $order WC_Order or WC_Order_Refund object. * @return int */ protected static function get_num_items_sold( $order ) { $num_items = 0; $line_items = $order->get_items( OrderItemType::LINE_ITEM ); foreach ( $line_items as $line_item ) { $num_items += $line_item->get_quantity(); } return $num_items; } /** * Get the net amount from an order without shipping, tax, or refunds. * * @param WC_Order|WC_Order_Refund $order WC_Order or WC_Order_Refund object. * @return float */ protected static function get_net_total( $order ) { $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() ); return (float) $net_total; } /** * Whether this refund is a single lump-sum refund for the full order (e.g. status set to refunded without line items). * * @param WC_Order_Refund|OrderRefund|Order $refund Refund order. * @param WC_Order|Order $parent_order Parent order (not a refund). * @return bool */ protected static function should_split_full_refund_using_parent_order( $refund, $parent_order ) { // The parent must be the original order, not another refund. if ( ! $parent_order instanceof WC_Order || 'shop_order_refund' === $parent_order->get_type() ) { return false; } if ( self::get_num_items_sold( $refund ) > 0 ) { return false; } $parent_refunds = $parent_order->get_refunds(); if ( 1 !== count( $parent_refunds ) ) { return false; } $refund_total = wc_format_decimal( abs( (float) $refund->get_total() ) ); $order_total = wc_format_decimal( (float) $parent_order->get_total() ); return $refund_total === $order_total; } /** * Check if the wc_order_stats table has the fulfillment_status column. * * @return boolean */ public static function has_fulfillment_status_column() { $column_status = get_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS ); if ( ! empty( $column_status ) ) { return 'yes' === $column_status; } global $wpdb; $table_name = self::get_db_table_name(); // Check if the table exists. $table_exists = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared. 'SHOW TABLES LIKE %s', $table_name ) ); // If table still does not exist, return false without setting the option to allow for table to be created with the column. if ( ! $table_exists ) { return false; } $column_exists = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared. "SHOW COLUMNS FROM `{$table_name}` LIKE %s", 'fulfillment_status' ) ); if ( ! empty( $column_exists ) ) { update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'yes', false ); return true; } // Update the option to indicate that the column does not exist. update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'no', false ); return false; } /** * Check to see if an order's customer has made previous orders or not * * @param WC_Order $order WC_Order object. * @param int|false $customer_id Customer ID. Optional. * @return bool */ public static function is_returning_customer( $order, $customer_id = null ) { if ( is_null( $customer_id ) ) { $customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_existing_customer_id_from_order( $order ); } if ( ! $customer_id ) { return false; } $oldest_orders = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_oldest_orders( $customer_id ); if ( empty( $oldest_orders ) ) { return false; } $first_order = $oldest_orders[0]; $second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false; $excluded_statuses = self::get_excluded_report_order_statuses(); // Order is older than previous first order. if ( $order->get_date_created() < wc_string_to_datetime( $first_order->date_created ) && ! in_array( $order->get_status(), $excluded_statuses, true ) ) { self::set_customer_first_order( $customer_id, $order->get_id() ); return false; } // The current order is the oldest known order. $is_first_order = (int) $order->get_id() === (int) $first_order->order_id; // Order date has changed and next oldest is now the first order. $date_change = $second_order && $order->get_date_created() > wc_string_to_datetime( $first_order->date_created ) && wc_string_to_datetime( $second_order->date_created ) < $order->get_date_created(); // Status has changed to an excluded status and next oldest order is now the first order. $status_change = $second_order && in_array( $order->get_status(), $excluded_statuses, true ); if ( $is_first_order && ( $date_change || $status_change ) ) { self::set_customer_first_order( $customer_id, $second_order->order_id ); return true; } return (int) $order->get_id() !== (int) $first_order->order_id; } /** * Set a customer's first order and all others to returning. * * @param int $customer_id Customer ID. * @param int $order_id Order ID. */ protected static function set_customer_first_order( $customer_id, $order_id ) { global $wpdb; $orders_stats_table = self::get_db_table_name(); $wpdb->query( $wpdb->prepare( 'UPDATE %i SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d', $orders_stats_table, $order_id, $customer_id ) ); } /** * Add fulfillment_status column to wc_order_stats table. * * @return bool|string True on success, error message string on failure. */ public static function add_fulfillment_status_column() { if ( self::has_fulfillment_status_column() ) { return true; } global $wpdb; $result = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching "ALTER TABLE {$wpdb->prefix}wc_order_stats ADD COLUMN fulfillment_status VARCHAR(50) DEFAULT NULL, ADD INDEX fulfillment_status (fulfillment_status)" ); if ( false === $result ) { return $wpdb->last_error ? $wpdb->last_error : __( 'Unknown database error occurred while adding fulfillment_status column.', 'woocommerce' ); } // Update the option to indicate that the column has been added. update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'yes', false ); return true; } }