Manual and scheduled sync previously processed every WooCommerce product inline in a single PHP request/AJAX call, with several sequential BC API calls per product. At real catalog sizes this exceeds PHP's max_execution_time and the proxy's read timeout, surfacing as a 502 Bad Gateway on "Sync Now". Adds WBC_Batch_Sync, which queues the catalog in chunks of 25 via Action Scheduler (bundled with WooCommerce) and processes them asynchronously. The manual sync AJAX call now returns immediately after queuing, and the admin UI polls for progress instead of waiting on one long request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
845 lines
30 KiB
PHP
845 lines
30 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,itemCategoryCode';
|
|
|
|
/**
|
|
* In-memory cache of category code => info (description, parent_code) for this run
|
|
*
|
|
* @var array
|
|
*/
|
|
private $category_cache = array();
|
|
|
|
/**
|
|
* Sync results
|
|
*
|
|
* @var array
|
|
*/
|
|
private $results = array(
|
|
'success' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'total' => 0,
|
|
'errors' => array(),
|
|
);
|
|
|
|
/**
|
|
* Process a batch of WooCommerce products by ID (WooCommerce-first approach)
|
|
*
|
|
* Instead of pulling all 60k+ items from BC, we iterate WooCommerce
|
|
* 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).
|
|
*
|
|
* @param int[] $product_ids WooCommerce product IDs to process.
|
|
* @return array Sync results for this batch.
|
|
*/
|
|
public function run_sync_for_ids( array $product_ids ) {
|
|
foreach ( $product_ids as $product_id ) {
|
|
$product = wc_get_product( $product_id );
|
|
|
|
if ( ! $product || empty( $product->get_sku() ) ) {
|
|
continue;
|
|
}
|
|
|
|
$this->process_wc_product( $product );
|
|
|
|
// Small delay between API calls to avoid rate limiting
|
|
usleep( 50000 ); // 50ms
|
|
}
|
|
|
|
return array(
|
|
'success' => empty( $this->results['errors'] ),
|
|
'results' => $this->results,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 ( ! $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;
|
|
}
|
|
|
|
// 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, in order: Item Reference (ReferenciasArticulo), GTIN, then item number.
|
|
*
|
|
* @param string $sku WooCommerce product SKU.
|
|
* @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 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 . "'",
|
|
'$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;
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
|
|
/**
|
|
* 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: Price List Code, Status = Activo, 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 Asset_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,
|
|
) );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync category/brand if enabled
|
|
if ( get_option( 'wbc_enable_brand_sync', 'no' ) === 'yes' ) {
|
|
if ( $this->sync_category_and_brand( $product, $item ) ) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync WooCommerce product_brand (leaf BC item category) and product_cat
|
|
* (parent BC item category) from BC.
|
|
*
|
|
* Requires the custom ItemCategoryHierarchy OData endpoint (exposes
|
|
* Code, Description, Parent_Category) since the standard BC API's
|
|
* itemCategories entity has no parent-category field.
|
|
*
|
|
* @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 );
|
|
|
|
if ( ! $category_info ) {
|
|
return false;
|
|
}
|
|
|
|
$changed = false;
|
|
|
|
// Leaf category -> brand (e.g. "Davines")
|
|
$brand_name = $category_info['description'] ?: $category_code;
|
|
if ( taxonomy_exists( 'product_brand' ) ) {
|
|
if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_brand', $brand_name ) ) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
// Parent category -> WooCommerce product category (e.g. "Haircare")
|
|
if ( ! empty( $category_info['parent_code'] ) ) {
|
|
$parent_info = $this->get_category_info( $category_info['parent_code'] );
|
|
$parent_name = $parent_info ? ( $parent_info['description'] ?: $category_info['parent_code'] ) : $category_info['parent_code'];
|
|
|
|
if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_cat', $parent_name ) ) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
return $changed;
|
|
}
|
|
|
|
/**
|
|
* Get category info (description, parent code) from the custom
|
|
* ItemCategoryHierarchy OData endpoint, cached per sync run.
|
|
*
|
|
* @param string $category_code BC item category code.
|
|
* @return array|false Array with 'description' and 'parent_code', or false.
|
|
*/
|
|
private function get_category_info( $category_code ) {
|
|
if ( isset( $this->category_cache[ $category_code ] ) ) {
|
|
return $this->category_cache[ $category_code ];
|
|
}
|
|
|
|
$escaped = WBC_API_Client::escape_odata_string( $category_code );
|
|
|
|
$result = WBC_API_Client::odata_get( 'ItemCategoryHierarchy', array(
|
|
'$filter' => "Code eq '" . $escaped . "'",
|
|
'$select' => 'Code,Description,Parent_Category',
|
|
'$top' => 1,
|
|
) );
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
WBC_Logger::warning( 'ProductSync', 'Failed to query ItemCategoryHierarchy', array(
|
|
'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(
|
|
'description' => $entries[0]['Description'] ?? '',
|
|
'parent_code' => $entries[0]['Parent_Category'] ?? '',
|
|
);
|
|
|
|
$this->category_cache[ $category_code ] = $info;
|
|
|
|
return $info;
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
|
|
/**
|
|
* 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' ),
|
|
);
|
|
}
|
|
|
|
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' ),
|
|
);
|
|
}
|
|
|
|
// 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' ),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 ),
|
|
);
|
|
}
|
|
}
|