commit ed99fc3cf525910e458b3739598d7ec95a5893d3 Author: dholly Date: Fri May 15 21:06:41 2026 +0700 init' diff --git a/assets/admin.css b/assets/admin.css new file mode 100644 index 0000000..7fe5f90 --- /dev/null +++ b/assets/admin.css @@ -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} diff --git a/assets/admin.js b/assets/admin.js new file mode 100644 index 0000000..627a9b3 --- /dev/null +++ b/assets/admin.js @@ -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'); + }); diff --git a/inc/class-admin.php b/inc/class-admin.php new file mode 100644 index 0000000..fa51c3d --- /dev/null +++ b/inc/class-admin.php @@ -0,0 +1,42 @@ + 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 '

'.esc_html($msg).'

'; + } + + $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; + } +} diff --git a/inc/class-inbound-email.php b/inc/class-inbound-email.php new file mode 100644 index 0000000..582d1df --- /dev/null +++ b/inc/class-inbound-email.php @@ -0,0 +1,3 @@ +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)); + } + } +} diff --git a/inc/class-tools.php b/inc/class-tools.php new file mode 100644 index 0000000..9bb3165 --- /dev/null +++ b/inc/class-tools.php @@ -0,0 +1,205 @@ + 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); + } + } +} diff --git a/inc/class-tracking-sync.php b/inc/class-tracking-sync.php new file mode 100644 index 0000000..a4fe6aa --- /dev/null +++ b/inc/class-tracking-sync.php @@ -0,0 +1,3 @@ +true),200); } } diff --git a/inc/class-zoho.php b/inc/class-zoho.php new file mode 100644 index 0000000..b322259 --- /dev/null +++ b/inc/class-zoho.php @@ -0,0 +1,293 @@ +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); + } +} diff --git a/inc/helpers.php b/inc/helpers.php new file mode 100644 index 0000000..762ed20 --- /dev/null +++ b/inc/helpers.php @@ -0,0 +1,11 @@ +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; }); diff --git a/views/settings-page.php b/views/settings-page.php new file mode 100644 index 0000000..ebbc62a --- /dev/null +++ b/views/settings-page.php @@ -0,0 +1,166 @@ + +
+

WooZoho Fulfillment

+

Автосоздание Sales Orders в Zoho. Версия

+ +
+ Статус подключения: + is_connected()): ?>ConnectedNot connected +
+ + + +
+ + +
+
+
+

Zoho датацентр и организация

+ + + + +
+ +
+

Патч адреса в SO (без JS)

+ + + + + +

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

+ +
diff --git a/woozoho-fulfillment.php b/woozoho-fulfillment.php new file mode 100644 index 0000000..e71f55a --- /dev/null +++ b/woozoho-fulfillment.php @@ -0,0 +1,80 @@ +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 '

WooZoho Fulfillment: WooCommerce не активирован. Плагин отключён.

'; + }); + } +}); + +// 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(); +});