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: GTIN, item number, then Item Reference (ReferenciasArticulo). * Item Reference is last on purpose: on this catalog's real data, Reference_No * is always identical to Item_No for every sampled row, so it never matches * anything the GTIN/number checks didn't already find - trying it first (as * originally implemented) cost an extra BC round trip on every single product * for zero benefit. Kept as a fallback in case that assumption doesn't hold * for some future item. * * @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 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]; } // Last resort: Item Reference $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]; } } 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 ); } /** * Process a single BC item - find the matching WooCommerce product and sync * * Used by the category-filtered (BC-first) sync path: instead of iterating * every WooCommerce product and asking BC "do you have this SKU?", we * already fetched this item from BC (filtered server-side by category), so * we just need to find the corresponding WooCommerce product locally - no * further BC round trip needed in the common case. * * @param array $item BC item data (id, number, gtin, displayName, unitPrice, inventory, itemCategoryCode). */ public function process_bc_item( $item ) { $this->results['total']++; $product = $this->find_wc_product_for_item( $item ); $is_new = false; if ( ! $product && get_option( 'wbc_create_missing_products', 'no' ) === 'yes' ) { $product = $this->create_wc_product_from_item( $item ); $is_new = (bool) $product; } if ( ! $product ) { $this->results['skipped']++; WBC_Logger::debug( 'ProductSync', 'No matching WooCommerce product for BC item', array( 'item_number' => $item['number'] ?? '', 'gtin' => $item['gtin'] ?? '', ) ); 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( $item, $location_code ); if ( $location_stock !== false ) { $item['inventory'] = $location_stock; } } $updated = $this->update_product( $product, $item ); if ( $updated ) { $this->results['success']++; if ( $is_new ) { WBC_Logger::info( 'ProductSync', 'Created new WooCommerce product from BC item', array( 'product_id' => $product->get_id(), 'item_number' => $item['number'] ?? '', 'sku' => $product->get_sku(), ) ); } } else { $this->results['failed']++; } } /** * Create a new WooCommerce product from a BC item * * Bare creation only (name, SKU, status) - stock/price/brand are filled * in right after by the normal update_product() call, so that logic * isn't duplicated here. * * @param array $item BC item data. * @return WC_Product|false */ private function create_wc_product_from_item( $item ) { $sku = ! empty( $item['gtin'] ) ? $item['gtin'] : ( $item['number'] ?? '' ); if ( empty( $sku ) ) { return false; } $product = new WC_Product_Simple(); $product->set_name( $item['displayName'] ?? $sku ); try { $product->set_sku( $sku ); $product->set_status( get_option( 'wbc_new_product_status', 'draft' ) === 'publish' ? 'publish' : 'draft' ); $product->save(); } catch ( Exception $e ) { WBC_Logger::error( 'ProductSync', 'Failed to create WooCommerce product from BC item', array( 'item_number' => $item['number'] ?? '', 'sku' => $sku, 'error' => $e->getMessage(), ) ); return false; } return $product; } /** * Find the WooCommerce product matching a BC item, via a local SKU lookup * (no BC round trip) - tries GTIN, then item number, then Item Reference * as a last resort. * * @param array $item BC item data. * @return WC_Product|false */ private function find_wc_product_for_item( $item ) { $gtin = $item['gtin'] ?? ''; $number = $item['number'] ?? ''; if ( ! empty( $gtin ) ) { $product_id = wc_get_product_id_by_sku( $gtin ); if ( $product_id ) { return wc_get_product( $product_id ); } } if ( ! empty( $number ) ) { $product_id = wc_get_product_id_by_sku( $number ); if ( $product_id ) { return wc_get_product( $product_id ); } } if ( ! empty( $number ) ) { $reference_no = $this->find_reference_no_by_item_number( $number ); if ( $reference_no ) { $product_id = wc_get_product_id_by_sku( $reference_no ); if ( $product_id ) { return wc_get_product( $product_id ); } } } return false; } /** * Look up a reference/barcode number for a BC item number via the custom * ReferenciasArticulo OData endpoint (reverse direction of find_item_number_by_reference). * * @param string $item_number BC item number. * @return string|false Reference number, or false if none found/on error. */ private function find_reference_no_by_item_number( $item_number ) { $escaped = WBC_API_Client::escape_odata_string( $item_number ); $result = WBC_API_Client::odata_get( 'ReferenciasArticulo', array( '$filter' => "Item_No eq '" . $escaped . "'", '$select' => 'Item_No,Reference_No,Reference_Type', '$top' => 1, ) ); if ( is_wp_error( $result ) ) { return false; } $entries = isset( $result['value'] ) ? $result['value'] : array(); if ( empty( $entries ) ) { return false; } return $entries[0]['Reference_No'] ?? 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: 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 (BC item category) from BC, and its * WooCommerce parent product category from a manually configured map. * * BC's item categories here are flat (e.g. "DAVINES", "CANTABRIA LABS") * with no parent-category field on the Item Category table to walk up * from brand to a grouping like "Haircare" - confirmed against the * published CategProd endpoint, which only exposes Code and Description. * That grouping is maintained in wp-admin instead (wbc_category_parent_map). * * @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 ); $brand_name = ( $category_info ? $category_info['description'] : '' ) ?: $category_code; $changed = false; if ( taxonomy_exists( 'product_brand' ) ) { if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_brand', $brand_name ) ) { $changed = true; } } $parent_name = $this->get_mapped_parent_category( $category_code ); if ( ! empty( $parent_name ) ) { if ( $this->set_product_taxonomy_term( $product->get_id(), 'product_cat', $parent_name ) ) { $changed = true; } } return $changed; } /** * Get category info (description) from the custom CategProd OData * endpoint, cached per sync run. * * Uses the standard v2.0 itemCategories entity (id, code, displayName) - * no custom endpoint needed for this part, since the display name is * already exposed by the standard API. Only the WooCommerce parent * category grouping needs the manual map (see get_mapped_parent_category()), * because that's the part with no BC equivalent at all. * * @param string $category_code BC item category code. * @return array|false Array with 'description', or false. */ private function get_category_info( $category_code ) { if ( isset( $this->category_cache[ $category_code ] ) ) { return $this->category_cache[ $category_code ]; } $result = WBC_API_Client::get( '/itemCategories', array( '$filter' => "code eq '" . WBC_API_Client::escape_odata_string( $category_code ) . "'", '$select' => 'code,displayName', '$top' => 1, ) ); if ( is_wp_error( $result ) ) { WBC_Logger::warning( 'ProductSync', 'Failed to query itemCategories', 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]['displayName'] ?? '', ); $this->category_cache[ $category_code ] = $info; return $info; } /** * Look up the WooCommerce parent product category for a BC category code * from the manually configured map (wbc_category_parent_map option: * one "CODE=Parent Name" pair per line). * * @param string $category_code BC item category code. * @return string Parent category name, or '' if not mapped. */ private function get_mapped_parent_category( $category_code ) { $raw = get_option( 'wbc_category_parent_map', '' ); if ( empty( trim( $raw ) ) ) { return ''; } foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) { $line = trim( $line ); if ( $line === '' || strpos( $line, '=' ) === false ) { continue; } list( $code, $parent_name ) = array_map( 'trim', explode( '=', $line, 2 ) ); if ( strcasecmp( $code, $category_code ) === 0 ) { return $parent_name; } } return ''; } /** * 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 ), ); } }