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