diff --git a/woo-business-central/admin/class-wbc-admin.php b/woo-business-central/admin/class-wbc-admin.php index 1161de3..d1826e6 100644 --- a/woo-business-central/admin/class-wbc-admin.php +++ b/woo-business-central/admin/class-wbc-admin.php @@ -68,6 +68,9 @@ class WBC_Admin { register_setting( 'wbc_sync', 'wbc_enable_price_sync', array( 'sanitize_callback' => array( $this, 'sanitize_checkbox' ), ) ); + register_setting( 'wbc_sync', 'wbc_location_code', 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 3bef8e9..bac838f 100644 --- a/woo-business-central/admin/partials/wbc-admin-display.php +++ b/woo-business-central/admin/partials/wbc-admin-display.php @@ -185,6 +185,22 @@ $tabs = array( +
+ +
++ +
+diff --git a/woo-business-central/includes/class-wbc-product-sync.php b/woo-business-central/includes/class-wbc-product-sync.php index f7490c7..1718693 100644 --- a/woo-business-central/includes/class-wbc-product-sync.php +++ b/woo-business-central/includes/class-wbc-product-sync.php @@ -2,6 +2,9 @@ /** * Product Sync from Business Central to WooCommerce * + * Iterates WooCommerce products and queries BC for each one (WooCommerce-first). + * This is efficient when WooCommerce has far fewer products than BC. + * * @package WooBusinessCentral */ @@ -17,11 +20,6 @@ if ( ! defined( 'ABSPATH' ) ) { */ class WBC_Product_Sync { - /** - * Items per page for BC API pagination - */ - const ITEMS_PER_PAGE = 100; - /** * Fields to select from BC items */ @@ -41,7 +39,11 @@ class WBC_Product_Sync { ); /** - * Run the product sync + * 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. */ @@ -64,46 +66,31 @@ class WBC_Product_Sync { ); } - WBC_Logger::info( 'ProductSync', 'Starting product sync' ); + WBC_Logger::info( 'ProductSync', 'Starting product sync (WooCommerce-first)' ); $start_time = microtime( true ); - $skip = 0; - $has_more = true; - // Fetch all items from BC with pagination - while ( $has_more ) { - $items = $this->fetch_items( $skip ); + // Get all WooCommerce products that have a SKU + $products = $this->get_wc_products_with_sku(); - if ( is_wp_error( $items ) ) { - $this->results['errors'][] = $items->get_error_message(); - WBC_Logger::error( 'ProductSync', 'Failed to fetch items from BC', array( - 'error' => $items->get_error_message(), - 'skip' => $skip, - ) ); - break; - } + 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' ), + ); + } - // Check if we have items - $item_list = isset( $items['value'] ) ? $items['value'] : array(); + WBC_Logger::info( 'ProductSync', 'Found WooCommerce products to sync', array( + 'count' => count( $products ), + ) ); - if ( empty( $item_list ) ) { - $has_more = false; - break; - } + // Process each WooCommerce product + foreach ( $products as $product ) { + $this->process_wc_product( $product ); - // Process each item - foreach ( $item_list as $item ) { - $this->process_item( $item ); - } - - // Check for more pages - $skip += self::ITEMS_PER_PAGE; - $has_more = count( $item_list ) >= self::ITEMS_PER_PAGE; - - // Add a small delay to avoid rate limiting - if ( $has_more ) { - usleep( 100000 ); // 100ms - } + // Small delay between API calls to avoid rate limiting + usleep( 50000 ); // 50ms } $duration = round( microtime( true ) - $start_time, 2 ); @@ -119,7 +106,8 @@ class WBC_Product_Sync { return array( 'success' => empty( $this->results['errors'] ), 'message' => sprintf( - __( 'Sync completed in %s seconds. Success: %d, Failed: %d, Skipped: %d', 'woo-business-central' ), + /* 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'], @@ -130,58 +118,68 @@ class WBC_Product_Sync { } /** - * Fetch items from Business Central + * Get all WooCommerce products that have a SKU set * - * @param int $skip Number of items to skip. - * @return array|WP_Error Items data or error. + * @return WC_Product[] Array of WooCommerce products. */ - private function fetch_items( $skip = 0 ) { - return WBC_API_Client::get_items( self::ITEMS_PER_PAGE, $skip, self::SELECT_FIELDS ); + 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 BC item + * Process a single WooCommerce product - find it in BC and sync * - * @param array $item BC item data. + * @param WC_Product $product WooCommerce product. */ - private function process_item( $item ) { + private function process_wc_product( $product ) { $this->results['total']++; - // Get item number and GTIN - $item_number = isset( $item['number'] ) ? $item['number'] : ''; - $gtin = isset( $item['gtin'] ) ? $item['gtin'] : ''; + $sku = $product->get_sku(); + $product_id = $product->get_id(); - if ( empty( $item_number ) && empty( $gtin ) ) { - $this->results['skipped']++; - WBC_Logger::debug( 'ProductSync', 'Skipping item without number or GTIN', array( 'item' => $item ) ); - return; - } + // Query BC for this item by GTIN (since SKU = EAN = GTIN) + $bc_item = $this->find_bc_item_by_sku( $sku ); - // Try to find WooCommerce product - $product_id = $this->find_wc_product( $item_number, $gtin ); - - if ( ! $product_id ) { - $this->results['skipped']++; - WBC_Logger::debug( 'ProductSync', 'No matching WC product found', array( - 'item_number' => $item_number, - 'gtin' => $gtin, - ) ); - return; - } - - // Get the product - $product = wc_get_product( $product_id ); - - if ( ! $product ) { - $this->results['skipped']++; - WBC_Logger::warning( 'ProductSync', 'Product ID found but product not loaded', array( + 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; } - // Update the product - $updated = $this->update_product( $product, $item ); + 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']++; @@ -191,32 +189,118 @@ class WBC_Product_Sync { } /** - * Find WooCommerce product by SKU (item number or GTIN) + * Find a BC item by WooCommerce SKU (tries GTIN first, then item number) * - * @param string $item_number BC item number. - * @param string $gtin BC GTIN/EAN. - * @return int|false Product ID or false if not found. + * @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_wc_product( $item_number, $gtin ) { - // First try to match by item number (SKU) - if ( ! empty( $item_number ) ) { - $product_id = wc_get_product_id_by_sku( $item_number ); - if ( $product_id ) { - return $product_id; - } + 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; } - // Fall back to GTIN match - if ( ! empty( $gtin ) ) { - $product_id = wc_get_product_id_by_sku( $gtin ); - if ( $product_id ) { - return $product_id; - } + 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 * @@ -226,25 +310,26 @@ class WBC_Product_Sync { */ private function update_product( $product, $item ) { $product_id = $product->get_id(); - $updated = false; 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 ) { - // Enable stock management if not already enabled if ( ! $product->get_manage_stock() ) { $product->set_manage_stock( true ); } wc_update_product_stock( $product, $stock ); - $updated = true; + $changed = true; WBC_Logger::debug( 'ProductSync', 'Updated stock', array( 'product_id' => $product_id, + 'sku' => $product->get_sku(), 'old_stock' => $current_stock, 'new_stock' => $stock, ) ); @@ -259,29 +344,36 @@ class WBC_Product_Sync { if ( $price > 0 && $price !== $current_price ) { $product->set_regular_price( $price ); - // Also update sale price if it's higher than the new regular 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( '' ); } - $product->save(); - $updated = true; + $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 via WooCommerce CRUD + // 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 ) { @@ -328,33 +420,39 @@ class WBC_Product_Sync { ); } - // Fetch item from BC - $result = WBC_API_Client::get_item_by_number( $sku ); + // Find item in BC by SKU + $bc_item = $this->find_bc_item_by_sku( $sku ); - if ( is_wp_error( $result ) ) { + if ( is_wp_error( $bc_item ) ) { return array( 'success' => false, - 'message' => $result->get_error_message(), + 'message' => $bc_item->get_error_message(), ); } - $items = isset( $result['value'] ) ? $result['value'] : array(); - - if ( empty( $items ) ) { + if ( ! $bc_item ) { return array( 'success' => false, 'message' => __( 'Item not found in Business Central.', 'woo-business-central' ), ); } - $item = $items[0]; - $updated = $this->update_product( $product, $item ); + // 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 updated successfully.', 'woo-business-central' ) - : __( 'Product is already up to date.', 'woo-business-central' ), + ? __( 'Product synced successfully.', 'woo-business-central' ) + : __( 'Product sync failed.', 'woo-business-central' ), ); } } diff --git a/woo-business-central/uninstall.php b/woo-business-central/uninstall.php index 9698e3e..1a52381 100644 --- a/woo-business-central/uninstall.php +++ b/woo-business-central/uninstall.php @@ -27,6 +27,7 @@ $options_to_delete = array( 'wbc_default_payment_terms_id', 'wbc_default_shipment_method_id', 'wbc_shipping_item_number', + 'wbc_location_code', 'wbc_last_sync_time', 'wbc_encryption_key', 'wbc_delete_data_on_uninstall',