score). Not autoloaded. * * @var string */ const HISTORY_OPTION = 'arwc_score_history'; /** * Default products processed per AJAX batch. * * @var int */ const DEFAULT_BATCH = 20; /** * Scoring engine. * * @var ARWC_Scorer */ protected $scorer; /** * Constructor. * * @param ARWC_Scorer $scorer Scoring engine. */ public function __construct( ARWC_Scorer $scorer ) { $this->scorer = $scorer; } /** * Number of products processed per batch (filterable). * * @return int */ public function get_batch_size() { $size = (int) apply_filters( 'arwc_scan_batch_size', self::DEFAULT_BATCH ); return max( 1, min( 200, $size ) ); } /** * Count of published products to be scanned. * * @return int */ public function get_total_products() { $result = wc_get_products( array( 'status' => 'publish', 'type' => array_keys( wc_get_product_types() ), 'limit' => 1, 'paginate' => true, 'return' => 'ids', ) ); return isset( $result->total ) ? (int) $result->total : 0; } /** * Reset stored scan state and start a fresh run. * * @return array The freshly initialized state. */ public function reset() { $state = array( 'version' => ARWC_VERSION, 'total' => $this->get_total_products(), 'processed' => 0, 'sum_score' => 0, 'completed' => false, 'rows' => array(), 'issue_counts' => array(), 'distribution' => array( 'good' => 0, 'fair' => 0, 'poor' => 0, ), ); $this->save_state( $state ); return $state; } /** * Scan one batch (one page) of products and persist the partial results. * * @param int $page 1-based page number. * @return array { * @type int $processed Total products processed so far. * @type int $total Total products to process. * @type bool $done Whether the scan is complete. * @type int $batch Number of products processed in this batch. * } */ public function scan_batch( $page ) { $page = max( 1, (int) $page ); // Page 1 starts a clean run. $state = ( 1 === $page ) ? $this->reset() : $this->get_state(); $per_page = $this->get_batch_size(); $query = wc_get_products( array( 'status' => 'publish', 'type' => array_keys( wc_get_product_types() ), 'limit' => $per_page, 'page' => $page, 'paginate' => true, 'orderby' => 'ID', 'order' => 'ASC', 'return' => 'objects', ) ); $products = isset( $query->products ) ? $query->products : array(); $batch_count = 0; foreach ( $products as $product ) { if ( ! $product instanceof WC_Product ) { continue; } $scored = $this->scorer->score_product( $product ); $grade = ARWC_Scorer::grade( $scored['score'] ); $state['rows'][] = array( 'id' => $product->get_id(), 'name' => $product->get_name(), 'type' => $product->get_type(), 'score' => $scored['score'], 'grade' => $grade, 'issues' => $scored['issues'], ); $state['processed']++; $state['sum_score'] += $scored['score']; $state['distribution'][ $grade ]++; // Aggregate issue frequency for the "top issues" summary. foreach ( $scored['issues'] as $issue ) { $code = $issue['code']; if ( ! isset( $state['issue_counts'][ $code ] ) ) { $state['issue_counts'][ $code ] = array( 'message' => $issue['message'], 'severity' => $issue['severity'], 'count' => 0, ); } $state['issue_counts'][ $code ]['count']++; } $batch_count++; } $total = isset( $query->total ) ? (int) $query->total : $state['total']; $state['total'] = $total; $done = ( $state['processed'] >= $total ) || ( 0 === $batch_count ); $state['completed'] = $done; $this->save_state( $state ); // On completion, drop a point into the daily score-history trend. if ( $done && $state['processed'] > 0 ) { $this->record_history( (int) round( $state['sum_score'] / $state['processed'] ) ); } return array( 'processed' => (int) $state['processed'], 'total' => (int) $total, 'done' => (bool) $done, 'batch' => (int) $batch_count, ); } /** * Re-score a single product and update its row in the stored scan, so the Fix * view reflects a change the moment it is applied (no full re-scan needed). * * Rebuilds the aggregate summary (store score, distribution, top issues) from * the row set so everything stays consistent. If the product was not part of * the last scan it is appended. Returns the fresh row plus the recomputed * summary, or null if the product/state is unavailable. * * @param int $product_id Product ID. * @return array{row:array,summary:array}|null */ public function update_product( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return null; } $scored = $this->scorer->score_product( $product ); $grade = ARWC_Scorer::grade( $scored['score'] ); $row = array( 'id' => $product->get_id(), 'name' => $product->get_name(), 'type' => $product->get_type(), 'score' => $scored['score'], 'grade' => $grade, 'issues' => $scored['issues'], ); $state = $this->get_state(); if ( is_array( $state ) ) { $found = false; if ( ! empty( $state['rows'] ) && is_array( $state['rows'] ) ) { foreach ( $state['rows'] as $i => $existing ) { if ( (int) $existing['id'] === (int) $product_id ) { $state['rows'][ $i ] = $row; $found = true; break; } } } if ( ! $found ) { $state['rows'][] = $row; } $this->recompute_aggregates( $state ); $this->save_state( $state ); // Keep today's trend point current as fixes change the store score. if ( (int) $state['processed'] > 0 ) { $this->record_history( (int) round( $state['sum_score'] / $state['processed'] ) ); } } return array( 'row' => $row, 'summary' => $this->get_summary(), ); } /** * Rebuild the derived aggregates (processed count, score sum, grade * distribution, issue frequencies) from the current row set. Keeps the summary * consistent after an individual row is re-scored. * * @param array $state Scan state passed by reference. * @return void */ protected function recompute_aggregates( array &$state ) { $sum = 0; $distribution = array( 'good' => 0, 'fair' => 0, 'poor' => 0, ); $issue_counts = array(); $rows = ( isset( $state['rows'] ) && is_array( $state['rows'] ) ) ? $state['rows'] : array(); foreach ( $rows as $row ) { $sum += (int) $row['score']; $grade = isset( $row['grade'] ) ? (string) $row['grade'] : ARWC_Scorer::grade( (int) $row['score'] ); if ( isset( $distribution[ $grade ] ) ) { $distribution[ $grade ]++; } $issues = ( isset( $row['issues'] ) && is_array( $row['issues'] ) ) ? $row['issues'] : array(); foreach ( $issues as $issue ) { $code = $issue['code']; if ( ! isset( $issue_counts[ $code ] ) ) { $issue_counts[ $code ] = array( 'message' => $issue['message'], 'severity' => $issue['severity'], 'count' => 0, ); } $issue_counts[ $code ]['count']++; } } $state['processed'] = count( $rows ); $state['sum_score'] = $sum; $state['distribution'] = $distribution; $state['issue_counts'] = $issue_counts; } /** * Get the persisted scan state. * * @return array|null Null if no scan has run. */ public function get_state() { $state = get_option( self::RESULTS_OPTION, null ); return is_array( $state ) ? $state : null; } /** * Persist the scan state (non-autoloaded to keep it off every page load). * * @param array $state State to save. * @return void */ protected function save_state( array $state ) { update_option( self::RESULTS_OPTION, $state, false ); } /** * Delete all stored scan data. Score history is deliberately kept so the trend * survives a "clear results" (it is a long-term record, not part of one scan). * * @return void */ public function clear() { delete_option( self::RESULTS_OPTION ); } /** * Record today's store score into the daily history (overwriting any earlier * point for the same day). Trimmed to a rolling window so the option stays small. * * @param int $score Store score 0–100. * @return void */ public function record_history( $score ) { $score = max( 0, min( 100, (int) $score ) ); $hist = get_option( self::HISTORY_OPTION, array() ); if ( ! is_array( $hist ) ) { $hist = array(); } $hist[ gmdate( 'Y-m-d' ) ] = $score; // Keep at most ~400 days of points. if ( count( $hist ) > 400 ) { ksort( $hist ); $hist = array_slice( $hist, -400, null, true ); } update_option( self::HISTORY_OPTION, $hist, false ); } /** * Get the daily score history for the last $days days (oldest first). * * @param int $days Window in days. * @return array Map of Y-m-d => score. */ public function get_history( $days ) { $hist = get_option( self::HISTORY_OPTION, array() ); if ( ! is_array( $hist ) || empty( $hist ) ) { return array(); } ksort( $hist ); $cutoff = gmdate( 'Y-m-d', time() - max( 1, (int) $days ) * DAY_IN_SECONDS ); $out = array(); foreach ( $hist as $date => $score ) { if ( (string) $date >= $cutoff ) { $out[ (string) $date ] = (int) $score; } } return $out; } /** * Build a dashboard-ready summary from the stored state. * * @return array|null Null if there is no completed/partial scan. */ public function get_summary() { $state = $this->get_state(); if ( null === $state ) { return null; } $processed = (int) $state['processed']; $store_score = $processed > 0 ? (int) round( $state['sum_score'] / $processed ) : 0; // Top issues, most frequent first. $issues = $state['issue_counts']; uasort( $issues, static function ( $a, $b ) { return $b['count'] <=> $a['count']; } ); return array( 'store_score' => $store_score, 'grade' => ARWC_Scorer::grade( $store_score ), 'total' => (int) $state['total'], 'processed' => $processed, 'completed' => ! empty( $state['completed'] ), 'distribution' => $state['distribution'], 'top_issues' => array_slice( $issues, 0, 10, true ), 'rows' => $state['rows'], ); } }