2026-02-17 09:59:53 +01:00
|
|
|
<?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';
|
|
|
|
|
|
2026-02-17 13:14:15 +01:00
|
|
|
/**
|
|
|
|
|
* OData V4 URL template: tenant_id, environment, company_name
|
|
|
|
|
*/
|
|
|
|
|
const ODATA_URL = 'https://api.businesscentral.dynamics.com/v2.0/%s/%s/ODataV4/Company(\'%s\')';
|
|
|
|
|
|
2026-02-17 09:59:53 +01:00
|
|
|
/**
|
|
|
|
|
* 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 );
|
fix: auto-recover from a stuck sync instead of blocking for hours
Root cause of the 2-hour stall: a category page's PHP process hung
(CLI-run cron processes typically have no execution time limit) and never
returned, so it never reached the code path that marks a page complete,
queues the next page, or marks the run failed. Action Scheduler's own
watchdog eventually marked the action failed after its 3600s timeout, but
by then the plugin's own run-tracking option was permanently stuck at
status=running with no further pages ever queued - and since manual and
scheduled syncs both check the same lock, this silently blocked all future
syncs (not just this one) for the full 4-hour lock safety-net.
Adds an 'updated_at' timestamp refreshed on every batch/page, and checks
it in queue_full_sync(): if a "running" sync hasn't made progress in over
10 minutes, treat it as abandoned, clear the lock, and start fresh instead
of waiting out the multi-hour safety-net. Also caps the 429 retry-after
sleep at 30s rather than trusting an external API-supplied value without
bound, as a defensive measure against another unbounded stall.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 17:23:35 +02:00
|
|
|
// Cap it - CLI-run cron processes often have no execution time
|
|
|
|
|
// limit, so blindly trusting an external retry-after value could
|
|
|
|
|
// stall a whole run for an unbounded amount of time.
|
|
|
|
|
$retry_after = min( $retry_after, 30 );
|
2026-02-17 09:59:53 +01:00
|
|
|
|
|
|
|
|
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 );
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 13:14:15 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 09:59:53 +01:00
|
|
|
/**
|
|
|
|
|
* 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 );
|
|
|
|
|
}
|
2026-07-09 11:39:11 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2026-02-17 09:59:53 +01:00
|
|
|
}
|