true, ); } $key = self::CACHE . (( $finance ? 'f' : 's' )) . '_' . get_woocommerce_currency(); $kept = get_transient( $key ); if ( is_array( $kept ) ) { return $kept; } $out = self::build( $finance ); set_transient( $key, $out, 10 * MINUTE_IN_SECONDS ); return $out; } /** Drops the kept cards (tests; a product's stock change waits the 10 minutes otherwise). */ public static function forget() : void { foreach ( array('f', 's') as $who ) { delete_transient( self::CACHE . $who . '_' . get_woocommerce_currency() ); } } /** Units and net amount per product or option over the last 30 days, from the facts (the same keyset walk as DashboardTotals::top()). */ public static function sold( string $currency ) : array { global $wpdb; $today = new \DateTimeImmutable('today', wp_timezone()); $at = $today->modify( '-' . (self::DAYS - 1) . ' days' )->format( 'Y-m-d' ) . ' 00:00:00'; $stop = $today->format( 'Y-m-d' ) . ' 23:59:59'; $after = 0; $deadline = microtime( true ) + 2.5; $sold = array(); do { if ( microtime( true ) > $deadline ) { throw new \RuntimeException(I18n::t( 'This list is taking too long to prepare. Try again in a moment.' )); } $rows = DashboardTotals::rows( $wpdb->get_results( $wpdb->prepare( 'SELECT f.order_id,f.created_at,f.items_json FROM %i f FORCE INDEX (`period`) JOIN %i i ON i.order_id=f.order_id WHERE f.currency=%s AND f.created_at>=%s AND f.created_at<=%s AND (f.created_at>%s OR f.order_id>%d) AND f.status NOT IN (' . implode( ',', array_fill( 0, count( SaleStatus::NOT_SALES ), '%s' ) ) . ') ORDER BY f.created_at,f.order_id LIMIT 500', array_merge( array( DashboardFacts::table(), OrderIndex::table(), $currency, $at, $stop, $at, $after ), SaleStatus::NOT_SALES ) ), ARRAY_A ) ); foreach ( $rows as $row ) { foreach ( (array) json_decode( (string) $row['items_json'], true ) as $item ) { $id = ( is_array( $item ) ? (int) ($item['id'] ?? 0) : 0 ); if ( $id <= 0 ) { continue; } $sold[$id] = array( 'qty' => ($sold[$id]['qty'] ?? 0.0) + (float) ($item['qty'] ?? 0), 'net' => ($sold[$id]['net'] ?? 0.0) + (float) ($item['net'] ?? 0), ); } $at = (string) $row['created_at']; $after = (int) $row['order_id']; } } while ( count( $rows ) === 500 ); return $sold; } /** A product's unit cost, or null when none is known (a missing cost is not 0). */ private static function cost( \WC_Product $p ) : ?float { $cost = null; // WooCommerce's own cost of goods (9.5+), when the store switched it on. if ( class_exists( 'Automattic\\WooCommerce\\Utilities\\FeaturesUtil' ) && \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' ) && method_exists( $p, 'get_cogs_effective_value' ) ) { $value = $p->get_cogs_effective_value(); $cost = ( is_numeric( $value ) && (float) $value > 0 ? (float) $value : null ); } return $cost; } /** An option's values ("Red, 3-6 months"), else what its own name adds to its product's ("Socks - Red"). */ private static function option_name( \WC_Product $option, \WC_Product $parent ) : string { $values = Store::plain( wc_get_formatted_variation( $option, true, false, false ) ); if ( $values !== '' ) { return $values; } $name = Store::plain( $option->get_name() ); $base = Store::plain( $parent->get_name() ); return ( $name !== $base && str_starts_with( $name, $base ) ? trim( (string) preg_replace( '/^[\\s\\-–]+/u', '', substr( $name, strlen( $base ) ) ) ) : '' ); } private static function build( bool $finance ) : array { $currency = get_woocommerce_currency(); $sold = self::sold( $currency ); // Options that share their product's stock add up on the product that holds it. uasort( $sold, static fn( $a, $b ) => $b['qty'] <=> $a['qty'] ); $groups = array(); foreach ( array_slice( $sold, 0, self::SCAN, true ) as $id => $s ) { if ( $s['qty'] <= 0 ) { continue; } $p = wc_get_product( $id ); if ( !$p || $p->get_status() !== 'publish' && !$p->is_type( 'variation' ) ) { continue; } $holder = ( $p->managing_stock() ? (int) $p->get_stock_managed_by_id() : $id ); $groups[$holder]['ids'][] = $id; $groups[$holder]['qty'] = ($groups[$holder]['qty'] ?? 0.0) + $s['qty']; $groups[$holder]['net'] = ($groups[$holder]['net'] ?? 0.0) + $s['net']; } $rows = array(); $pictures = array(); $decimals = wc_get_price_decimals(); foreach ( $groups as $holder => $g ) { if ( $g['qty'] < self::MIN_UNITS ) { continue; } $p = wc_get_product( $holder ); if ( !$p ) { continue; } $parent = ( $p->is_type( 'variation' ) ? wc_get_product( $p->get_parent_id() ) : null ); if ( $parent && $parent->get_status() !== 'publish' ) { continue; } $managed = $p->managing_stock(); $stock = ( $managed ? (float) $p->get_stock_quantity() : null ); $out = ( $managed ? $stock <= 0 : $p->get_stock_status() === 'outofstock' ); $per_day = $g['qty'] / self::DAYS; $days_left = ( $managed && $stock > 0 ? $stock / $per_day : (( $out ? 0.0 : null )) ); if ( !$out && ($days_left === null || $days_left > self::SOON) ) { continue; } $price = $g['net'] / $g['qty']; $restock = (int) max( 0, ceil( round( $g['qty'] - max( 0.0, (float) $stock ), 6 ) ) ); $row = array( 'id' => $holder, 'name' => Store::plain( ( $parent ? $parent->get_name() : $p->get_name() ) ), 'variant' => ( $parent ? self::option_name( $p, $parent ) : (( count( $g['ids'] ) > 1 || $p->is_type( 'variable' ) ? I18n::t( 'All options' ) : '' )) ), 'sku' => (string) $p->get_sku(), 'sold' => round( $g['qty'], 2 ), 'stock' => $stock, 'out' => $out, 'days_left' => ( $days_left === null ? null : round( $days_left, 1 ) ), 'per_day' => round( $per_day, 2 ), 'restock' => $restock, 'revenue_day' => round( $g['net'] / self::DAYS, $decimals ), 'url' => admin_url( 'admin.php?page=storesuit-products&id=' . (( $parent ? $parent->get_id() : $holder )) ), ); if ( $finance ) { $cost = self::cost( $p ); $unit = ( $cost === null ? null : $price - $cost ); $row += array( 'price' => round( $price, $decimals ), 'cost' => ( $cost === null ? null : round( $cost, $decimals ) ), 'margin' => ( $unit === null || $price <= 0 ? null : round( 100 * $unit / $price, 1 ) ), 'profit_day' => ( $unit === null ? null : round( $unit * $per_day, $decimals ) ), 'profit_restock' => ( $unit === null ? null : round( $unit * $restock, $decimals ) ), ); } $rows[] = $row; $pictures[$holder] = array($holder, ( $parent ? $parent->get_id() : 0 )); } // Profit per day first where the cost is known; otherwise (or without the finance permission) sales per day. usort( $rows, static function ( $a, $b ) use($finance) { if ( $finance && ($a['profit_day'] === null) !== ($b['profit_day'] === null) ) { return ( $a['profit_day'] === null ? 1 : -1 ); } if ( $finance && $a['profit_day'] !== null ) { return array($b['profit_day'], $b['per_day']) <=> array($a['profit_day'], $a['per_day']); } return array($b['per_day'], $b['revenue_day']) <=> array($a['per_day'], $a['revenue_day']); } ); $buy = ''; // Money stays with the finance permission: the sales amount per day only orders the rows for everyone else. $shown = array_slice( $rows, 0, self::LIMIT ); if ( !$finance ) { $shown = array_map( static function ( $r ) { unset($r['revenue_day']); return $r; }, $shown ); } // The picture left of the name (owner, 2 Nov 2026), for the rows shown only. $images = Store::thumbnails( array_intersect_key( $pictures, array_flip( array_column( $shown, 'id' ) ) ) ); foreach ( $shown as $i => $row ) { $shown[$i]['image'] = $images[$row['id']] ?? ''; } return array( 'rows' => $shown, 'total' => count( $rows ), 'days' => self::DAYS, 'soon' => self::SOON, 'currency' => $currency, 'finance' => $finance, 'costs' => ( $finance ? count( array_filter( $rows, static fn( $r ) => $r['cost'] !== null ) ) : null ), 'buy_url' => $buy, ); } }