feat: category filter, item reference SKU lookup, brand sync, and on-demand BC write-back
- Filter product sync to an allow-list of BC item category codes - Look up items by ReferenciasArticulo item reference before falling back to GTIN/item number - Sync BC item category to product_brand and parent category to product_cat via a new ItemCategoryHierarchy endpoint - Add on-demand push of product name/photo back to BC (product edit meta box + bulk action), including binary PATCH support for item pictures Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,12 @@ class WBC_Admin {
|
||||
register_setting( 'wbc_sync', 'wbc_sale_price_list', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
register_setting( 'wbc_sync', 'wbc_category_filter', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
register_setting( 'wbc_sync', 'wbc_enable_brand_sync', array(
|
||||
'sanitize_callback' => array( $this, 'sanitize_checkbox' ),
|
||||
) );
|
||||
|
||||
// Order settings (own group)
|
||||
register_setting( 'wbc_orders', 'wbc_enable_order_sync', array(
|
||||
@@ -356,4 +362,185 @@ class WBC_Admin {
|
||||
'total' => WBC_Logger::get_log_count( $args ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the "Business Central" meta box on the product edit screen
|
||||
*/
|
||||
public function add_product_meta_box() {
|
||||
add_meta_box(
|
||||
'wbc_product_sync',
|
||||
__( 'Business Central', 'woo-business-central' ),
|
||||
array( $this, 'render_product_meta_box' ),
|
||||
'product',
|
||||
'side',
|
||||
'default'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the product meta box with an on-demand push-to-BC button
|
||||
*
|
||||
* @param WP_Post $post Current product post.
|
||||
*/
|
||||
public function render_product_meta_box( $post ) {
|
||||
$product = wc_get_product( $post->ID );
|
||||
$item_number = $product ? $product->get_meta( '_wbc_bc_item_number' ) : '';
|
||||
$last_sync = $product ? $product->get_meta( '_wbc_last_sync' ) : '';
|
||||
?>
|
||||
<p>
|
||||
<?php if ( $item_number ) : ?>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: Business Central item number */
|
||||
esc_html__( 'BC item: %s', 'woo-business-central' ),
|
||||
esc_html( $item_number )
|
||||
);
|
||||
?>
|
||||
<br />
|
||||
<?php else : ?>
|
||||
<?php esc_html_e( 'Not yet linked to a BC item.', 'woo-business-central' ); ?>
|
||||
<br />
|
||||
<?php endif; ?>
|
||||
<?php if ( $last_sync ) : ?>
|
||||
<small>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: last sync datetime */
|
||||
esc_html__( 'Last synced: %s', 'woo-business-central' ),
|
||||
esc_html( $last_sync )
|
||||
);
|
||||
?>
|
||||
</small>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<button type="button" class="button button-secondary" id="wbc-push-to-bc" data-product-id="<?php echo esc_attr( $post->ID ); ?>">
|
||||
<?php esc_html_e( 'Push name/photo to Business Central', 'woo-business-central' ); ?>
|
||||
</button>
|
||||
<span id="wbc-push-status" class="wbc-status"></span>
|
||||
<script>
|
||||
( function( $ ) {
|
||||
$( '#wbc-push-to-bc' ).on( 'click', function( e ) {
|
||||
e.preventDefault();
|
||||
var $btn = $( this );
|
||||
var $status = $( '#wbc-push-status' );
|
||||
$btn.prop( 'disabled', true );
|
||||
$status.removeClass( 'success error' ).addClass( 'loading' ).text( <?php echo wp_json_encode( __( 'Pushing...', 'woo-business-central' ) ); ?> );
|
||||
$.post( ajaxurl, {
|
||||
action: 'wbc_push_single_to_bc',
|
||||
product_id: $btn.data( 'product-id' ),
|
||||
nonce: <?php echo wp_json_encode( wp_create_nonce( 'wbc_admin_nonce' ) ); ?>
|
||||
} ).done( function( response ) {
|
||||
$btn.prop( 'disabled', false );
|
||||
$status.removeClass( 'loading' );
|
||||
if ( response.success ) {
|
||||
$status.addClass( 'success' ).text( response.data.message );
|
||||
} else {
|
||||
$status.addClass( 'error' ).text( ( response.data && response.data.message ) || 'Failed' );
|
||||
}
|
||||
} ).fail( function() {
|
||||
$btn.prop( 'disabled', false );
|
||||
$status.removeClass( 'loading' ).addClass( 'error' ).text( 'Request failed' );
|
||||
} );
|
||||
} );
|
||||
} )( jQuery );
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Push a single product's name/photo to Business Central
|
||||
*/
|
||||
public function ajax_push_single_to_bc() {
|
||||
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) );
|
||||
}
|
||||
|
||||
$product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;
|
||||
|
||||
if ( ! $product_id ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Invalid product.', 'woo-business-central' ) ) );
|
||||
}
|
||||
|
||||
$sync = new WBC_Product_Sync();
|
||||
$result = $sync->push_product_to_bc( $product_id );
|
||||
|
||||
if ( ! empty( $result['success'] ) ) {
|
||||
wp_send_json_success( $result );
|
||||
} else {
|
||||
wp_send_json_error( $result );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add "Push to Business Central" bulk action on the Products list
|
||||
*
|
||||
* @param array $actions Existing bulk actions.
|
||||
* @return array Modified bulk actions.
|
||||
*/
|
||||
public function add_bulk_action( $actions ) {
|
||||
$actions['wbc_push_to_bc'] = __( 'Push to Business Central', 'woo-business-central' );
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "Push to Business Central" bulk action
|
||||
*
|
||||
* @param string $redirect_to Redirect URL.
|
||||
* @param string $action Bulk action name.
|
||||
* @param array $post_ids Selected product IDs.
|
||||
* @return string Modified redirect URL.
|
||||
*/
|
||||
public function handle_bulk_action( $redirect_to, $action, $post_ids ) {
|
||||
if ( $action !== 'wbc_push_to_bc' ) {
|
||||
return $redirect_to;
|
||||
}
|
||||
|
||||
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
||||
return $redirect_to;
|
||||
}
|
||||
|
||||
$sync = new WBC_Product_Sync();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ( $post_ids as $post_id ) {
|
||||
$result = $sync->push_product_to_bc( $post_id );
|
||||
|
||||
if ( ! empty( $result['success'] ) ) {
|
||||
$success++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return add_query_arg( array(
|
||||
'wbc_pushed' => $success,
|
||||
'wbc_push_failed' => $failed,
|
||||
), $redirect_to );
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an admin notice with the result of the bulk push action
|
||||
*/
|
||||
public function bulk_action_admin_notice() {
|
||||
if ( empty( $_REQUEST['wbc_pushed'] ) && empty( $_REQUEST['wbc_push_failed'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$success = isset( $_REQUEST['wbc_pushed'] ) ? absint( $_REQUEST['wbc_pushed'] ) : 0;
|
||||
$failed = isset( $_REQUEST['wbc_push_failed'] ) ? absint( $_REQUEST['wbc_push_failed'] ) : 0;
|
||||
|
||||
printf(
|
||||
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
|
||||
esc_attr( $failed > 0 ? 'warning' : 'success' ),
|
||||
esc_html( sprintf(
|
||||
/* translators: 1: success count, 2: failed count */
|
||||
__( 'Pushed %1$d product(s) to Business Central. %2$d failed.', 'woo-business-central' ),
|
||||
$success,
|
||||
$failed
|
||||
) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ $tabs = array(
|
||||
value="<?php echo esc_attr( get_option( 'wbc_company_name', '' ) ); ?>"
|
||||
class="regular-text" placeholder="e.g. TRADE FORCE BRANS" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Exact company name in Business Central (used for custom OData endpoints like ItemByLocation and ListaPrecios).', 'woo-business-central' ); ?>
|
||||
<?php esc_html_e( 'Exact company name in Business Central (used for custom OData endpoints like ItemByLocation, ListaPrecios, ReferenciasArticulo and ItemCategoryHierarchy).', 'woo-business-central' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -214,6 +214,32 @@ $tabs = array(
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="wbc_category_filter"><?php esc_html_e( 'Category Filter', 'woo-business-central' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" id="wbc_category_filter" name="wbc_category_filter"
|
||||
value="<?php echo esc_attr( get_option( 'wbc_category_filter', '' ) ); ?>"
|
||||
class="regular-text" placeholder="e.g. HAIRCARE,SKINCARE" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Optional. Comma-separated BC item category codes. Only products whose BC item category is in this list will be synced. Leave empty to sync all categories.', 'woo-business-central' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Enable Brand/Category Sync', 'woo-business-central' ); ?></th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" name="wbc_enable_brand_sync" value="yes"
|
||||
<?php checked( get_option( 'wbc_enable_brand_sync', 'no' ), 'yes' ); ?> />
|
||||
<?php esc_html_e( 'Sync BC item category to the product_brand taxonomy, and its parent category to the WooCommerce product category', 'woo-business-central' ); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Requires the custom ItemCategoryHierarchy OData endpoint and the WooCommerce Brands plugin (product_brand taxonomy).', 'woo-business-central' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="wbc_regular_price_list"><?php esc_html_e( 'Regular Price List Code', 'woo-business-central' ); ?></label>
|
||||
|
||||
@@ -498,4 +498,67 @@ class WBC_API_Client {
|
||||
|
||||
return self::get( '/items', $params );
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH raw binary content to an endpoint (e.g. item picture upload)
|
||||
*
|
||||
* @param string $endpoint API endpoint (e.g. '/items(id)/picture/pictureContent').
|
||||
* @param string $binary_data Raw binary body.
|
||||
* @param string $content_type MIME type of the binary content.
|
||||
* @return true|WP_Error True on success (204), or error.
|
||||
*/
|
||||
public static function patch_binary( $endpoint, $binary_data, $content_type ) {
|
||||
$token = WBC_OAuth::get_access_token();
|
||||
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$url = self::build_url( $endpoint, true );
|
||||
|
||||
WBC_Logger::debug( 'API', 'PATCH binary request', array( 'url' => $url, 'content_type' => $content_type ) );
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||||
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||
CURLOPT_POSTFIELDS => $binary_data,
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Content-Type: ' . $content_type,
|
||||
'If-Match: *',
|
||||
),
|
||||
) );
|
||||
|
||||
$response = curl_exec( $ch );
|
||||
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$curl_error = curl_error( $ch );
|
||||
|
||||
curl_close( $ch );
|
||||
|
||||
if ( $response === false ) {
|
||||
WBC_Logger::error( 'API', 'cURL error on binary PATCH', array( 'error' => $curl_error, 'url' => $url ) );
|
||||
return new WP_Error( 'wbc_curl_error', $curl_error );
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
$data = json_decode( $response, true );
|
||||
$error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'API request failed', 'woo-business-central' );
|
||||
|
||||
WBC_Logger::error( 'API', 'Binary PATCH failed', array(
|
||||
'http_code' => $http_code,
|
||||
'error' => $data['error'] ?? null,
|
||||
'url' => $url,
|
||||
) );
|
||||
|
||||
return new WP_Error( 'wbc_api_error', $error_message, array( 'status' => $http_code ) );
|
||||
}
|
||||
|
||||
WBC_Logger::debug( 'API', 'Binary PATCH successful', array( 'http_code' => $http_code, 'url' => $url ) );
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,13 @@ class WBC_Loader {
|
||||
$this->add_action( 'wp_ajax_wbc_get_companies', $admin, 'ajax_get_companies' );
|
||||
$this->add_action( 'admin_init', $admin, 'handle_csv_export' );
|
||||
|
||||
// On-demand push-to-BC (individual product + bulk action)
|
||||
$this->add_action( 'add_meta_boxes', $admin, 'add_product_meta_box' );
|
||||
$this->add_action( 'wp_ajax_wbc_push_single_to_bc', $admin, 'ajax_push_single_to_bc' );
|
||||
$this->add_filter( 'bulk_actions-edit-product', $admin, 'add_bulk_action' );
|
||||
$this->add_filter( 'handle_bulk_actions-edit-product', $admin, 'handle_bulk_action', 10, 3 );
|
||||
$this->add_action( 'admin_notices', $admin, 'bulk_action_admin_notice' );
|
||||
|
||||
// Add settings link to plugins page
|
||||
$this->add_filter( 'plugin_action_links_' . WBC_PLUGIN_BASENAME, $admin, 'add_settings_link' );
|
||||
}
|
||||
|
||||
@@ -23,7 +23,14 @@ class WBC_Product_Sync {
|
||||
/**
|
||||
* Fields to select from BC items
|
||||
*/
|
||||
const SELECT_FIELDS = 'id,number,gtin,displayName,unitPrice,inventory';
|
||||
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
|
||||
@@ -169,6 +176,16 @@ class WBC_Product_Sync {
|
||||
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' ) {
|
||||
@@ -189,15 +206,41 @@ class WBC_Product_Sync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a BC item by WooCommerce SKU (tries GTIN first, then item number)
|
||||
* Find a BC item by WooCommerce SKU
|
||||
*
|
||||
* @param string $sku WooCommerce product SKU (which is the EAN code).
|
||||
* 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 by GTIN first (since SKU = EAN = GTIN in this setup)
|
||||
// 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,
|
||||
@@ -230,6 +273,63 @@ class WBC_Product_Sync {
|
||||
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
|
||||
*
|
||||
@@ -445,6 +545,13 @@ class WBC_Product_Sync {
|
||||
}
|
||||
}
|
||||
|
||||
// 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'] ?? '' );
|
||||
@@ -470,6 +577,137 @@ class WBC_Product_Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -521,6 +759,13 @@ class WBC_Product_Sync {
|
||||
);
|
||||
}
|
||||
|
||||
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 ) ) {
|
||||
@@ -539,4 +784,126 @@ class WBC_Product_Sync {
|
||||
: __( '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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user