Manual and scheduled sync previously processed every WooCommerce product inline in a single PHP request/AJAX call, with several sequential BC API calls per product. At real catalog sizes this exceeds PHP's max_execution_time and the proxy's read timeout, surfacing as a 502 Bad Gateway on "Sync Now". Adds WBC_Batch_Sync, which queues the catalog in chunks of 25 via Action Scheduler (bundled with WooCommerce) and processes them asynchronously. The manual sync AJAX call now returns immediately after queuing, and the admin UI polls for progress instead of waiting on one long request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
565 lines
19 KiB
PHP
565 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' ),
|
|
) );
|
|
|
|
// 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
|
|
) )
|
|
);
|
|
}
|
|
}
|