diff --git a/woo-business-central/admin/class-wbc-admin.php b/woo-business-central/admin/class-wbc-admin.php
index d1826e6..587cf83 100644
--- a/woo-business-central/admin/class-wbc-admin.php
+++ b/woo-business-central/admin/class-wbc-admin.php
@@ -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(
diff --git a/woo-business-central/admin/partials/wbc-admin-display.php b/woo-business-central/admin/partials/wbc-admin-display.php
index bac838f..fd0d7aa 100644
--- a/woo-business-central/admin/partials/wbc-admin-display.php
+++ b/woo-business-central/admin/partials/wbc-admin-display.php
@@ -126,6 +126,19 @@ $tabs = array(
+
+ |
+
+ |
+
+
+
+
+
+ |
+
@@ -197,7 +210,33 @@ $tabs = array(
-
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+
|
diff --git a/woo-business-central/includes/class-wbc-api-client.php b/woo-business-central/includes/class-wbc-api-client.php
index 7ed0de3..3741285 100644
--- a/woo-business-central/includes/class-wbc-api-client.php
+++ b/woo-business-central/includes/class-wbc-api-client.php
@@ -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
*
diff --git a/woo-business-central/includes/class-wbc-product-sync.php b/woo-business-central/includes/class-wbc-product-sync.php
index 1718693..08cdf5b 100644
--- a/woo-business-central/includes/class-wbc-product-sync.php
+++ b/woo-business-central/includes/class-wbc-product-sync.php
@@ -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