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>
502 lines
16 KiB
PHP
502 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Business Central API Client
|
|
*
|
|
* @package WooBusinessCentral
|
|
*/
|
|
|
|
// Prevent direct access
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class WBC_API_Client
|
|
*
|
|
* HTTP client for Business Central API using native cURL.
|
|
*/
|
|
class WBC_API_Client {
|
|
|
|
/**
|
|
* Base API URL template
|
|
*/
|
|
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
|
|
*/
|
|
const MAX_RETRIES = 3;
|
|
|
|
/**
|
|
* Retry delay in seconds
|
|
*/
|
|
const RETRY_DELAY = 2;
|
|
|
|
/**
|
|
* Request timeout in seconds
|
|
*/
|
|
const TIMEOUT = 30;
|
|
|
|
/**
|
|
* Get the base API URL
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function get_base_url() {
|
|
$environment = WBC_OAuth::get_environment();
|
|
return sprintf( self::BASE_URL, $environment );
|
|
}
|
|
|
|
/**
|
|
* Get company ID for URL
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function get_company_id() {
|
|
return WBC_OAuth::get_company_id();
|
|
}
|
|
|
|
/**
|
|
* Build full endpoint URL
|
|
*
|
|
* @param string $endpoint The API endpoint.
|
|
* @param bool $include_company Whether to include company ID in URL.
|
|
* @return string Full URL.
|
|
*/
|
|
private static function build_url( $endpoint, $include_company = true ) {
|
|
$base_url = self::get_base_url();
|
|
|
|
if ( $include_company ) {
|
|
$company_id = self::get_company_id();
|
|
return $base_url . '/companies(' . $company_id . ')' . $endpoint;
|
|
}
|
|
|
|
return $base_url . $endpoint;
|
|
}
|
|
|
|
/**
|
|
* Make an HTTP request to the Business Central API
|
|
*
|
|
* @param string $method HTTP method (GET, POST, PATCH, DELETE).
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $params Query parameters (for GET) or body data (for POST/PATCH).
|
|
* @param bool $include_company Whether to include company ID in URL.
|
|
* @param int $retry_count Current retry attempt.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
private static function request( $method, $endpoint, $params = array(), $include_company = true, $retry_count = 0 ) {
|
|
// Get access token
|
|
$token = WBC_OAuth::get_access_token();
|
|
|
|
if ( is_wp_error( $token ) ) {
|
|
return $token;
|
|
}
|
|
|
|
// Build URL
|
|
$url = self::build_url( $endpoint, $include_company );
|
|
|
|
// Add query parameters for GET requests
|
|
if ( $method === 'GET' && ! empty( $params ) ) {
|
|
$url .= '?' . http_build_query( $params );
|
|
}
|
|
|
|
WBC_Logger::debug( 'API', "Making $method request", array(
|
|
'url' => $url,
|
|
'method' => $method,
|
|
) );
|
|
|
|
// Initialize cURL
|
|
$ch = curl_init();
|
|
|
|
// Set base options
|
|
$curl_options = array(
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => self::TIMEOUT,
|
|
CURLOPT_HTTPHEADER => array(
|
|
'Authorization: Bearer ' . $token,
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
),
|
|
);
|
|
|
|
// Set method-specific options
|
|
switch ( $method ) {
|
|
case 'POST':
|
|
$curl_options[ CURLOPT_POST ] = true;
|
|
if ( ! empty( $params ) ) {
|
|
$curl_options[ CURLOPT_POSTFIELDS ] = wp_json_encode( $params );
|
|
}
|
|
break;
|
|
|
|
case 'PATCH':
|
|
$curl_options[ CURLOPT_CUSTOMREQUEST ] = 'PATCH';
|
|
if ( ! empty( $params ) ) {
|
|
$curl_options[ CURLOPT_POSTFIELDS ] = wp_json_encode( $params );
|
|
}
|
|
// Add If-Match header for PATCH requests
|
|
$curl_options[ CURLOPT_HTTPHEADER ][] = 'If-Match: *';
|
|
break;
|
|
|
|
case 'DELETE':
|
|
$curl_options[ CURLOPT_CUSTOMREQUEST ] = 'DELETE';
|
|
break;
|
|
|
|
default: // GET
|
|
$curl_options[ CURLOPT_HTTPGET ] = true;
|
|
break;
|
|
}
|
|
|
|
curl_setopt_array( $ch, $curl_options );
|
|
|
|
// Execute request
|
|
$response = curl_exec( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
$curl_error = curl_error( $ch );
|
|
|
|
curl_close( $ch );
|
|
|
|
// Handle cURL errors
|
|
if ( $response === false ) {
|
|
WBC_Logger::error( 'API', 'cURL error', array(
|
|
'error' => $curl_error,
|
|
'url' => $url,
|
|
) );
|
|
return new WP_Error( 'wbc_curl_error', $curl_error );
|
|
}
|
|
|
|
// Parse response
|
|
$data = json_decode( $response, true );
|
|
|
|
// Handle 401 (unauthorized) - token might be expired
|
|
if ( $http_code === 401 && $retry_count < self::MAX_RETRIES ) {
|
|
WBC_Logger::warning( 'API', 'Received 401, refreshing token and retrying', array(
|
|
'retry_count' => $retry_count + 1,
|
|
) );
|
|
|
|
// Clear token cache and retry
|
|
WBC_OAuth::clear_token_cache();
|
|
sleep( self::RETRY_DELAY );
|
|
|
|
return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 );
|
|
}
|
|
|
|
// Handle rate limiting (429)
|
|
if ( $http_code === 429 && $retry_count < self::MAX_RETRIES ) {
|
|
$retry_after = isset( $data['error']['retryAfterSeconds'] ) ? (int) $data['error']['retryAfterSeconds'] : self::RETRY_DELAY * ( $retry_count + 1 );
|
|
|
|
WBC_Logger::warning( 'API', 'Rate limited, retrying after delay', array(
|
|
'retry_after' => $retry_after,
|
|
'retry_count' => $retry_count + 1,
|
|
) );
|
|
|
|
sleep( $retry_after );
|
|
|
|
return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 );
|
|
}
|
|
|
|
// Handle server errors (5xx)
|
|
if ( $http_code >= 500 && $retry_count < self::MAX_RETRIES ) {
|
|
WBC_Logger::warning( 'API', 'Server error, retrying', array(
|
|
'http_code' => $http_code,
|
|
'retry_count' => $retry_count + 1,
|
|
) );
|
|
|
|
sleep( self::RETRY_DELAY * ( $retry_count + 1 ) );
|
|
|
|
return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 );
|
|
}
|
|
|
|
// Handle other errors
|
|
if ( $http_code >= 400 ) {
|
|
$error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'API request failed', 'woo-business-central' );
|
|
|
|
WBC_Logger::error( 'API', 'Request 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,
|
|
'error' => $data['error'] ?? null,
|
|
) );
|
|
}
|
|
|
|
WBC_Logger::debug( 'API', 'Request successful', array(
|
|
'http_code' => $http_code,
|
|
'url' => $url,
|
|
) );
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* GET request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $params Query parameters.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get( $endpoint, $params = array(), $include_company = true ) {
|
|
return self::request( 'GET', $endpoint, $params, $include_company );
|
|
}
|
|
|
|
/**
|
|
* POST request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $data Request body data.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function post( $endpoint, $data = array(), $include_company = true ) {
|
|
return self::request( 'POST', $endpoint, $data, $include_company );
|
|
}
|
|
|
|
/**
|
|
* PATCH request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $data Request body data.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function patch( $endpoint, $data = array(), $include_company = true ) {
|
|
return self::request( 'PATCH', $endpoint, $data, $include_company );
|
|
}
|
|
|
|
/**
|
|
* DELETE request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function delete( $endpoint, $include_company = true ) {
|
|
return self::request( 'DELETE', $endpoint, array(), $include_company );
|
|
}
|
|
|
|
/**
|
|
* Get all companies
|
|
*
|
|
* @return array|WP_Error Array of companies or error.
|
|
*/
|
|
public static function get_companies() {
|
|
return self::get( '/companies', array(), false );
|
|
}
|
|
|
|
/**
|
|
* Get items with pagination support
|
|
*
|
|
* @param int $top Number of items to fetch.
|
|
* @param int $skip Number of items to skip.
|
|
* @param string $select Fields to select.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get_items( $top = 100, $skip = 0, $select = '' ) {
|
|
$params = array(
|
|
'$top' => $top,
|
|
'$skip' => $skip,
|
|
);
|
|
|
|
if ( ! empty( $select ) ) {
|
|
$params['$select'] = $select;
|
|
}
|
|
|
|
return self::get( '/items', $params );
|
|
}
|
|
|
|
/**
|
|
* Get customers with optional filter
|
|
*
|
|
* @param string $filter OData filter expression.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get_customers( $filter = '' ) {
|
|
$params = array();
|
|
|
|
if ( ! empty( $filter ) ) {
|
|
$params['$filter'] = $filter;
|
|
}
|
|
|
|
return self::get( '/customers', $params );
|
|
}
|
|
|
|
/**
|
|
* Create a customer
|
|
*
|
|
* @param array $data Customer data.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function create_customer( $data ) {
|
|
return self::post( '/customers', $data );
|
|
}
|
|
|
|
/**
|
|
* Create a sales order
|
|
*
|
|
* @param array $data Sales order data.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function create_sales_order( $data ) {
|
|
return self::post( '/salesOrders', $data );
|
|
}
|
|
|
|
/**
|
|
* Create a sales order line
|
|
*
|
|
* @param string $order_id Sales order ID.
|
|
* @param array $data Sales order line data.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function create_sales_order_line( $order_id, $data ) {
|
|
return self::post( '/salesOrders(' . $order_id . ')/salesOrderLines', $data );
|
|
}
|
|
|
|
/**
|
|
* Escape a string value for use in OData filter expressions
|
|
*
|
|
* @param string $value The value to escape.
|
|
* @return string Escaped value safe for OData filters.
|
|
*/
|
|
public static function escape_odata_string( $value ) {
|
|
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
|
|
*
|
|
* @param string $item_number Item number.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get_item_by_number( $item_number ) {
|
|
$params = array(
|
|
'$filter' => "number eq '" . self::escape_odata_string( $item_number ) . "'",
|
|
);
|
|
|
|
return self::get( '/items', $params );
|
|
}
|
|
}
|