Files
WooBC/woo-business-central/includes/class-wbc-product-sync.php
Malin b64397dcd3 feat: WooCommerce Business Central integration plugin
Native PHP plugin (no Composer) that syncs:
- Product stock and pricing from BC to WooCommerce (scheduled cron)
- Orders from WooCommerce to BC (on payment received)
- Auto-creates customers in BC from WooCommerce billing data

Product matching: WooCommerce SKU → BC Item Number, fallback to GTIN (EAN).
OAuth2 client credentials auth with encrypted secret storage.
Admin settings page with connection test, manual sync, and log viewer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:59:53 +01:00

361 lines
11 KiB
PHP

<?php
/**
* Product Sync from Business Central to WooCommerce
*
* @package WooBusinessCentral
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class WBC_Product_Sync
*
* Handles syncing stock and pricing from Business Central to WooCommerce.
*/
class WBC_Product_Sync {
/**
* Items per page for BC API pagination
*/
const ITEMS_PER_PAGE = 100;
/**
* Fields to select from BC items
*/
const SELECT_FIELDS = 'id,number,gtin,displayName,unitPrice,inventory';
/**
* Sync results
*
* @var array
*/
private $results = array(
'success' => 0,
'failed' => 0,
'skipped' => 0,
'total' => 0,
'errors' => array(),
);
/**
* Run the product sync
*
* @return array Sync results.
*/
public function run_sync() {
// Check if sync is enabled
if ( get_option( 'wbc_enable_stock_sync', 'yes' ) !== 'yes' && get_option( 'wbc_enable_price_sync', 'yes' ) !== 'yes' ) {
WBC_Logger::info( 'ProductSync', 'Sync skipped - both stock and price sync are disabled' );
return array(
'success' => true,
'message' => __( 'Sync skipped - both stock and price sync are disabled.', 'woo-business-central' ),
);
}
// Check if credentials are configured
if ( ! WBC_OAuth::is_configured() ) {
WBC_Logger::error( 'ProductSync', 'Sync failed - API credentials not configured' );
return array(
'success' => false,
'message' => __( 'Sync failed - API credentials not configured.', 'woo-business-central' ),
);
}
WBC_Logger::info( 'ProductSync', 'Starting product sync' );
$start_time = microtime( true );
$skip = 0;
$has_more = true;
// Fetch all items from BC with pagination
while ( $has_more ) {
$items = $this->fetch_items( $skip );
if ( is_wp_error( $items ) ) {
$this->results['errors'][] = $items->get_error_message();
WBC_Logger::error( 'ProductSync', 'Failed to fetch items from BC', array(
'error' => $items->get_error_message(),
'skip' => $skip,
) );
break;
}
// Check if we have items
$item_list = isset( $items['value'] ) ? $items['value'] : array();
if ( empty( $item_list ) ) {
$has_more = false;
break;
}
// Process each item
foreach ( $item_list as $item ) {
$this->process_item( $item );
}
// Check for more pages
$skip += self::ITEMS_PER_PAGE;
$has_more = count( $item_list ) >= self::ITEMS_PER_PAGE;
// Add a small delay to avoid rate limiting
if ( $has_more ) {
usleep( 100000 ); // 100ms
}
}
$duration = round( microtime( true ) - $start_time, 2 );
WBC_Logger::info( 'ProductSync', 'Product sync completed', array(
'duration_seconds' => $duration,
'total' => $this->results['total'],
'success' => $this->results['success'],
'failed' => $this->results['failed'],
'skipped' => $this->results['skipped'],
) );
return array(
'success' => empty( $this->results['errors'] ),
'message' => sprintf(
__( 'Sync completed in %s seconds. Success: %d, Failed: %d, Skipped: %d', 'woo-business-central' ),
$duration,
$this->results['success'],
$this->results['failed'],
$this->results['skipped']
),
'results' => $this->results,
);
}
/**
* Fetch items from Business Central
*
* @param int $skip Number of items to skip.
* @return array|WP_Error Items data or error.
*/
private function fetch_items( $skip = 0 ) {
return WBC_API_Client::get_items( self::ITEMS_PER_PAGE, $skip, self::SELECT_FIELDS );
}
/**
* Process a single BC item
*
* @param array $item BC item data.
*/
private function process_item( $item ) {
$this->results['total']++;
// Get item number and GTIN
$item_number = isset( $item['number'] ) ? $item['number'] : '';
$gtin = isset( $item['gtin'] ) ? $item['gtin'] : '';
if ( empty( $item_number ) && empty( $gtin ) ) {
$this->results['skipped']++;
WBC_Logger::debug( 'ProductSync', 'Skipping item without number or GTIN', array( 'item' => $item ) );
return;
}
// Try to find WooCommerce product
$product_id = $this->find_wc_product( $item_number, $gtin );
if ( ! $product_id ) {
$this->results['skipped']++;
WBC_Logger::debug( 'ProductSync', 'No matching WC product found', array(
'item_number' => $item_number,
'gtin' => $gtin,
) );
return;
}
// Get the product
$product = wc_get_product( $product_id );
if ( ! $product ) {
$this->results['skipped']++;
WBC_Logger::warning( 'ProductSync', 'Product ID found but product not loaded', array(
'product_id' => $product_id,
) );
return;
}
// Update the product
$updated = $this->update_product( $product, $item );
if ( $updated ) {
$this->results['success']++;
} else {
$this->results['failed']++;
}
}
/**
* Find WooCommerce product by SKU (item number or GTIN)
*
* @param string $item_number BC item number.
* @param string $gtin BC GTIN/EAN.
* @return int|false Product ID or false if not found.
*/
private function find_wc_product( $item_number, $gtin ) {
// First try to match by item number (SKU)
if ( ! empty( $item_number ) ) {
$product_id = wc_get_product_id_by_sku( $item_number );
if ( $product_id ) {
return $product_id;
}
}
// Fall back to GTIN match
if ( ! empty( $gtin ) ) {
$product_id = wc_get_product_id_by_sku( $gtin );
if ( $product_id ) {
return $product_id;
}
}
return false;
}
/**
* 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();
$updated = false;
try {
// 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 ) {
// Enable stock management if not already enabled
if ( ! $product->get_manage_stock() ) {
$product->set_manage_stock( true );
}
wc_update_product_stock( $product, $stock );
$updated = true;
WBC_Logger::debug( 'ProductSync', 'Updated stock', array(
'product_id' => $product_id,
'old_stock' => $current_stock,
'new_stock' => $stock,
) );
}
}
// Update price if enabled
if ( get_option( 'wbc_enable_price_sync', 'yes' ) === 'yes' ) {
$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 );
// Also update sale price if it's higher than the new regular price
$sale_price = (float) $product->get_sale_price();
if ( $sale_price > 0 && $sale_price >= $price ) {
$product->set_sale_price( '' );
}
$product->save();
$updated = true;
WBC_Logger::debug( 'ProductSync', 'Updated price', array(
'product_id' => $product_id,
'old_price' => $current_price,
'new_price' => $price,
) );
}
}
// Store BC item info in meta via WooCommerce CRUD
$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();
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;
}
}
/**
* 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' ),
);
}
// Fetch item from BC
$result = WBC_API_Client::get_item_by_number( $sku );
if ( is_wp_error( $result ) ) {
return array(
'success' => false,
'message' => $result->get_error_message(),
);
}
$items = isset( $result['value'] ) ? $result['value'] : array();
if ( empty( $items ) ) {
return array(
'success' => false,
'message' => __( 'Item not found in Business Central.', 'woo-business-central' ),
);
}
$item = $items[0];
$updated = $this->update_product( $product, $item );
return array(
'success' => true,
'message' => $updated
? __( 'Product updated successfully.', 'woo-business-central' )
: __( 'Product is already up to date.', 'woo-business-central' ),
);
}
}