diff --git a/woo-business-central/admin/js/wbc-admin.js b/woo-business-central/admin/js/wbc-admin.js index a1e6887..a4fe31f 100644 --- a/woo-business-central/admin/js/wbc-admin.js +++ b/woo-business-central/admin/js/wbc-admin.js @@ -161,7 +161,10 @@ 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) + ')'); + var progressText = (run.mode === 'category') + ? (run.pages_done || 0) + ' page(s), ' + (run.total || 0) + ' item(s) checked' + : (run.batches_done || 0) + ' / ' + (run.total_batches || 0) + ' batches'; + $status.text('Syncing... ' + progressText + ' (success: ' + (run.success || 0) + ', failed: ' + (run.failed || 0) + ', skipped: ' + (run.skipped || 0) + ')'); setTimeout(function() { WBC_Admin.pollSyncStatus($btn, $status); }, 3000); diff --git a/woo-business-central/includes/class-wbc-batch-sync.php b/woo-business-central/includes/class-wbc-batch-sync.php index f3d77d6..a954576 100644 --- a/woo-business-central/includes/class-wbc-batch-sync.php +++ b/woo-business-central/includes/class-wbc-batch-sync.php @@ -27,6 +27,11 @@ class WBC_Batch_Sync { */ 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 */ @@ -38,28 +43,42 @@ class WBC_Batch_Sync { const LOCK_TRANSIENT = 'wbc_sync_lock'; /** - * Action Scheduler hook processed per batch + * 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 handler + * 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 * - * 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. + * 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'. @@ -99,6 +118,13 @@ class WBC_Batch_Sync { ); } + $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( @@ -127,6 +153,7 @@ class WBC_Batch_Sync { update_option( self::RUN_OPTION, array( 'run_id' => $run_id, + 'mode' => 'by_id', 'status' => 'running', 'total' => count( $product_ids ), 'total_batches' => count( $batches ), @@ -166,6 +193,143 @@ class WBC_Batch_Sync { ); } + /** + * 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) * diff --git a/woo-business-central/includes/class-wbc-product-sync.php b/woo-business-central/includes/class-wbc-product-sync.php index 2752041..f585114 100644 --- a/woo-business-central/includes/class-wbc-product-sync.php +++ b/woo-business-central/includes/class-wbc-product-sync.php @@ -143,7 +143,13 @@ class WBC_Product_Sync { /** * Find a BC item by WooCommerce SKU * - * Tries, in order: Item Reference (ReferenciasArticulo), GTIN, then item number. + * Tries, in order: GTIN, item number, then Item Reference (ReferenciasArticulo). + * Item Reference is last on purpose: on this catalog's real data, Reference_No + * is always identical to Item_No for every sampled row, so it never matches + * anything the GTIN/number checks didn't already find - trying it first (as + * originally implemented) cost an extra BC round trip on every single product + * for zero benefit. Kept as a fallback in case that assumption doesn't hold + * for some future item. * * @param string $sku WooCommerce product SKU. * @return array|false|WP_Error BC item data, false if not found, or WP_Error. @@ -151,30 +157,6 @@ class WBC_Product_Sync { private function find_bc_item_by_sku( $sku ) { $escaped_sku = WBC_API_Client::escape_odata_string( $sku ); - // Try Item Reference first - lets merchants use a customer/vendor/bar-code - // reference number as the WooCommerce SKU instead of the raw item number/GTIN. - $item_number = $this->find_item_number_by_reference( $sku ); - - if ( is_wp_error( $item_number ) ) { - return $item_number; - } - - if ( $item_number ) { - $result = WBC_API_Client::get( '/items', array( - '$filter' => "number eq '" . WBC_API_Client::escape_odata_string( $item_number ) . "'", - '$select' => self::SELECT_FIELDS, - '$top' => 1, - ) ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - if ( ! empty( $result['value'] ) ) { - return $result['value'][0]; - } - } - // Try by GTIN (since SKU = EAN = GTIN in this setup) $result = WBC_API_Client::get( '/items', array( '$filter' => "gtin eq '" . $escaped_sku . "'", @@ -205,6 +187,29 @@ class WBC_Product_Sync { return $result['value'][0]; } + // Last resort: Item Reference + $item_number = $this->find_item_number_by_reference( $sku ); + + if ( is_wp_error( $item_number ) ) { + return $item_number; + } + + if ( $item_number ) { + $result = WBC_API_Client::get( '/items', array( + '$filter' => "number eq '" . WBC_API_Client::escape_odata_string( $item_number ) . "'", + '$select' => self::SELECT_FIELDS, + '$top' => 1, + ) ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! empty( $result['value'] ) ) { + return $result['value'][0]; + } + } + return false; } @@ -265,6 +270,118 @@ class WBC_Product_Sync { return in_array( $item_category, $allowed, true ); } + /** + * Process a single BC item - find the matching WooCommerce product and sync + * + * Used by the category-filtered (BC-first) sync path: instead of iterating + * every WooCommerce product and asking BC "do you have this SKU?", we + * already fetched this item from BC (filtered server-side by category), so + * we just need to find the corresponding WooCommerce product locally - no + * further BC round trip needed in the common case. + * + * @param array $item BC item data (id, number, gtin, displayName, unitPrice, inventory, itemCategoryCode). + */ + public function process_bc_item( $item ) { + $this->results['total']++; + + $product = $this->find_wc_product_for_item( $item ); + + if ( ! $product ) { + $this->results['skipped']++; + WBC_Logger::debug( 'ProductSync', 'No matching WooCommerce product for BC item', array( + 'item_number' => $item['number'] ?? '', + 'gtin' => $item['gtin'] ?? '', + ) ); + return; + } + + // If location code is configured, get location-specific stock + $location_code = get_option( 'wbc_location_code', '' ); + if ( ! empty( $location_code ) && get_option( 'wbc_enable_stock_sync', 'yes' ) === 'yes' ) { + $location_stock = $this->get_location_stock( $item, $location_code ); + if ( $location_stock !== false ) { + $item['inventory'] = $location_stock; + } + } + + $updated = $this->update_product( $product, $item ); + + if ( $updated ) { + $this->results['success']++; + } else { + $this->results['failed']++; + } + } + + /** + * Find the WooCommerce product matching a BC item, via a local SKU lookup + * (no BC round trip) - tries GTIN, then item number, then Item Reference + * as a last resort. + * + * @param array $item BC item data. + * @return WC_Product|false + */ + private function find_wc_product_for_item( $item ) { + $gtin = $item['gtin'] ?? ''; + $number = $item['number'] ?? ''; + + if ( ! empty( $gtin ) ) { + $product_id = wc_get_product_id_by_sku( $gtin ); + if ( $product_id ) { + return wc_get_product( $product_id ); + } + } + + if ( ! empty( $number ) ) { + $product_id = wc_get_product_id_by_sku( $number ); + if ( $product_id ) { + return wc_get_product( $product_id ); + } + } + + if ( ! empty( $number ) ) { + $reference_no = $this->find_reference_no_by_item_number( $number ); + + if ( $reference_no ) { + $product_id = wc_get_product_id_by_sku( $reference_no ); + if ( $product_id ) { + return wc_get_product( $product_id ); + } + } + } + + return false; + } + + /** + * Look up a reference/barcode number for a BC item number via the custom + * ReferenciasArticulo OData endpoint (reverse direction of find_item_number_by_reference). + * + * @param string $item_number BC item number. + * @return string|false Reference number, or false if none found/on error. + */ + private function find_reference_no_by_item_number( $item_number ) { + $escaped = WBC_API_Client::escape_odata_string( $item_number ); + + $result = WBC_API_Client::odata_get( 'ReferenciasArticulo', array( + '$filter' => "Item_No eq '" . $escaped . "'", + '$select' => 'Item_No,Reference_No,Reference_Type', + '$top' => 1, + ) ); + + if ( is_wp_error( $result ) ) { + return false; + } + + $entries = isset( $result['value'] ) ? $result['value'] : array(); + + if ( empty( $entries ) ) { + return false; + } + + return $entries[0]['Reference_No'] ?? false; + } + /** * Get stock quantity for a specific BC location using ItemByLocation endpoint *