2026-07-09 13:19:11 +02:00
|
|
|
<?php
|
|
|
|
|
/**
|
|
|
|
|
* Background batch orchestration for product sync
|
|
|
|
|
*
|
|
|
|
|
* Splits the WooCommerce-first product sync into small chunks processed via
|
|
|
|
|
* Action Scheduler (bundled with WooCommerce), so neither the "Sync Now"
|
|
|
|
|
* AJAX request nor the WP-Cron event has to process the entire catalog in a
|
|
|
|
|
* single PHP execution. At real catalog sizes (tens of thousands of SKUs)
|
|
|
|
|
* that single-request approach exceeds PHP's max_execution_time and the
|
|
|
|
|
* webserver/proxy's read timeout, surfacing as a 502 Bad Gateway.
|
|
|
|
|
*
|
|
|
|
|
* @package WooBusinessCentral
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Prevent direct access
|
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Class WBC_Batch_Sync
|
|
|
|
|
*/
|
|
|
|
|
class WBC_Batch_Sync {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Number of products processed per background action
|
|
|
|
|
*/
|
|
|
|
|
const BATCH_SIZE = 25;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Option name storing the current/last run's progress
|
|
|
|
|
*/
|
|
|
|
|
const RUN_OPTION = 'wbc_sync_run';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transient used as a simple "a sync is already running" guard
|
|
|
|
|
*/
|
|
|
|
|
const LOCK_TRANSIENT = 'wbc_sync_lock';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Action Scheduler hook processed per batch
|
|
|
|
|
*/
|
|
|
|
|
const ACTION_HOOK = 'wbc_process_sync_batch';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Action Scheduler group name
|
|
|
|
|
*/
|
|
|
|
|
const ACTION_GROUP = 'wbc_product_sync';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register the Action Scheduler batch handler
|
|
|
|
|
*/
|
|
|
|
|
public function register_hooks() {
|
2026-07-09 14:00:31 +02:00
|
|
|
add_action( self::ACTION_HOOK, array( $this, 'process_batch' ), 10, 2 );
|
2026-07-09 13:19:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Kick off a full catalog sync in the background
|
|
|
|
|
*
|
|
|
|
|
* Gathers WooCommerce product IDs with a SKU (ID-only query - never
|
|
|
|
|
* loads full product objects for the whole catalog into memory), splits
|
|
|
|
|
* them into batches, and enqueues one background action per batch.
|
|
|
|
|
* Returns immediately; actual work happens asynchronously.
|
|
|
|
|
*
|
|
|
|
|
* @return array Result with 'success' and 'message'.
|
|
|
|
|
*/
|
|
|
|
|
public static function queue_full_sync() {
|
|
|
|
|
if ( get_transient( self::LOCK_TRANSIENT ) ) {
|
|
|
|
|
$run = get_option( self::RUN_OPTION, array() );
|
|
|
|
|
return array(
|
|
|
|
|
'success' => 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)
|
|
|
|
|
*
|
2026-07-09 14:00:31 +02:00
|
|
|
* Action Scheduler spreads the associative args array passed to
|
|
|
|
|
* as_enqueue_async_action() into separate positional parameters here
|
|
|
|
|
* (it does not pass a single combined array) - the parameter order
|
|
|
|
|
* must match the key order used when the action was enqueued.
|
|
|
|
|
*
|
|
|
|
|
* @param string $run_id Run ID this batch belongs to.
|
|
|
|
|
* @param array $product_ids WooCommerce product IDs in this batch.
|
2026-07-09 13:19:11 +02:00
|
|
|
*/
|
2026-07-09 14:00:31 +02:00
|
|
|
public function process_batch( $run_id, $product_ids = array() ) {
|
2026-07-09 13:19:11 +02:00
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
}
|