Files
WooBC/woo-business-central/includes/class-wbc-product-sync.php
Malin 2495b82e66 feat: integrate custom OData endpoints for stock and price sync
Replace broken itemLedgerEntries approach with custom ItemByLocation
OData V4 endpoint for location-specific stock. Add ListaPrecios
endpoint for price list sync (B2C regular, B2C_OF sale price) with
filters for active status and all-customers assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:14:15 +01:00

545 lines
20 KiB
PHP

<?php
/**
* 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
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class WBC_Product_Sync
*
* Handles syncing stock and pricing from Business Central to WooCommerce.
*/
class WBC_Product_Sync {
/**
* Fields to select from BC items
*/
const SELECT_FIELDS = 'id,number,gtin,displayName,unitPrice,inventory';
/**
* Sync results
*
* @var array
*/
private $results = array(
'success' => 0,
'failed' => 0,
'skipped' => 0,
'total' => 0,
'errors' => array(),
);
/**
* 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.
*/
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' ),
);
}
// 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' ),
);
}
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,
);
}
/**
* 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
*
* @param WC_Product $product WooCommerce product.
*/
private function process_wc_product( $product ) {
$this->results['total']++;
$sku = $product->get_sku();
$product_id = $product->get_id();
// Query BC for this item by GTIN (since SKU = EAN = GTIN)
$bc_item = $this->find_bc_item_by_sku( $sku );
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;
}
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']++;
} else {
$this->results['failed']++;
}
}
/**
* Find a BC item by WooCommerce SKU (tries GTIN first, then item number)
*
* @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_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;
}
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 using ItemByLocation endpoint
*
* Uses the custom OData V4 endpoint "ItemByLocation" which returns
* No, Description, Location_Code, Remaining_Quantity.
*
* 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;
}
$escaped_number = WBC_API_Client::escape_odata_string( $item_number );
$escaped_location = WBC_API_Client::escape_odata_string( $location_code );
$result = WBC_API_Client::odata_get( 'ItemByLocation', array(
'$filter' => "No eq '" . $escaped_number . "' and Location_Code eq '" . $escaped_location . "'",
'$select' => 'Remaining_Quantity',
) );
if ( is_wp_error( $result ) ) {
WBC_Logger::warning( 'ProductSync', 'Failed to get location stock from ItemByLocation, 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 ) ) {
WBC_Logger::debug( 'ProductSync', 'No ItemByLocation entry for item/location', array(
'item_number' => $item_number,
'location_code' => $location_code,
) );
return 0.0;
}
// Should return a single row for item + location
$qty = (float) ( $entries[0]['Remaining_Quantity'] ?? 0 );
WBC_Logger::debug( 'ProductSync', 'Got location-specific stock from ItemByLocation', array(
'item_number' => $item_number,
'location_code' => $location_code,
'stock' => $qty,
) );
return $qty;
}
/**
* Get price from ListaPrecios custom endpoint
*
* Filters by: Assign-to Type = All Customers, Status = Active,
* Price List Code = specified code, and Product No. = item number.
*
* @param string $item_number BC item number.
* @param string $price_list_code Price list code (e.g. 'B2C').
* @return float|false Unit price or false if not found.
*/
private function get_price_from_list( $item_number, $price_list_code ) {
if ( empty( $item_number ) || empty( $price_list_code ) ) {
return false;
}
$escaped_number = WBC_API_Client::escape_odata_string( $item_number );
$escaped_code = WBC_API_Client::escape_odata_string( $price_list_code );
$result = WBC_API_Client::odata_get( 'ListaPrecios', array(
'$filter' => "Price_List_Code eq '" . $escaped_code . "'"
. " and Status eq 'Active'"
. " and Assign_to_Type eq 'All Customers'"
. " and Product_No eq '" . $escaped_number . "'",
'$select' => 'Unit_Price',
'$top' => 1,
) );
if ( is_wp_error( $result ) ) {
WBC_Logger::warning( 'ProductSync', 'Failed to get price from ListaPrecios', array(
'item_number' => $item_number,
'price_list_code' => $price_list_code,
'error' => $result->get_error_message(),
) );
return false;
}
$entries = isset( $result['value'] ) ? $result['value'] : array();
if ( empty( $entries ) ) {
WBC_Logger::debug( 'ProductSync', 'No price list entry found', array(
'item_number' => $item_number,
'price_list_code' => $price_list_code,
) );
return false;
}
$price = (float) ( $entries[0]['Unit_Price'] ?? 0 );
WBC_Logger::debug( 'ProductSync', 'Got price from ListaPrecios', array(
'item_number' => $item_number,
'price_list_code' => $price_list_code,
'price' => $price,
) );
return $price;
}
/**
* Update WooCommerce product with BC data
*
* @param WC_Product $product WooCommerce product.
* @param array $item BC item data.
* @return bool Whether the product was updated.
*/
private function update_product( $product, $item ) {
$product_id = $product->get_id();
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 ) {
if ( ! $product->get_manage_stock() ) {
$product->set_manage_stock( true );
}
wc_update_product_stock( $product, $stock );
$changed = true;
WBC_Logger::debug( 'ProductSync', 'Updated stock', array(
'product_id' => $product_id,
'sku' => $product->get_sku(),
'old_stock' => $current_stock,
'new_stock' => $stock,
) );
}
}
// Update prices if enabled
if ( get_option( 'wbc_enable_price_sync', 'yes' ) === 'yes' ) {
$item_number = $item['number'] ?? '';
$regular_list = get_option( 'wbc_regular_price_list', '' );
$sale_list = get_option( 'wbc_sale_price_list', '' );
// Get regular price from price list, or fall back to item unitPrice
if ( ! empty( $regular_list ) && ! empty( $item_number ) ) {
$price = $this->get_price_from_list( $item_number, $regular_list );
if ( $price === false ) {
$price = isset( $item['unitPrice'] ) ? (float) $item['unitPrice'] : 0;
}
} else {
$price = isset( $item['unitPrice'] ) ? (float) $item['unitPrice'] : 0;
}
$current_price = (float) $product->get_regular_price();
if ( $price > 0 && $price !== $current_price ) {
$product->set_regular_price( $price );
$changed = true;
WBC_Logger::debug( 'ProductSync', 'Updated regular price', array(
'product_id' => $product_id,
'sku' => $product->get_sku(),
'old_price' => $current_price,
'new_price' => $price,
'source' => ! empty( $regular_list ) ? 'ListaPrecios:' . $regular_list : 'unitPrice',
) );
}
// Get sale price from price list
if ( ! empty( $sale_list ) && ! empty( $item_number ) ) {
$sale_price = $this->get_price_from_list( $item_number, $sale_list );
if ( $sale_price !== false && $sale_price > 0 ) {
$current_sale = (float) $product->get_sale_price();
// Only set sale price if it's less than the regular price
if ( $sale_price < $price && $sale_price !== $current_sale ) {
$product->set_sale_price( $sale_price );
$changed = true;
WBC_Logger::debug( 'ProductSync', 'Updated sale price', array(
'product_id' => $product_id,
'sku' => $product->get_sku(),
'old_sale' => $current_sale,
'new_sale' => $sale_price,
'source' => 'ListaPrecios:' . $sale_list,
) );
}
} else {
// No active sale price found - clear any existing sale price
if ( ! empty( $product->get_sale_price() ) ) {
$product->set_sale_price( '' );
$changed = true;
WBC_Logger::debug( 'ProductSync', 'Cleared sale price (no active entry in list)', array(
'product_id' => $product_id,
'sku' => $product->get_sku(),
'sale_list' => $sale_list,
) );
}
}
}
}
// 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 ) {
WBC_Logger::error( 'ProductSync', 'Failed to update product', array(
'product_id' => $product_id,
'error' => $e->getMessage(),
) );
$this->results['errors'][] = sprintf( 'Product %d: %s', $product_id, $e->getMessage() );
return false;
}
}
/**
* Get sync results
*
* @return array
*/
public function get_results() {
return $this->results;
}
/**
* Sync a single product by WooCommerce product ID
*
* @param int $product_id WooCommerce product ID.
* @return array Sync result.
*/
public function sync_single_product( $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return array(
'success' => false,
'message' => __( 'Product not found.', 'woo-business-central' ),
);
}
$sku = $product->get_sku();
if ( empty( $sku ) ) {
return array(
'success' => false,
'message' => __( 'Product has no SKU.', 'woo-business-central' ),
);
}
// Find item in BC by SKU
$bc_item = $this->find_bc_item_by_sku( $sku );
if ( is_wp_error( $bc_item ) ) {
return array(
'success' => false,
'message' => $bc_item->get_error_message(),
);
}
if ( ! $bc_item ) {
return array(
'success' => false,
'message' => __( 'Item not found in Business Central.', 'woo-business-central' ),
);
}
// 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 synced successfully.', 'woo-business-central' )
: __( 'Product sync failed.', 'woo-business-central' ),
);
}
}