Two fixes bundled together: 1. Shrink CATEGORY_PAGE_SIZE from 100 to 25. Each item in a category page can trigger several sequential BC calls (location stock, price lists, brand/category lookup) beyond the page fetch itself - at 100 items/page this measured 150-250+ seconds on live data, exceeding PHP's execution limit and leaving the Action Scheduler action stuck "in-progress" forever instead of completing or failing visibly. 2. BC's Item Category table has no parent-category field - confirmed via the actual published CategProd endpoint (Code, Description only) and the standard itemCategories entity (id, code, displayName only). The original ItemCategoryHierarchy AL snippet assumed a Parent Category field that doesn't exist. Switched to the standard /itemCategories API for the brand display name (no custom endpoint needed for that part), and replaced the BC-side parent-category walk with a WordPress-side manual map (wbc_category_parent_map: "CODE=Parent Name" per line) for the WooCommerce product category grouping, since BC has nothing to offer there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
568 lines
19 KiB
PHP
568 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* Admin Settings for WooCommerce Business Central Integration
|
|
*
|
|
* @package WooBusinessCentral
|
|
*/
|
|
|
|
// Prevent direct access
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class WBC_Admin
|
|
*
|
|
* Handles admin settings page and AJAX actions.
|
|
*/
|
|
class WBC_Admin {
|
|
|
|
/**
|
|
* Settings page slug
|
|
*/
|
|
const PAGE_SLUG = 'wbc-settings';
|
|
|
|
/**
|
|
* Add admin menu
|
|
*/
|
|
public function add_admin_menu() {
|
|
add_submenu_page(
|
|
'woocommerce',
|
|
__( 'Business Central', 'woo-business-central' ),
|
|
__( 'Business Central', 'woo-business-central' ),
|
|
'manage_woocommerce',
|
|
self::PAGE_SLUG,
|
|
array( $this, 'render_settings_page' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Register settings - each tab has its own option group to prevent
|
|
* cross-tab overwrites when saving from a single tab.
|
|
*/
|
|
public function register_settings() {
|
|
// Connection settings (own group)
|
|
register_setting( 'wbc_connection', 'wbc_tenant_id', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_connection', 'wbc_client_id', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_connection', 'wbc_client_secret', array(
|
|
'sanitize_callback' => array( $this, 'sanitize_client_secret' ),
|
|
) );
|
|
register_setting( 'wbc_connection', 'wbc_environment', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_connection', 'wbc_company_id', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_connection', 'wbc_company_name', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
|
|
// Sync settings (own group)
|
|
register_setting( 'wbc_sync', 'wbc_sync_frequency', array(
|
|
'sanitize_callback' => array( $this, 'sanitize_frequency' ),
|
|
) );
|
|
register_setting( 'wbc_sync', 'wbc_enable_stock_sync', array(
|
|
'sanitize_callback' => array( $this, 'sanitize_checkbox' ),
|
|
) );
|
|
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',
|
|
) );
|
|
register_setting( 'wbc_sync', 'wbc_regular_price_list', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
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' ),
|
|
) );
|
|
register_setting( 'wbc_sync', 'wbc_category_parent_map', array(
|
|
'sanitize_callback' => 'sanitize_textarea_field',
|
|
) );
|
|
|
|
// Order settings (own group)
|
|
register_setting( 'wbc_orders', 'wbc_enable_order_sync', array(
|
|
'sanitize_callback' => array( $this, 'sanitize_checkbox' ),
|
|
) );
|
|
register_setting( 'wbc_orders', 'wbc_default_payment_terms_id', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_orders', 'wbc_default_shipment_method_id', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_orders', 'wbc_shipping_item_number', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_orders', 'wbc_default_customer_number', array(
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
) );
|
|
register_setting( 'wbc_orders', 'wbc_auto_release_order', array(
|
|
'sanitize_callback' => array( $this, 'sanitize_checkbox' ),
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Sanitize client secret
|
|
*
|
|
* Do NOT use sanitize_text_field() here - it strips characters like %XX
|
|
* sequences, angle brackets, etc. that are common in Azure AD client secrets.
|
|
*
|
|
* @param string $value Input value.
|
|
* @return string Sanitized value.
|
|
*/
|
|
public function sanitize_client_secret( $value ) {
|
|
if ( empty( $value ) ) {
|
|
// Keep existing value if empty (masked field)
|
|
return get_option( 'wbc_client_secret', '' );
|
|
}
|
|
|
|
// If it looks like a masked value, keep existing
|
|
if ( strpos( $value, '***' ) !== false ) {
|
|
return get_option( 'wbc_client_secret', '' );
|
|
}
|
|
|
|
// Only trim whitespace, then encrypt - preserve all secret characters
|
|
$value = trim( wp_unslash( $value ) );
|
|
|
|
// Clear cached OAuth token since secret changed
|
|
WBC_OAuth::clear_token_cache();
|
|
|
|
return WBC_OAuth::encrypt( $value );
|
|
}
|
|
|
|
/**
|
|
* Sanitize frequency
|
|
*
|
|
* @param string $value Input value.
|
|
* @return string Sanitized value.
|
|
*/
|
|
public function sanitize_frequency( $value ) {
|
|
$allowed = array( 'hourly', 'twice_daily', 'daily' );
|
|
$value = sanitize_text_field( $value );
|
|
|
|
if ( ! in_array( $value, $allowed, true ) ) {
|
|
return 'daily';
|
|
}
|
|
|
|
// Reschedule cron if frequency changed
|
|
$old_frequency = get_option( 'wbc_sync_frequency', 'daily' );
|
|
if ( $value !== $old_frequency ) {
|
|
WBC_Cron::reschedule_sync( $value );
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Sanitize checkbox
|
|
*
|
|
* @param string $value Input value.
|
|
* @return string Sanitized value.
|
|
*/
|
|
public function sanitize_checkbox( $value ) {
|
|
return $value === 'yes' ? 'yes' : 'no';
|
|
}
|
|
|
|
/**
|
|
* Enqueue admin scripts
|
|
*
|
|
* @param string $hook Current admin page hook.
|
|
*/
|
|
public function enqueue_scripts( $hook ) {
|
|
if ( strpos( $hook, self::PAGE_SLUG ) === false ) {
|
|
return;
|
|
}
|
|
|
|
wp_enqueue_style(
|
|
'wbc-admin-style',
|
|
WBC_PLUGIN_URL . 'admin/css/wbc-admin.css',
|
|
array(),
|
|
WBC_VERSION
|
|
);
|
|
|
|
wp_enqueue_script(
|
|
'wbc-admin-script',
|
|
WBC_PLUGIN_URL . 'admin/js/wbc-admin.js',
|
|
array( 'jquery' ),
|
|
WBC_VERSION,
|
|
true
|
|
);
|
|
|
|
wp_localize_script( 'wbc-admin-script', 'wbc_admin', array(
|
|
'ajax_url' => admin_url( 'admin-ajax.php' ),
|
|
'nonce' => wp_create_nonce( 'wbc_admin_nonce' ),
|
|
'strings' => array(
|
|
'testing' => __( 'Testing connection...', 'woo-business-central' ),
|
|
'syncing' => __( 'Syncing products...', 'woo-business-central' ),
|
|
'clearing' => __( 'Clearing logs...', 'woo-business-central' ),
|
|
'loading' => __( 'Loading...', 'woo-business-central' ),
|
|
'confirm_clear' => __( 'Are you sure you want to clear all logs?', 'woo-business-central' ),
|
|
),
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Add settings link to plugin list
|
|
*
|
|
* @param array $links Existing links.
|
|
* @return array Modified links.
|
|
*/
|
|
public function add_settings_link( $links ) {
|
|
$settings_link = sprintf(
|
|
'<a href="%s">%s</a>',
|
|
admin_url( 'admin.php?page=' . self::PAGE_SLUG ),
|
|
__( 'Settings', 'woo-business-central' )
|
|
);
|
|
|
|
array_unshift( $links, $settings_link );
|
|
|
|
return $links;
|
|
}
|
|
|
|
/**
|
|
* Render settings page
|
|
*/
|
|
public function render_settings_page() {
|
|
// Check user capabilities
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_die( __( 'You do not have sufficient permissions to access this page.', 'woo-business-central' ) );
|
|
}
|
|
|
|
// Handle tab
|
|
$current_tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'connection';
|
|
|
|
include WBC_PLUGIN_DIR . 'admin/partials/wbc-admin-display.php';
|
|
}
|
|
|
|
/**
|
|
* AJAX: Test connection
|
|
*/
|
|
public function ajax_test_connection() {
|
|
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) );
|
|
}
|
|
|
|
$result = WBC_OAuth::test_connection();
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
|
|
}
|
|
|
|
wp_send_json_success( $result );
|
|
}
|
|
|
|
/**
|
|
* AJAX: Manual sync
|
|
*
|
|
* Queues background batches and returns immediately - the full sync no
|
|
* longer runs inline in this request (that approach hits PHP/proxy
|
|
* timeouts at real catalog sizes and surfaces as a 502 Bad Gateway).
|
|
* Poll ajax_sync_status() for progress.
|
|
*/
|
|
public function ajax_manual_sync() {
|
|
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) );
|
|
}
|
|
|
|
$result = WBC_Batch_Sync::queue_full_sync();
|
|
|
|
if ( isset( $result['success'] ) && $result['success'] ) {
|
|
wp_send_json_success( $result );
|
|
} else {
|
|
wp_send_json_error( $result );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* AJAX: Get background sync progress
|
|
*/
|
|
public function ajax_sync_status() {
|
|
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) );
|
|
}
|
|
|
|
wp_send_json_success( WBC_Batch_Sync::get_status() );
|
|
}
|
|
|
|
/**
|
|
* AJAX: Clear logs
|
|
*/
|
|
public function ajax_clear_logs() {
|
|
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) );
|
|
}
|
|
|
|
WBC_Logger::clear_logs();
|
|
|
|
wp_send_json_success( array( 'message' => __( 'Logs cleared successfully.', 'woo-business-central' ) ) );
|
|
}
|
|
|
|
/**
|
|
* AJAX: Get companies
|
|
*/
|
|
public function ajax_get_companies() {
|
|
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'woo-business-central' ) ) );
|
|
}
|
|
|
|
$result = WBC_API_Client::get_companies();
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
|
|
}
|
|
|
|
$companies = isset( $result['value'] ) ? $result['value'] : array();
|
|
|
|
wp_send_json_success( array( 'companies' => $companies ) );
|
|
}
|
|
|
|
/**
|
|
* Handle CSV export of logs
|
|
*/
|
|
public function handle_csv_export() {
|
|
if ( ! isset( $_GET['wbc_export_logs'] ) || $_GET['wbc_export_logs'] !== '1' ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'wbc_export_logs' ) ) {
|
|
wp_die( esc_html__( 'Security check failed.', 'woo-business-central' ) );
|
|
}
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_die( esc_html__( 'Permission denied.', 'woo-business-central' ) );
|
|
}
|
|
|
|
$csv = WBC_Logger::export_to_csv();
|
|
|
|
header( 'Content-Type: text/csv; charset=utf-8' );
|
|
header( 'Content-Disposition: attachment; filename=wbc-logs-' . gmdate( 'Y-m-d' ) . '.csv' );
|
|
header( 'Pragma: no-cache' );
|
|
header( 'Expires: 0' );
|
|
|
|
echo $csv; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV output
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Get logs for display
|
|
*
|
|
* @param array $args Query arguments.
|
|
* @return array Logs data.
|
|
*/
|
|
public static function get_logs_for_display( $args = array() ) {
|
|
$defaults = array(
|
|
'limit' => 50,
|
|
'offset' => 0,
|
|
'level' => '',
|
|
);
|
|
|
|
$args = wp_parse_args( $args, $defaults );
|
|
|
|
return array(
|
|
'logs' => WBC_Logger::get_logs( $args ),
|
|
'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' ) : '';
|
|
?>
|
|
<p>
|
|
<?php if ( $item_number ) : ?>
|
|
<?php
|
|
printf(
|
|
/* translators: %s: Business Central item number */
|
|
esc_html__( 'BC item: %s', 'woo-business-central' ),
|
|
esc_html( $item_number )
|
|
);
|
|
?>
|
|
<br />
|
|
<?php else : ?>
|
|
<?php esc_html_e( 'Not yet linked to a BC item.', 'woo-business-central' ); ?>
|
|
<br />
|
|
<?php endif; ?>
|
|
<?php if ( $last_sync ) : ?>
|
|
<small>
|
|
<?php
|
|
printf(
|
|
/* translators: %s: last sync datetime */
|
|
esc_html__( 'Last synced: %s', 'woo-business-central' ),
|
|
esc_html( $last_sync )
|
|
);
|
|
?>
|
|
</small>
|
|
<?php endif; ?>
|
|
</p>
|
|
<button type="button" class="button button-secondary" id="wbc-push-to-bc" data-product-id="<?php echo esc_attr( $post->ID ); ?>">
|
|
<?php esc_html_e( 'Push name/photo to Business Central', 'woo-business-central' ); ?>
|
|
</button>
|
|
<span id="wbc-push-status" class="wbc-status"></span>
|
|
<script>
|
|
( function( $ ) {
|
|
$( '#wbc-push-to-bc' ).on( 'click', function( e ) {
|
|
e.preventDefault();
|
|
var $btn = $( this );
|
|
var $status = $( '#wbc-push-status' );
|
|
$btn.prop( 'disabled', true );
|
|
$status.removeClass( 'success error' ).addClass( 'loading' ).text( <?php echo wp_json_encode( __( 'Pushing...', 'woo-business-central' ) ); ?> );
|
|
$.post( ajaxurl, {
|
|
action: 'wbc_push_single_to_bc',
|
|
product_id: $btn.data( 'product-id' ),
|
|
nonce: <?php echo wp_json_encode( wp_create_nonce( 'wbc_admin_nonce' ) ); ?>
|
|
} ).done( function( response ) {
|
|
$btn.prop( 'disabled', false );
|
|
$status.removeClass( 'loading' );
|
|
if ( response.success ) {
|
|
$status.addClass( 'success' ).text( response.data.message );
|
|
} else {
|
|
$status.addClass( 'error' ).text( ( response.data && response.data.message ) || 'Failed' );
|
|
}
|
|
} ).fail( function() {
|
|
$btn.prop( 'disabled', false );
|
|
$status.removeClass( 'loading' ).addClass( 'error' ).text( 'Request failed' );
|
|
} );
|
|
} );
|
|
} )( jQuery );
|
|
</script>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* AJAX: Push a single product's name/photo to Business Central
|
|
*/
|
|
public function ajax_push_single_to_bc() {
|
|
check_ajax_referer( 'wbc_admin_nonce', 'nonce' );
|
|
|
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
|
wp_send_json_error( array( 'message' => __( '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(
|
|
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
|
|
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
|
|
) )
|
|
);
|
|
}
|
|
}
|