0, 'failed' => 0, 'skipped' => 0, 'total' => 0, 'errors' => array(), ); /** * Run the product sync (WooCommerce-first approach) * * Instead of pulling all 60k+ items from BC, we iterate WooCommerce * products and query BC for each one by SKU/GTIN. This means ~100 * targeted API calls instead of 600+ paginated calls. * * @return array Sync results. */ public function run_sync() { // Check if sync is enabled if ( get_option( 'wbc_enable_stock_sync', 'yes' ) !== 'yes' && get_option( 'wbc_enable_price_sync', 'yes' ) !== 'yes' ) { WBC_Logger::info( 'ProductSync', 'Sync skipped - both stock and price sync are disabled' ); return array( 'success' => true, 'message' => __( 'Sync skipped - both stock and price sync are disabled.', 'woo-business-central' ), ); } // Check if credentials are configured if ( ! WBC_OAuth::is_configured() ) { WBC_Logger::error( 'ProductSync', 'Sync failed - API credentials not configured' ); return array( 'success' => false, 'message' => __( 'Sync failed - API credentials not configured.', 'woo-business-central' ), ); } WBC_Logger::info( 'ProductSync', 'Starting product sync (WooCommerce-first)' ); $start_time = microtime( true ); // Get all WooCommerce products that have a SKU $products = $this->get_wc_products_with_sku(); if ( empty( $products ) ) { WBC_Logger::info( 'ProductSync', 'No WooCommerce products with SKU found' ); return array( 'success' => true, 'message' => __( 'No WooCommerce products with SKU found.', 'woo-business-central' ), ); } WBC_Logger::info( 'ProductSync', 'Found WooCommerce products to sync', array( 'count' => count( $products ), ) ); // Process each WooCommerce product foreach ( $products as $product ) { $this->process_wc_product( $product ); // Small delay between API calls to avoid rate limiting usleep( 50000 ); // 50ms } $duration = round( microtime( true ) - $start_time, 2 ); WBC_Logger::info( 'ProductSync', 'Product sync completed', array( 'duration_seconds' => $duration, 'total' => $this->results['total'], 'success' => $this->results['success'], 'failed' => $this->results['failed'], 'skipped' => $this->results['skipped'], ) ); return array( 'success' => empty( $this->results['errors'] ), 'message' => sprintf( /* translators: 1: duration, 2: success count, 3: failed count, 4: skipped count */ __( 'Sync completed in %1$s seconds. Success: %2$d, Failed: %3$d, Skipped: %4$d', 'woo-business-central' ), $duration, $this->results['success'], $this->results['failed'], $this->results['skipped'] ), 'results' => $this->results, ); } /** * Get all WooCommerce products that have a SKU set * * @return WC_Product[] Array of WooCommerce products. */ private function get_wc_products_with_sku() { $products = wc_get_products( array( 'limit' => -1, 'status' => 'publish', 'return' => 'objects', ) ); // Filter to only products with SKUs return array_filter( $products, function ( $product ) { return ! empty( $product->get_sku() ); } ); } /** * Process a single WooCommerce product - find it in BC and sync * * @param WC_Product $product WooCommerce product. */ private function process_wc_product( $product ) { $this->results['total']++; $sku = $product->get_sku(); $product_id = $product->get_id(); // Query BC for this item by GTIN (since SKU = EAN = GTIN) $bc_item = $this->find_bc_item_by_sku( $sku ); if ( is_wp_error( $bc_item ) ) { $this->results['failed']++; $this->results['errors'][] = sprintf( 'Product %d (SKU: %s): %s', $product_id, $sku, $bc_item->get_error_message() ); WBC_Logger::error( 'ProductSync', 'API error looking up product in BC', array( 'product_id' => $product_id, 'sku' => $sku, 'error' => $bc_item->get_error_message(), ) ); return; } if ( ! $bc_item ) { $this->results['skipped']++; WBC_Logger::debug( 'ProductSync', 'No matching BC item found for WC product', array( 'product_id' => $product_id, 'sku' => $sku, ) ); return; } // If location code is configured, get location-specific stock $location_code = get_option( 'wbc_location_code', '' ); if ( ! empty( $location_code ) && get_option( 'wbc_enable_stock_sync', 'yes' ) === 'yes' ) { $location_stock = $this->get_location_stock( $bc_item, $location_code ); if ( $location_stock !== false ) { $bc_item['inventory'] = $location_stock; } } // Update the WooCommerce product with BC data $updated = $this->update_product( $product, $bc_item ); if ( $updated ) { $this->results['success']++; } else { $this->results['failed']++; } } /** * Find a BC item by WooCommerce SKU (tries GTIN first, then item number) * * @param string $sku WooCommerce product SKU (which is the EAN code). * @return array|false|WP_Error BC item data, false if not found, or WP_Error. */ private function find_bc_item_by_sku( $sku ) { $escaped_sku = WBC_API_Client::escape_odata_string( $sku ); // Try by GTIN first (since SKU = EAN = GTIN in this setup) $result = WBC_API_Client::get( '/items', array( '$filter' => "gtin eq '" . $escaped_sku . "'", '$select' => self::SELECT_FIELDS, '$top' => 1, ) ); if ( is_wp_error( $result ) ) { return $result; } if ( ! empty( $result['value'] ) ) { return $result['value'][0]; } // Fall back to item number match $result = WBC_API_Client::get( '/items', array( '$filter' => "number eq '" . $escaped_sku . "'", '$select' => self::SELECT_FIELDS, '$top' => 1, ) ); if ( is_wp_error( $result ) ) { return $result; } if ( ! empty( $result['value'] ) ) { return $result['value'][0]; } return false; } /** * Get stock quantity for a specific BC location using ItemByLocation endpoint * * 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. * * @param array $bc_item BC item data. * @param string $location_code BC location code (e.g. 'ICP'). * @return float|false Stock quantity at location, or false to use default. */ private function get_location_stock( $bc_item, $location_code ) { $item_number = $bc_item['number'] ?? ''; if ( empty( $item_number ) ) { return false; } $escaped_number = WBC_API_Client::escape_odata_string( $item_number ); $escaped_location = WBC_API_Client::escape_odata_string( $location_code ); $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 from ItemByLocation, using total inventory', array( 'item_number' => $item_number, 'location_code' => $location_code, 'error' => $result->get_error_message(), ) ); return false; } $entries = isset( $result['value'] ) ? $result['value'] : array(); if ( empty( $entries ) ) { WBC_Logger::debug( 'ProductSync', 'No ItemByLocation entry for item/location', array( 'item_number' => $item_number, 'location_code' => $location_code, ) ); return 0.0; } // Should return a single row for item + location $qty = (float) ( $entries[0]['Remaining_Quantity'] ?? 0 ); WBC_Logger::debug( 'ProductSync', 'Got location-specific stock from ItemByLocation', array( 'item_number' => $item_number, 'location_code' => $location_code, 'stock' => $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; } /** * Update WooCommerce product with BC data * * @param WC_Product $product WooCommerce product. * @param array $item BC item data. * @return bool Whether the product was updated. */ private function update_product( $product, $item ) { $product_id = $product->get_id(); try { $changed = false; // Update stock if enabled if ( get_option( 'wbc_enable_stock_sync', 'yes' ) === 'yes' ) { $stock = isset( $item['inventory'] ) ? (float) $item['inventory'] : 0; $current_stock = (float) $product->get_stock_quantity(); if ( $stock !== $current_stock ) { if ( ! $product->get_manage_stock() ) { $product->set_manage_stock( true ); } wc_update_product_stock( $product, $stock ); $changed = true; WBC_Logger::debug( 'ProductSync', 'Updated stock', array( 'product_id' => $product_id, 'sku' => $product->get_sku(), 'old_stock' => $current_stock, 'new_stock' => $stock, ) ); } } // Update prices if enabled if ( get_option( 'wbc_enable_price_sync', 'yes' ) === 'yes' ) { $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 ); $changed = true; 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 $product->update_meta_data( '_wbc_bc_item_id', $item['id'] ?? '' ); $product->update_meta_data( '_wbc_bc_item_number', $item['number'] ?? '' ); $product->update_meta_data( '_wbc_last_sync', current_time( 'mysql' ) ); $product->save(); if ( ! $changed ) { WBC_Logger::debug( 'ProductSync', 'Product already up to date', array( 'product_id' => $product_id, 'sku' => $product->get_sku(), ) ); } return true; } catch ( Exception $e ) { WBC_Logger::error( 'ProductSync', 'Failed to update product', array( 'product_id' => $product_id, 'error' => $e->getMessage(), ) ); $this->results['errors'][] = sprintf( 'Product %d: %s', $product_id, $e->getMessage() ); return false; } } /** * Get sync results * * @return array */ public function get_results() { return $this->results; } /** * Sync a single product by WooCommerce product ID * * @param int $product_id WooCommerce product ID. * @return array Sync result. */ public function sync_single_product( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return array( 'success' => false, 'message' => __( 'Product not found.', 'woo-business-central' ), ); } $sku = $product->get_sku(); if ( empty( $sku ) ) { return array( 'success' => false, 'message' => __( 'Product has no SKU.', 'woo-business-central' ), ); } // Find item in BC by SKU $bc_item = $this->find_bc_item_by_sku( $sku ); if ( is_wp_error( $bc_item ) ) { return array( 'success' => false, 'message' => $bc_item->get_error_message(), ); } if ( ! $bc_item ) { return array( 'success' => false, 'message' => __( 'Item not found in Business Central.', 'woo-business-central' ), ); } // Check location stock $location_code = get_option( 'wbc_location_code', '' ); if ( ! empty( $location_code ) ) { $location_stock = $this->get_location_stock( $bc_item, $location_code ); if ( $location_stock !== false ) { $bc_item['inventory'] = $location_stock; } } $updated = $this->update_product( $product, $bc_item ); return array( 'success' => true, 'message' => $updated ? __( 'Product synced successfully.', 'woo-business-central' ) : __( 'Product sync failed.', 'woo-business-central' ), ); } }