This commit is contained in:
14 changed files with 987 additions and 0 deletions

15
assets/admin.css Normal file
View File

@@ -0,0 +1,15 @@
#wzhf .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
#wzhf .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
#wzhf label{display:block;font-weight:600;margin-bottom:6px}
#wzhf input[type=text],#wzhf input[type=number],#wzhf select,#wzhf input[type=password]{width:100%;max-width:560px}
#wzhf .tabs{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
#wzhf .tabs a{padding:8px 12px;border-radius:8px;border:1px solid #e5e7eb;text-decoration:none}
#wzhf .tabs a.active{background:#2271b1;color:#fff;border-color:#2271b1}
#wzhf .muted{color:#6b7280}
#wzhf .status-pill{display:inline-block;padding:4px 8px;border-radius:9999px;font-size:12px;border:1px solid #e5e7eb}
#wzhf .status-ok{background:#ecfdf5;color:#065f46;border-color:#a7f3d0}
#wzhf .status-bad{background:#fef2f2;color:#991b1b;border-color:#fecaca}
#wzhf .mono{font-family:ui-monospace,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}
#wzhf .tools-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
#wzhf .result{margin-top:8px;font-size:12px;color:#065f46}
#wzhf .error{color:#991b1b}

23
assets/admin.js Normal file
View File

@@ -0,0 +1,23 @@
jQuery(function($){
// Tabs
$('#wzhf .tabs a').on('click', function(e){ e.preventDefault(); var tab=$(this).data('tab'); $('#wzhf .tabs a').removeClass('active'); $(this).addClass('active'); $('#wzhf .tab').hide(); $('#wzhf .tab[data-tab="'+tab+'"]').show(); history.replaceState(null,'','#'+tab); });
var hash=location.hash.replace('#',''); if(hash && $('#wzhf .tabs a[data-tab="'+hash+'"]').length){ $('#wzhf .tabs a[data-tab="'+hash+'"]').trigger('click'); } else { $('#wzhf .tabs a').first().trigger('click'); }
function postTool(op, payload, outEl){
$(outEl).removeClass('error').text('Running...');
fetch(WZHF.rest, {method:'POST', headers:{'Content-Type':'application/json','X-WP-Nonce':WZHF.nonce}, body: JSON.stringify(Object.assign({op:op}, payload||{}))})
.then(r=>r.json().then(data=>({ok:r.ok,status:r.status,data})))
.then(res=>{ if(res.ok){ $(outEl).text('✔ ' + (res.data.message || 'OK')); } else { $(outEl).addClass('error').text('✖ ' + (res.data && (res.data.error||res.data.message) || ('HTTP '+res.status))); } })
.catch(err=>{ $(outEl).addClass('error').text('✖ ' + err); });
}
$(document).on('click','#btn-reset-map', function(e){ e.preventDefault(); var id=parseInt($('#tool-order-id').val(),10)||0; if(!id) return alert('Укажи Order ID'); postTool('reset_map', {order_id:id}, '#res-reset-map'); });
$(document).on('click','#btn-resend-so', function(e){ e.preventDefault(); var id=parseInt($('#tool-order-id').val(),10)||0; if(!id) return alert('Укажи Order ID'); postTool('resend_so', {order_id:id}, '#res-resend-so'); });
});
$(document).on('click','#btn-patch-so-address', function(e){
e.preventDefault();
var id=parseInt($('#tool-order-id').val(),10)||0;
if(!id) return alert('Укажи Order ID');
postTool('patch_so_address', {order_id:id}, '#res-patch-so');
});

42
inc/class-admin.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Admin {
public function __construct(){ add_action('admin_menu',array($this,'menu')); add_action('admin_enqueue_scripts',array($this,'assets')); add_action('admin_post_wzhf_save',array($this,'save')); add_action('admin_post_wzhf_patch_so',array($this,'patch_so_server')); }
public function menu(){ add_menu_page('WooZoho','WooZoho','manage_woocommerce',WZHF_SLUG,array($this,'render'),'dashicons-portfolio',56); }
public function assets($hook){
if($hook!=='toplevel_page_'.WZHF_SLUG) return;
wp_enqueue_style('wzhf-admin',WZHF_URL.'assets/admin.css',array(),WZHF_VER);
wp_enqueue_script('wzhf-admin',WZHF_URL.'assets/admin.js',array('jquery'),WZHF_VER,true);
wp_localize_script('wzhf-admin','WZHF',array(
'rest'=> rest_url(WZHF_NS.'/tools'),
'nonce'=> wp_create_nonce('wp_rest')
));
}
public function save(){
if(!wzhf_admin_cap()) wp_die('Forbidden',403); if(!wzhf_verified_nonce()) wp_die('Bad nonce',403);
$opt=wzhf_get_option(); $fields=array('dc','org','cid','sec','scopes','trigger_mode','create_missing','set_status_after_so','status_after_so','contact_addr_sync');
foreach($fields as $f){ $v=isset($_POST[$f])?sanitize_text_field(wp_unslash($_POST[$f])):''; if(in_array($f,array('create_missing','set_status_after_so'))) $v=isset($_POST[$f])?'1':'0'; $opt[$f]=$v; }
if(empty($opt['contact_addr_sync'])) $opt['contact_addr_sync']='always';
update_option(WZHF_OPT,$opt,false); do_action('update_option_'.WZHF_OPT,array(),$opt,WZHF_OPT); wp_safe_redirect(admin_url('admin.php?page='.WZHF_SLUG.'&saved=1')); exit;
}
public function render(){ if(!wzhf_admin_cap()) return; $opt=wzhf_get_option(); $redir=esc_url_raw(rest_url(WZHF_NS.'/oauth/callback'));
if(isset($_GET['wzhf_msg'])){
$msg = sanitize_text_field($_GET['wzhf_msg']);
echo '<div class="notice notice-success"><p>'.esc_html($msg).'</p></div>';
}
$auth=''; if(!empty($opt['cid']) && !empty($opt['dc']) && !empty($opt['scopes'])){ $acc='https://accounts.zoho'.trim($opt['dc']).'/oauth/v2/auth'; $params=http_build_query(array('response_type'=>'code','client_id'=>$opt['cid'],'scope'=>$opt['scopes'],'redirect_uri'=>$redir,'access_type'=>'offline','prompt'=>'consent')); $auth=$acc.'?'.$params; }
$z = new WZHF_Zoho(); $connected=$z->is_connected(); include WZHF_DIR.'views/settings-page.php';
}
public function patch_so_server(){
if(!wzhf_admin_cap()) wp_die('Forbidden',403);
if(!wzhf_verified_nonce()) wp_die('Bad nonce',403);
$order_id = isset($_POST['order_id_patch']) ? absint($_POST['order_id_patch']) : 0;
if(!$order_id){ wp_safe_redirect(admin_url('admin.php?page='.WZHF_SLUG.'&wzhf_msg='.rawurlencode('Укажите Order ID'))); exit; }
$res = WZHF_Tools::patch_so_from_order($order_id);
if(!empty($res['ok'])){ wp_safe_redirect(admin_url('admin.php?page='.WZHF_SLUG.'&tab=tools&wzhf_msg='.rawurlencode('SO '.$res['so'].' — адрес обновлён'))); }
else { wp_safe_redirect(admin_url('admin.php?page='.WZHF_SLUG+'&tab=tools&wzhf_msg='.rawurlencode('Патч не применён: '.($res['error']??'unknown')))); }
exit;
}
}

View File

@@ -0,0 +1,3 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Inbound_Email { public static function bootstrap(){} }

View File

@@ -0,0 +1,3 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Inventory_Sync { public static function bootstrap(){} }

16
inc/class-logger.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Logger {
public static function log($m,$ctx=array()){
$upload=wp_upload_dir(); $dir=trailingslashit($upload['basedir']).'wzhf-logs';
if(!file_exists($dir)) wp_mkdir_p($dir);
$file=$dir.'/log-'.gmdate('Y-m-d').'.log';
$line='['.gmdate('H:i:s').'] '.$m.(!empty($ctx)?' | '.json_encode($ctx,JSON_UNESCAPED_UNICODE):'').PHP_EOL;
@file_put_contents($file,$line,FILE_APPEND);
}
public static function latest_logs($n=5){
$upload=wp_upload_dir(); $dir=trailingslashit($upload['basedir']).'wzhf-logs'; if(!is_dir($dir)) return array();
$files=array_values(array_filter(scandir($dir),function($f){return strpos($f,'log-')===0;})); rsort($files); $files=array_slice($files,0,$n); $out=array();
foreach($files as $f){ $p=$dir.'/'.$f; $out[$f]=@file_get_contents($p); } return $out;
}
}

124
inc/class-orders-sync.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Orders_Sync {
const SWEEPER_HOOK = 'wzhf_paid_sweeper_event';
public static function bootstrap(){
add_action('woocommerce_payment_complete',array(__CLASS__,'on_paid'),20);
add_action('woocommerce_checkout_order_processed',array(__CLASS__,'on_processed'),20,3);
add_action('woocommerce_new_order',array(__CLASS__,'on_new_order'),20,1);
add_action('woocommerce_order_status_changed',array(__CLASS__,'on_status_changed'),20,4);
add_action('woocommerce_order_status_processing', array(__CLASS__,'on_status_processing'), 20, 1);
add_action('woocommerce_order_status_completed', array(__CLASS__,'on_status_completed'), 20, 1);
add_action(self::SWEEPER_HOOK, array(__CLASS__,'run_sweeper'));
add_action('update_option_'.WZHF_OPT, array(__CLASS__,'maybe_reschedule_sweeper'), 10, 3);
self::maybe_reschedule_sweeper(null, wzhf_get_option(), WZHF_OPT);
}
public static function maybe_reschedule_sweeper($old, $new, $name){
$mode = isset($new['trigger_mode']) ? $new['trigger_mode'] : 'processed';
$enabled = ($mode === 'paid');
$ts = wp_next_scheduled(self::SWEEPER_HOOK);
if($enabled){
if($ts) wp_clear_scheduled_hook(self::SWEEPER_HOOK);
wp_schedule_event(time()+120, 'wzhf_5min', self::SWEEPER_HOOK);
} else {
if($ts) wp_clear_scheduled_hook(self::SWEEPER_HOOK);
}
}
private static function should_send_now($order){
$mode=wzhf_get_option('trigger_mode','processed');
if($mode==='processed') return true;
if($mode==='paid') return $order->is_paid();
return false;
}
public static function on_processed($order_id,$posted,$order){
if(!self::should_send_now($order)) return;
WZHF_Logger::log('trigger:on_processed', array('order_id'=>$order_id));
self::create_sales_order($order_id);
}
public static function on_paid($order_id){
if(wzhf_get_option('trigger_mode','processed')!=='paid') return;
WZHF_Logger::log('trigger:on_paid', array('order_id'=>$order_id));
self::create_sales_order($order_id);
}
public static function on_new_order($order_id){
$order = wc_get_order($order_id); if(!$order) return;
$mode = wzhf_get_option('trigger_mode','processed');
if($mode==='processed' || ($mode==='paid' && $order->is_paid())){
WZHF_Logger::log('trigger:on_new_order', array('order_id'=>$order_id, 'is_paid'=>$order->is_paid()));
self::create_sales_order($order_id);
}
}
public static function on_status_changed($order_id,$old_status,$new_status,$order){
if(wzhf_get_option('trigger_mode','processed')!=='paid') return;
if(in_array($new_status, array('processing','completed'))){
WZHF_Logger::log('trigger:on_status_changed', array('order_id'=>$order_id,'from'=>$old_status,'to'=>$new_status));
self::create_sales_order($order_id);
}
}
public static function on_status_processing($order_id){
if(wzhf_get_option('trigger_mode','processed')!=='paid') return;
WZHF_Logger::log('trigger:on_status_processing', array('order_id'=>$order_id));
self::create_sales_order($order_id);
}
public static function on_status_completed($order_id){
if(wzhf_get_option('trigger_mode','processed')!=='paid') return;
WZHF_Logger::log('trigger:on_status_completed', array('order_id'=>$order_id));
self::create_sales_order($order_id);
}
public static function run_sweeper(){
if(wzhf_get_option('trigger_mode','processed')!=='paid') return;
$z = new WZHF_Zoho(); if(!$z->is_connected()) return;
WZHF_Logger::log('sweeper:start');
$args = array(
'status' => array('processing','completed'),
'limit' => 25,
'orderby' => 'date',
'order' => 'DESC',
'return' => 'ids',
);
$ids = wc_get_orders($args);
global $wpdb; $map=$wpdb->prefix.'wzhf_orders';
foreach((array)$ids as $order_id){
$already=$wpdb->get_var($wpdb->prepare("SELECT zoho_salesorder_id FROM $map WHERE wc_order_id=%d",$order_id));
if($already) continue;
WZHF_Logger::log('sweeper:create', array('order_id'=>$order_id));
self::create_sales_order($order_id);
}
WZHF_Logger::log('sweeper:end', array('count'=>count($ids)));
}
public static function create_sales_order($order_id){
$order=wc_get_order($order_id); if(!$order) return;
WZHF_Logger::log('SO create:start', array('order_id'=>$order_id));
global $wpdb; $map=$wpdb->prefix.'wzhf_orders';
$exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s",$map));
if(!$exists){
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
dbDelta("CREATE TABLE {$map} (wc_order_id BIGINT UNSIGNED NOT NULL, zoho_salesorder_id VARCHAR(64) NOT NULL, PRIMARY KEY (wc_order_id), KEY zoho_salesorder_id (zoho_salesorder_id)) $charset;");
}
$already=$wpdb->get_var($wpdb->prepare("SELECT zoho_salesorder_id FROM $map WHERE wc_order_id=%d",$order_id));
if($already){ WZHF_Logger::log('SO create:skip-existing', array('order_id'=>$order_id,'so'=>$already)); return; }
$z=new WZHF_Zoho(); if(!$z->is_connected()){ WZHF_Logger::log('Zoho not connected; skip'); return; }
$settings=array('create_missing'=>wzhf_sanitize_bool(wzhf_get_option('create_missing','0')));
list($so,$err)=$z->create_sales_order_from_wc($order,$settings);
if($so){
$wpdb->insert($map,array('wc_order_id'=>$order_id,'zoho_salesorder_id'=>$so['salesorder_id']));
$order->add_order_note('Zoho SO создан: '.$so['salesorder_id']);
if(wzhf_sanitize_bool(wzhf_get_option('set_status_after_so','0'))){
$target=wzhf_get_option('status_after_so','processing');
$order->update_status($target,'Статус обновлён после создания SO');
}
} else {
$order->add_order_note('Ошибка создания Zoho SO');
WZHF_Logger::log('SO create failed',array('order_id'=>$order_id,'err'=>$err));
}
}
}

205
inc/class-tools.php Normal file
View File

@@ -0,0 +1,205 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Tools {
public static function bootstrap(){
add_action('rest_api_init', function(){
register_rest_route(WZHF_NS, '/tools', array(
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => function(){ return current_user_can('manage_woocommerce'); },
'callback' => array(__CLASS__, 'handle'),
));
});
}
public static function patch_so_from_order($order_id){
$order = wc_get_order($order_id);
if (!$order) return array('ok'=>false,'error'=>'order_not_found');
global $wpdb; $map = $wpdb->prefix . 'wzhf_orders';
$so_id = $wpdb->get_var($wpdb->prepare("SELECT zoho_salesorder_id FROM $map WHERE wc_order_id=%d", $order_id));
if(!$so_id) return array('ok'=>false,'error'=>'no_mapping');
// Build clamped shipping address from order (fallback billing)
$ship_addr1 = trim($order->get_shipping_address_1());
$ship_addr2 = trim($order->get_shipping_address_2());
if($ship_addr1==='' && $ship_addr2===''){
$ship_addr1 = trim($order->get_billing_address_1());
$ship_addr2 = trim($order->get_billing_address_2());
}
$ship_city = trim($order->get_shipping_city()) ?: trim($order->get_billing_city());
$ship_state = trim($order->get_shipping_state()) ?: trim($order->get_billing_state());
$ship_zip = trim($order->get_shipping_postcode()) ?: trim($order->get_billing_postcode());
$ship_ctry = trim($order->get_shipping_country()) ?: trim($order->get_billing_country());
$addr_line = trim(preg_replace('/\s+/u',' ', trim($ship_addr1.' '.($ship_addr2?:''))));
if(function_exists('mb_substr')){
$address = mb_substr($addr_line,0,90,'UTF-8');
$city = mb_substr($ship_city,0,40,'UTF-8');
$state = mb_substr($ship_state,0,30,'UTF-8');
$zip = mb_substr($ship_zip,0,20,'UTF-8');
$country = mb_substr($ship_ctry,0,30,'UTF-8');
} else {
$address = substr($addr_line,0,90);
$city = substr($ship_city,0,40);
$state = substr($ship_state,0,30);
$zip = substr($ship_zip,0,20);
$country = substr($ship_ctry,0,30);
}
$payload = array('shipping_address'=>array(
'address'=>$address,'city'=>$city,'state'=>$state,'zip'=>$zip,'country'=>$country
));
WZHF_Logger::log('SO patch:addr-lengths', array(
'order_id'=>$order_id,
'address'=> (function_exists('mb_strlen')?mb_strlen($address,'UTF-8'):strlen($address)),
'city'=> (function_exists('mb_strlen')?mb_strlen($city,'UTF-8'):strlen($city)),
'state'=> (function_exists('mb_strlen')?mb_strlen($state,'UTF-8'):strlen($state)),
'zip'=> (function_exists('mb_strlen')?mb_strlen($zip,'UTF-8'):strlen($zip)),
'country'=> (function_exists('mb_strlen')?mb_strlen($country,'UTF-8'):strlen($country))
));
$z = new WZHF_Zoho();
if(!$z->is_connected()) return array('ok'=>false,'error'=>'zoho_disconnected');
// Reuse private request via reflection
$ref = new ReflectionClass('WZHF_Zoho');
$m = $ref->getMethod('request'); $m->setAccessible(true);
$res = $m->invoke($z, 'PUT', '/salesorders/'.rawurlencode($so_id), $payload);
$body = $res[0]; $err = $res[1];
if($body && !empty($body['salesorder'])){
WZHF_Logger::log('SO patch:ok', array('order_id'=>$order_id,'so'=>$so_id));
return array('ok'=>true,'so'=>$so_id);
} else {
WZHF_Logger::log('SO patch:error', array('order_id'=>$order_id,'so'=>$so_id,'err'=>$err));
return array('ok'=>false,'error'=>'patch_failed','details'=>$err);
}
}
public static function handle(WP_REST_Request $req){
if (!current_user_can('manage_woocommerce')) {
return new WP_REST_Response(array('error' => 'forbidden'), 403);
}
$op = sanitize_text_field($req->get_param('op') ? $req->get_param('op') : '');
switch ($op) {
case 'reset_map': {
$order_id = absint($req->get_param('order_id'));
if (!$order_id) return new WP_REST_Response(array('error' => 'order_id required'), 400);
global $wpdb;
$map = $wpdb->prefix . 'wzhf_orders';
$wpdb->delete($map, array('wc_order_id' => $order_id));
delete_post_meta($order_id, '_tracking_number');
delete_post_meta($order_id, '_tracking_carrier');
WZHF_Logger::log('tools:reset_map', array('order_id' => $order_id));
return new WP_REST_Response(array('ok' => true, 'message' => 'Mapping reset'), 200);
}
case 'resend_so': {
$order_id = absint($req->get_param('order_id'));
if (!$order_id) return new WP_REST_Response(array('error' => 'order_id required'), 400);
$order = wc_get_order($order_id);
if (!$order) return new WP_REST_Response(array('error' => 'order_not_found'), 404);
global $wpdb;
$map = $wpdb->prefix . 'wzhf_orders';
// Drop mapping to allow re-send
$wpdb->delete($map, array('wc_order_id' => $order_id));
WZHF_Logger::log('tools:resend_so', array('order_id' => $order_id));
// Try to create
WZHF_Orders_Sync::create_sales_order($order_id);
// Check result
$created = $wpdb->get_var($wpdb->prepare("SELECT zoho_salesorder_id FROM $map WHERE wc_order_id=%d", $order_id));
if ($created) {
return new WP_REST_Response(array('ok' => true, 'message' => 'Created SO: ' . $created), 200);
}
// Not created; hint common reasons
$z = new WZHF_Zoho();
if (!$z->is_connected()) {
return new WP_REST_Response(array('error' => 'zoho_disconnected', 'message' => 'Zoho not connected (refresh OAuth).'), 500);
}
return new WP_REST_Response(array('error' => 'resend_failed', 'message' => 'SO not created. See WooZoho → Логи for details.'), 500);
}
case 'patch_so_address': {
$order_id = absint($req->get_param('order_id'));
if (!$order_id) return new WP_REST_Response(array('error' => 'order_id required'), 400);
$order = wc_get_order($order_id);
if (!$order) return new WP_REST_Response(array('error' => 'order_not_found'), 404);
global $wpdb; $map = $wpdb->prefix . 'wzhf_orders';
$so_id = $wpdb->get_var($wpdb->prepare("SELECT zoho_salesorder_id FROM $map WHERE wc_order_id=%d", $order_id));
if(!$so_id) return new WP_REST_Response(array('error'=>'no_mapping','message'=>'SO mapping not found. Создайте SO сначала.'), 400);
// Build clamped shipping address from order
$ship_addr1 = trim($order->get_shipping_address_1());
$ship_addr2 = trim($order->get_shipping_address_2());
if($ship_addr1==='' && $ship_addr2===''){
// fallback to billing
$ship_addr1 = trim($order->get_billing_address_1());
$ship_addr2 = trim($order->get_billing_address_2());
}
$ship_city = trim($order->get_shipping_city()) ?: trim($order->get_billing_city());
$ship_state = trim($order->get_shipping_state()) ?: trim($order->get_billing_state());
$ship_zip = trim($order->get_shipping_postcode()) ?: trim($order->get_billing_postcode());
$ship_ctry = trim($order->get_shipping_country()) ?: trim($order->get_billing_country());
$addr_line = trim(preg_replace('/\s+/u',' ', trim($ship_addr1.' '.($ship_addr2?:''))));
// Clamp
if(function_exists('mb_substr')){
$address = mb_substr($addr_line,0,90,'UTF-8');
$city = mb_substr($ship_city,0,40,'UTF-8');
$state = mb_substr($ship_state,0,30,'UTF-8');
$zip = mb_substr($ship_zip,0,20,'UTF-8');
$country = mb_substr($ship_ctry,0,30,'UTF-8');
} else {
$address = substr($addr_line,0,90);
$city = substr($ship_city,0,40);
$state = substr($ship_state,0,30);
$zip = substr($ship_zip,0,20);
$country = substr($ship_ctry,0,30);
}
$payload = array('shipping_address'=>array(
'address'=>$address,'city'=>$city,'state'=>$state,'zip'=>$zip,'country'=>$country
));
WZHF_Logger::log('SO patch:addr-lengths', array(
'order_id'=>$order_id,
'address'=> (function_exists('mb_strlen')?mb_strlen($address,'UTF-8'):strlen($address)),
'city'=> (function_exists('mb_strlen')?mb_strlen($city,'UTF-8'):strlen($city)),
'state'=> (function_exists('mb_strlen')?mb_strlen($state,'UTF-8'):strlen($state)),
'zip'=> (function_exists('mb_strlen')?mb_strlen($zip,'UTF-8'):strlen($zip)),
'country'=> (function_exists('mb_strlen')?mb_strlen($country,'UTF-8'):strlen($country))
));
$z = new WZHF_Zoho();
if(!$z->is_connected()) return new WP_REST_Response(array('error'=>'zoho_disconnected'),500);
// Use Zoho request via reflection (quick reuse)
$ref = new ReflectionClass('WZHF_Zoho');
$m = $ref->getMethod('request'); $m->setAccessible(true);
$res = $m->invoke($z, 'PUT', '/salesorders/'.rawurlencode($so_id), $payload);
$body = $res[0]; $err = $res[1];
if($body && !empty($body['salesorder'])){
WZHF_Logger::log('SO patch:ok', array('order_id'=>$order_id,'so'=>$so_id));
return new WP_REST_Response(array('ok'=>true,'message'=>'Patched SO address: '+$so_id),200);
} else {
WZHF_Logger::log('SO patch:error', array('order_id'=>$order_id,'so'=>$so_id,'err'=>$err));
return new WP_REST_Response(array('error'=>'patch_failed','details'=>$err),500);
}
}
default:
return new WP_REST_Response(array('error' => 'unknown_op'), 400);
}
}
}

View File

@@ -0,0 +1,3 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Tracking_Sync { public static function bootstrap(){} }

3
inc/class-webhooks.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Webhooks { public static function bootstrap(){} public static function handle(WP_REST_Request $req){ return new WP_REST_Response(array('ok'=>true),200); } }

293
inc/class-zoho.php Normal file
View File

@@ -0,0 +1,293 @@
<?php
if (!defined('ABSPATH')) exit;
class WZHF_Zoho {
private $opt;
public function __construct(){ $this->opt=wzhf_get_option(); }
private function accounts_base(){ $dc=isset($this->opt['dc'])?$this->opt['dc']:'.eu'; return 'https://accounts.zoho'.$dc; }
private function api_base(){ $dc=isset($this->opt['dc'])?$this->opt['dc']:'.eu'; return 'https://www.zohoapis'.$dc.'/inventory/v1'; }
private function tokens(){ $t=get_option('wzhf_tokens'); return is_array($t)?$t:array(); }
private function save_tokens($t){ update_option('wzhf_tokens',$t,false); }
public function is_connected(){ $t=$this->tokens(); return !empty($t['access_token']) && !empty($t['refresh_token']); }
public function ensure_token(){
$t=$this->tokens();
if(empty($t['access_token']) || time()>(int)(isset($t['expires_at'])?$t['expires_at']:0)){
if(empty($t['refresh_token'])) return false;
$res=wp_remote_post($this->accounts_base().'/oauth/v2/token', array('body'=>array(
'grant_type'=>'refresh_token','refresh_token'=>$t['refresh_token'],'client_id'=>isset($this->opt['cid'])?$this->opt['cid']:'','client_secret'=>isset($this->opt['sec'])?$this->opt['sec']:''
), 'timeout'=>25 ));
if(is_wp_error($res)){ WZHF_Logger::log('Refresh token error',array('err'=>$res->get_error_message())); return false; }
$data=json_decode(wp_remote_retrieve_body($res),true);
if(!empty($data['access_token'])){ $t['access_token']=$data['access_token']; $t['expires_at']=time()+((int)(isset($data['expires_in'])?$data['expires_in']:3600))-60; $this->save_tokens($t); }
else { WZHF_Logger::log('Refresh response missing token',array('raw'=>$data)); return false; }
}
return isset($t['access_token'])?$t['access_token']:false;
}
public static function oauth_callback(WP_REST_Request $req){
$code=$req->get_param('code'); $opt=wzhf_get_option(); if(!$code) return new WP_REST_Response(array('error'=>'no_code'),400);
$res=wp_remote_post('https://accounts.zoho'.(isset($opt['dc'])?$opt['dc']:'.eu').'/oauth/v2/token', array( 'body'=>array(
'grant_type'=>'authorization_code','code'=>$code,'client_id'=>isset($opt['cid'])?$opt['cid']:'','client_secret'=>isset($opt['sec'])?$opt['sec']:'','redirect_uri'=>rest_url(WZHF_NS.'/oauth/callback')
), 'timeout'=>25 ));
if(is_wp_error($res)) return new WP_REST_Response(array('error'=>$res->get_error_message()),500);
$data=json_decode(wp_remote_retrieve_body($res),true);
if(empty($data['access_token'])) return new WP_REST_Response(array('error'=>'token_exchange_failed','raw'=>$data),500);
update_option('wzhf_tokens',array( 'access_token'=>$data['access_token'],'refresh_token'=>isset($data['refresh_token'])?$data['refresh_token']:'','expires_at'=>time()+((int)(isset($data['expires_in'])?$data['expires_in']:3600))-60 ), false);
wp_safe_redirect(admin_url('admin.php?page='.WZHF_SLUG.'&connected=1')); exit;
}
private function headers(){ $t=$this->ensure_token(); if(!$t) return array('Authorization'=>''); return array('Authorization'=>'Zoho-oauthtoken '.$t, 'Content-Type'=>'application/json;charset=UTF-8'); }
private function request($method,$path,$args=null,$query=array()){
$url=$this->api_base().$path; if(!isset($query['organization_id'])) $query['organization_id']=isset($this->opt['org'])?$this->opt['org']:'';
$url .= (strpos($url,'?')===false?'?':'&').http_build_query($query);
$res=wp_remote_request($url, array('method'=>$method,'headers'=>$this->headers(),'body'=>$args?wp_json_encode($args):null,'timeout'=>30));
if(is_wp_error($res)){ WZHF_Logger::log('Zoho HTTP error',array('err'=>$res->get_error_message(),'url'=>$url)); return array(null,$res->get_error_message()); }
$code=wp_remote_retrieve_response_code($res); $body=json_decode(wp_remote_retrieve_body($res),true);
if($code>=400){ WZHF_Logger::log('Zoho API error',array('code'=>$code,'body'=>$body,'path'=>$path)); return array(null,$body); }
return array($body,null);
}
private function clamp($s,$max){
$s = is_string($s) ? $s : trim((string)$s);
$s = preg_replace('/\s+/u',' ', trim($s));
if(function_exists('mb_strlen') && function_exists('mb_substr')){
if(mb_strlen($s,'UTF-8')>$max){ return mb_substr($s,0,$max,'UTF-8'); }
} else {
if(strlen($s)>$max){ return substr($s,0,$max); }
}
return $s;
}
private function pick($a,$b){ $a = trim((string)$a); $b = trim((string)$b); return $a!=='' ? $a : $b; }
private function get_contact($contact_id){
list($res,$err) = $this->request('GET','/contacts/'.rawurlencode($contact_id),null,array());
if($res && !empty($res['contact'])) return $res['contact'];
return null;
}
private function update_contact_addresses($contact_id,$order,$mode){
// Build billing
$b_addr1 = $this->pick($order->get_billing_address_1(), $order->get_shipping_address_1());
$b_addr2 = $this->pick($order->get_billing_address_2(), $order->get_shipping_address_2());
$b_city = $this->pick($order->get_billing_city(), $order->get_shipping_city());
$b_state = $this->pick($order->get_billing_state(), $order->get_shipping_state());
$b_zip = $this->pick($order->get_billing_postcode(), $order->get_shipping_postcode());
$b_ctry = $this->pick($order->get_billing_country(), $order->get_shipping_country());
$b_line = trim($b_addr1.' '.($b_addr2?:''));
// Build shipping
$s_addr1 = $this->pick($order->get_shipping_address_1(), $order->get_billing_address_1());
$s_addr2 = $this->pick($order->get_shipping_address_2(), $order->get_billing_address_2());
$s_city = $this->pick($order->get_shipping_city(), $order->get_billing_city());
$s_state = $this->pick($order->get_shipping_state(), $order->get_billing_state());
$s_zip = $this->pick($order->get_shipping_postcode(), $order->get_billing_postcode());
$s_ctry = $this->pick($order->get_shipping_country(), $order->get_billing_country());
$s_line = trim($s_addr1.' '.($s_addr2?:''));
$new_ba = array(
'address' => $this->clamp($b_line, 90),
'city' => $this->clamp($b_city, 40),
'state' => $this->clamp($b_state, 30),
'zip' => $this->clamp($b_zip, 20),
'country' => $this->clamp($b_ctry, 30)
);
$new_sa = array(
'address' => $this->clamp($s_line, 90),
'city' => $this->clamp($s_city, 40),
'state' => $this->clamp($s_state, 30),
'zip' => $this->clamp($s_zip, 20),
'country' => $this->clamp($s_ctry, 30)
);
$full = $this->get_contact($contact_id);
$cur_ba = $full && isset($full['billing_address']) ? $full['billing_address'] : array();
$cur_sa = $full && isset($full['shipping_address']) ? $full['shipping_address'] : array();
$is_empty_ba = empty(trim(isset($cur_ba['address'])?$cur_ba['address']:''));
$is_empty_sa = empty(trim(isset($cur_sa['address'])?$cur_sa['address']:''));
$opt = wzhf_get_option();
$mode = in_array($mode, array('always','if_empty','never')) ? $mode : (isset($opt['contact_addr_sync'])?$opt['contact_addr_sync']:'always');
$should = false;
if($mode==='always'){ $should=true; }
else if($mode==='if_empty'){ $should = ($is_empty_ba || $is_empty_sa); }
else { $should=false; }
if(!$should){
WZHF_Logger::log('contact_update_addresses_skipped', array('contact_id'=>$contact_id,'mode'=>$mode));
return true;
}
$payload = array('billing_address'=>$new_ba,'shipping_address'=>$new_sa);
WZHF_Logger::log('contact_update_addresses_applied', array('contact_id'=>$contact_id,'mode'=>$mode));
list($res,$err) = $this->request('PUT','/contacts/'.rawurlencode($contact_id), $payload);
return $res && !empty($res['contact']);
}
private function create_contact_from_order($order){
// name/company
$name = $order->get_billing_company();
if(!$name){
$name = trim($order->get_billing_first_name().' '.$order->get_billing_last_name());
if(!$name) $name = 'WC Customer '.$order->get_billing_email();
}
$email = $order->get_billing_email();
// build billing & shipping
$b_addr1 = $this->pick($order->get_billing_address_1(), $order->get_shipping_address_1());
$b_addr2 = $this->pick($order->get_billing_address_2(), $order->get_shipping_address_2());
$b_city = $this->pick($order->get_billing_city(), $order->get_shipping_city());
$b_state = $this->pick($order->get_billing_state(), $order->get_shipping_state());
$b_zip = $this->pick($order->get_billing_postcode(), $order->get_shipping_postcode());
$b_ctry = $this->pick($order->get_billing_country(), $order->get_shipping_country());
$b_line = trim($b_addr1.' '.($b_addr2?:''));
$s_addr1 = $this->pick($order->get_shipping_address_1(), $order->get_billing_address_1());
$s_addr2 = $this->pick($order->get_shipping_address_2(), $order->get_billing_address_2());
$s_city = $this->pick($order->get_shipping_city(), $order->get_billing_city());
$s_state = $this->pick($order->get_shipping_state(), $order->get_billing_state());
$s_zip = $this->pick($order->get_shipping_postcode(), $order->get_billing_postcode());
$s_ctry = $this->pick($order->get_shipping_country(), $order->get_billing_country());
$s_line = trim($s_addr1.' '.($s_addr2?:''));
$contact = array(
'contact_name' => $this->clamp($name, 100),
'contact_type' => 'customer',
'company_name' => $this->clamp($order->get_billing_company() ? $order->get_billing_company() : $name, 100),
'email' => $email,
'billing_address' => array(
'address' => $this->clamp($b_line, 90),
'city' => $this->clamp($b_city, 40),
'state' => $this->clamp($b_state, 30),
'zip' => $this->clamp($b_zip, 20),
'country' => $this->clamp($b_ctry, 30),
'phone' => $this->clamp($order->get_billing_phone(), 30),
),
'shipping_address' => array(
'address' => $this->clamp($s_line, 90),
'city' => $this->clamp($s_city, 40),
'state' => $this->clamp($s_state, 30),
'zip' => $this->clamp($s_zip, 20),
'country' => $this->clamp($s_ctry, 30),
),
'contact_persons' => array()
);
if($email){
$contact['contact_persons'][] = array(
'first_name' => $this->clamp($order->get_billing_first_name(), 50),
'last_name' => $this->clamp($order->get_billing_last_name(), 50),
'email' => $email,
'is_primary_contact' => true
);
}
list($res,$err)=$this->request('POST','/contacts',$contact);
if($res && !empty($res['contact'])) return array($res['contact'], null);
WZHF_Logger::log('contact_create_failed', array('order_id'=>$order->get_id(),'err'=>$err));
return array(null, $err ? $err : 'contact_create_failed');
}
private function find_item_by_sku($sku){
if(!$sku) return null; $key='wzhf_item_'.md5($sku); $c=get_transient($key); if($c) return $c;
list($res,$err)=$this->request('GET','/items',null,array('search_text'=>$sku));
if($res && !empty($res['items'])){
foreach($res['items'] as $it){
$skuVal = isset($it['sku']) ? $it['sku'] : '';
if($skuVal===$sku){ set_transient($key,$it,3600); return $it; }
}
}
return null;
}
private function get_normilize_product_title($product) {
$title = '';
if (method_exists($product, 'get_name')) { $title = (string) $product->get_name(); }
if (!$title) { $title = (string) get_the_title($product->id); }
if (!$title) { return ''; }
$packageCount = get_field("number_packages", $product->id);
if ($packageCount > 1) { $title .= ' ' . $packageCount . '-Pack Bundle'; }
return $title;
}
private function create_item_from_wc($product){
$rate = $product->get_regular_price();
if($rate==='' || $rate===null) $rate = $product->get_price();
$payload = array(
'name' => $this->get_normilize_product_title($product),
'rate' => (float)$rate,
'sku' => $product->get_sku(),
'item_type' => 'goods'
);
list($res,$err)=$this->request('POST','/items',$payload);
return $res && isset($res['item']) ? $res['item'] : null;
}
public function create_sales_order_from_wc($order,$settings=array()){
// line items
$lines=array(); $create_missing=!empty($settings['create_missing']);
foreach($order->get_items() as $item){
$product=$item->get_product(); if(!$product) continue;
$sku=$product->get_sku(); $zi=$sku?$this->find_item_by_sku($sku):null; if(!$zi && $create_missing){ $zi=$this->create_item_from_wc($product); }
$line=array('name'=>$this->get_normilize_product_title($product),'rate'=>(float)$order->get_item_subtotal($item,false),'quantity'=>(float)$item->get_quantity(), 'tax_id' => '5868409000003263039');
if($zi && !empty($zi['item_id'])) $line['item_id']=$zi['item_id']; if($sku) $line['sku']=$sku; $lines[]=$line;
}
// Diagnostics: compute address lengths (we don't send them in SO)
$ship_addr1 = $this->pick($order->get_shipping_address_1(), $order->get_billing_address_1());
$ship_addr2 = $this->pick($order->get_shipping_address_2(), $order->get_billing_address_2());
$ship_city = $this->pick($order->get_shipping_city(), $order->get_billing_city());
$ship_state = $this->pick($order->get_shipping_state(), $order->get_billing_state());
$ship_zip = $this->pick($order->get_shipping_postcode(), $order->get_billing_postcode());
$ship_ctry = $this->pick($order->get_shipping_country(), $order->get_billing_country());
$addr_line = trim($ship_addr1.' '.($ship_addr2?:''));
$addr = array(
'address'=>$this->clamp($addr_line, 90),
'city'=>$this->clamp($ship_city, 40),
'state'=>$this->clamp($ship_state, 30),
'zip'=>$this->clamp($ship_zip, 20),
'country'=>$this->clamp($ship_ctry, 30)
);
$len = array(
'address'=>(function_exists('mb_strlen')?mb_strlen($addr['address'],'UTF-8'):strlen($addr['address'])),
'city'=>(function_exists('mb_strlen')?mb_strlen($addr['city'],'UTF-8'):strlen($addr['city'])),
'state'=>(function_exists('mb_strlen')?mb_strlen($addr['state'],'UTF-8'):strlen($addr['state'])),
'zip'=>(function_exists('mb_strlen')?mb_strlen($addr['zip'],'UTF-8'):strlen($addr['zip'])),
'country'=>(function_exists('mb_strlen')?mb_strlen($addr['country'],'UTF-8'):strlen($addr['country']))
);
WZHF_Logger::log('SO create:addr-lengths', $len);
WZHF_Logger::log('SO create:payload-no-address');
// Ensure customer (and sync contact addresses as per setting)
$email = $order->get_billing_email();
$customer_id = null;
if($email){
list($find1,$e1)=$this->request('GET','/contacts',null,array('email'=>$email));
if($find1 && !empty($find1['contacts'])) $customer_id=$find1['contacts'][0]['contact_id'];
if(!$customer_id){
list($find2,$e2)=$this->request('GET','/contacts',null,array('search_text'=>$email));
if($find2 && !empty($find2['contacts'])) $customer_id=$find2['contacts'][0]['contact_id'];
}
}
if(!$customer_id){
list($cr,$cerr) = $this->create_contact_from_order($order);
if($cr && !empty($cr['contact_id'])) $customer_id = $cr['contact_id'];
} else {
$mode = isset($this->opt['contact_addr_sync']) ? $this->opt['contact_addr_sync'] : 'always';
$this->update_contact_addresses($customer_id, $order, $mode);
}
if(!$customer_id){ WZHF_Logger::log('No customer_id resolved for SO',array('order_id'=>$order->get_id())); return array(null, 'no_customer'); }
$body=array(
'customer_id'=>$customer_id,
'reference_number'=>(string)$order->get_id(),
'date'=>current_time('Y-m-d'),
'line_items'=>$lines,
'notes'=>'WC Order #'.$order->get_order_number()
);
error_log('=====LOG START=====');
error_log(json_encode($body));
error_log('=====LOG END=====');
list($res,$err)=$this->request('POST','/salesorders',$body);
if(!$res || empty($res['salesorder'])) return array(null, ($err ? $err : 'unknown'));
return array($res['salesorder'],null);
}
}

11
inc/helpers.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
if (!defined('ABSPATH')) exit;
function wzhf_get_option($key=null,$default=null){ $opt=get_option(WZHF_OPT,array()); if($key===null) return is_array($opt)?$opt:array(); return isset($opt[$key])?$opt[$key]:$default; }
function wzhf_update_option($key,$value){ $opt=get_option(WZHF_OPT,array()); if(!is_array($opt)) $opt=array(); $opt[$key]=$value; update_option(WZHF_OPT,$opt,false); return true; }
function wzhf_admin_cap(){ return current_user_can('manage_woocommerce'); }
function wzhf_nonce_field(){ wp_nonce_field('wzhf_save_settings','wzhf_nonce'); }
function wzhf_verified_nonce(){ return check_admin_referer('wzhf_save_settings','wzhf_nonce'); }
function wzhf_selected($a,$b){ if((string)$a===(string)$b) echo ' selected'; }
function wzhf_checked($a,$b=true){ if(!!$a===!!$b) echo ' checked'; }
function wzhf_sanitize_bool($v){ return $v==='1'||$v===1||$v===true||$v==='on'; }
add_filter('cron_schedules', function($s){ $s['wzhf_5min']=array('interval'=>300,'display'=>__('Every 5 Minutes','woozoho-fulfillment')); $s['wzhf_15min']=array('interval'=>900,'display'=>__('Every 15 Minutes','woozoho-fulfillment')); $s['wzhf_1h']=array('interval'=>3600,'display'=>__('Every hour','woozoho-fulfillment')); return $s; });

166
views/settings-page.php Normal file
View File

@@ -0,0 +1,166 @@
<?php if (!defined('ABSPATH')) exit; ?>
<div class="wrap" id="wzhf">
<h1>WooZoho Fulfillment</h1>
<p class="muted">Автосоздание Sales Orders в Zoho. Версия <?php echo esc_html(WZHF_VER); ?></p>
<div class="card">
<strong>Статус подключения:</strong>
<?php $z = new WZHF_Zoho(); if ($z->is_connected()): ?><span class="status-pill status-ok">Connected</span><?php else: ?><span class="status-pill status-bad">Not connected</span><?php endif; ?>
</div>
<div class="tabs">
<a href="#" data-tab="general" class="active">Общее</a>
<a href="#" data-tab="oauth">OAuth</a>
<a href="#" data-tab="orders">Заказы</a>
<a href="#" data-tab="tools">Тесты / Инструменты</a>
<a href="#" data-tab="logs">Логи</a>
</div>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="wzhf_save"><?php wzhf_nonce_field(); ?>
<div class="tab" data-tab="general">
<div class="grid">
<div class="card">
<h2>Zoho датацентр и организация</h2>
<label>Датацентр</label>
<select name="dc">
<?php $dc=esc_attr(isset($opt['dc'])?$opt['dc']:'.eu'); ?>
<option value=".eu" <?php wzhf_selected($dc,'.eu');?>>.eu</option>
<option value=".com" <?php wzhf_selected($dc,'.com');?>>.com</option>
<option value=".in" <?php wzhf_selected($dc,'.in');?>>.in</option>
<option value=".com.au" <?php wzhf_selected($dc,'.com.au');?>>.com.au</option>
</select>
<label>Organization ID</label>
<input type="text" name="org" value="<?php echo esc_attr(isset($opt['org'])?$opt['org']:''); ?>" />
</div>
<div class="card">
<h2>Патч адреса в SO (без JS)</h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="wzhf_patch_so" />
<?php wzhf_nonce_field(); ?>
<label>Order ID (для серверного действия)</label>
<input type="number" name="order_id_patch" min="1" placeholder="например, 20441" />
<p><button type="submit" class="button">Обновить адрес в существующем SO</button></p>
</form>
</div>
</div>
</div>
<div class="tab" data-tab="oauth" style="display:none">
<div class="grid">
<div class="card">
<h2>OAuth Клиент</h2>
<label>Client ID</label><input type="text" name="cid" value="<?php echo esc_attr(isset($opt['cid'])?$opt['cid']:''); ?>" />
<label>Client Secret</label><input type="password" name="sec" value="<?php echo esc_attr(isset($opt['sec'])?$opt['sec']:''); ?>" />
<label>Scopes</label><input type="text" name="scopes" value="<?php echo esc_attr(isset($opt['scopes'])?$opt['scopes']:'ZohoInventory.FullAccess.all'); ?>" />
<p class="muted">Redirect URI: <code class="mono"><?php echo esc_html(rest_url(WZHF_NS.'/oauth/callback')); ?></code></p>
<?php if(!$connected): ?>
<?php if(!empty($auth)): ?><p><a class="button button-primary" href="<?php echo esc_url($auth); ?>">Подключить Zoho (OAuth)</a></p>
<?php else: ?><p class="muted">Заполните Client ID/Secret и Scopes, сохраните — появится кнопка подключения.</p><?php endif; ?>
<?php else: ?><p><span class="status-pill status-ok">Соединение установлено</span></p><?php endif; ?>
</div>
<div class="card">
<h2>Патч адреса в SO (без JS)</h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="wzhf_patch_so" />
<?php wzhf_nonce_field(); ?>
<label>Order ID (для серверного действия)</label>
<input type="number" name="order_id_patch" min="1" placeholder="например, 20441" />
<p><button type="submit" class="button">Обновить адрес в существующем SO</button></p>
</form>
</div>
</div>
</div>
<div class="tab" data-tab="orders" style="display:none">
<div class="grid">
<div class="card">
<h2>Когда отправлять заказ в Zoho</h2>
<?php $tr=esc_attr(isset($opt['trigger_mode'])?$opt['trigger_mode']:'processed'); ?>
<label>Триггер</label>
<select name="trigger_mode">
<option value="processed" <?php wzhf_selected($tr,'processed');?>>Сразу после оформления</option>
<option value="paid" <?php wzhf_selected($tr,'paid');?>>После оплаты</option>
</select>
<label><input type="checkbox" name="create_missing" <?php wzhf_checked(!empty($opt['create_missing'])); ?> /> Создавать отсутствующие товары в Zoho по SKU</label>
<label><input type="checkbox" name="set_status_after_so" <?php wzhf_checked(!empty($opt['set_status_after_so'])); ?> /> Менять статус заказа после создания SO</label>
<?php $sa=esc_attr(isset($opt['status_after_so'])?$opt['status_after_so']:'processing'); ?>
<select name="status_after_so">
<option value="processing" <?php wzhf_selected($sa,'processing');?>>processing</option>
<option value="on-hold" <?php wzhf_selected($sa,'on-hold');?>>on-hold</option>
<option value="completed" <?php wzhf_selected($sa,'completed');?>>completed</option>
</select>
<label>Синхронизация адресов контакта</label>
<?php $cas = esc_attr(isset($opt['contact_addr_sync'])?$opt['contact_addr_sync']:'always'); ?>
<select name="contact_addr_sync">
<option value="always" <?php wzhf_selected($cas,'always');?>>Всегда из заказа</option>
<option value="if_empty" <?php wzhf_selected($cas,'if_empty');?>>Только если пусто в Zoho</option>
<option value="never" <?php wzhf_selected($cas,'never');?>>Не синхронизировать</option>
</select>
</div>
<div class="card">
<h2>Патч адреса в SO (без JS)</h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="wzhf_patch_so" />
<?php wzhf_nonce_field(); ?>
<label>Order ID (для серверного действия)</label>
<input type="number" name="order_id_patch" min="1" placeholder="например, 20441" />
<p><button type="submit" class="button">Обновить адрес в существующем SO</button></p>
</form>
</div>
</div>
</div>
<div class="tab" data-tab="tools" style="display:none">
<div class="tools-grid">
<div class="card">
<h2>С конкретным заказом</h2>
<label>Order ID</label>
<input type="number" id="tool-order-id" min="1" placeholder="например, 20372" />
<p>
<a href="#" class="button" id="btn-reset-map">Сбросить mapping и трек</a>
<span class="result" id="res-reset-map"></span>
</p>
<p>
<a href="#" class="button" id="btn-resend-so">Отправить заказ в Zoho заново (создать SO)</a>
<span class="result" id="res-resend-so"></span>
</p>
<p>
<a href="#" class="button" id="btn-patch-so-address">Обновить адрес в существующем SO (из заказа)</a>
<span class="result" id="res-patch-so"></span>
</p>
</div>
<div class="card">
<h2>Патч адреса в SO (без JS)</h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="wzhf_patch_so" />
<?php wzhf_nonce_field(); ?>
<label>Order ID (для серверного действия)</label>
<input type="number" name="order_id_patch" min="1" placeholder="например, 20441" />
<p><button type="submit" class="button">Обновить адрес в существующем SO</button></p>
</form>
</div>
</div>
</div>
<div class="tab" data-tab="logs" style="display:none">
<div class="card">
<h2>Последние логи</h2>
<?php $logs=WZHF_Logger::latest_logs(); if(empty($logs)): ?>
<p class="muted">Логи пока пусты.</p>
<?php else: foreach($logs as $file=>$content): ?>
<h3><?php echo esc_html($file); ?></h3>
<pre class="mono" style="max-height:300px;overflow:auto;background:#0b1020;color:#cde3ff;padding:12px;border-radius:8px;"><?php echo esc_html($content); ?></pre>
<?php endforeach; endif; ?>
</div>
</div>
<p><button type="submit" class="button button-primary">Сохранить настройки</button></p>
</form>
</div>

80
woozoho-fulfillment.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
/**
* Plugin Name: WooZoho Fulfillment
* Description: WooCommerce ↔ Zoho Inventory: auto-create Sales Orders. Stable build with contact address auto-sync before SO.
* Version: 0.5.6o
* Author: Aleksandr Kapustian
* Requires Plugins: woocommerce
* Text Domain: woozoho-fulfillment
*/
if (!defined('ABSPATH')) exit;
define('WZHF_VER','0.5.6o');
define('WZHF_SLUG','woozoho-fulfillment');
define('WZHF_OPT','wzhf_options');
define('WZHF_NS','wzhf/v1');
define('WZHF_DIR', plugin_dir_path(__FILE__));
define('WZHF_URL', plugin_dir_url(__FILE__));
register_activation_hook(__FILE__, function () {
if ( !class_exists('WooCommerce') ) {
update_option('wzhf_missing_wc_on_activation', '1', false);
}
// Ensure mapping table exists
global $wpdb;
$table = $wpdb->prefix . 'wzhf_orders';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
wc_order_id BIGINT UNSIGNED NOT NULL,
zoho_salesorder_id VARCHAR(64) NOT NULL,
PRIMARY KEY (wc_order_id),
KEY zoho_salesorder_id (zoho_salesorder_id)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
});
add_action('admin_init', function(){
if ( get_option('wzhf_missing_wc_on_activation') === '1' ) {
delete_option('wzhf_missing_wc_on_activation');
if ( function_exists('is_plugin_active') && is_plugin_active(plugin_basename(__FILE__)) ) {
deactivate_plugins(plugin_basename(__FILE__));
}
add_action('admin_notices', function(){
echo '<div class="notice notice-error"><p>WooZoho Fulfillment: WooCommerce не активирован. Плагин отключён.</p></div>';
});
}
});
// Includes
require_once __DIR__.'/inc/helpers.php';
require_once __DIR__.'/inc/class-logger.php';
require_once __DIR__.'/inc/class-zoho.php';
require_once __DIR__.'/inc/class-orders-sync.php';
require_once __DIR__.'/inc/class-webhooks.php';
require_once __DIR__.'/inc/class-inventory-sync.php';
require_once __DIR__.'/inc/class-tracking-sync.php';
require_once __DIR__.'/inc/class-inbound-email.php';
require_once __DIR__.'/inc/class-tools.php';
require_once __DIR__.'/inc/class-admin.php';
add_action('init', function (){
add_action('rest_api_init', function(){
register_rest_route(WZHF_NS, '/oauth/callback', array(
'methods'=>'GET','callback'=>array('WZHF_Zoho','oauth_callback'),'permission_callback'=>'__return_true'
));
register_rest_route(WZHF_NS, '/webhook', array(
'methods'=>'POST','callback'=>array('WZHF_Webhooks','handle'),'permission_callback'=>'__return_true'
));
});
});
add_action('plugins_loaded', function(){
new WZHF_Admin();
WZHF_Orders_Sync::bootstrap();
WZHF_Inventory_Sync::bootstrap();
WZHF_Webhooks::bootstrap();
WZHF_Tracking_Sync::bootstrap();
WZHF_Inbound_Email::bootstrap();
WZHF_Tools::bootstrap();
});