feat: integrate custom OData endpoints for stock and price sync
Replace broken itemLedgerEntries approach with custom ItemByLocation OData V4 endpoint for location-specific stock. Add ListaPrecios endpoint for price list sync (B2C regular, B2C_OF sale price) with filters for active status and all-customers assignment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,9 @@ class WBC_Admin {
|
||||
register_setting( 'wbc_connection', 'wbc_company_id', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
register_setting( 'wbc_connection', 'wbc_company_name', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
|
||||
// Sync settings (own group)
|
||||
register_setting( 'wbc_sync', 'wbc_sync_frequency', array(
|
||||
@@ -71,6 +74,12 @@ class WBC_Admin {
|
||||
register_setting( 'wbc_sync', 'wbc_location_code', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
register_setting( 'wbc_sync', 'wbc_regular_price_list', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
register_setting( 'wbc_sync', 'wbc_sale_price_list', array(
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
) );
|
||||
|
||||
// Order settings (own group)
|
||||
register_setting( 'wbc_orders', 'wbc_enable_order_sync', array(
|
||||
|
||||
@@ -126,6 +126,19 @@ $tabs = array(
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="wbc_company_name"><?php esc_html_e( 'Company Name', 'woo-business-central' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" id="wbc_company_name" name="wbc_company_name"
|
||||
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' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="submit">
|
||||
@@ -197,7 +210,33 @@ $tabs = array(
|
||||
<?php esc_html_e( 'Optional. BC location code to filter stock by (e.g. "ICP"). Leave empty to use total inventory across all locations.', 'woo-business-central' ); ?>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Note: Location-specific stock requires "Item Ledger Entries" to be available in your BC API. If unavailable, total inventory will be used as fallback.', 'woo-business-central' ); ?>
|
||||
<?php esc_html_e( 'Uses the custom ItemByLocation OData endpoint. Requires Company Name to be set in Connection settings.', '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>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" id="wbc_regular_price_list" name="wbc_regular_price_list"
|
||||
value="<?php echo esc_attr( get_option( 'wbc_regular_price_list', '' ) ); ?>"
|
||||
class="regular-text" placeholder="e.g. B2C" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'BC price list code for WooCommerce regular price. Leave empty to use item unit price.', 'woo-business-central' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="wbc_sale_price_list"><?php esc_html_e( 'Sale Price List Code', 'woo-business-central' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" id="wbc_sale_price_list" name="wbc_sale_price_list"
|
||||
value="<?php echo esc_attr( get_option( 'wbc_sale_price_list', '' ) ); ?>"
|
||||
class="regular-text" placeholder="e.g. B2C_OF" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'BC price list code for WooCommerce sale price. Leave empty to skip sale price sync.', 'woo-business-central' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -22,6 +22,11 @@ class WBC_API_Client {
|
||||
*/
|
||||
const BASE_URL = 'https://api.businesscentral.dynamics.com/v2.0/%s/api/v2.0';
|
||||
|
||||
/**
|
||||
* OData V4 URL template: tenant_id, environment, company_name
|
||||
*/
|
||||
const ODATA_URL = 'https://api.businesscentral.dynamics.com/v2.0/%s/%s/ODataV4/Company(\'%s\')';
|
||||
|
||||
/**
|
||||
* Maximum retry attempts
|
||||
*/
|
||||
@@ -365,6 +370,121 @@ class WBC_API_Client {
|
||||
return str_replace( "'", "''", $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OData V4 base URL for custom endpoints
|
||||
*
|
||||
* @return string|WP_Error Base URL or error if company name not configured.
|
||||
*/
|
||||
private static function get_odata_base_url() {
|
||||
$tenant_id = WBC_OAuth::get_tenant_id();
|
||||
$environment = WBC_OAuth::get_environment();
|
||||
$company_name = get_option( 'wbc_company_name', '' );
|
||||
|
||||
if ( empty( $company_name ) ) {
|
||||
return new WP_Error( 'wbc_missing_company_name', __( 'Company Name is not configured (required for OData endpoints).', 'woo-business-central' ) );
|
||||
}
|
||||
|
||||
return sprintf( self::ODATA_URL, $tenant_id, $environment, rawurlencode( $company_name ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request to a custom OData V4 endpoint
|
||||
*
|
||||
* @param string $endpoint OData endpoint name (e.g. 'ItemByLocation').
|
||||
* @param array $params OData query parameters ($filter, $select, etc.).
|
||||
* @return array|WP_Error Response data or error.
|
||||
*/
|
||||
public static function odata_get( $endpoint, $params = array() ) {
|
||||
$base_url = self::get_odata_base_url();
|
||||
|
||||
if ( is_wp_error( $base_url ) ) {
|
||||
return $base_url;
|
||||
}
|
||||
|
||||
$url = $base_url . '/' . $endpoint;
|
||||
|
||||
if ( ! empty( $params ) ) {
|
||||
$url .= '?' . http_build_query( $params );
|
||||
}
|
||||
|
||||
// Get access token
|
||||
$token = WBC_OAuth::get_access_token();
|
||||
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
WBC_Logger::debug( 'API', 'OData GET request', array( 'url' => $url ) );
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: application/json',
|
||||
),
|
||||
) );
|
||||
|
||||
$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', 'OData cURL error', array( 'error' => $curl_error, 'url' => $url ) );
|
||||
return new WP_Error( 'wbc_curl_error', $curl_error );
|
||||
}
|
||||
|
||||
$data = json_decode( $response, true );
|
||||
|
||||
// Handle 401 - retry with fresh token
|
||||
if ( $http_code === 401 ) {
|
||||
WBC_OAuth::clear_token_cache();
|
||||
$token = WBC_OAuth::get_access_token( true );
|
||||
|
||||
if ( is_wp_error( $token ) ) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT,
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: application/json',
|
||||
),
|
||||
) );
|
||||
|
||||
$response = curl_exec( $ch );
|
||||
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
curl_close( $ch );
|
||||
|
||||
$data = json_decode( $response, true );
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
$error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'OData request failed', 'woo-business-central' );
|
||||
WBC_Logger::error( 'API', 'OData request failed', array(
|
||||
'http_code' => $http_code,
|
||||
'error' => $data['error'] ?? null,
|
||||
'url' => $url,
|
||||
) );
|
||||
return new WP_Error( 'wbc_odata_error', $error_message, array( 'status' => $http_code ) );
|
||||
}
|
||||
|
||||
WBC_Logger::debug( 'API', 'OData request successful', array( 'http_code' => $http_code, 'url' => $url ) );
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific item by number
|
||||
*
|
||||
|
||||
@@ -231,11 +231,10 @@ class WBC_Product_Sync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock quantity for a specific BC location
|
||||
* Get stock quantity for a specific BC location using ItemByLocation endpoint
|
||||
*
|
||||
* Uses the OData web services endpoint for Item Ledger Entries.
|
||||
* Requires the BC admin to publish "Item Ledger Entries" (Table 32)
|
||||
* as an OData v4 web service named "ItemLedgerEntries".
|
||||
* 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.
|
||||
*
|
||||
@@ -250,24 +249,16 @@ class WBC_Product_Sync {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build OData web services URL for Item Ledger Entries
|
||||
$environment = WBC_OAuth::get_environment();
|
||||
$company_id = WBC_OAuth::get_company_id();
|
||||
$escaped_number = WBC_API_Client::escape_odata_string( $item_number );
|
||||
$escaped_location = WBC_API_Client::escape_odata_string( $location_code );
|
||||
|
||||
// Query Item Ledger Entries filtered by item number and location
|
||||
// Sum the "quantity" field to get current stock at this location
|
||||
$endpoint = '/itemLedgerEntries';
|
||||
$params = array(
|
||||
'$filter' => "itemNumber eq '" . $escaped_number . "' and locationCode eq '" . $escaped_location . "'",
|
||||
'$select' => 'quantity',
|
||||
);
|
||||
|
||||
$result = WBC_API_Client::get( $endpoint, $params );
|
||||
$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, using total inventory', array(
|
||||
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(),
|
||||
@@ -278,27 +269,80 @@ class WBC_Product_Sync {
|
||||
$entries = isset( $result['value'] ) ? $result['value'] : array();
|
||||
|
||||
if ( empty( $entries ) ) {
|
||||
// No ledger entries for this location = 0 stock
|
||||
WBC_Logger::debug( 'ProductSync', 'No ledger entries for location', array(
|
||||
WBC_Logger::debug( 'ProductSync', 'No ItemByLocation entry for item/location', array(
|
||||
'item_number' => $item_number,
|
||||
'location_code' => $location_code,
|
||||
) );
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Sum all quantities (entries can be positive or negative)
|
||||
$total_qty = 0.0;
|
||||
foreach ( $entries as $entry ) {
|
||||
$total_qty += (float) ( $entry['quantity'] ?? 0 );
|
||||
}
|
||||
// Should return a single row for item + location
|
||||
$qty = (float) ( $entries[0]['Remaining_Quantity'] ?? 0 );
|
||||
|
||||
WBC_Logger::debug( 'ProductSync', 'Got location-specific stock', array(
|
||||
WBC_Logger::debug( 'ProductSync', 'Got location-specific stock from ItemByLocation', array(
|
||||
'item_number' => $item_number,
|
||||
'location_code' => $location_code,
|
||||
'stock' => $total_qty,
|
||||
'stock' => $qty,
|
||||
) );
|
||||
|
||||
return $total_qty;
|
||||
return $qty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price from ListaPrecios custom endpoint
|
||||
*
|
||||
* Filters by: Assign-to Type = All Customers, Status = Active,
|
||||
* Price List Code = specified code, 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 Assign_to_Type eq 'All Customers'"
|
||||
. " and Product_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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,29 +380,71 @@ class WBC_Product_Sync {
|
||||
}
|
||||
}
|
||||
|
||||
// Update price if enabled
|
||||
// Update prices if enabled
|
||||
if ( get_option( 'wbc_enable_price_sync', 'yes' ) === 'yes' ) {
|
||||
$price = isset( $item['unitPrice'] ) ? (float) $item['unitPrice'] : 0;
|
||||
$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 );
|
||||
|
||||
// Clear sale price if it's now >= the new regular price
|
||||
$sale_price = (float) $product->get_sale_price();
|
||||
if ( $sale_price > 0 && $sale_price >= $price ) {
|
||||
$product->set_sale_price( '' );
|
||||
}
|
||||
|
||||
$changed = true;
|
||||
|
||||
WBC_Logger::debug( 'ProductSync', 'Updated price', array(
|
||||
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,
|
||||
) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store BC item info in meta
|
||||
|
||||
Reference in New Issue
Block a user