Files
WooBC/woo-business-central/includes/class-wbc-batch-sync.php
Malin 0a08086272 perf: filter by BC category before fetching instead of per-product lookups
When wbc_category_filter is set, sync now pages through BC's /items
endpoint filtered server-side by itemCategoryCode (or-chained eq - BC's
classic OData rejects the `in` operator) and matches each returned item to
a WooCommerce product locally via wc_get_product_id_by_sku(), instead of
querying BC once per WooCommerce product only to discard most of them.
Confirmed against live data that BC's `in` filter isn't supported here but
chained `eq ... or ...` is.

Also reorders find_bc_item_by_sku() to try GTIN/number before Item
Reference (ReferenciasArticulo). Empirically, on this catalog Reference_No
is always identical to Item_No, so the reference lookup never matched
anything the GTIN/number checks didn't already find - it was costing an
extra BC round trip on every single product for no benefit. Kept as a
last-resort fallback both directions (SKU->item and item->SKU) in case
that doesn't hold for some future item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 14:30:51 +02:00

397 lines
15 KiB
PHP

<?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;
/**
* Number of BC items fetched per page in category-filtered mode
*/
const CATEGORY_PAGE_SIZE = 100;
/**
* 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 (WooCommerce-first mode)
*/
const ACTION_HOOK = 'wbc_process_sync_batch';
/**
* Action Scheduler hook processed per page (category-filtered, BC-first mode)
*/
const CATEGORY_ACTION_HOOK = 'wbc_process_category_page';
/**
* Action Scheduler group name
*/
const ACTION_GROUP = 'wbc_product_sync';
/**
* Register the Action Scheduler batch handlers
*/
public function register_hooks() {
add_action( self::ACTION_HOOK, array( $this, 'process_batch' ), 10, 2 );
add_action( self::CATEGORY_ACTION_HOOK, array( $this, 'process_category_page' ), 10, 2 );
}
/**
* Kick off a full catalog sync in the background
*
* If a category filter is configured, syncs BC-first: pages through
* /items filtered server-side by itemCategoryCode and matches each item
* to a WooCommerce product locally. This avoids querying BC once per
* WooCommerce product only to discard most of them - with a filter set,
* the vast majority of products aren't in an allowed category, so doing
* the filtering on BC's side first is far fewer API calls.
*
* Without a filter, falls back to the WooCommerce-first approach (ID-only
* query, batched, never loads the whole catalog as full objects), which
* doesn't have BC-side filtering available to narrow the scope.
*
* 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' ),
);
}
$category_filter = get_option( 'wbc_category_filter', '' );
$categories = array_filter( array_map( 'trim', explode( ',', $category_filter ) ) );
if ( ! empty( $categories ) ) {
return self::queue_category_filtered_sync( array_values( $categories ) );
}
// 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,
'mode' => 'by_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 )
),
);
}
/**
* Kick off a BC-first, category-filtered sync
*
* Pages through BC's /items endpoint filtered server-side by
* itemCategoryCode (or-chained eq - BC's classic OData rejects the `in`
* operator here) and self-chains one background action per page, since
* the total item count isn't known upfront. Each page is matched against
* WooCommerce locally (no further BC calls in the common case).
*
* @param string[] $categories Allowed BC item category codes.
* @return array Result with 'success' and 'message'.
*/
private static function queue_category_filtered_sync( array $categories ) {
$run_id = uniqid( 'wbc_', true );
update_option( self::RUN_OPTION, array(
'run_id' => $run_id,
'mode' => 'category',
'status' => 'running',
'categories' => $categories,
'skip' => 0,
'pages_done' => 0,
'total' => 0,
'success' => 0,
'failed' => 0,
'skipped' => 0,
'started_at' => current_time( 'mysql' ),
'finished_at' => '',
), false );
set_transient( self::LOCK_TRANSIENT, $run_id, 4 * HOUR_IN_SECONDS );
as_enqueue_async_action( self::CATEGORY_ACTION_HOOK, array( $run_id, 0 ), self::ACTION_GROUP );
WBC_Logger::info( 'BatchSync', 'Queued category-filtered background sync', array(
'run_id' => $run_id,
'categories' => $categories,
) );
return array(
'success' => true,
'started' => true,
'message' => sprintf(
/* translators: %s: comma-separated category codes */
__( 'Sync started in the background, filtered to categories: %s.', 'woo-business-central' ),
implode( ', ', $categories )
),
);
}
/**
* Process one page of BC items filtered by category (called by Action Scheduler)
*
* Self-chains: enqueues the next page if this one came back full, or marks
* the run complete otherwise. Positional args, not a combined array - see
* the note on process_batch().
*
* @param string $run_id Run ID this page belongs to.
* @param int $skip OData $skip offset for this page.
*/
public function process_category_page( $run_id, $skip = 0 ) {
$run = get_option( self::RUN_OPTION, array() );
if ( empty( $run ) || ( $run['run_id'] ?? '' ) !== $run_id ) {
return;
}
$categories = $run['categories'] ?? array();
if ( empty( $categories ) ) {
return;
}
$filter_parts = array_map( function ( $code ) {
return "itemCategoryCode eq '" . WBC_API_Client::escape_odata_string( $code ) . "'";
}, $categories );
$result = WBC_API_Client::get( '/items', array(
'$filter' => '(' . implode( ' or ', $filter_parts ) . ')',
'$select' => WBC_Product_Sync::SELECT_FIELDS,
'$top' => self::CATEGORY_PAGE_SIZE,
'$skip' => $skip,
) );
if ( is_wp_error( $result ) ) {
WBC_Logger::error( 'BatchSync', 'Failed to fetch category page from BC, stopping run', array(
'run_id' => $run_id,
'skip' => $skip,
'error' => $result->get_error_message(),
) );
$run['status'] = 'failed';
$run['finished_at'] = current_time( 'mysql' );
update_option( self::RUN_OPTION, $run, false );
delete_transient( self::LOCK_TRANSIENT );
return;
}
$items = isset( $result['value'] ) ? $result['value'] : array();
$sync = new WBC_Product_Sync();
foreach ( $items as $item ) {
$sync->process_bc_item( $item );
usleep( 50000 ); // 50ms, matches the rate limiting used elsewhere
}
$page_results = $sync->get_results();
// Best-effort counters - see note on process_batch().
$run = get_option( self::RUN_OPTION, $run );
if ( ( $run['run_id'] ?? '' ) !== $run_id ) {
return;
}
$run['pages_done'] = (int) ( $run['pages_done'] ?? 0 ) + 1;
$run['total'] = (int) ( $run['total'] ?? 0 ) + count( $items );
$run['success'] = (int) ( $run['success'] ?? 0 ) + $page_results['success'];
$run['failed'] = (int) ( $run['failed'] ?? 0 ) + $page_results['failed'];
$run['skipped'] = (int) ( $run['skipped'] ?? 0 ) + $page_results['skipped'];
$has_more_pages = count( $items ) === self::CATEGORY_PAGE_SIZE;
if ( $has_more_pages ) {
$next_skip = $skip + self::CATEGORY_PAGE_SIZE;
$run['skip'] = $next_skip;
update_option( self::RUN_OPTION, $run, false );
as_enqueue_async_action( self::CATEGORY_ACTION_HOOK, array( $run_id, $next_skip ), self::ACTION_GROUP );
} else {
$run['status'] = 'completed';
$run['finished_at'] = current_time( 'mysql' );
update_option( self::RUN_OPTION, $run, false );
WBC_Cron::update_last_sync_time();
delete_transient( self::LOCK_TRANSIENT );
WBC_Logger::info( 'BatchSync', 'Category-filtered background sync completed', $run );
}
}
/**
* Process a single batch (called by Action Scheduler)
*
* 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.
*/
public function process_batch( $run_id, $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;
}
}