init'
This commit is contained in:
42
inc/class-admin.php
Normal file
42
inc/class-admin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
3
inc/class-inbound-email.php
Normal file
3
inc/class-inbound-email.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
class WZHF_Inbound_Email { public static function bootstrap(){} }
|
||||
3
inc/class-inventory-sync.php
Normal file
3
inc/class-inventory-sync.php
Normal 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
16
inc/class-logger.php
Normal 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
124
inc/class-orders-sync.php
Normal 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
205
inc/class-tools.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
inc/class-tracking-sync.php
Normal file
3
inc/class-tracking-sync.php
Normal 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
3
inc/class-webhooks.php
Normal 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
293
inc/class-zoho.php
Normal 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
11
inc/helpers.php
Normal 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; });
|
||||
Reference in New Issue
Block a user