get_stock_quantity() on a never-managed product returns 0, which equaled BC's inventory for out-of-stock items, so the "did the value change?" guard treated it as no-op and never called set_manage_stock(true). The product was left at WooCommerce's default stock_status of "instock" with no quantity tracked - looked fine for in-stock items (real quantity forced the block to run) but silently wrong for anything actually out of stock. Now also runs whenever stock isn't managed yet, regardless of whether the value looks unchanged. Also enables manage_stock at product creation time so newly created products never sit in that ambiguous state to begin with. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1049 lines
37 KiB
PHP
1049 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->set_manage_stock( true );
|
|
$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();
|
|
$needs_manage_stock = ! $product->get_manage_stock();
|
|
|
|
// Also run this when stock management isn't on yet, even if the
|
|
// (defaulted, unmanaged) current value happens to already equal
|
|
// the BC value - otherwise a brand-new product with 0 BC stock
|
|
// never gets manage_stock enabled (0 !== 0 looks like "no
|
|
// change") and is left at WooCommerce's default stock_status
|
|
// of "instock" with no quantity tracked at all.
|
|
if ( $stock !== $current_stock || $needs_manage_stock ) {
|
|
if ( $needs_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 ),
|
|
);
|
|
}
|
|
}
|