diff --git a/woo-business-central/admin/class-wbc-admin.php b/woo-business-central/admin/class-wbc-admin.php index f3b14b4..7ff33b8 100644 --- a/woo-business-central/admin/class-wbc-admin.php +++ b/woo-business-central/admin/class-wbc-admin.php @@ -262,6 +262,11 @@ class WBC_Admin { /** * AJAX: Manual sync + * + * Queues background batches and returns immediately - the full sync no + * longer runs inline in this request (that approach hits PHP/proxy + * timeouts at real catalog sizes and surfaces as a 502 Bad Gateway). + * Poll ajax_sync_status() for progress. */ public function ajax_manual_sync() { check_ajax_referer( 'wbc_admin_nonce', 'nonce' ); @@ -270,7 +275,7 @@ class WBC_Admin { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) ); } - $result = WBC_Cron::run_sync_now(); + $result = WBC_Batch_Sync::queue_full_sync(); if ( isset( $result['success'] ) && $result['success'] ) { wp_send_json_success( $result ); @@ -279,6 +284,19 @@ class WBC_Admin { } } + /** + * AJAX: Get background sync progress + */ + public function ajax_sync_status() { + check_ajax_referer( 'wbc_admin_nonce', 'nonce' ); + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) ); + } + + wp_send_json_success( WBC_Batch_Sync::get_status() ); + } + /** * AJAX: Clear logs */ diff --git a/woo-business-central/admin/js/wbc-admin.js b/woo-business-central/admin/js/wbc-admin.js index c5d6afe..a1e6887 100644 --- a/woo-business-central/admin/js/wbc-admin.js +++ b/woo-business-central/admin/js/wbc-admin.js @@ -11,6 +11,36 @@ */ init: function() { this.bindEvents(); + this.checkInitialSyncStatus(); + }, + + /** + * If a background sync is already running (e.g. triggered by cron, + * or the admin reloaded the page mid-sync), resume polling for it. + */ + checkInitialSyncStatus: function() { + var $btn = $('#wbc-manual-sync'); + var $status = $('#wbc-sync-status'); + + if (!$btn.length) { + return; + } + + $.ajax({ + url: wbc_admin.ajax_url, + type: 'POST', + data: { + action: 'wbc_sync_status', + nonce: wbc_admin.nonce + }, + success: function(response) { + if (response.success && response.data.status === 'running') { + $btn.prop('disabled', true); + $status.removeClass('success error').addClass('loading'); + WBC_Admin.pollSyncStatus($btn, $status); + } + } + }); }, /** @@ -74,6 +104,10 @@ /** * Run manual sync + * + * The sync itself runs in the background (Action Scheduler batches), + * so this just kicks it off and then polls for progress instead of + * waiting on one long-lived request. */ manualSync: function(e) { e.preventDefault(); @@ -92,13 +126,51 @@ nonce: wbc_admin.nonce }, success: function(response) { - $btn.prop('disabled', false); - $status.removeClass('loading'); - if (response.success) { - $status.addClass('success').text(response.data.message); + WBC_Admin.pollSyncStatus($btn, $status); } else { - $status.addClass('error').text(response.data.message || 'Sync failed'); + $btn.prop('disabled', false); + $status.removeClass('loading').addClass('error').text((response.data && response.data.message) || 'Sync failed'); + } + }, + error: function(xhr, status, error) { + $btn.prop('disabled', false); + $status.removeClass('loading').addClass('error').text('Request failed: ' + error); + } + }); + }, + + /** + * Poll background sync progress until it completes + */ + pollSyncStatus: function($btn, $status) { + $.ajax({ + url: wbc_admin.ajax_url, + type: 'POST', + data: { + action: 'wbc_sync_status', + nonce: wbc_admin.nonce + }, + success: function(response) { + if (!response.success) { + $btn.prop('disabled', false); + $status.removeClass('loading').addClass('error').text((response.data && response.data.message) || 'Failed to get sync status'); + return; + } + + var run = response.data; + + if (run.status === 'running') { + $status.text('Syncing... ' + (run.batches_done || 0) + ' / ' + (run.total_batches || 0) + ' batches (success: ' + (run.success || 0) + ', failed: ' + (run.failed || 0) + ', skipped: ' + (run.skipped || 0) + ')'); + setTimeout(function() { + WBC_Admin.pollSyncStatus($btn, $status); + }, 3000); + } else if (run.status === 'completed') { + $btn.prop('disabled', false); + $status.removeClass('loading').addClass('success').text('Sync completed. Success: ' + (run.success || 0) + ', Failed: ' + (run.failed || 0) + ', Skipped: ' + (run.skipped || 0)); + } else { + $btn.prop('disabled', false); + $status.removeClass('loading'); } }, error: function(xhr, status, error) { diff --git a/woo-business-central/includes/class-wbc-batch-sync.php b/woo-business-central/includes/class-wbc-batch-sync.php new file mode 100644 index 0000000..ad75db7 --- /dev/null +++ b/woo-business-central/includes/class-wbc-batch-sync.php @@ -0,0 +1,229 @@ + true, + 'started' => false, + 'message' => __( 'A sync is already running.', 'woo-business-central' ), + 'run' => $run, + ); + } + + if ( get_option( 'wbc_enable_stock_sync', 'yes' ) !== 'yes' && get_option( 'wbc_enable_price_sync', 'yes' ) !== 'yes' ) { + WBC_Logger::info( 'BatchSync', 'Sync skipped - both stock and price sync are disabled' ); + return array( + 'success' => true, + 'message' => __( 'Sync skipped - both stock and price sync are disabled.', 'woo-business-central' ), + ); + } + + if ( ! WBC_OAuth::is_configured() ) { + WBC_Logger::error( 'BatchSync', 'Sync failed - API credentials not configured' ); + return array( + 'success' => false, + 'message' => __( 'Sync failed - API credentials not configured.', 'woo-business-central' ), + ); + } + + if ( ! function_exists( 'as_enqueue_async_action' ) ) { + WBC_Logger::error( 'BatchSync', 'Action Scheduler not available (ships with WooCommerce)' ); + return array( + 'success' => false, + 'message' => __( 'Background task runner is not available.', 'woo-business-central' ), + ); + } + + // ID-only query with a meta filter, so 60k+ catalogs don't get loaded + // as full WC_Product objects just to check for a SKU. + $product_ids = wc_get_products( array( + 'limit' => -1, + 'status' => 'publish', + 'return' => 'ids', + 'meta_query' => array( // phpcs:ignore WooCommerce.Commenting -- deliberate, avoids full-object load at scale. + array( + 'key' => '_sku', + 'value' => '', + 'compare' => '!=', + ), + ), + ) ); + + if ( empty( $product_ids ) ) { + WBC_Logger::info( 'BatchSync', 'No WooCommerce products with SKU found' ); + return array( + 'success' => true, + 'message' => __( 'No WooCommerce products with SKU found.', 'woo-business-central' ), + ); + } + + $run_id = uniqid( 'wbc_', true ); + $batches = array_chunk( $product_ids, self::BATCH_SIZE ); + + update_option( self::RUN_OPTION, array( + 'run_id' => $run_id, + 'status' => 'running', + 'total' => count( $product_ids ), + 'total_batches' => count( $batches ), + 'batches_done' => 0, + 'success' => 0, + 'failed' => 0, + 'skipped' => 0, + 'started_at' => current_time( 'mysql' ), + 'finished_at' => '', + ), false ); + + // Safety-net expiry in case a batch throws and never reports back; + // prevents the lock from wedging the sync button forever. + set_transient( self::LOCK_TRANSIENT, $run_id, 4 * HOUR_IN_SECONDS ); + + foreach ( $batches as $batch ) { + as_enqueue_async_action( self::ACTION_HOOK, array( + 'run_id' => $run_id, + 'product_ids' => $batch, + ), self::ACTION_GROUP ); + } + + WBC_Logger::info( 'BatchSync', 'Queued background sync', array( + 'run_id' => $run_id, + 'total_products' => count( $product_ids ), + 'total_batches' => count( $batches ), + ) ); + + return array( + 'success' => true, + 'started' => true, + 'message' => sprintf( + /* translators: %d: number of products */ + __( 'Sync started in the background for %d product(s).', 'woo-business-central' ), + count( $product_ids ) + ), + ); + } + + /** + * Process a single batch (called by Action Scheduler) + * + * @param array $args Action args: run_id, product_ids. + */ + public function process_batch( $args ) { + $run_id = $args['run_id'] ?? ''; + $product_ids = $args['product_ids'] ?? array(); + + $run = get_option( self::RUN_OPTION, array() ); + + // A newer sync superseded this run (e.g. re-triggered manually) - drop it. + if ( empty( $run ) || ( $run['run_id'] ?? '' ) !== $run_id ) { + return; + } + + $sync = new WBC_Product_Sync(); + $sync->run_sync_for_ids( $product_ids ); + $batch_results = $sync->get_results(); + + // Progress counters are best-effort: Action Scheduler can run batches + // concurrently, and update_option() read-modify-write isn't atomic. + // Good enough for a progress indicator; not used for anything + // correctness-critical. + $run = get_option( self::RUN_OPTION, $run ); + if ( ( $run['run_id'] ?? '' ) !== $run_id ) { + return; + } + + $run['batches_done'] = (int) ( $run['batches_done'] ?? 0 ) + 1; + $run['success'] = (int) ( $run['success'] ?? 0 ) + $batch_results['success']; + $run['failed'] = (int) ( $run['failed'] ?? 0 ) + $batch_results['failed']; + $run['skipped'] = (int) ( $run['skipped'] ?? 0 ) + $batch_results['skipped']; + + if ( $run['batches_done'] >= $run['total_batches'] ) { + $run['status'] = 'completed'; + $run['finished_at'] = current_time( 'mysql' ); + WBC_Cron::update_last_sync_time(); + delete_transient( self::LOCK_TRANSIENT ); + + WBC_Logger::info( 'BatchSync', 'Background sync completed', $run ); + } + + update_option( self::RUN_OPTION, $run, false ); + } + + /** + * Get the current/last sync run status + * + * @return array Run status, or array('status' => 'none') if never run. + */ + public static function get_status() { + $run = get_option( self::RUN_OPTION, array() ); + + if ( empty( $run ) ) { + return array( 'status' => 'none' ); + } + + return $run; + } +} diff --git a/woo-business-central/includes/class-wbc-cron.php b/woo-business-central/includes/class-wbc-cron.php index bccd296..a4aa165 100644 --- a/woo-business-central/includes/class-wbc-cron.php +++ b/woo-business-central/includes/class-wbc-cron.php @@ -42,17 +42,20 @@ class WBC_Cron { /** * Run scheduled product sync + * + * Queues background batches via WBC_Batch_Sync rather than running the + * whole catalog inline - WP-Cron events run under the same PHP time/memory + * limits as any other request and would otherwise time out at scale. */ public function run_scheduled_sync() { WBC_Logger::info( 'Cron', 'Starting scheduled product sync' ); - $product_sync = new WBC_Product_Sync(); - $result = $product_sync->run_sync(); + $result = WBC_Batch_Sync::queue_full_sync(); if ( isset( $result['success'] ) && $result['success'] ) { - WBC_Logger::info( 'Cron', 'Scheduled product sync completed', $result ); + WBC_Logger::info( 'Cron', 'Scheduled product sync queued', $result ); } else { - WBC_Logger::error( 'Cron', 'Scheduled product sync failed', $result ); + WBC_Logger::error( 'Cron', 'Scheduled product sync failed to queue', $result ); } } @@ -137,23 +140,6 @@ class WBC_Cron { update_option( 'wbc_last_sync_time', current_time( 'mysql' ) ); } - /** - * Run sync now (manual trigger) - * - * @return array Sync result. - */ - public static function run_sync_now() { - WBC_Logger::info( 'Cron', 'Manual sync triggered' ); - - $product_sync = new WBC_Product_Sync(); - $result = $product_sync->run_sync(); - - // Update last sync time - self::update_last_sync_time(); - - return $result; - } - /** * Clear all scheduled events */ diff --git a/woo-business-central/includes/class-wbc-loader.php b/woo-business-central/includes/class-wbc-loader.php index d7e94e8..1ff9e28 100644 --- a/woo-business-central/includes/class-wbc-loader.php +++ b/woo-business-central/includes/class-wbc-loader.php @@ -96,6 +96,10 @@ class WBC_Loader { $cron = new WBC_Cron(); $this->add_action( 'wbc_product_sync_event', $cron, 'run_scheduled_sync' ); + // Register background batch sync handler (Action Scheduler) + $batch_sync = new WBC_Batch_Sync(); + $batch_sync->register_hooks(); + // Register order sync handler $order_sync = new WBC_Order_Sync(); $this->add_action( 'woocommerce_order_status_processing', $order_sync, 'sync_order', 10, 1 ); @@ -109,6 +113,7 @@ class WBC_Loader { $this->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' ); $this->add_action( 'wp_ajax_wbc_test_connection', $admin, 'ajax_test_connection' ); $this->add_action( 'wp_ajax_wbc_manual_sync', $admin, 'ajax_manual_sync' ); + $this->add_action( 'wp_ajax_wbc_sync_status', $admin, 'ajax_sync_status' ); $this->add_action( 'wp_ajax_wbc_clear_logs', $admin, 'ajax_clear_logs' ); $this->add_action( 'wp_ajax_wbc_get_companies', $admin, 'ajax_get_companies' ); $this->add_action( 'admin_init', $admin, 'handle_csv_export' ); diff --git a/woo-business-central/includes/class-wbc-product-sync.php b/woo-business-central/includes/class-wbc-product-sync.php index c61ef41..2752041 100644 --- a/woo-business-central/includes/class-wbc-product-sync.php +++ b/woo-business-central/includes/class-wbc-product-sync.php @@ -46,102 +46,37 @@ class WBC_Product_Sync { ); /** - * Run the product sync (WooCommerce-first approach) + * Process a batch of WooCommerce products by ID (WooCommerce-first approach) * * Instead of pulling all 60k+ items from BC, we iterate WooCommerce - * products and query BC for each one by SKU/GTIN. This means ~100 - * targeted API calls instead of 600+ paginated calls. + * products and query BC for each one by reference/GTIN/number. This is + * called per-batch by WBC_Batch_Sync so a single request never has to + * process the whole catalog (which would exceed PHP/proxy timeouts at + * real-world catalog sizes). * - * @return array Sync results. + * @param int[] $product_ids WooCommerce product IDs to process. + * @return array Sync results for this batch. */ - public function run_sync() { - // Check if sync is enabled - if ( get_option( 'wbc_enable_stock_sync', 'yes' ) !== 'yes' && get_option( 'wbc_enable_price_sync', 'yes' ) !== 'yes' ) { - WBC_Logger::info( 'ProductSync', 'Sync skipped - both stock and price sync are disabled' ); - return array( - 'success' => true, - 'message' => __( 'Sync skipped - both stock and price sync are disabled.', 'woo-business-central' ), - ); - } + public function run_sync_for_ids( array $product_ids ) { + foreach ( $product_ids as $product_id ) { + $product = wc_get_product( $product_id ); - // Check if credentials are configured - if ( ! WBC_OAuth::is_configured() ) { - WBC_Logger::error( 'ProductSync', 'Sync failed - API credentials not configured' ); - return array( - 'success' => false, - 'message' => __( 'Sync failed - API credentials not configured.', 'woo-business-central' ), - ); - } + if ( ! $product || empty( $product->get_sku() ) ) { + continue; + } - WBC_Logger::info( 'ProductSync', 'Starting product sync (WooCommerce-first)' ); - - $start_time = microtime( true ); - - // Get all WooCommerce products that have a SKU - $products = $this->get_wc_products_with_sku(); - - if ( empty( $products ) ) { - WBC_Logger::info( 'ProductSync', 'No WooCommerce products with SKU found' ); - return array( - 'success' => true, - 'message' => __( 'No WooCommerce products with SKU found.', 'woo-business-central' ), - ); - } - - WBC_Logger::info( 'ProductSync', 'Found WooCommerce products to sync', array( - 'count' => count( $products ), - ) ); - - // Process each WooCommerce product - foreach ( $products as $product ) { $this->process_wc_product( $product ); // Small delay between API calls to avoid rate limiting usleep( 50000 ); // 50ms } - $duration = round( microtime( true ) - $start_time, 2 ); - - WBC_Logger::info( 'ProductSync', 'Product sync completed', array( - 'duration_seconds' => $duration, - 'total' => $this->results['total'], - 'success' => $this->results['success'], - 'failed' => $this->results['failed'], - 'skipped' => $this->results['skipped'], - ) ); - return array( - 'success' => empty( $this->results['errors'] ), - 'message' => sprintf( - /* translators: 1: duration, 2: success count, 3: failed count, 4: skipped count */ - __( 'Sync completed in %1$s seconds. Success: %2$d, Failed: %3$d, Skipped: %4$d', 'woo-business-central' ), - $duration, - $this->results['success'], - $this->results['failed'], - $this->results['skipped'] - ), - 'results' => $this->results, + 'success' => empty( $this->results['errors'] ), + 'results' => $this->results, ); } - /** - * Get all WooCommerce products that have a SKU set - * - * @return WC_Product[] Array of WooCommerce products. - */ - private function get_wc_products_with_sku() { - $products = wc_get_products( array( - 'limit' => -1, - 'status' => 'publish', - 'return' => 'objects', - ) ); - - // Filter to only products with SKUs - return array_filter( $products, function ( $product ) { - return ! empty( $product->get_sku() ); - } ); - } - /** * Process a single WooCommerce product - find it in BC and sync * diff --git a/woo-business-central/woo-business-central.php b/woo-business-central/woo-business-central.php index 535d22c..db079b4 100644 --- a/woo-business-central/woo-business-central.php +++ b/woo-business-central/woo-business-central.php @@ -131,6 +131,7 @@ function wbc_init() { require_once WBC_PLUGIN_DIR . 'includes/class-wbc-oauth.php'; require_once WBC_PLUGIN_DIR . 'includes/class-wbc-api-client.php'; require_once WBC_PLUGIN_DIR . 'includes/class-wbc-product-sync.php'; + require_once WBC_PLUGIN_DIR . 'includes/class-wbc-batch-sync.php'; require_once WBC_PLUGIN_DIR . 'includes/class-wbc-customer-sync.php'; require_once WBC_PLUGIN_DIR . 'includes/class-wbc-order-sync.php'; require_once WBC_PLUGIN_DIR . 'includes/class-wbc-cron.php';