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

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