From 0aff5cf6d88eda1cdfb950c4ed750180c32f805c Mon Sep 17 00:00:00 2001 From: Malin Date: Thu, 9 Jul 2026 11:39:11 +0200 Subject: [PATCH] feat: category filter, item reference SKU lookup, brand sync, and on-demand BC write-back - Filter product sync to an allow-list of BC item category codes - Look up items by ReferenciasArticulo item reference before falling back to GTIN/item number - Sync BC item category to product_brand and parent category to product_cat via a new ItemCategoryHierarchy endpoint - Add on-demand push of product name/photo back to BC (product edit meta box + bulk action), including binary PATCH support for item pictures Co-Authored-By: Claude Sonnet 5 --- .../admin/class-wbc-admin.php | 187 +++++++++ .../admin/partials/wbc-admin-display.php | 28 +- .../includes/class-wbc-api-client.php | 63 +++ .../includes/class-wbc-loader.php | 7 + .../includes/class-wbc-product-sync.php | 375 +++++++++++++++++- 5 files changed, 655 insertions(+), 5 deletions(-) diff --git a/woo-business-central/admin/class-wbc-admin.php b/woo-business-central/admin/class-wbc-admin.php index c0430f9..f3b14b4 100644 --- a/woo-business-central/admin/class-wbc-admin.php +++ b/woo-business-central/admin/class-wbc-admin.php @@ -80,6 +80,12 @@ class WBC_Admin { register_setting( 'wbc_sync', 'wbc_sale_price_list', array( 'sanitize_callback' => 'sanitize_text_field', ) ); + register_setting( 'wbc_sync', 'wbc_category_filter', array( + 'sanitize_callback' => 'sanitize_text_field', + ) ); + register_setting( 'wbc_sync', 'wbc_enable_brand_sync', array( + 'sanitize_callback' => array( $this, 'sanitize_checkbox' ), + ) ); // Order settings (own group) register_setting( 'wbc_orders', 'wbc_enable_order_sync', array( @@ -356,4 +362,185 @@ class WBC_Admin { 'total' => WBC_Logger::get_log_count( $args ), ); } + + /** + * Register the "Business Central" meta box on the product edit screen + */ + public function add_product_meta_box() { + add_meta_box( + 'wbc_product_sync', + __( 'Business Central', 'woo-business-central' ), + array( $this, 'render_product_meta_box' ), + 'product', + 'side', + 'default' + ); + } + + /** + * Render the product meta box with an on-demand push-to-BC button + * + * @param WP_Post $post Current product post. + */ + public function render_product_meta_box( $post ) { + $product = wc_get_product( $post->ID ); + $item_number = $product ? $product->get_meta( '_wbc_bc_item_number' ) : ''; + $last_sync = $product ? $product->get_meta( '_wbc_last_sync' ) : ''; + ?> +

+ + +
+ + +
+ + + + + + +

+ + + + __( 'Permission denied.', 'woo-business-central' ) ) ); + } + + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; + + if ( ! $product_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid product.', 'woo-business-central' ) ) ); + } + + $sync = new WBC_Product_Sync(); + $result = $sync->push_product_to_bc( $product_id ); + + if ( ! empty( $result['success'] ) ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } + } + + /** + * Add "Push to Business Central" bulk action on the Products list + * + * @param array $actions Existing bulk actions. + * @return array Modified bulk actions. + */ + public function add_bulk_action( $actions ) { + $actions['wbc_push_to_bc'] = __( 'Push to Business Central', 'woo-business-central' ); + return $actions; + } + + /** + * Handle the "Push to Business Central" bulk action + * + * @param string $redirect_to Redirect URL. + * @param string $action Bulk action name. + * @param array $post_ids Selected product IDs. + * @return string Modified redirect URL. + */ + public function handle_bulk_action( $redirect_to, $action, $post_ids ) { + if ( $action !== 'wbc_push_to_bc' ) { + return $redirect_to; + } + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return $redirect_to; + } + + $sync = new WBC_Product_Sync(); + $success = 0; + $failed = 0; + + foreach ( $post_ids as $post_id ) { + $result = $sync->push_product_to_bc( $post_id ); + + if ( ! empty( $result['success'] ) ) { + $success++; + } else { + $failed++; + } + } + + return add_query_arg( array( + 'wbc_pushed' => $success, + 'wbc_push_failed' => $failed, + ), $redirect_to ); + } + + /** + * Show an admin notice with the result of the bulk push action + */ + public function bulk_action_admin_notice() { + if ( empty( $_REQUEST['wbc_pushed'] ) && empty( $_REQUEST['wbc_push_failed'] ) ) { + return; + } + + $success = isset( $_REQUEST['wbc_pushed'] ) ? absint( $_REQUEST['wbc_pushed'] ) : 0; + $failed = isset( $_REQUEST['wbc_push_failed'] ) ? absint( $_REQUEST['wbc_push_failed'] ) : 0; + + printf( + '

%s

', + esc_attr( $failed > 0 ? 'warning' : 'success' ), + esc_html( sprintf( + /* translators: 1: success count, 2: failed count */ + __( 'Pushed %1$d product(s) to Business Central. %2$d failed.', 'woo-business-central' ), + $success, + $failed + ) ) + ); + } } diff --git a/woo-business-central/admin/partials/wbc-admin-display.php b/woo-business-central/admin/partials/wbc-admin-display.php index fca3b52..64fe84c 100644 --- a/woo-business-central/admin/partials/wbc-admin-display.php +++ b/woo-business-central/admin/partials/wbc-admin-display.php @@ -135,7 +135,7 @@ $tabs = array( value="" class="regular-text" placeholder="e.g. TRADE FORCE BRANS" />

- +

@@ -214,6 +214,32 @@ $tabs = array(

+ + + + + + +

+ +

+ + + + + + +

+ +

+ + diff --git a/woo-business-central/includes/class-wbc-api-client.php b/woo-business-central/includes/class-wbc-api-client.php index 3741285..359ee5a 100644 --- a/woo-business-central/includes/class-wbc-api-client.php +++ b/woo-business-central/includes/class-wbc-api-client.php @@ -498,4 +498,67 @@ class WBC_API_Client { return self::get( '/items', $params ); } + + /** + * PATCH raw binary content to an endpoint (e.g. item picture upload) + * + * @param string $endpoint API endpoint (e.g. '/items(id)/picture/pictureContent'). + * @param string $binary_data Raw binary body. + * @param string $content_type MIME type of the binary content. + * @return true|WP_Error True on success (204), or error. + */ + public static function patch_binary( $endpoint, $binary_data, $content_type ) { + $token = WBC_OAuth::get_access_token(); + + if ( is_wp_error( $token ) ) { + return $token; + } + + $url = self::build_url( $endpoint, true ); + + WBC_Logger::debug( 'API', 'PATCH binary request', array( 'url' => $url, 'content_type' => $content_type ) ); + + $ch = curl_init(); + + curl_setopt_array( $ch, array( + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => self::TIMEOUT, + CURLOPT_CUSTOMREQUEST => 'PATCH', + CURLOPT_POSTFIELDS => $binary_data, + CURLOPT_HTTPHEADER => array( + 'Authorization: Bearer ' . $token, + 'Content-Type: ' . $content_type, + 'If-Match: *', + ), + ) ); + + $response = curl_exec( $ch ); + $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $curl_error = curl_error( $ch ); + + curl_close( $ch ); + + if ( $response === false ) { + WBC_Logger::error( 'API', 'cURL error on binary PATCH', array( 'error' => $curl_error, 'url' => $url ) ); + return new WP_Error( 'wbc_curl_error', $curl_error ); + } + + if ( $http_code >= 400 ) { + $data = json_decode( $response, true ); + $error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'API request failed', 'woo-business-central' ); + + WBC_Logger::error( 'API', 'Binary PATCH failed', array( + 'http_code' => $http_code, + 'error' => $data['error'] ?? null, + 'url' => $url, + ) ); + + return new WP_Error( 'wbc_api_error', $error_message, array( 'status' => $http_code ) ); + } + + WBC_Logger::debug( 'API', 'Binary PATCH successful', array( 'http_code' => $http_code, 'url' => $url ) ); + + return true; + } } diff --git a/woo-business-central/includes/class-wbc-loader.php b/woo-business-central/includes/class-wbc-loader.php index 907e9b5..d7e94e8 100644 --- a/woo-business-central/includes/class-wbc-loader.php +++ b/woo-business-central/includes/class-wbc-loader.php @@ -113,6 +113,13 @@ class WBC_Loader { $this->add_action( 'wp_ajax_wbc_get_companies', $admin, 'ajax_get_companies' ); $this->add_action( 'admin_init', $admin, 'handle_csv_export' ); + // On-demand push-to-BC (individual product + bulk action) + $this->add_action( 'add_meta_boxes', $admin, 'add_product_meta_box' ); + $this->add_action( 'wp_ajax_wbc_push_single_to_bc', $admin, 'ajax_push_single_to_bc' ); + $this->add_filter( 'bulk_actions-edit-product', $admin, 'add_bulk_action' ); + $this->add_filter( 'handle_bulk_actions-edit-product', $admin, 'handle_bulk_action', 10, 3 ); + $this->add_action( 'admin_notices', $admin, 'bulk_action_admin_notice' ); + // Add settings link to plugins page $this->add_filter( 'plugin_action_links_' . WBC_PLUGIN_BASENAME, $admin, 'add_settings_link' ); } diff --git a/woo-business-central/includes/class-wbc-product-sync.php b/woo-business-central/includes/class-wbc-product-sync.php index 29f28ac..c61ef41 100644 --- a/woo-business-central/includes/class-wbc-product-sync.php +++ b/woo-business-central/includes/class-wbc-product-sync.php @@ -23,7 +23,14 @@ class WBC_Product_Sync { /** * Fields to select from BC items */ - const SELECT_FIELDS = 'id,number,gtin,displayName,unitPrice,inventory'; + const SELECT_FIELDS = 'id,number,gtin,displayName,unitPrice,inventory,itemCategoryCode'; + + /** + * In-memory cache of category code => info (description, parent_code) for this run + * + * @var array + */ + private $category_cache = array(); /** * Sync results @@ -169,6 +176,16 @@ class WBC_Product_Sync { 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' ) { @@ -189,15 +206,41 @@ class WBC_Product_Sync { } /** - * Find a BC item by WooCommerce SKU (tries GTIN first, then item number) + * Find a BC item by WooCommerce SKU * - * @param string $sku WooCommerce product SKU (which is the EAN code). + * 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 by GTIN first (since SKU = EAN = GTIN in this setup) + // 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, @@ -230,6 +273,63 @@ class WBC_Product_Sync { 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 * @@ -445,6 +545,13 @@ class WBC_Product_Sync { } } + // 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'] ?? '' ); @@ -470,6 +577,137 @@ class WBC_Product_Sync { } } + /** + * 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 * @@ -521,6 +759,13 @@ class WBC_Product_Sync { ); } + 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 ) ) { @@ -539,4 +784,126 @@ class WBC_Product_Sync { : __( '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 ), + ); + } }