scheduler = $scheduler; } /** * @param int $batch Batch index. * @return array{checked:int,changed:int,done:bool} */ public function run_batch( $batch = 0 ) { global $wpdb; $cursor = (int) get_option( self::OPTION_CURSOR, 0 ); $writer = new PriceRevisionWriter(); $repo = new PriceRevisionRepository(); $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('product','product_variation') AND post_status IN ('publish','private') AND ID > %d ORDER BY ID ASC LIMIT %d", $cursor, self::BATCH_SIZE ) ); $checked = 0; $changed = 0; foreach ( (array) $ids as $id ) { $id = (int) $id; $cursor = $id; ++$checked; if ( ! function_exists( 'wc_get_product' ) ) { break; } $product = wc_get_product( $id ); if ( ! $product ) { continue; } $is_variation = $product->is_type( 'variation' ); $variation_id = $is_variation ? (int) $product->get_id() : 0; $product_id = $is_variation ? (int) $product->get_parent_id() : (int) $product->get_id(); $snapshot = $writer->snapshot_from_product( $product ); $hash = $writer->revision_hash( $product_id, $variation_id, $snapshot ); $open = $repo->get_open( $product_id, $variation_id ); if ( $open && $open['price_revision_hash'] === $hash ) { continue; } // Drift without going through supported hooks → gap_detected. $result = $writer->observe_product( $product, 'reconciliation', 'gap_detected' ); if ( ! empty( $result['changed'] ) ) { ++$changed; } } update_option( self::OPTION_CURSOR, $cursor, false ); $done = empty( $ids ) || count( $ids ) < self::BATCH_SIZE; if ( $done ) { update_option( self::OPTION_CURSOR, 0, false ); // reset for next day. update_option( 'aksmb_price_reconcile_done_gmt', gmdate( 'Y-m-d H:i:s' ), false ); return array( 'checked' => $checked, 'changed' => $changed, 'done' => true, 'continuation_enqueued' => false, 'continuation_error' => null, ); } $continuation_enqueued = false; $continuation_error = null; if ( $this->scheduler ) { $action_id = $this->scheduler->enqueue( Constants::ACTION_RECONCILE_PRICE_REVISIONS, array( 'batch' => (int) $batch + 1 ), Constants::SCHEDULER_GROUP, null, 'reconcile_' . ( (int) $batch + 1 ) ); if ( SchedulerResult::is_enqueued( $action_id ) ) { $continuation_enqueued = true; } else { $continuation_error = 'scheduler_enqueue_failed'; update_option( 'aksmb_price_reconcile_continuation_failed', array( 'batch' => (int) $batch + 1, 'at_gmt' => gmdate( 'Y-m-d H:i:s' ), 'error' => $continuation_error, ), false ); $this->record_continuation_event( 'price_reconcile', (string) ( (int) $batch + 1 ), array( 'batch' => (int) $batch + 1 ), $continuation_error ); } } else { $continuation_error = 'scheduler_unavailable'; } return array( 'checked' => $checked, 'changed' => $changed, 'done' => false, 'continuation_enqueued' => $continuation_enqueued, 'continuation_error' => $continuation_error, ); } /** * Best-effort operational event when WPDB is available. * * @param string $ref_type Ref type. * @param string $ref_id Ref id. * @param array $context Context. * @param string $error Error code. * @return void */ private function record_continuation_event( $ref_type, $ref_id, array $context, $error ) { global $wpdb; if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { return; } ( new OperationalEventsRepository() )->record( $ref_type, $ref_id, 'running', 'continuation_failed', $context, $error ); } }