$to ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid date range.' )); } if ( $channel && !in_array( $channel, array_merge( array_keys( Orders::CHANNELS ), array('pos', 'web', 'unassigned') ), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Invalid channel.' )); } if ( get_option( 'storesuit_order_index_ready' ) !== '1' ) { throw new \RuntimeException(I18n::t( 'The order index is being prepared. Open the home page and wait for it to finish.' )); } $index = ( $channel ? $wpdb->get_results( $wpdb->prepare( 'SELECT order_id,needs_payment,channel FROM %i WHERE created_at BETWEEN %s AND %s AND channel=%s ORDER BY order_id LIMIT 10001', OrderIndex::table(), $from . ' 00:00:00', $to . ' 23:59:59', $channel ), ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( 'SELECT order_id,needs_payment,channel FROM %i WHERE created_at BETWEEN %s AND %s ORDER BY order_id LIMIT 10001', OrderIndex::table(), $from . ' 00:00:00', $to . ' 23:59:59' ), ARRAY_A ) ); if ( count( $index ) > 10000 ) { throw new \InvalidArgumentException(I18n::t( 'Narrow the date range for this report (10,000 order limit).' )); } $sales = self::sales( $index ); // Collections (the shared register) and purchases are the paid build's. $cash = array(); $purchases = array(); // The stock as the Reports card shows it: the last count, counted again in the background after a change (a live // store's overview took 6.6 s, nearly all of it counting the stock again; Almira audit R0-7, 28 Oct 2026). $stock = self::stock_shown(); return array( 'from' => $from, 'to' => $to, 'channel' => $channel, 'sales' => $sales, 'cash' => $cash, 'purchases' => $purchases, 'stock' => $stock['stock'], 'as_of' => current_time( 'mysql' ), ) + (( isset( $stock['counted_at'] ) ? array( 'stock_counted_at' => $stock['counted_at'], ) : array() )); } /** Period sales read in bulk from WooCommerce's own order storage: the status, currency, total, refunds and channel each order would load with, not one wc_get_order() per order. The balance due needs the order's payments, so only the orders the index lists as awaiting payment are loaded for it; every other order owes nothing. */ private static function sales( array $index ) : array { global $wpdb; $hpos = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); $valid = get_woocommerce_currencies(); $base = get_woocommerce_currency(); $sales = array(); $due = array(); // The order meta read with each order; the paid build also reads its till's sale and receiptless-return marks. $keys = array('_storesuit_channel'); foreach ( array_chunk( $index, self::CHUNK ) as $chunk ) { $ids = array_map( 'intval', array_column( $chunk, 'order_id' ) ); $orders = array(); $meta = array(); $refunded = array(); if ( $hpos ) { foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT id,status,currency,total_amount FROM {$wpdb->prefix}wc_orders WHERE type='shop_order' AND id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ), ARRAY_A ) as $r ) { $orders[(int) $r['id']] = array($r['status'], $r['currency'], $r['total_amount']); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT order_id id,meta_key,meta_value FROM {$wpdb->prefix}wc_orders_meta WHERE order_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') AND meta_key IN (' . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ') ORDER BY id', array_merge( $ids, $keys ) ), ARRAY_A ); foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT parent_order_id id,SUM(total_amount) total FROM {$wpdb->prefix}wc_orders WHERE type='shop_order_refund' AND parent_order_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') GROUP BY parent_order_id', $ids ), ARRAY_A ) as $r ) { $refunded[(int) $r['id']] = -1 * (float) $r['total']; } } else { foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT ID id,post_status status FROM {$wpdb->posts} WHERE post_type='shop_order' AND ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ), ARRAY_A ) as $r ) { $orders[(int) $r['id']] = array($r['status'], '', ''); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id id,meta_key,meta_value FROM {$wpdb->postmeta} WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') AND meta_key IN (' . implode( ',', array_fill( 0, count( $keys ) + 2, '%s' ) ) . ') ORDER BY meta_id', array_merge( $ids, array('_order_currency', '_order_total'), $keys ) ), ARRAY_A ); foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT r.post_parent id,SUM(m.meta_value) total FROM {$wpdb->posts} r INNER JOIN {$wpdb->postmeta} m ON m.post_id=r.ID AND m.meta_key='_refund_amount' WHERE r.post_type='shop_order_refund' AND r.post_parent IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') GROUP BY r.post_parent', $ids ), ARRAY_A ) as $r ) { $refunded[(int) $r['id']] = (float) $r['total']; } } foreach ( $rows as $m ) { if ( !isset( $meta[(int) $m['id']][$m['meta_key']] ) ) { $meta[(int) $m['id']][$m['meta_key']] = $m['meta_value']; } } // Gift card parts are part of the sale and card returns are refunds, as in the report library and the accountant's files (0.60). $cards = array(); foreach ( $chunk as $row ) { $id = (int) $row['order_id']; if ( !isset( $orders[$id] ) ) { continue; } [$status, $currency, $total] = $orders[$id]; $m = $meta[$id] ?? array(); $status = ( str_starts_with( (string) $status, 'wc-' ) ? substr( $status, 3 ) : (( (string) $status ?: 'pending' )) ); if ( in_array( $status, SaleStatus::NOT_SALES, true ) ) { continue; } // As WC_Order's setters read them back: a missing currency is the store's, an unknown one stays empty; // the total is rounded to the store's decimals. if ( !$hpos ) { $currency = $m['_order_currency'] ?? ''; $total = $m['_order_total'] ?? ''; } $c = ( null === $currency ? '' : (( $currency ? ( isset( $valid[$currency] ) ? $currency : '' ) : $base )) ); $total = ( null === $total ? 0.0 : (float) wc_format_decimal( ( '' === $total || !is_string( $total ) ? (float) $total : $total ), wc_get_price_decimals() ) ); $back = $refunded[$id] ?? 0.0; if ( isset( $cards[$id] ) ) { $total = round( $total + $cards[$id][0], wc_get_price_decimals() ); $back = round( $back + $cards[$id][1], wc_get_price_decimals() ); } $ch = ( maybe_unserialize( $m['_storesuit_channel'] ?? '' ) ?: (( (string) ($row['channel'] ?? '') ?: 'web' )) ); $return = false; $key = $ch . ':' . $c; if ( $return ) { $back = $total; $total = 0.0; } // 'refunded' stays every refund (net is unchanged); 'exchanged' is the part of it exchanges passed on as credit. $exchanged = 0.0; if ( !isset( $sales[$key] ) ) { $sales[$key] = array( 'channel' => $ch, 'currency' => $c, 'count' => 0, 'gross' => 0.0, 'refunded' => 0.0, 'exchanged' => 0.0, 'net' => 0.0, 'due' => 0.0, ); } if ( !$return ) { ++$sales[$key]['count']; } $sales[$key]['gross'] += $total; $sales[$key]['refunded'] += $back; $sales[$key]['exchanged'] += $exchanged; $sales[$key]['net'] += $total - $back; if ( (int) $row['needs_payment'] === 1 ) { $due[$id] = $key; } } } foreach ( $due as $id => $key ) { $o = wc_get_order( $id ); if ( $o instanceof \WC_Order ) { $sales[$key]['due'] += Orders::outstanding( $o ); } } return array_values( $sales ); } public static function stock_summary() : array { Store::require_cap( 'manage_woocommerce' ); Store::require_cap( 'storesuit_finance' ); return array( 'stock' => self::stock(), ); } /** The last count, kept when a change drops the summary, and the background job that counts again. */ private const STOCK_LAST = 'storesuit_stock_summary_last'; public const STOCK_RECOUNT = 'storesuit_stock_summary_recount'; /** The Reports page's card (router 'dashboard_stock'): after a change the last count is shown at once and the stock is counted again in the background, not while the page waits (admin speed, 28 Oct 2026: on a live store whose products change all day the count was nearly always dropped and the Reports page waited 9 s for it). Counted now when nothing was counted before; stock_summary() and the report library always count. */ public static function stock_card() : array { Store::require_cap( 'manage_woocommerce' ); Store::require_cap( 'storesuit_finance' ); return self::stock_shown(); } /** The kept count, else the last one (counted again in the background), else a count now; for the card and the overview. */ private static function stock_shown() : array { $kept = get_transient( self::STOCK_CACHE ); if ( is_array( $kept ) && ($kept['build'] ?? '') === self::stock_build() ) { return array( 'stock' => $kept['stock'], ); } $last = get_option( self::STOCK_LAST ); if ( is_array( $last ) && ($last['build'] ?? '') === self::stock_build() && is_array( $last['stock'] ?? null ) ) { Store::later( self::STOCK_RECOUNT, array(), 'storesuit', 0 ); return array( 'stock' => $last['stock'], 'counted_at' => (string) ($last['at'] ?? ''), ); } return array( 'stock' => self::stock(), ); } /** The background count (Store::later() from stock_card()); it keeps the summary as a read on the page would. */ public static function stock_recount() : void { self::stock(); } /** The stock summary loads every line that keeps stock: 5,000 lines took 5-8 s on a shared host (admin audit C1, 28 Oct 2026). It is kept until a stock level, a cost or a product changes, an hour at most. WooCommerce's stock update writes no meta through the API, so its own stock hooks count too; bulk edits here end in clean_post_cache(). */ private const STOCK_CACHE = 'storesuit_stock_summary'; private static bool $stock_dropped = false; public static function hooks() : void { foreach ( array( 'woocommerce_product_set_stock', 'woocommerce_variation_set_stock', 'update_option_woocommerce_currency', 'update_option_woocommerce_price_num_decimals' ) as $hook ) { add_action( $hook, array(__CLASS__, 'stock_changed') ); } // A cost changed by the engines (the value at cost is the paid build's). foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta', 'clean_post_cache' ) as $hook ) { add_action( $hook, array(__CLASS__, 'product_changed'), 10, 2 ); } add_action( self::STOCK_RECOUNT, array(__CLASS__, 'stock_recount') ); // The product list's view counters and Home's "not for sale" preview are counted again there too. add_action( Products::HEALTH_RECOUNT, array(Products::class, 'health_recount') ); add_action( Today::PREVIEW_RECOUNT, array(Today::class, 'preview_recount') ); } /** A meta change (meta id, post id) or a cleaned post cache (post id, post) on a product or one of its options. */ public static function product_changed( $first, $second ) : void { if ( self::$stock_dropped && !Products::health_pending() ) { return; } $id = ( doing_action( 'clean_post_cache' ) ? (int) $first : (int) $second ); if ( in_array( get_post_type( $id ), array('product', 'product_variation'), true ) ) { self::stock_changed(); } } /** Once per request, until the summary is read again; the product list's view counters are marked stale (Products::health_changed()). */ public static function stock_changed() : void { Products::health_changed(); if ( self::$stock_dropped ) { return; } self::$stock_dropped = true; delete_transient( self::STOCK_CACHE ); } /** Current stock valuation, never presented as period profit or historical COGS. The products, their parents, terms and meta are fetched in bulk first, so each wc_get_product() and cost read below is served from the cache. The free build counts products, units and negative stock; the value at cost is the paid build's. The products are those of the stock levels report (STOCK_STATES, drafts included) and a line below zero holds no value, as there (CAT-01). */ private static function stock() : array { global $wpdb; $kept = get_transient( self::STOCK_CACHE ); if ( is_array( $kept ) && ($kept['build'] ?? '') === self::stock_build() ) { return $kept['stock']; } $stock_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->posts} parent ON parent.ID=p.post_parent WHERE p.post_type IN ('product','product_variation') AND p.post_status IN (" . implode( ',', array_fill( 0, count( self::STOCK_STATES ), '%s' ) ) . ") AND (p.post_type='product' OR parent.post_status IN (" . implode( ',', array_fill( 0, count( self::STOCK_STATES ), '%s' ) ) . ")) AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id=p.ID AND m.meta_key=%s AND m.meta_value=%s)", array_merge( self::STOCK_STATES, self::STOCK_STATES, array('_manage_stock', 'yes') ) ) ) ); if ( $wpdb->last_error !== '' ) { throw new \RuntimeException(I18n::t( 'This report section could not be loaded. Other figures remain available.' )); } $stock = array( 'products' => 0, 'units' => 0.0, 'value' => 0.0, 'missing_cost' => 0, 'negative' => 0, 'currency' => get_woocommerce_currency(), ); foreach ( array_chunk( $stock_ids, self::CHUNK ) as $chunk ) { $parents = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_parent FROM {$wpdb->posts} WHERE post_type='product_variation' AND post_parent>0 AND ID IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', $chunk ) ) ); _prime_post_caches( array_merge( $chunk, $parents ), true, true ); VariableStock::prime( $chunk ); // A product with options whose every option keeps its own stock holds none itself, whatever its leftover _stock says (audit A1). foreach ( $chunk as $id ) { $p = wc_get_product( $id ); if ( !VariableStock::holds_stock( $p ) ) { continue; } ++$stock['products']; $qty = (float) $p->get_stock_quantity(); $stock['units'] += max( 0, $qty ); if ( $qty < 0 ) { ++$stock['negative']; } } } self::$stock_dropped = false; set_transient( self::STOCK_CACHE, array( 'build' => self::stock_build(), 'stock' => $stock, ), HOUR_IN_SECONDS ); update_option( self::STOCK_LAST, array( 'build' => self::stock_build(), 'stock' => $stock, 'at' => current_time( 'mysql' ), ), false ); return $stock; } /** The free and the paid build count different figures (the value at cost is the paid build's); the number changes with the counting rule (2: audit A1). */ private static function stock_build() : string { $build = 'free'; return $build . '-2'; } }