feat: WooCommerce-first sync + location code filter
Reversed the product sync direction: instead of pulling all 60k+ items from BC and matching against WooCommerce (600+ paginated API calls that timeout), now iterates the ~100 WooCommerce products and queries BC for each one by GTIN/item number (1-2 API calls per product). Added Location Code setting (e.g. "ICP") to filter stock by BC location. Uses Item Ledger Entries endpoint to sum per-location stock. Falls back to total inventory if the endpoint is unavailable. Also registered wbc_location_code in sync settings group and uninstall. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
/**
|
||||
* Product Sync from Business Central to WooCommerce
|
||||
*
|
||||
* Iterates WooCommerce products and queries BC for each one (WooCommerce-first).
|
||||
* This is efficient when WooCommerce has far fewer products than BC.
|
||||
*
|
||||
* @package WooBusinessCentral
|
||||
*/
|
||||
|
||||
@@ -17,11 +20,6 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*/
|
||||
class WBC_Product_Sync {
|
||||
|
||||
/**
|
||||
* Items per page for BC API pagination
|
||||
*/
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
* Fields to select from BC items
|
||||
*/
|
||||
@@ -41,7 +39,11 @@ class WBC_Product_Sync {
|
||||
);
|
||||
|
||||
/**
|
||||
* Run the product sync
|
||||
* Run the product sync (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.
|
||||
*
|
||||
* @return array Sync results.
|
||||
*/
|
||||
@@ -64,46 +66,31 @@ class WBC_Product_Sync {
|
||||
);
|
||||
}
|
||||
|
||||
WBC_Logger::info( 'ProductSync', 'Starting product sync' );
|
||||
WBC_Logger::info( 'ProductSync', 'Starting product sync (WooCommerce-first)' );
|
||||
|
||||
$start_time = microtime( true );
|
||||
$skip = 0;
|
||||
$has_more = true;
|
||||
|
||||
// Fetch all items from BC with pagination
|
||||
while ( $has_more ) {
|
||||
$items = $this->fetch_items( $skip );
|
||||
// Get all WooCommerce products that have a SKU
|
||||
$products = $this->get_wc_products_with_sku();
|
||||
|
||||
if ( is_wp_error( $items ) ) {
|
||||
$this->results['errors'][] = $items->get_error_message();
|
||||
WBC_Logger::error( 'ProductSync', 'Failed to fetch items from BC', array(
|
||||
'error' => $items->get_error_message(),
|
||||
'skip' => $skip,
|
||||
) );
|
||||
break;
|
||||
}
|
||||
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' ),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we have items
|
||||
$item_list = isset( $items['value'] ) ? $items['value'] : array();
|
||||
WBC_Logger::info( 'ProductSync', 'Found WooCommerce products to sync', array(
|
||||
'count' => count( $products ),
|
||||
) );
|
||||
|
||||
if ( empty( $item_list ) ) {
|
||||
$has_more = false;
|
||||
break;
|
||||
}
|
||||
// Process each WooCommerce product
|
||||
foreach ( $products as $product ) {
|
||||
$this->process_wc_product( $product );
|
||||
|
||||
// Process each item
|
||||
foreach ( $item_list as $item ) {
|
||||
$this->process_item( $item );
|
||||
}
|
||||
|
||||
// Check for more pages
|
||||
$skip += self::ITEMS_PER_PAGE;
|
||||
$has_more = count( $item_list ) >= self::ITEMS_PER_PAGE;
|
||||
|
||||
// Add a small delay to avoid rate limiting
|
||||
if ( $has_more ) {
|
||||
usleep( 100000 ); // 100ms
|
||||
}
|
||||
// Small delay between API calls to avoid rate limiting
|
||||
usleep( 50000 ); // 50ms
|
||||
}
|
||||
|
||||
$duration = round( microtime( true ) - $start_time, 2 );
|
||||
@@ -119,7 +106,8 @@ class WBC_Product_Sync {
|
||||
return array(
|
||||
'success' => empty( $this->results['errors'] ),
|
||||
'message' => sprintf(
|
||||
__( 'Sync completed in %s seconds. Success: %d, Failed: %d, Skipped: %d', 'woo-business-central' ),
|
||||
/* 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'],
|
||||
@@ -130,58 +118,68 @@ class WBC_Product_Sync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch items from Business Central
|
||||
* Get all WooCommerce products that have a SKU set
|
||||
*
|
||||
* @param int $skip Number of items to skip.
|
||||
* @return array|WP_Error Items data or error.
|
||||
* @return WC_Product[] Array of WooCommerce products.
|
||||
*/
|
||||
private function fetch_items( $skip = 0 ) {
|
||||
return WBC_API_Client::get_items( self::ITEMS_PER_PAGE, $skip, self::SELECT_FIELDS );
|
||||
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 BC item
|
||||
* Process a single WooCommerce product - find it in BC and sync
|
||||
*
|
||||
* @param array $item BC item data.
|
||||
* @param WC_Product $product WooCommerce product.
|
||||
*/
|
||||
private function process_item( $item ) {
|
||||
private function process_wc_product( $product ) {
|
||||
$this->results['total']++;
|
||||
|
||||
// Get item number and GTIN
|
||||
$item_number = isset( $item['number'] ) ? $item['number'] : '';
|
||||
$gtin = isset( $item['gtin'] ) ? $item['gtin'] : '';
|
||||
$sku = $product->get_sku();
|
||||
$product_id = $product->get_id();
|
||||
|
||||
if ( empty( $item_number ) && empty( $gtin ) ) {
|
||||
$this->results['skipped']++;
|
||||
WBC_Logger::debug( 'ProductSync', 'Skipping item without number or GTIN', array( 'item' => $item ) );
|
||||
return;
|
||||
}
|
||||
// Query BC for this item by GTIN (since SKU = EAN = GTIN)
|
||||
$bc_item = $this->find_bc_item_by_sku( $sku );
|
||||
|
||||
// Try to find WooCommerce product
|
||||
$product_id = $this->find_wc_product( $item_number, $gtin );
|
||||
|
||||
if ( ! $product_id ) {
|
||||
$this->results['skipped']++;
|
||||
WBC_Logger::debug( 'ProductSync', 'No matching WC product found', array(
|
||||
'item_number' => $item_number,
|
||||
'gtin' => $gtin,
|
||||
) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the product
|
||||
$product = wc_get_product( $product_id );
|
||||
|
||||
if ( ! $product ) {
|
||||
$this->results['skipped']++;
|
||||
WBC_Logger::warning( 'ProductSync', 'Product ID found but product not loaded', array(
|
||||
if ( is_wp_error( $bc_item ) ) {
|
||||
$this->results['failed']++;
|
||||
$this->results['errors'][] = sprintf( 'Product %d (SKU: %s): %s', $product_id, $sku, $bc_item->get_error_message() );
|
||||
WBC_Logger::error( 'ProductSync', 'API error looking up product in BC', array(
|
||||
'product_id' => $product_id,
|
||||
'sku' => $sku,
|
||||
'error' => $bc_item->get_error_message(),
|
||||
) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the product
|
||||
$updated = $this->update_product( $product, $item );
|
||||
if ( ! $bc_item ) {
|
||||
$this->results['skipped']++;
|
||||
WBC_Logger::debug( 'ProductSync', 'No matching BC item found for WC product', array(
|
||||
'product_id' => $product_id,
|
||||
'sku' => $sku,
|
||||
) );
|
||||
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( $bc_item, $location_code );
|
||||
if ( $location_stock !== false ) {
|
||||
$bc_item['inventory'] = $location_stock;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the WooCommerce product with BC data
|
||||
$updated = $this->update_product( $product, $bc_item );
|
||||
|
||||
if ( $updated ) {
|
||||
$this->results['success']++;
|
||||
@@ -191,32 +189,118 @@ class WBC_Product_Sync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find WooCommerce product by SKU (item number or GTIN)
|
||||
* Find a BC item by WooCommerce SKU (tries GTIN first, then item number)
|
||||
*
|
||||
* @param string $item_number BC item number.
|
||||
* @param string $gtin BC GTIN/EAN.
|
||||
* @return int|false Product ID or false if not found.
|
||||
* @param string $sku WooCommerce product SKU (which is the EAN code).
|
||||
* @return array|false|WP_Error BC item data, false if not found, or WP_Error.
|
||||
*/
|
||||
private function find_wc_product( $item_number, $gtin ) {
|
||||
// First try to match by item number (SKU)
|
||||
if ( ! empty( $item_number ) ) {
|
||||
$product_id = wc_get_product_id_by_sku( $item_number );
|
||||
if ( $product_id ) {
|
||||
return $product_id;
|
||||
}
|
||||
private function find_bc_item_by_sku( $sku ) {
|
||||
$escaped_sku = WBC_API_Client::escape_odata_string( $sku );
|
||||
|
||||
// Try by GTIN first (since SKU = EAN = GTIN in this setup)
|
||||
$result = WBC_API_Client::get( '/items', array(
|
||||
'$filter' => "gtin eq '" . $escaped_sku . "'",
|
||||
'$select' => self::SELECT_FIELDS,
|
||||
'$top' => 1,
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Fall back to GTIN match
|
||||
if ( ! empty( $gtin ) ) {
|
||||
$product_id = wc_get_product_id_by_sku( $gtin );
|
||||
if ( $product_id ) {
|
||||
return $product_id;
|
||||
}
|
||||
if ( ! empty( $result['value'] ) ) {
|
||||
return $result['value'][0];
|
||||
}
|
||||
|
||||
// Fall back to item number match
|
||||
$result = WBC_API_Client::get( '/items', array(
|
||||
'$filter' => "number eq '" . $escaped_sku . "'",
|
||||
'$select' => self::SELECT_FIELDS,
|
||||
'$top' => 1,
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( ! empty( $result['value'] ) ) {
|
||||
return $result['value'][0];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock quantity for a specific BC location
|
||||
*
|
||||
* Uses the OData web services endpoint for Item Ledger Entries.
|
||||
* Requires the BC admin to publish "Item Ledger Entries" (Table 32)
|
||||
* as an OData v4 web service named "ItemLedgerEntries".
|
||||
*
|
||||
* Falls back to the item's total inventory if the OData call fails.
|
||||
*
|
||||
* @param array $bc_item BC item data.
|
||||
* @param string $location_code BC location code (e.g. 'ICP').
|
||||
* @return float|false Stock quantity at location, or false to use default.
|
||||
*/
|
||||
private function get_location_stock( $bc_item, $location_code ) {
|
||||
$item_number = $bc_item['number'] ?? '';
|
||||
|
||||
if ( empty( $item_number ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build OData web services URL for Item Ledger Entries
|
||||
$environment = WBC_OAuth::get_environment();
|
||||
$company_id = WBC_OAuth::get_company_id();
|
||||
$escaped_number = WBC_API_Client::escape_odata_string( $item_number );
|
||||
$escaped_location = WBC_API_Client::escape_odata_string( $location_code );
|
||||
|
||||
// Query Item Ledger Entries filtered by item number and location
|
||||
// Sum the "quantity" field to get current stock at this location
|
||||
$endpoint = '/itemLedgerEntries';
|
||||
$params = array(
|
||||
'$filter' => "itemNumber eq '" . $escaped_number . "' and locationCode eq '" . $escaped_location . "'",
|
||||
'$select' => 'quantity',
|
||||
);
|
||||
|
||||
$result = WBC_API_Client::get( $endpoint, $params );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
WBC_Logger::warning( 'ProductSync', 'Failed to get location stock, using total inventory', array(
|
||||
'item_number' => $item_number,
|
||||
'location_code' => $location_code,
|
||||
'error' => $result->get_error_message(),
|
||||
) );
|
||||
return false;
|
||||
}
|
||||
|
||||
$entries = isset( $result['value'] ) ? $result['value'] : array();
|
||||
|
||||
if ( empty( $entries ) ) {
|
||||
// No ledger entries for this location = 0 stock
|
||||
WBC_Logger::debug( 'ProductSync', 'No ledger entries for location', array(
|
||||
'item_number' => $item_number,
|
||||
'location_code' => $location_code,
|
||||
) );
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Sum all quantities (entries can be positive or negative)
|
||||
$total_qty = 0.0;
|
||||
foreach ( $entries as $entry ) {
|
||||
$total_qty += (float) ( $entry['quantity'] ?? 0 );
|
||||
}
|
||||
|
||||
WBC_Logger::debug( 'ProductSync', 'Got location-specific stock', array(
|
||||
'item_number' => $item_number,
|
||||
'location_code' => $location_code,
|
||||
'stock' => $total_qty,
|
||||
) );
|
||||
|
||||
return $total_qty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update WooCommerce product with BC data
|
||||
*
|
||||
@@ -226,25 +310,26 @@ class WBC_Product_Sync {
|
||||
*/
|
||||
private function update_product( $product, $item ) {
|
||||
$product_id = $product->get_id();
|
||||
$updated = false;
|
||||
|
||||
try {
|
||||
$changed = false;
|
||||
|
||||
// Update stock if enabled
|
||||
if ( get_option( 'wbc_enable_stock_sync', 'yes' ) === 'yes' ) {
|
||||
$stock = isset( $item['inventory'] ) ? (float) $item['inventory'] : 0;
|
||||
$current_stock = (float) $product->get_stock_quantity();
|
||||
|
||||
if ( $stock !== $current_stock ) {
|
||||
// Enable stock management if not already enabled
|
||||
if ( ! $product->get_manage_stock() ) {
|
||||
$product->set_manage_stock( true );
|
||||
}
|
||||
|
||||
wc_update_product_stock( $product, $stock );
|
||||
$updated = true;
|
||||
$changed = true;
|
||||
|
||||
WBC_Logger::debug( 'ProductSync', 'Updated stock', array(
|
||||
'product_id' => $product_id,
|
||||
'sku' => $product->get_sku(),
|
||||
'old_stock' => $current_stock,
|
||||
'new_stock' => $stock,
|
||||
) );
|
||||
@@ -259,29 +344,36 @@ class WBC_Product_Sync {
|
||||
if ( $price > 0 && $price !== $current_price ) {
|
||||
$product->set_regular_price( $price );
|
||||
|
||||
// Also update sale price if it's higher than the new regular price
|
||||
// Clear sale price if it's now >= the new regular price
|
||||
$sale_price = (float) $product->get_sale_price();
|
||||
if ( $sale_price > 0 && $sale_price >= $price ) {
|
||||
$product->set_sale_price( '' );
|
||||
}
|
||||
|
||||
$product->save();
|
||||
$updated = true;
|
||||
$changed = true;
|
||||
|
||||
WBC_Logger::debug( 'ProductSync', 'Updated price', array(
|
||||
'product_id' => $product_id,
|
||||
'sku' => $product->get_sku(),
|
||||
'old_price' => $current_price,
|
||||
'new_price' => $price,
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
// Store BC item info in meta via WooCommerce CRUD
|
||||
// Store BC item info in meta
|
||||
$product->update_meta_data( '_wbc_bc_item_id', $item['id'] ?? '' );
|
||||
$product->update_meta_data( '_wbc_bc_item_number', $item['number'] ?? '' );
|
||||
$product->update_meta_data( '_wbc_last_sync', current_time( 'mysql' ) );
|
||||
$product->save();
|
||||
|
||||
if ( ! $changed ) {
|
||||
WBC_Logger::debug( 'ProductSync', 'Product already up to date', array(
|
||||
'product_id' => $product_id,
|
||||
'sku' => $product->get_sku(),
|
||||
) );
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch ( Exception $e ) {
|
||||
@@ -328,33 +420,39 @@ class WBC_Product_Sync {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch item from BC
|
||||
$result = WBC_API_Client::get_item_by_number( $sku );
|
||||
// Find item in BC by SKU
|
||||
$bc_item = $this->find_bc_item_by_sku( $sku );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
if ( is_wp_error( $bc_item ) ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
'message' => $bc_item->get_error_message(),
|
||||
);
|
||||
}
|
||||
|
||||
$items = isset( $result['value'] ) ? $result['value'] : array();
|
||||
|
||||
if ( empty( $items ) ) {
|
||||
if ( ! $bc_item ) {
|
||||
return array(
|
||||
'success' => false,
|
||||
'message' => __( 'Item not found in Business Central.', 'woo-business-central' ),
|
||||
);
|
||||
}
|
||||
|
||||
$item = $items[0];
|
||||
$updated = $this->update_product( $product, $item );
|
||||
// Check location stock
|
||||
$location_code = get_option( 'wbc_location_code', '' );
|
||||
if ( ! empty( $location_code ) ) {
|
||||
$location_stock = $this->get_location_stock( $bc_item, $location_code );
|
||||
if ( $location_stock !== false ) {
|
||||
$bc_item['inventory'] = $location_stock;
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $this->update_product( $product, $bc_item );
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => $updated
|
||||
? __( 'Product updated successfully.', 'woo-business-central' )
|
||||
: __( 'Product is already up to date.', 'woo-business-central' ),
|
||||
? __( 'Product synced successfully.', 'woo-business-central' )
|
||||
: __( 'Product sync failed.', 'woo-business-central' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user