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 * * 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". * * 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; } // 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 ); if ( is_wp_error( $result ) ) { WBC_Logger::warning( 'ProductSync', 'Failed to get location stock, 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 ) ) { // No ledger entries for this location = 0 stock WBC_Logger::debug( 'ProductSync', 'No ledger entries for 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 ); } WBC_Logger::debug( 'ProductSync', 'Got location-specific stock', array( 'item_number' => $item_number, 'location_code' => $location_code, 'stock' => $total_qty, ) ); return $total_qty; } /** * 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 price if enabled if ( get_option( 'wbc_enable_price_sync', 'yes' ) === 'yes' ) { $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( 'product_id' => $product_id, 'sku' => $product->get_sku(), 'old_price' => $current_price, 'new_price' => $price, ) ); } } // 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' ), ); } }