2026-02-17 09:59:53 +01:00
< ? php
/**
* Product Sync from Business Central to WooCommerce
*
2026-02-17 10:26:02 +01:00
* Iterates WooCommerce products and queries BC for each one ( WooCommerce - first ) .
* This is efficient when WooCommerce has far fewer products than BC .
*
2026-02-17 09:59:53 +01:00
* @ 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
*/
2026-07-09 11:39:11 +02:00
const SELECT_FIELDS = 'id,number,gtin,displayName,unitPrice,inventory,itemCategoryCode' ;
/**
* In - memory cache of category code => info ( description , parent_code ) for this run
*
* @ var array
*/
private $category_cache = array ();
2026-02-17 09:59:53 +01:00
/**
* Sync results
*
* @ var array
*/
private $results = array (
'success' => 0 ,
'failed' => 0 ,
'skipped' => 0 ,
'total' => 0 ,
'errors' => array (),
);
/**
2026-07-09 13:19:11 +02:00
* Process a batch of WooCommerce products by ID ( WooCommerce - first approach )
2026-02-17 10:26:02 +01:00
*
* Instead of pulling all 60 k + items from BC , we iterate WooCommerce
2026-07-09 13:19:11 +02:00
* products and query BC for each one by reference / GTIN / number . This is
* called per - batch by WBC_Batch_Sync so a single request never has to
* process the whole catalog ( which would exceed PHP / proxy timeouts at
* real - world catalog sizes ) .
2026-02-17 09:59:53 +01:00
*
2026-07-09 13:19:11 +02:00
* @ param int [] $product_ids WooCommerce product IDs to process .
* @ return array Sync results for this batch .
2026-02-17 09:59:53 +01:00
*/
2026-07-09 13:19:11 +02:00
public function run_sync_for_ids ( array $product_ids ) {
foreach ( $product_ids as $product_id ) {
$product = wc_get_product ( $product_id );
2026-02-17 09:59:53 +01:00
2026-07-09 13:19:11 +02:00
if ( ! $product || empty ( $product -> get_sku () ) ) {
continue ;
}
2026-02-17 09:59:53 +01:00
2026-02-17 10:26:02 +01:00
$this -> process_wc_product ( $product );
2026-02-17 09:59:53 +01:00
2026-02-17 10:26:02 +01:00
// Small delay between API calls to avoid rate limiting
usleep ( 50000 ); // 50ms
2026-02-17 09:59:53 +01:00
}
return array (
2026-07-09 13:19:11 +02:00
'success' => empty ( $this -> results [ 'errors' ] ),
'results' => $this -> results ,
2026-02-17 09:59:53 +01:00
);
}
/**
2026-02-17 10:26:02 +01:00
* Process a single WooCommerce product - find it in BC and sync
2026-02-17 09:59:53 +01:00
*
2026-02-17 10:26:02 +01:00
* @ param WC_Product $product WooCommerce product .
2026-02-17 09:59:53 +01:00
*/
2026-02-17 10:26:02 +01:00
private function process_wc_product ( $product ) {
2026-02-17 09:59:53 +01:00
$this -> results [ 'total' ] ++ ;
2026-02-17 10:26:02 +01:00
$sku = $product -> get_sku ();
$product_id = $product -> get_id ();
2026-02-17 09:59:53 +01:00
2026-02-17 10:26:02 +01:00
// Query BC for this item by GTIN (since SKU = EAN = GTIN)
$bc_item = $this -> find_bc_item_by_sku ( $sku );
2026-02-17 09:59:53 +01:00
2026-02-17 10:26:02 +01:00
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 (),
2026-02-17 09:59:53 +01:00
) );
return ;
}
2026-02-17 10:26:02 +01:00
if ( ! $bc_item ) {
2026-02-17 09:59:53 +01:00
$this -> results [ 'skipped' ] ++ ;
2026-02-17 10:26:02 +01:00
WBC_Logger :: debug ( 'ProductSync' , 'No matching BC item found for WC product' , array (
2026-02-17 09:59:53 +01:00
'product_id' => $product_id ,
2026-02-17 10:26:02 +01:00
'sku' => $sku ,
2026-02-17 09:59:53 +01:00
) );
return ;
}
2026-07-09 11:39:11 +02:00
if ( ! $this -> is_category_allowed ( $bc_item ) ) {
$this -> results [ 'skipped' ] ++ ;
WBC_Logger :: debug ( 'ProductSync' , 'Item category not in allow-list, skipping' , array (
'product_id' => $product_id ,
'sku' => $sku ,
'category' => $bc_item [ 'itemCategoryCode' ] ? ? '' ,
) );
return ;
}
2026-02-17 10:26:02 +01:00
// 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 );
2026-02-17 09:59:53 +01:00
if ( $updated ) {
$this -> results [ 'success' ] ++ ;
} else {
$this -> results [ 'failed' ] ++ ;
}
}
/**
2026-07-09 11:39:11 +02:00
* Find a BC item by WooCommerce SKU
*
2026-07-09 14:30:51 +02:00
* 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 .
2026-02-17 09:59:53 +01:00
*
2026-07-09 11:39:11 +02:00
* @ param string $sku WooCommerce product SKU .
2026-02-17 10:26:02 +01:00
* @ return array | false | WP_Error BC item data , false if not found , or WP_Error .
2026-02-17 09:59:53 +01:00
*/
2026-02-17 10:26:02 +01:00
private function find_bc_item_by_sku ( $sku ) {
$escaped_sku = WBC_API_Client :: escape_odata_string ( $sku );
2026-07-09 11:39:11 +02:00
// Try by GTIN (since SKU = EAN = GTIN in this setup)
2026-02-17 10:26:02 +01:00
$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 ;
2026-02-17 09:59:53 +01:00
}
2026-02-17 10:26:02 +01:00
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 ];
2026-02-17 09:59:53 +01:00
}
2026-07-09 14:30:51 +02:00
// 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 ];
}
}
2026-02-17 09:59:53 +01:00
return false ;
}
2026-07-09 11:39:11 +02:00
/**
* Look up an item number by cross - reference code using the custom
* ReferenciasArticulo OData endpoint ( exposes Item_No , Reference_No , Reference_Type ) .
*
* @ param string $reference_no The reference / cross - reference code ( WooCommerce SKU ) .
* @ return string | false | WP_Error Item number , false if no match , or WP_Error .
*/
private function find_item_number_by_reference ( $reference_no ) {
$escaped = WBC_API_Client :: escape_odata_string ( $reference_no );
$result = WBC_API_Client :: odata_get ( 'ReferenciasArticulo' , array (
'$filter' => " Reference_No eq ' " . $escaped . " ' " ,
'$select' => 'Item_No,Reference_No,Reference_Type' ,
'$top' => 1 ,
) );
if ( is_wp_error ( $result ) ) {
WBC_Logger :: warning ( 'ProductSync' , 'Failed to query ReferenciasArticulo, continuing with GTIN/number lookup' , array (
'reference_no' => $reference_no ,
'error' => $result -> get_error_message (),
) );
return false ;
}
$entries = isset ( $result [ 'value' ] ) ? $result [ 'value' ] : array ();
if ( empty ( $entries ) ) {
return false ;
}
return $entries [ 0 ][ 'Item_No' ] ? ? false ;
}
/**
* Check whether an item ' s category passes the configured allow - list filter
*
* @ param array $item BC item data .
* @ return bool True if allowed ( or no filter configured ) .
*/
private function is_category_allowed ( $item ) {
$filter = get_option ( 'wbc_category_filter' , '' );
if ( empty ( trim ( $filter ) ) ) {
return true ;
}
$allowed = array_filter ( array_map ( 'trim' , explode ( ',' , $filter ) ) );
if ( empty ( $allowed ) ) {
return true ;
}
$item_category = $item [ 'itemCategoryCode' ] ? ? '' ;
return in_array ( $item_category , $allowed , true );
}
2026-07-09 14:30:51 +02:00
/**
* 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 );
2026-07-09 14:48:15 +02:00
$is_new = false ;
if ( ! $product && get_option ( 'wbc_create_missing_products' , 'no' ) === 'yes' ) {
$product = $this -> create_wc_product_from_item ( $item );
$is_new = ( bool ) $product ;
}
2026-07-09 14:30:51 +02:00
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' ] ++ ;
2026-07-09 14:48:15 +02:00
if ( $is_new ) {
WBC_Logger :: info ( 'ProductSync' , 'Created new WooCommerce product from BC item' , array (
'product_id' => $product -> get_id (),
'item_number' => $item [ 'number' ] ? ? '' ,
'sku' => $product -> get_sku (),
) );
}
2026-07-09 14:30:51 +02:00
} else {
$this -> results [ 'failed' ] ++ ;
}
}
2026-07-09 14:48:15 +02:00
/**
* Create a new WooCommerce product from a BC item
*
* Bare creation only ( name , SKU , status ) - stock / price / brand are filled
* in right after by the normal update_product () call , so that logic
* isn ' t duplicated here .
*
* @ param array $item BC item data .
* @ return WC_Product | false
*/
private function create_wc_product_from_item ( $item ) {
$sku = ! empty ( $item [ 'gtin' ] ) ? $item [ 'gtin' ] : ( $item [ 'number' ] ? ? '' );
if ( empty ( $sku ) ) {
return false ;
}
$product = new WC_Product_Simple ();
$product -> set_name ( $item [ 'displayName' ] ? ? $sku );
try {
$product -> set_sku ( $sku );
$product -> set_status ( get_option ( 'wbc_new_product_status' , 'draft' ) === 'publish' ? 'publish' : 'draft' );
$product -> save ();
} catch ( Exception $e ) {
WBC_Logger :: error ( 'ProductSync' , 'Failed to create WooCommerce product from BC item' , array (
'item_number' => $item [ 'number' ] ? ? '' ,
'sku' => $sku ,
'error' => $e -> getMessage (),
) );
return false ;
}
return $product ;
}
2026-07-09 14:30:51 +02:00
/**
* 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 ;
}
2026-02-17 10:26:02 +01:00
/**
2026-02-17 13:14:15 +01:00
* Get stock quantity for a specific BC location using ItemByLocation endpoint
2026-02-17 10:26:02 +01:00
*
2026-02-17 13:14:15 +01:00
* Uses the custom OData V4 endpoint " ItemByLocation " which returns
* No , Description , Location_Code , Remaining_Quantity .
2026-02-17 10:26:02 +01:00
*
* 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 );
2026-02-17 13:14:15 +01:00
$result = WBC_API_Client :: odata_get ( 'ItemByLocation' , array (
'$filter' => " No eq ' " . $escaped_number . " ' and Location_Code eq ' " . $escaped_location . " ' " ,
'$select' => 'Remaining_Quantity' ,
) );
2026-02-17 10:26:02 +01:00
if ( is_wp_error ( $result ) ) {
2026-02-17 13:14:15 +01:00
WBC_Logger :: warning ( 'ProductSync' , 'Failed to get location stock from ItemByLocation, using total inventory' , array (
2026-02-17 10:26:02 +01:00
'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 ) ) {
2026-02-17 13:14:15 +01:00
WBC_Logger :: debug ( 'ProductSync' , 'No ItemByLocation entry for item/location' , array (
2026-02-17 10:26:02 +01:00
'item_number' => $item_number ,
'location_code' => $location_code ,
) );
return 0.0 ;
}
2026-02-17 13:14:15 +01:00
// Should return a single row for item + location
$qty = ( float ) ( $entries [ 0 ][ 'Remaining_Quantity' ] ? ? 0 );
2026-02-17 10:26:02 +01:00
2026-02-17 13:14:15 +01:00
WBC_Logger :: debug ( 'ProductSync' , 'Got location-specific stock from ItemByLocation' , array (
2026-02-17 10:26:02 +01:00
'item_number' => $item_number ,
'location_code' => $location_code ,
2026-02-17 13:14:15 +01:00
'stock' => $qty ,
2026-02-17 10:26:02 +01:00
) );
2026-02-17 13:14:15 +01:00
return $qty ;
}
/**
* Get price from ListaPrecios custom endpoint
*
2026-02-17 13:40:31 +01:00
* Filters by : Price List Code , Status = Activo , and Product No . = item number .
2026-02-17 13:14:15 +01:00
*
* @ 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 . " ' "
2026-02-17 13:48:04 +01:00
. " and Status eq 'Active' "
2026-02-17 13:45:47 +01:00
. " and Asset_No eq ' " . $escaped_number . " ' " ,
2026-02-17 13:14:15 +01:00
'$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 ;
2026-02-17 10:26:02 +01:00
}
2026-02-17 09:59:53 +01:00
/**
* 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 {
2026-02-17 10:26:02 +01:00
$changed = false ;
2026-02-17 09:59:53 +01:00
// 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 );
2026-02-17 10:26:02 +01:00
$changed = true ;
2026-02-17 09:59:53 +01:00
WBC_Logger :: debug ( 'ProductSync' , 'Updated stock' , array (
'product_id' => $product_id ,
2026-02-17 10:26:02 +01:00
'sku' => $product -> get_sku (),
2026-02-17 09:59:53 +01:00
'old_stock' => $current_stock ,
'new_stock' => $stock ,
) );
}
}
2026-02-17 13:14:15 +01:00
// Update prices if enabled
2026-02-17 09:59:53 +01:00
if ( get_option ( 'wbc_enable_price_sync' , 'yes' ) === 'yes' ) {
2026-02-17 13:14:15 +01:00
$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 ;
}
2026-02-17 09:59:53 +01:00
$current_price = ( float ) $product -> get_regular_price ();
if ( $price > 0 && $price !== $current_price ) {
$product -> set_regular_price ( $price );
2026-02-17 10:26:02 +01:00
$changed = true ;
2026-02-17 09:59:53 +01:00
2026-02-17 13:14:15 +01:00
WBC_Logger :: debug ( 'ProductSync' , 'Updated regular price' , array (
2026-02-17 09:59:53 +01:00
'product_id' => $product_id ,
2026-02-17 10:26:02 +01:00
'sku' => $product -> get_sku (),
2026-02-17 09:59:53 +01:00
'old_price' => $current_price ,
'new_price' => $price ,
2026-02-17 13:14:15 +01:00
'source' => ! empty ( $regular_list ) ? 'ListaPrecios:' . $regular_list : 'unitPrice' ,
2026-02-17 09:59:53 +01:00
) );
}
2026-02-17 13:14:15 +01:00
// 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 ,
) );
}
}
}
2026-02-17 09:59:53 +01:00
}
2026-07-09 11:39:11 +02:00
// Sync category/brand if enabled
if ( get_option ( 'wbc_enable_brand_sync' , 'no' ) === 'yes' ) {
if ( $this -> sync_category_and_brand ( $product , $item ) ) {
$changed = true ;
}
}
2026-02-17 10:26:02 +01:00
// Store BC item info in meta
2026-02-17 09:59:53 +01:00
$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 ();
2026-02-17 10:26:02 +01:00
if ( ! $changed ) {
WBC_Logger :: debug ( 'ProductSync' , 'Product already up to date' , array (
'product_id' => $product_id ,
'sku' => $product -> get_sku (),
) );
}
2026-02-17 09:59:53 +01:00
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 ;
}
}
2026-07-09 11:39:11 +02:00
/**
2026-07-09 14:41:59 +02:00
* Sync WooCommerce product_brand ( BC item category ) from BC , and its
* WooCommerce parent product category from a manually configured map .
2026-07-09 11:39:11 +02:00
*
2026-07-09 14:41:59 +02:00
* BC ' s item categories here are flat ( e . g . " DAVINES " , " CANTABRIA LABS " )
* with no parent - category field on the Item Category table to walk up
* from brand to a grouping like " Haircare " - confirmed against the
* published CategProd endpoint , which only exposes Code and Description .
* That grouping is maintained in wp - admin instead ( wbc_category_parent_map ) .
2026-07-09 11:39:11 +02:00
*
* @ param WC_Product $product WooCommerce product .
* @ param array $item BC item data .
* @ return bool Whether anything changed .
*/
private function sync_category_and_brand ( $product , $item ) {
$category_code = $item [ 'itemCategoryCode' ] ? ? '' ;
if ( empty ( $category_code ) ) {
return false ;
}
$category_info = $this -> get_category_info ( $category_code );
2026-07-09 14:41:59 +02:00
$brand_name = ( $category_info ? $category_info [ 'description' ] : '' ) ? : $category_code ;
2026-07-09 11:39:11 +02:00
$changed = false ;
if ( taxonomy_exists ( 'product_brand' ) ) {
if ( $this -> set_product_taxonomy_term ( $product -> get_id (), 'product_brand' , $brand_name ) ) {
$changed = true ;
}
}
2026-07-09 14:41:59 +02:00
$parent_name = $this -> get_mapped_parent_category ( $category_code );
2026-07-09 11:39:11 +02:00
2026-07-09 14:41:59 +02:00
if ( ! empty ( $parent_name ) ) {
2026-07-09 11:39:11 +02:00
if ( $this -> set_product_taxonomy_term ( $product -> get_id (), 'product_cat' , $parent_name ) ) {
$changed = true ;
}
}
return $changed ;
}
/**
2026-07-09 14:41:59 +02:00
* Get category info ( description ) from the custom CategProd OData
* endpoint , cached per sync run .
*
* Uses the standard v2 . 0 itemCategories entity ( id , code , displayName ) -
* no custom endpoint needed for this part , since the display name is
* already exposed by the standard API . Only the WooCommerce parent
* category grouping needs the manual map ( see get_mapped_parent_category ()),
* because that ' s the part with no BC equivalent at all .
2026-07-09 11:39:11 +02:00
*
* @ param string $category_code BC item category code .
2026-07-09 14:41:59 +02:00
* @ return array | false Array with 'description' , or false .
2026-07-09 11:39:11 +02:00
*/
private function get_category_info ( $category_code ) {
if ( isset ( $this -> category_cache [ $category_code ] ) ) {
return $this -> category_cache [ $category_code ];
}
2026-07-09 14:41:59 +02:00
$result = WBC_API_Client :: get ( '/itemCategories' , array (
'$filter' => " code eq ' " . WBC_API_Client :: escape_odata_string ( $category_code ) . " ' " ,
'$select' => 'code,displayName' ,
2026-07-09 11:39:11 +02:00
'$top' => 1 ,
) );
if ( is_wp_error ( $result ) ) {
2026-07-09 14:41:59 +02:00
WBC_Logger :: warning ( 'ProductSync' , 'Failed to query itemCategories' , array (
2026-07-09 11:39:11 +02:00
'category_code' => $category_code ,
'error' => $result -> get_error_message (),
) );
$this -> category_cache [ $category_code ] = false ;
return false ;
}
$entries = isset ( $result [ 'value' ] ) ? $result [ 'value' ] : array ();
if ( empty ( $entries ) ) {
$this -> category_cache [ $category_code ] = false ;
return false ;
}
$info = array (
2026-07-09 14:41:59 +02:00
'description' => $entries [ 0 ][ 'displayName' ] ? ? '' ,
2026-07-09 11:39:11 +02:00
);
$this -> category_cache [ $category_code ] = $info ;
return $info ;
}
2026-07-09 14:41:59 +02:00
/**
* Look up the WooCommerce parent product category for a BC category code
* from the manually configured map ( wbc_category_parent_map option :
* one " CODE=Parent Name " pair per line ) .
*
* @ param string $category_code BC item category code .
* @ return string Parent category name , or '' if not mapped .
*/
private function get_mapped_parent_category ( $category_code ) {
$raw = get_option ( 'wbc_category_parent_map' , '' );
if ( empty ( trim ( $raw ) ) ) {
return '' ;
}
foreach ( preg_split ( '/\r\n|\r|\n/' , $raw ) as $line ) {
$line = trim ( $line );
if ( $line === '' || strpos ( $line , '=' ) === false ) {
continue ;
}
list ( $code , $parent_name ) = array_map ( 'trim' , explode ( '=' , $line , 2 ) );
if ( strcasecmp ( $code , $category_code ) === 0 ) {
return $parent_name ;
}
}
return '' ;
}
2026-07-09 11:39:11 +02:00
/**
* Assign a product to a single term in a taxonomy , creating the term if needed .
*
* @ param int $product_id Product ID .
* @ param string $taxonomy Taxonomy name .
* @ param string $term_name Term name .
* @ return bool Whether the assignment changed anything .
*/
private function set_product_taxonomy_term ( $product_id , $taxonomy , $term_name ) {
$existing_terms = wp_get_object_terms ( $product_id , $taxonomy , array ( 'fields' => 'names' ) );
if ( ! is_wp_error ( $existing_terms ) && in_array ( $term_name , $existing_terms , true ) && count ( $existing_terms ) === 1 ) {
return false ;
}
$term = term_exists ( $term_name , $taxonomy );
if ( ! $term ) {
$term = wp_insert_term ( $term_name , $taxonomy );
}
if ( is_wp_error ( $term ) ) {
WBC_Logger :: warning ( 'ProductSync' , 'Failed to create/find taxonomy term' , array (
'taxonomy' => $taxonomy ,
'term' => $term_name ,
'error' => $term -> get_error_message (),
) );
return false ;
}
$term_id = is_array ( $term ) ? ( int ) $term [ 'term_id' ] : ( int ) $term ;
$result = wp_set_object_terms ( $product_id , array ( $term_id ), $taxonomy , false );
return ! is_wp_error ( $result );
}
2026-02-17 09:59:53 +01:00
/**
* 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' ),
);
}
2026-02-17 10:26:02 +01:00
// Find item in BC by SKU
$bc_item = $this -> find_bc_item_by_sku ( $sku );
2026-02-17 09:59:53 +01:00
2026-02-17 10:26:02 +01:00
if ( is_wp_error ( $bc_item ) ) {
2026-02-17 09:59:53 +01:00
return array (
'success' => false ,
2026-02-17 10:26:02 +01:00
'message' => $bc_item -> get_error_message (),
2026-02-17 09:59:53 +01:00
);
}
2026-02-17 10:26:02 +01:00
if ( ! $bc_item ) {
2026-02-17 09:59:53 +01:00
return array (
'success' => false ,
'message' => __ ( 'Item not found in Business Central.' , 'woo-business-central' ),
);
}
2026-07-09 11:39:11 +02:00
if ( ! $this -> is_category_allowed ( $bc_item ) ) {
return array (
'success' => false ,
'message' => __ ( 'Item category is not in the configured sync allow-list.' , 'woo-business-central' ),
);
}
2026-02-17 10:26:02 +01:00
// 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 );
2026-02-17 09:59:53 +01:00
return array (
'success' => true ,
'message' => $updated
2026-02-17 10:26:02 +01:00
? __ ( 'Product synced successfully.' , 'woo-business-central' )
: __ ( 'Product sync failed.' , 'woo-business-central' ),
2026-02-17 09:59:53 +01:00
);
}
2026-07-09 11:39:11 +02:00
/**
* Push a WooCommerce product ' s name and featured image back to Business Central .
*
* On - demand only ( never run automatically ) : PATCHes the matching BC item ' s
* displayName and , if a featured image is set , its picture . BC ' s displayName
* field is limited to 100 characters ( Item . Description is Text [ 100 ]), so the
* WooCommerce product title is used rather than the ( much longer ) description .
*
* @ param int $product_id WooCommerce product ID .
* @ return array Result with 'success' and 'message' .
*/
public function push_product_to_bc ( $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' ),
);
}
$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 || empty ( $bc_item [ 'id' ] ) ) {
return array (
'success' => false ,
'message' => __ ( 'Item not found in Business Central.' , 'woo-business-central' ),
);
}
$item_id = $bc_item [ 'id' ];
$messages = array ();
// Push name -> displayName
$name = substr ( $product -> get_name (), 0 , 100 );
$patch_result = WBC_API_Client :: patch ( '/items(' . $item_id . ')' , array (
'displayName' => $name ,
) );
if ( is_wp_error ( $patch_result ) ) {
WBC_Logger :: error ( 'ProductSync' , 'Failed to push product name to BC' , array (
'product_id' => $product_id ,
'sku' => $sku ,
'error' => $patch_result -> get_error_message (),
) );
return array (
'success' => false ,
'message' => sprintf (
/* translators: %s: error message */
__ ( 'Failed to update name in Business Central: %s' , 'woo-business-central' ),
$patch_result -> get_error_message ()
),
);
}
$messages [] = __ ( 'Name updated in Business Central.' , 'woo-business-central' );
// Push featured image -> picture
$image_id = $product -> get_image_id ();
if ( ! empty ( $image_id ) ) {
$file_path = get_attached_file ( $image_id );
if ( $file_path && file_exists ( $file_path ) ) {
$file_type = wp_check_filetype ( $file_path );
$content_type = ! empty ( $file_type [ 'type' ] ) ? $file_type [ 'type' ] : 'application/octet-stream' ;
$binary = file_get_contents ( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local media library file, not a remote URL.
if ( $binary !== false ) {
$picture_result = WBC_API_Client :: patch_binary (
'/items(' . $item_id . ')/picture/pictureContent' ,
$binary ,
$content_type
);
if ( is_wp_error ( $picture_result ) ) {
WBC_Logger :: error ( 'ProductSync' , 'Failed to push product image to BC' , array (
'product_id' => $product_id ,
'sku' => $sku ,
'error' => $picture_result -> get_error_message (),
) );
$messages [] = sprintf (
/* translators: %s: error message */
__ ( 'Image update failed: %s' , 'woo-business-central' ),
$picture_result -> get_error_message ()
);
} else {
$messages [] = __ ( 'Image updated in Business Central.' , 'woo-business-central' );
}
}
}
}
WBC_Logger :: info ( 'ProductSync' , 'Pushed product to BC' , array (
'product_id' => $product_id ,
'sku' => $sku ,
'item_id' => $item_id ,
) );
return array (
'success' => true ,
'message' => implode ( ' ' , $messages ),
);
}
2026-02-17 09:59:53 +01:00
}