info (description, parent_code) for this run * * @var array */ private $category_cache = array(); /** * Sync results * * @var array */ private $results = array( 'success' => 0, 'failed' => 0, 'skipped' => 0, 'total' => 0, 'errors' => array(), ); /** * Process a batch of WooCommerce products by ID (WooCommerce-first approach) * * Instead of pulling all 60k+ items from BC, we iterate WooCommerce * products and query BC for each one by reference/GTIN/number. This is * called per-batch by WBC_Batch_Sync so a single request never has to * process the whole catalog (which would exceed PHP/proxy timeouts at * real-world catalog sizes). * * @param int[] $product_ids WooCommerce product IDs to process. * @return array Sync results for this batch. */ public function run_sync_for_ids( array $product_ids ) { foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product || empty( $product->get_sku() ) ) { continue; } $this->process_wc_product( $product ); // Small delay between API calls to avoid rate limiting usleep( 50000 ); // 50ms } return array( 'success' => empty( $this->results['errors'] ), 'results' => $this->results, ); } /** * 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 ( ! $this->is_category_allowed( $bc_item ) ) { $this->results['skipped']++; WBC_Logger::debug( 'ProductSync', 'Item category not in allow-list, skipping', array( 'product_id' => $product_id, 'sku' => $sku, 'category' => $bc_item['itemCategoryCode'] ?? '', ) ); 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, in order: Item Reference (ReferenciasArticulo), GTIN, then item number. * * @param string $sku WooCommerce product SKU. * @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 Item Reference first - lets merchants use a customer/vendor/bar-code // reference number as the WooCommerce SKU instead of the raw item number/GTIN. $item_number = $this->find_item_number_by_reference( $sku ); if ( is_wp_error( $item_number ) ) { return $item_number; } if ( $item_number ) { $result = WBC_API_Client::get( '/items', array( '$filter' => "number eq '" . WBC_API_Client::escape_odata_string( $item_number ) . "'", '$select' => self::SELECT_FIELDS, '$top' => 1, ) ); if ( is_wp_error( $result ) ) { return $result; } if ( ! empty( $result['value'] ) ) { return $result['value'][0]; } } // Try by GTIN (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; } /** * Look up an item number by cross-reference code using the custom * ReferenciasArticulo OData endpoint (exposes Item_No, Reference_No, Reference_Type). * * @param string $reference_no The reference/cross-reference code (WooCommerce SKU). * @return string|false|WP_Error Item number, false if no match, or WP_Error. */ private function find_item_number_by_reference( $reference_no ) { $escaped = WBC_API_Client::escape_odata_string( $reference_no ); $result = WBC_API_Client::odata_get( 'ReferenciasArticulo', array( '$filter' => "Reference_No eq '" . $escaped . "'", '$select' => 'Item_No,Reference_No,Reference_Type', '$top' => 1, ) ); if ( is_wp_error( $result ) ) { WBC_Logger::warning( 'ProductSync', 'Failed to query ReferenciasArticulo, continuing with GTIN/number lookup', array( 'reference_no' => $reference_no, 'error' => $result->get_error_message(), ) ); return false; } $entries = isset( $result['value'] ) ? $result['value'] : array(); if ( empty( $entries ) ) { return false; } return $entries[0]['Item_No'] ?? false; } /** * Check whether an item's category passes the configured allow-list filter * * @param array $item BC item data. * @return bool True if allowed (or no filter configured). */ private function is_category_allowed( $item ) { $filter = get_option( 'wbc_category_filter', '' ); if ( empty( trim( $filter ) ) ) { return true; } $allowed = array_filter( array_map( 'trim', explode( ',', $filter ) ) ); if ( empty( $allowed ) ) { return true; } $item_category = $item['itemCategoryCode'] ?? ''; return in_array( $item_category, $allowed, true ); } /** * 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: Price List Code, Status = Activo, 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 Asset_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, ) ); } } } } // Sync category/brand if enabled if ( get_option( 'wbc_enable_brand_sync', 'no' ) === 'yes' ) { if ( $this->sync_category_and_brand( $product, $item ) ) { $changed = true; } } // 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; } } /** * Sync WooCommerce product_brand (leaf BC item category) and product_cat * (parent BC item category) from BC. * * Requires the custom ItemCategoryHierarchy OData endpoint (exposes * Code, Description, Parent_Category) since the standard BC API's * itemCategories entity has no parent-category field. * * @param WC_Product $product WooCommerce product. * @param array $item BC item data. * @return bool Whether anything changed. */ private function sync_category_and_brand( $product, $item ) { $category_code = $item['itemCategoryCode'] ?? ''; if ( empty( $category_code ) ) { return false; } $category_info = $this->get_category_info( $category_code ); if ( ! $category_info ) { return false; } $changed = false; // Leaf category -> brand (e.g. "Davines") $brand_name = $category_info['description'] ?: $category_code; if ( taxonomy_exists( 'product_brand' ) ) { if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_brand', $brand_name ) ) { $changed = true; } } // Parent category -> WooCommerce product category (e.g. "Haircare") if ( ! empty( $category_info['parent_code'] ) ) { $parent_info = $this->get_category_info( $category_info['parent_code'] ); $parent_name = $parent_info ? ( $parent_info['description'] ?: $category_info['parent_code'] ) : $category_info['parent_code']; if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_cat', $parent_name ) ) { $changed = true; } } return $changed; } /** * Get category info (description, parent code) from the custom * ItemCategoryHierarchy OData endpoint, cached per sync run. * * @param string $category_code BC item category code. * @return array|false Array with 'description' and 'parent_code', or false. */ private function get_category_info( $category_code ) { if ( isset( $this->category_cache[ $category_code ] ) ) { return $this->category_cache[ $category_code ]; } $escaped = WBC_API_Client::escape_odata_string( $category_code ); $result = WBC_API_Client::odata_get( 'ItemCategoryHierarchy', array( '$filter' => "Code eq '" . $escaped . "'", '$select' => 'Code,Description,Parent_Category', '$top' => 1, ) ); if ( is_wp_error( $result ) ) { WBC_Logger::warning( 'ProductSync', 'Failed to query ItemCategoryHierarchy', array( 'category_code' => $category_code, 'error' => $result->get_error_message(), ) ); $this->category_cache[ $category_code ] = false; return false; } $entries = isset( $result['value'] ) ? $result['value'] : array(); if ( empty( $entries ) ) { $this->category_cache[ $category_code ] = false; return false; } $info = array( 'description' => $entries[0]['Description'] ?? '', 'parent_code' => $entries[0]['Parent_Category'] ?? '', ); $this->category_cache[ $category_code ] = $info; return $info; } /** * Assign a product to a single term in a taxonomy, creating the term if needed. * * @param int $product_id Product ID. * @param string $taxonomy Taxonomy name. * @param string $term_name Term name. * @return bool Whether the assignment changed anything. */ private function set_product_taxonomy_term( $product_id, $taxonomy, $term_name ) { $existing_terms = wp_get_object_terms( $product_id, $taxonomy, array( 'fields' => 'names' ) ); if ( ! is_wp_error( $existing_terms ) && in_array( $term_name, $existing_terms, true ) && count( $existing_terms ) === 1 ) { return false; } $term = term_exists( $term_name, $taxonomy ); if ( ! $term ) { $term = wp_insert_term( $term_name, $taxonomy ); } if ( is_wp_error( $term ) ) { WBC_Logger::warning( 'ProductSync', 'Failed to create/find taxonomy term', array( 'taxonomy' => $taxonomy, 'term' => $term_name, 'error' => $term->get_error_message(), ) ); return false; } $term_id = is_array( $term ) ? (int) $term['term_id'] : (int) $term; $result = wp_set_object_terms( $product_id, array( $term_id ), $taxonomy, false ); return ! is_wp_error( $result ); } /** * 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' ), ); } if ( ! $this->is_category_allowed( $bc_item ) ) { return array( 'success' => false, 'message' => __( 'Item category is not in the configured sync allow-list.', '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' ), ); } /** * Push a WooCommerce product's name and featured image back to Business Central. * * On-demand only (never run automatically): PATCHes the matching BC item's * displayName and, if a featured image is set, its picture. BC's displayName * field is limited to 100 characters (Item.Description is Text[100]), so the * WooCommerce product title is used rather than the (much longer) description. * * @param int $product_id WooCommerce product ID. * @return array Result with 'success' and 'message'. */ public function push_product_to_bc( $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' ), ); } $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 || empty( $bc_item['id'] ) ) { return array( 'success' => false, 'message' => __( 'Item not found in Business Central.', 'woo-business-central' ), ); } $item_id = $bc_item['id']; $messages = array(); // Push name -> displayName $name = substr( $product->get_name(), 0, 100 ); $patch_result = WBC_API_Client::patch( '/items(' . $item_id . ')', array( 'displayName' => $name, ) ); if ( is_wp_error( $patch_result ) ) { WBC_Logger::error( 'ProductSync', 'Failed to push product name to BC', array( 'product_id' => $product_id, 'sku' => $sku, 'error' => $patch_result->get_error_message(), ) ); return array( 'success' => false, 'message' => sprintf( /* translators: %s: error message */ __( 'Failed to update name in Business Central: %s', 'woo-business-central' ), $patch_result->get_error_message() ), ); } $messages[] = __( 'Name updated in Business Central.', 'woo-business-central' ); // Push featured image -> picture $image_id = $product->get_image_id(); if ( ! empty( $image_id ) ) { $file_path = get_attached_file( $image_id ); if ( $file_path && file_exists( $file_path ) ) { $file_type = wp_check_filetype( $file_path ); $content_type = ! empty( $file_type['type'] ) ? $file_type['type'] : 'application/octet-stream'; $binary = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local media library file, not a remote URL. if ( $binary !== false ) { $picture_result = WBC_API_Client::patch_binary( '/items(' . $item_id . ')/picture/pictureContent', $binary, $content_type ); if ( is_wp_error( $picture_result ) ) { WBC_Logger::error( 'ProductSync', 'Failed to push product image to BC', array( 'product_id' => $product_id, 'sku' => $sku, 'error' => $picture_result->get_error_message(), ) ); $messages[] = sprintf( /* translators: %s: error message */ __( 'Image update failed: %s', 'woo-business-central' ), $picture_result->get_error_message() ); } else { $messages[] = __( 'Image updated in Business Central.', 'woo-business-central' ); } } } } WBC_Logger::info( 'ProductSync', 'Pushed product to BC', array( 'product_id' => $product_id, 'sku' => $sku, 'item_id' => $item_id, ) ); return array( 'success' => true, 'message' => implode( ' ', $messages ), ); } }