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:
2026-02-17 13:14:15 +01:00
parent 2c36344932
commit 2495b82e66
4 changed files with 292 additions and 38 deletions

View File

@@ -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(

View File

@@ -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>

View File

@@ -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
*

View File

@@ -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