array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 120, 'default' => 14, ), 'lookback_days' => array( 'type' => 'integer', 'minimum' => 7, 'maximum' => 90, 'default' => 30, ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 20, ), ) ); } /** * Runs the data function. * * @param array $args Validated arguments. * @return array */ public function run( array $args ): array { $within = (int) ( $args['within_days'] ?? 14 ); $lookback = (int) ( $args['lookback_days'] ?? 30 ); $limit = (int) ( $args['limit'] ?? 20 ); // Lookback ends yesterday so a part-day does not drag the rate down. $yesterday = ( new DateTimeImmutable( 'yesterday', wp_timezone() ) ); $period = Period::from_dates( $yesterday->modify( '-' . ( $lookback - 1 ) . ' days' )->format( 'Y-m-d' ), $yesterday->format( 'Y-m-d' ) ); $product_sales = self::sold( '/reports/products', 'product_id', $period ); $variation_sales = self::sold( '/reports/variations', 'variation_id', $period ); $candidates = array(); // Stock managed per variation. foreach ( $variation_sales as $variation_id => $sold ) { $variation = wc_get_product( $variation_id ); if ( ! $variation || true !== $variation->managing_stock() ) { continue; // false, or 'parent' when the parent product manages stock. } $candidates[] = self::row( $variation, $sold, $lookback ); } // Stock managed on the product itself (simple products, or variable products with parent-level stock). foreach ( $product_sales as $product_id => $sold ) { $product = wc_get_product( $product_id ); if ( ! $product || ! $product->get_manage_stock() ) { continue; } $candidates[] = self::row( $product, $sold, $lookback ); } $soon = array_values( array_filter( $candidates, static fn( array $row ): bool => null !== $row['days_left'] && $row['days_left'] <= $within ) ); usort( $soon, static fn( array $a, array $b ): int => $a['days_left'] <=> $b['days_left'] ); $total_soon = count( $soon ); $soon = array_slice( $soon, 0, $limit ); return array( 'lookback_period' => $period->to_array(), 'within_days' => $within, 'products_checked' => count( $candidates ), 'running_out' => $soon, 'running_out_count' => $total_soon, 'rows_read' => count( $product_sales ) + count( $variation_sales ), 'notes' => array( 'Only products with managed stock that sold in the lookback period are included. Days left = stock / average units sold per day.' ), 'chart' => array( 'type' => 'bar', 'title' => __( 'Days of stock left', 'askshelf' ), 'format' => 'days', 'points' => array_map( static fn( array $r ): array => array( 'label' => $r['name'], 'value' => $r['days_left'], ), $soon ), ), ); } /** * Units sold per id over the period, most sold first. * * @param string $route Analytics route. * @param string $id_key Id field in the rows. * @param Period $period Period. * @return array */ private static function sold( string $route, string $id_key, Period $period ): array { $sold = array(); $pages = (int) ceil( self::MAX_ROWS / 100 ); for ( $page = 1; $page <= $pages; $page++ ) { $response = Analytics::get( $route, array( 'after' => $period->after(), 'before' => $period->before(), 'orderby' => 'items_sold', 'order' => 'desc', 'per_page' => 100, 'page' => $page, ) ); $rows = is_array( $response['data'] ) ? $response['data'] : array(); foreach ( $rows as $row ) { if ( (int) $row['items_sold'] > 0 ) { $sold[ (int) $row[ $id_key ] ] = (int) $row['items_sold']; } } if ( count( $rows ) < 100 ) { break; } }//end for return $sold; } /** * One result row. * * @param \WC_Product $product Product or variation. * @param int $sold Units sold in the lookback. * @param int $lookback Lookback days. * @return array */ private static function row( \WC_Product $product, int $sold, int $lookback ): array { $stock = (int) $product->get_stock_quantity(); $per_day = $sold / $lookback; $days = $per_day > 0 ? max( 0.0, round( $stock / $per_day, 1 ) ) : null; return array( 'product_id' => $product->get_id(), 'name' => wp_strip_all_tags( $product->get_name() ), 'sku' => (string) $product->get_sku(), 'stock' => $stock, 'sold_per_day' => round( $per_day, 2 ), 'units_sold' => $sold, 'days_left' => $days, 'backorders' => $product->backorders_allowed(), ); } }