The plugin only ever updated existing WooCommerce products - nothing created new ones, so a category-filtered sync would just skip every BC item without a matching product, no matter how the filter was configured. Adds an opt-in "Create Missing Products" setting (default off, so existing update-only behavior is unchanged unless explicitly enabled) that creates a bare WC_Product_Simple (name, SKU from GTIN/number, configurable draft/publish status) for unmatched BC items in the category-filtered sync path, then runs it straight through the existing update_product() call for stock/price/brand - no duplicated logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1041 lines
37 KiB
PHP
1041 lines
37 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: GTIN, item number, then Item Reference (ReferenciasArticulo).
|
|
* Item Reference is last on purpose: on this catalog's real data, Reference_No
|
|
* is always identical to Item_No for every sampled row, so it never matches
|
|
* anything the GTIN/number checks didn't already find - trying it first (as
|
|
* originally implemented) cost an extra BC round trip on every single product
|
|
* for zero benefit. Kept as a fallback in case that assumption doesn't hold
|
|
* for some future item.
|
|
*
|
|
* @param string $sku WooCommerce product SKU.
|
|
* @return array|false|WP_Error BC item data, false if not found, or WP_Error.
|
|
*/
|
|
private function find_bc_item_by_sku( $sku ) {
|
|
$escaped_sku = WBC_API_Client::escape_odata_string( $sku );
|
|
|
|
// 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];
|
|
}
|
|
|
|
// Last resort: Item Reference
|
|
$item_number = $this->find_item_number_by_reference( $sku );
|
|
|
|
if ( is_wp_error( $item_number ) ) {
|
|
return $item_number;
|
|
}
|
|
|
|
if ( $item_number ) {
|
|
$result = WBC_API_Client::get( '/items', array(
|
|
'$filter' => "number eq '" . WBC_API_Client::escape_odata_string( $item_number ) . "'",
|
|
'$select' => self::SELECT_FIELDS,
|
|
'$top' => 1,
|
|
) );
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
return $result;
|
|
}
|
|
|
|
if ( ! empty( $result['value'] ) ) {
|
|
return $result['value'][0];
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
$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;
|
|
}
|
|
|
|
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']++;
|
|
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(),
|
|
) );
|
|
}
|
|
} else {
|
|
$this->results['failed']++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Find the WooCommerce product matching a BC item, via a local SKU lookup
|
|
* (no BC round trip) - tries GTIN, then item number, then Item Reference
|
|
* as a last resort.
|
|
*
|
|
* @param array $item BC item data.
|
|
* @return WC_Product|false
|
|
*/
|
|
private function find_wc_product_for_item( $item ) {
|
|
$gtin = $item['gtin'] ?? '';
|
|
$number = $item['number'] ?? '';
|
|
|
|
if ( ! empty( $gtin ) ) {
|
|
$product_id = wc_get_product_id_by_sku( $gtin );
|
|
if ( $product_id ) {
|
|
return wc_get_product( $product_id );
|
|
}
|
|
}
|
|
|
|
if ( ! empty( $number ) ) {
|
|
$product_id = wc_get_product_id_by_sku( $number );
|
|
if ( $product_id ) {
|
|
return wc_get_product( $product_id );
|
|
}
|
|
}
|
|
|
|
if ( ! empty( $number ) ) {
|
|
$reference_no = $this->find_reference_no_by_item_number( $number );
|
|
|
|
if ( $reference_no ) {
|
|
$product_id = wc_get_product_id_by_sku( $reference_no );
|
|
if ( $product_id ) {
|
|
return wc_get_product( $product_id );
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Look up a reference/barcode number for a BC item number via the custom
|
|
* ReferenciasArticulo OData endpoint (reverse direction of find_item_number_by_reference).
|
|
*
|
|
* @param string $item_number BC item number.
|
|
* @return string|false Reference number, or false if none found/on error.
|
|
*/
|
|
private function find_reference_no_by_item_number( $item_number ) {
|
|
$escaped = WBC_API_Client::escape_odata_string( $item_number );
|
|
|
|
$result = WBC_API_Client::odata_get( 'ReferenciasArticulo', array(
|
|
'$filter' => "Item_No eq '" . $escaped . "'",
|
|
'$select' => 'Item_No,Reference_No,Reference_Type',
|
|
'$top' => 1,
|
|
) );
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
return false;
|
|
}
|
|
|
|
$entries = isset( $result['value'] ) ? $result['value'] : array();
|
|
|
|
if ( empty( $entries ) ) {
|
|
return false;
|
|
}
|
|
|
|
return $entries[0]['Reference_No'] ?? false;
|
|
}
|
|
|
|
/**
|
|
* Get stock quantity for a specific BC location using ItemByLocation endpoint
|
|
*
|
|
* 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 (BC item category) from BC, and its
|
|
* WooCommerce parent product category from a manually configured map.
|
|
*
|
|
* 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).
|
|
*
|
|
* @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 );
|
|
$brand_name = ( $category_info ? $category_info['description'] : '' ) ?: $category_code;
|
|
|
|
$changed = false;
|
|
|
|
if ( taxonomy_exists( 'product_brand' ) ) {
|
|
if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_brand', $brand_name ) ) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$parent_name = $this->get_mapped_parent_category( $category_code );
|
|
|
|
if ( ! empty( $parent_name ) ) {
|
|
if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_cat', $parent_name ) ) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
return $changed;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param string $category_code BC item category code.
|
|
* @return array|false Array with 'description', or false.
|
|
*/
|
|
private function get_category_info( $category_code ) {
|
|
if ( isset( $this->category_cache[ $category_code ] ) ) {
|
|
return $this->category_cache[ $category_code ];
|
|
}
|
|
|
|
$result = WBC_API_Client::get( '/itemCategories', array(
|
|
'$filter' => "code eq '" . WBC_API_Client::escape_odata_string( $category_code ) . "'",
|
|
'$select' => 'code,displayName',
|
|
'$top' => 1,
|
|
) );
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
WBC_Logger::warning( 'ProductSync', 'Failed to query itemCategories', 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]['displayName'] ?? '',
|
|
);
|
|
|
|
$this->category_cache[ $category_code ] = $info;
|
|
|
|
return $info;
|
|
}
|
|
|
|
/**
|
|
* 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 '';
|
|
}
|
|
|
|
/**
|
|
* 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 ),
|
|
);
|
|
}
|
|
}
|