Schema::date(), 'end_date' => Schema::date(), 'order_by' => array( 'type' => 'string', 'enum' => array( 'gross_profit', 'margin', 'net_sales' ), 'default' => 'gross_profit', ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 25, 'default' => 10, ), 'compare' => Schema::compare(), ), array( 'start_date', 'end_date' ) ); } /** * Runs the data function. * * @param array $args Validated arguments. * @return array */ public function run( array $args ): array { $period = Schema::period( $args ); if ( ! Orders::cogs_enabled() ) { return array( 'period' => $period->to_array(), 'cogs_enabled' => false, 'rows_read' => 0, 'notes' => array( 'Profit needs product costs. Turn on Cost of Goods Sold in WooCommerce › Settings › Advanced › Features, then add a cost to each product. Costs are recorded on orders placed after that.' ), ); } $order_by = (string) ( $args['order_by'] ?? 'gross_profit' ); $limit = (int) ( $args['limit'] ?? 10 ); $current = self::query( $period ); $products = array_values( array_filter( $current['products'], static fn( array $p ): bool => $p['net_sales_with_cost'] > 0 || $p['cost_of_goods'] > 0 ) ); usort( $products, static function ( array $a, array $b ) use ( $order_by ): int { $key = 'margin' === $order_by ? 'margin_pct' : $order_by; return ( $b[ $key ] ?? 0 ) <=> ( $a[ $key ] ?? 0 ); } ); $result = array( 'period' => $period->to_array(), 'cogs_enabled' => true, 'totals' => $current['totals'], 'products' => array_slice( $products, 0, $limit ), 'rows_read' => $current['rows'], 'notes' => array( 'Gross profit = net sales minus cost of goods, for order lines that have a cost recorded. Net sales exclude refunds, coupons, taxes and shipping, as in WooCommerce Analytics.', 'cost_coverage_pct is the share of net sales that had a cost recorded. Lines without a cost are left out of profit and margin.', ), ); $compare = (string) ( $args['compare'] ?? 'none' ); if ( 'none' !== $compare ) { $previous_period = $period->comparison( $compare ); $previous = self::query( $previous_period ); $result['comparison'] = array( 'period' => $previous_period->to_array(), 'totals' => $previous['totals'], 'change' => SalesSummary::change( array_intersect_key( $current['totals'], array_flip( array( 'net_sales', 'cost_of_goods', 'gross_profit' ) ) ), $previous['totals'] ), ); $result['rows_read'] += $previous['rows']; } $result['chart'] = array( 'type' => 'bar', 'title' => __( 'Gross profit by product', 'askshelf' ), 'format' => 'money', 'points' => array_map( static fn( array $p ): array => array( 'label' => $p['name'], 'value' => $p['gross_profit'], ), $result['products'] ), ); return $result; } /** * Totals and per-product profit for a period. * * @param Period $period Period. * @return array{totals: array, products: list>, rows: int} */ public static function query( Period $period ): array { global $wpdb; [ $from, $to ] = Orders::bounds( $period ); $column = Analytics::date_column(); // Refund lines carry a negative quantity, so their cost is subtracted. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only report query on WooCommerce's Analytics tables. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT l.product_id, SUM(l.product_qty) AS units, SUM(l.product_net_revenue) AS net_sales, SUM(CASE WHEN ABS(COALESCE(m.meta_value, 0)) > 0 THEN l.product_net_revenue ELSE 0 END) AS net_sales_with_cost, SUM(CASE WHEN ABS(COALESCE(m.meta_value, 0)) > 0 THEN SIGN(l.product_qty) * ABS(m.meta_value) ELSE 0 END) AS cost, COUNT(*) AS line_count FROM %i l INNER JOIN %i s ON s.order_id = l.order_id LEFT JOIN %i m ON m.order_item_id = l.order_item_id AND m.meta_key = %s WHERE s.%i >= %s AND s.%i <= %s AND FIND_IN_SET(s.status, %s) = 0 GROUP BY l.product_id', $wpdb->prefix . 'wc_order_product_lookup', Orders::stats_table(), $wpdb->prefix . 'woocommerce_order_itemmeta', '_cogs_value', $column, $from, $column, $to, Orders::excluded_csv() ), ARRAY_A ); // phpcs:enable $products = array(); $net = 0.0; $with_cost = 0.0; $cost = 0.0; $lines = 0; foreach ( is_array( $rows ) ? $rows : array() as $row ) { $row_net = (float) $row['net_sales']; $row_with_cost = (float) $row['net_sales_with_cost']; $row_cost = (float) $row['cost']; $net += $row_net; $with_cost += $row_with_cost; $cost += $row_cost; $lines += (int) $row['line_count']; $products[] = array( 'product_id' => (int) $row['product_id'], 'name' => self::product_name( (int) $row['product_id'] ), 'units_sold' => (int) $row['units'], 'net_sales' => Analytics::money( $row_net ), 'net_sales_with_cost' => Analytics::money( $row_with_cost ), 'cost_of_goods' => Analytics::money( $row_cost ), 'gross_profit' => Analytics::money( $row_with_cost - $row_cost ), 'margin_pct' => self::pct( $row_with_cost - $row_cost, $row_with_cost ), ); } return array( 'totals' => array( 'net_sales' => Analytics::money( $net ), 'net_sales_with_cost' => Analytics::money( $with_cost ), 'cost_of_goods' => Analytics::money( $cost ), 'gross_profit' => Analytics::money( $with_cost - $cost ), 'margin_pct' => self::pct( $with_cost - $cost, $with_cost ), 'cost_coverage_pct' => self::pct( $with_cost, $net ), ), 'products' => $products, 'rows' => $lines, ); } /** * A percentage rounded to one decimal, or null when the base is zero. * * @param float $part Part. * @param float $base Base. */ private static function pct( float $part, float $base ): ?float { return abs( $base ) < 0.005 ? null : round( $part / $base * 100, 1 ) + 0.0; } /** * Product name, or a placeholder for deleted products. * * @param int $product_id Product id. */ public static function product_name( int $product_id ): string { $product = wc_get_product( $product_id ); if ( ! $product ) { /* translators: %d: product id. */ return sprintf( __( 'Deleted product #%d', 'askshelf' ), $product_id ); } return wp_strip_all_tags( $product->get_name() ); } }