__('Cancellations Intelligence & Prevention', 'ai-eshop-optimizer'), 'description' => __('A full-stack defence against the silent profit leak that is cancelled / refunded / failed orders. Captures WHY customers cancel at the moment it happens, builds a per-customer risk profile from their history, and uses that profile to PREVENT the next cancellation before it costs you anything — automatic COD blocking and warning notices at checkout for repeat cancellers and returners.', 'ai-eshop-optimizer'), 'bullets' => array( __('Live capture: every cancel / refund flips into a structured row with reason, products, lost revenue and refund type.', 'ai-eshop-optimizer'), __('Per-customer risk_band (0 = normal · 1 = elevated · 2 = high) derived from their cancel + return + refund pattern.', 'ai-eshop-optimizer'), __('Preventative — auto-block Cash-on-Delivery for high-risk users so chronic refusers cannot place another COD order that costs you outbound + return shipping.', 'ai-eshop-optimizer'), __('Preventative — optional warning notice on the checkout page for elevated / high-risk customers (purely informational; the customer still places the order).', 'ai-eshop-optimizer'), __('Per-product cancellation + return rates — find the SKUs whose PDP copy or photography is misleading customers.', 'ai-eshop-optimizer'), __('Cross-cuts with attribution + campaigns — see which traffic source and which UTM campaign deliver the highest-cancellation cohort.', 'ai-eshop-optimizer'), __('Outbound carrier-cost resolver — reads the actual courier cost behind every cancelled-after-shipment order so the ROI report is honest, not aspirational.', 'ai-eshop-optimizer'), __('Five drill-down tabs — Overview · Returns ledger · Products · Customers · Reason vocabulary editor.', 'ai-eshop-optimizer'), ), ); } public function register_hooks() { if (is_admin()) { add_action('admin_enqueue_scripts', function () { if ((isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '') === 'ai-eshop-dmm' && (isset($_GET['dmm_tab']) ? sanitize_text_field(wp_unslash($_GET['dmm_tab'])) : '') === 'cancellations') { $css = 'assets/css/aieo-cancellations-admin.css'; wp_enqueue_style('aieo-cancellations-admin', AIEO_URI . $css, array(), file_exists(AIEO_DIR . $css) ? (string) filemtime(AIEO_DIR . $css) : '1'); } }); } add_action('rest_api_init', array($this, 'register_order_route')); add_action('wp_ajax_aieo_dmm_cancel_reason_save', array($this, 'ajax_save_reason')); add_action('wp_ajax_aieo_dmm_cancel_reason_disable', array($this, 'ajax_disable_reason')); add_action('wp_ajax_aieo_dmm_metrics_backfill', array($this, 'ajax_metrics_backfill')); add_action('wp_ajax_aieo_dmm_metrics_backfill_step', array($this, 'ajax_metrics_backfill_step')); add_action('wp_ajax_aieo_dmm_metrics_recompute_stale', array($this, 'ajax_metrics_recompute_stale')); add_action('wp_ajax_aieo_dmm_risk_gating_toggle', array($this, 'ajax_risk_gating_toggle')); add_action('wp_ajax_aieo_dmm_outbound_cost_step', array($this, 'ajax_outbound_cost_step')); } public function get_tab_arg() { return 'cn_section'; } public function get_tabs() { return array( 'overview' => array('label' => __('Overview', 'ai-eshop-optimizer'), 'icon' => 'layout-dashboard'), 'returns' => array('label' => __('Returns ledger', 'ai-eshop-optimizer'), 'icon' => 'scroll-text'), 'products' => array('label' => __('Products', 'ai-eshop-optimizer'), 'icon' => 'package'), 'customers' => array('label' => __('Customers', 'ai-eshop-optimizer'), 'icon' => 'users'), 'vocabulary' => array('label' => __('Reason vocabulary', 'ai-eshop-optimizer'), 'icon' => 'book-open'), ); } public function render_subtab() { $base_url = admin_url('admin.php?page=' . AIEO_DMM::MENU_SLUG . '&dmm_tab=cancellations'); $tabs = $this->get_tabs(); $section = isset($_GET['cn_section']) ? sanitize_key((string) $_GET['cn_section']) : 'overview'; if (!isset($tabs[$section])) { $section = 'overview'; } AIEO_DMM_UI::tab_strip($this->get_id(), $tabs, $section, array( 'arg' => $this->get_tab_arg(), 'wrapper' => 'div', 'margin' => '14px', )); if ('overview' === $section || !isset($tabs[$section])) { $this->render_overview(); return; } $this->band_open(array('cols' => 1)); if ($section === 'vocabulary') { $this->render_vocabulary(); } elseif ($section === 'returns') { $this->render_returns_ledger($base_url); } elseif ($section === 'products') { $this->render_products($base_url); } elseif ($section === 'customers') { $this->render_customers($base_url); } $this->band_close(); } private function card_open(array $c) { list($open, $close) = AIEO_DMM_UI::card($c); echo $open; return $close; } private function band_open(array $opts) { if (function_exists('rooster_admin_band_open')) { rooster_admin_band_open($opts); } } private function band_close() { if (function_exists('rooster_admin_band_close')) { rooster_admin_band_close(); } } private function chrome_on() { return class_exists('AIEO_Chrome') && AIEO_Chrome::on() && function_exists('rooster_admin_card_section_html'); } private function stats(array $stats) { if (class_exists('\Rooster\Admin\Ui')) { echo '
'; foreach ($stats as $s) { echo \Rooster\Admin\Ui::stat((string) $s[0], (string) $s[1], !empty($s[2]) ? array('hint' => (string) $s[2]) : array()); } echo '
'; return; } echo '
'; foreach ($stats as $s) { echo '
' . esc_html((string) $s[0]) . '
' . esc_html((string) $s[1]) . (!empty($s[2]) ? ' (' . esc_html((string) $s[2]) . ')' : '') . '
'; } echo '
'; } private function money_text($amount) { return function_exists('wc_price') ? html_entity_decode(wp_strip_all_tags(wc_price((float) $amount)), ENT_QUOTES, 'UTF-8') : number_format_i18n((float) $amount, 2); } private function state_of($kind, array $opts) { if (class_exists('\Rooster\Admin\States')) { echo \Rooster\Admin\States::state((string) $kind, $opts); return; } echo '

' . esc_html((string) ($opts['title'] ?? '')) . '

' . (string) ($opts['action'] ?? ''); } private function list_state($any, $no_match, $clear) { if (!$any) { $this->state_of('first-use', array('title' => __('No cancellations logged yet.', 'ai-eshop-optimizer'))); return; } $this->state_of('no-results', array('title' => $no_match, 'clear' => $clear)); } private function guidance($text, array $opts = array()) { echo class_exists('\Rooster\Admin\Guidance') ? \Rooster\Admin\Guidance::text((string) $text, 'guidance', $opts) : '

' . esc_html($text) . '

'; } private function callout($html, $tone = 'info') { if (class_exists('\Rooster\Admin\Ui')) { echo \Rooster\Admin\Ui::notice($html, $tone, array('inline' => true)); return; } echo '

' . $html . '

'; } private function filter($label, $control, $id) { if (class_exists('\Rooster\Admin\Ui')) { return \Rooster\Admin\Ui::filter((string) $label, $control, $id); } return ''; } private function select_html($id, $name, array $options, $current) { return AIEO_Kit::select((string) $name, array_map('strval', $options), (string) $current, array('id' => (string) $id)); } private function render_operator_guide_card() { $close = $this->card_open(array( 'anchor' => 'cancellations-1-the-four-event-types-different-costs-and-d', 'icon' => 'shapes', 'title' => __('Cancellations and returns: 4 types', 'ai-eshop-optimizer'), 'description' => __('Collapsible guide: how Cancellation, Returned, Kept and Refused differ in shipping cost and in signal, then how to log one and where the reports are.', 'ai-eshop-optimizer'), 'col' => 1, )); ob_start(); ?>

four distinct cases. Each carries a different cost to the shop and a different signal to the recommendation algorithm:', 'ai-eshop-optimizer')); ?>

'cancellation', 'type' => '' . esc_html__('Cancellation', 'ai-eshop-optimizer') . '', 'when' => __('Voided before shipping (out of stock, payment failed, duplicate order)', 'ai-eshop-optimizer'), 'shipping' => __('None', 'ai-eshop-optimizer'), 'product' => '', 'customer' => '', ), array( 'id' => 'returned', 'type' => '' . esc_html__('Returned', 'ai-eshop-optimizer') . '', 'when' => __('Shipped, customer received it, opened it, sent it back (wrong size, quality issue)', 'ai-eshop-optimizer'), 'shipping' => __('Outbound + Return', 'ai-eshop-optimizer'), 'product' => __('STRONG NEGATIVE — they saw it and rejected it', 'ai-eshop-optimizer'), 'customer' => __('Mild', 'ai-eshop-optimizer'), ), array( 'id' => 'kept', 'type' => __('Kept (refund only)', 'ai-eshop-optimizer'), 'when' => __('Shipped, we refunded but told them to keep the item', 'ai-eshop-optimizer'), 'shipping' => __('Outbound only', 'ai-eshop-optimizer'), 'product' => __('Weak', 'ai-eshop-optimizer'), 'customer' => __('Mild', 'ai-eshop-optimizer'), ), array( 'id' => 'refused', 'type' => __('Refused (never received)', 'ai-eshop-optimizer'), 'when' => __('Shipped but the customer never received it — refused at the door, never collected from the pickup point, address unreachable', 'ai-eshop-optimizer'), 'shipping' => __('Outbound + Return (carrier brought it back)', 'ai-eshop-optimizer'), 'product' => __('Almost zero — never opened', 'ai-eshop-optimizer'), 'customer' => __('STRONGEST NEGATIVE — flaky / unreliable customer', 'ai-eshop-optimizer'), ), ); AIEO_Table::render(array( 'density' => 'compact', 'sortable' => false, 'columns' => array( array('key' => 'type', 'label' => __('Type', 'ai-eshop-optimizer'), 'type' => 'prose'), array('key' => 'when', 'label' => __('When it happens', 'ai-eshop-optimizer'), 'type' => 'prose'), array('key' => 'shipping', 'label' => __('Shipping cost', 'ai-eshop-optimizer'), 'type' => 'prose'), array('key' => 'product', 'label' => __('Signal about the PRODUCT', 'ai-eshop-optimizer'), 'type' => 'prose'), array('key' => 'customer', 'label' => __('Signal about the CUSTOMER', 'ai-eshop-optimizer'), 'type' => 'prose'), ), 'rows' => $guide_rows, 'row_key' => 'id', 'page_id' => 'cancellations', )); ?> callout(wp_kses_post(__('Why the Returned vs Refused distinction matters: A product with 10% «Returned» rate (customers who saw it and didn’t want it) is a bad product — the recommendation engine down-ranks it. A product with 10% «Refused» rate tells you nothing about the product (those customers never opened it). Instead the algorithm penalises those customers with a higher risk band — they’re the COD-blocking candidates.', 'ai-eshop-optimizer'))); ?>

  1. WooCommerce → Orders.', 'ai-eshop-optimizer')); ?>
  2. «AIEO Cancellation Reason» metabox.', 'ai-eshop-optimizer')); ?>
  3. Reason from the dropdown — e.g. «Wrong size / fit (returned after receipt)».', 'ai-eshop-optimizer')); ?>
  4. Note with details.', 'ai-eshop-optimizer')); ?>
  5. multi-item order, see partial-return below.', 'ai-eshop-optimizer')); ?>
  6. shipping cost fields (only shown for post-shipment types):', 'ai-eshop-optimizer')); ?>
    • Outbound: what we paid the courier to send it. Leave blank to auto-resolve from the carrier rate.', 'ai-eshop-optimizer')); ?>
    • Return: what we paid to fetch it back. Enter manually — e.g. %s.', 'ai-eshop-optimizer'), html_entity_decode(wp_strip_all_tags(wc_price(3.50))))); ?>
  7. Update or change the order status. The log row is captured automatically.', 'ai-eshop-optimizer')); ?>

  1. AIEO Cancellation Reason metabox shows a checkbox list of every line item (only for orders with 2+ products — single-item orders skip the picker).', 'ai-eshop-optimizer')); ?>
  2. Tick only the items being returned.', 'ai-eshop-optimizer')); ?>
  3. Partial in the Scope column automatically.', 'ai-eshop-optimizer')); ?>

Important: the checkboxes affect analytics only — the actual money refund happens through WooCommerce’s standard Refund button on the order edit screen.', 'ai-eshop-optimizer')); ?>

callout(wp_kses_post(__('Tip: before you change the order status to «Cancelled» or «Refunded», fill in the reason and type first in the metabox — that way the log row is captured with the right data from the start. Editing afterwards also works (the row updates in place).', 'ai-eshop-optimizer'))); ?>
0) { $this->render_product_detail($product_id, $base_url); return; } $this->render_products_list($base_url); } private function render_products_list($base_url) { global $wpdb; $pbm = $wpdb->prefix . 'aieo_dmm_product_behavior_metrics'; $reasons_index = class_exists('AIEO_DMM_Cancellation_Reasons_Data') ? AIEO_DMM_Cancellation_Reasons_Data::all() : array(); $sort = isset($_GET['rl_sort']) ? sanitize_key((string) $_GET['rl_sort']) : 'rate'; $sort_map = array( 'rate' => 'cancellation_rate DESC, total_cancellations DESC', 'count' => 'total_cancellations DESC, cancellation_rate DESC', 'last' => 'last_cancelled_at DESC', 'orders' => 'total_orders DESC', ); $order_by = $sort_map[$sort] ?? $sort_map['rate']; $min_orders = isset($_GET['rl_min']) ? max(0, (int) $_GET['rl_min']) : 5; $reason = isset($_GET['rl_reason']) ? sanitize_key((string) $_GET['rl_reason']) : ''; $where = array('total_cancellations > 0', 'total_orders >= %d'); $params = array($min_orders); if ($reason !== '') { $where[] = 'top_reason_slug = %s'; $params[] = $reason; } $where_sql = implode(' AND ', $where); $count_sql = "SELECT COUNT(*) FROM {$pbm} WHERE {$where_sql}"; $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, ...$params)); $per_page = 50; $pageno = isset($_GET['rl_p']) ? max(1, (int) $_GET['rl_p']) : 1; $offset = ($pageno - 1) * $per_page; $rows = $wpdb->get_results($wpdb->prepare( "SELECT product_id, total_orders, total_cancellations, total_refund_lines, cancellation_rate, refund_rate, last_cancelled_at, top_reason_slug FROM {$pbm} WHERE {$where_sql} ORDER BY {$order_by} LIMIT %d OFFSET %d", ...array_merge($params, array($per_page, $offset)) ), ARRAY_A); $close = $this->card_open(array( 'anchor' => 'cancellations-products-with-cancellations-s', 'description' => __('Each product that was cancelled or returned, with its rate and top reason, to find the ones that cause trouble.', 'ai-eshop-optimizer'), 'icon' => 'boxes', 'title' => __('Products with cancellations', 'ai-eshop-optimizer'), 'meta' => AIEO_DMM_UI::title_badge(number_format_i18n($total)), 'flush' => !empty($rows), )); $reason_opts = array('' => __('— any —', 'ai-eshop-optimizer')); foreach ($reasons_index as $slug => $r) { $reason_opts[(string) $slug] = (string) $r['label']; } echo '
'; echo ''; echo ''; echo ''; $aieo_ctl = $this->select_html('aieo-cn-f-psort', 'rl_sort', array( 'rate' => __('Cancellation rate (high → low)', 'ai-eshop-optimizer'), 'count' => __('Cancellation count (high → low)', 'ai-eshop-optimizer'), 'last' => __('Most recent cancellation', 'ai-eshop-optimizer'), 'orders' => __('Most ordered (popularity)', 'ai-eshop-optimizer'), ), $sort); echo $this->filter(__('Sort by', 'ai-eshop-optimizer'), $aieo_ctl, 'aieo-cn-f-psort'); echo $this->filter(__('Min orders', 'ai-eshop-optimizer'), '', 'aieo-cn-f-pmin'); echo $this->filter(__('Top reason', 'ai-eshop-optimizer'), $this->select_html('aieo-cn-f-preason', 'rl_reason', $reason_opts, $reason), 'aieo-cn-f-preason'); echo '
'; echo '
'; if (empty($rows)) { $any = (bool) $wpdb->get_var("SELECT 1 FROM {$pbm} WHERE total_cancellations > 0 LIMIT 1"); $this->list_state($any, __('No products match the current filter.', 'ai-eshop-optimizer'), add_query_arg('cn_section', 'products', $base_url)); echo $close; return; } $aieo_cnp_rows = array(); foreach ($rows as $r) { $title = get_the_title((int) $r['product_id']); $reason_label = isset($r['top_reason_slug'], $reasons_index[$r['top_reason_slug']]) ? $reasons_index[$r['top_reason_slug']]['label'] : (string) ($r['top_reason_slug'] ?? ''); $aieo_cnp_rows[] = array( 'pid' => (int) $r['product_id'], 'product' => $title ? $title : ('#' . (int) $r['product_id']), 'detail_url' => add_query_arg(array('cn_section' => 'products', 'rl_pid' => (int) $r['product_id']), $base_url), 'orders' => (int) $r['total_orders'], 'cancels' => (int) $r['total_cancellations'], 'refunds' => (int) $r['total_refund_lines'], 'rate' => round((float) $r['cancellation_rate'] * 100, 2), 'reason' => $reason_label, 'last' => (string) ($r['last_cancelled_at'] ?? ''), ); } AIEO_Table::render(array( 'id' => 'aieo-cn-products', 'page_id' => 'cancellations', 'row_key' => 'pid', 'sortable' => false, 'density' => 'compact', 'rows' => $aieo_cnp_rows, 'actions' => array( array( 'id' => 'drill', 'label' => __('Drill', 'ai-eshop-optimizer'), 'variant' => 'secondary', 'href' => static function ($r) { return $r['detail_url']; }, ), ), 'columns' => array( array( 'key' => 'product', 'label' => __('Product', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'detail_url'), 'suffix' => static function ($r) { return '#' . (int) $r['pid']; }, ), array('key' => 'orders', 'label' => __('Orders', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'cancels', 'label' => __('Cancels', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'refunds', 'label' => __('Refund lines', 'ai-eshop-optimizer'), 'type' => 'integer'), array( 'key' => 'rate', 'label' => __('Rate', 'ai-eshop-optimizer'), 'type' => 'percent', 'format' => array('precision' => 2), 'bands' => array( array('min' => 15, 'tone' => 'danger'), array('min' => 10, 'max' => 14.99, 'tone' => 'warning'), ), ), array('key' => 'reason', 'label' => __('Top reason', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'last', 'label' => __('Last', 'ai-eshop-optimizer'), 'type' => 'datetime', 'format' => array('tz' => 'utc'), 'nowrap' => true), ), )); $pages = (int) ceil($total / $per_page); if ($pages > 1) { echo \Rooster\Admin\Ui::pager($pageno, $pages, static function ($i) use ($sort, $min_orders, $reason, $base_url) { $args = array('cn_section' => 'products', 'rl_p' => $i); if ($sort !== 'rate') { $args['rl_sort'] = $sort; } if ($min_orders !== 5) { $args['rl_min'] = $min_orders; } if ($reason !== '') { $args['rl_reason'] = $reason; } return add_query_arg($args, $base_url); }, array('total' => $total, 'per_page' => $per_page)); } echo $close; } private function render_product_detail($product_id, $base_url) { global $wpdb; $pbm = $wpdb->prefix . 'aieo_dmm_product_behavior_metrics'; $oc = $wpdb->prefix . 'aieo_dmm_order_cancellations'; $lr = $wpdb->prefix . 'aieo_dmm_order_line_refunds'; $reasons_index = class_exists('AIEO_DMM_Cancellation_Reasons_Data') ? AIEO_DMM_Cancellation_Reasons_Data::all() : array(); $title = get_the_title($product_id); if (!$title) { $title = '#' . $product_id; } $back_link = '← ' . esc_html__('Back to products list', 'ai-eshop-optimizer') . ''; $back = '

' . $back_link . '

'; $edit_tool = '' . esc_html__('Edit product', 'ai-eshop-optimizer') . ''; $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$pbm} WHERE product_id = %d", $product_id), ARRAY_A); if (!$row) { $close = $this->card_open(array( 'anchor' => 'cancellations-edit-product', 'icon' => 'pencil-line', 'title' => $title, 'tools' => $edit_tool, )); $this->state_of('first-use', array('title' => __('No metrics for this product yet.', 'ai-eshop-optimizer'), 'action' => $back_link)); echo $close; return; } $close = $this->card_open(array( 'anchor' => 'cancellations-edit-product', 'icon' => 'pencil-line', 'title' => $title, 'tools' => $edit_tool, )); echo $back; $this->stats(array( array(__('Total orders', 'ai-eshop-optimizer'), number_format_i18n((int) $row['total_orders'])), array(__('Cancellations (full order)', 'ai-eshop-optimizer'), number_format_i18n((int) $row['total_cancellations'])), array(__('Refund lines (partial)', 'ai-eshop-optimizer'), number_format_i18n((int) $row['total_refund_lines'])), array(__('Cancellation rate', 'ai-eshop-optimizer'), round((float) $row['cancellation_rate'] * 100, 2) . '%'), array(__('Top reason', 'ai-eshop-optimizer'), isset($reasons_index[$row['top_reason_slug']]) ? $reasons_index[$row['top_reason_slug']]['label'] : (string) ($row['top_reason_slug'] ?? '—')), array(__('Last cancelled', 'ai-eshop-optimizer'), (string) ($row['last_cancelled_at'] ?? '—')), )); echo $close; $events = $wpdb->get_results($wpdb->prepare( "(SELECT 'cancel' AS kind, cancelled_at AS event_at, order_id, user_id, reason_slug, reason_note, order_total AS amount, NULL AS qty FROM {$oc} WHERE JSON_CONTAINS(product_ids, JSON_ARRAY(%d))) UNION ALL (SELECT 'refund' AS kind, refunded_at AS event_at, order_id, user_id, reason_slug, reason_note, amount_refunded AS amount, qty_refunded AS qty FROM {$lr} WHERE product_id = %d) ORDER BY event_at DESC LIMIT 500", $product_id, $product_id ), ARRAY_A); $close = $this->card_open(array( 'anchor' => 'cancellations-events-most-recent-first', 'icon' => 'scroll-text', 'title' => __('Cancellation and refund history', 'ai-eshop-optimizer'), 'description' => __('Cancellations and refunds logged for the selected product (latest 500), with order, customer, reason, quantity and amount, newest first.', 'ai-eshop-optimizer'), 'flush' => true, )); $aieo_cne_rows = array(); foreach ($events as $e) { $u = (int) $e['user_id'] > 0 ? get_user_by('id', (int) $e['user_id']) : null; $aieo_cne_rows[] = array( 'when' => (string) $e['event_at'], 'kind' => 'cancel' === $e['kind'] ? __('Full cancel', 'ai-eshop-optimizer') : __('Line refund', 'ai-eshop-optimizer'), 'kind_tone' => 'cancel' === $e['kind'] ? 'danger' : 'warning', 'order_no' => '#' . (int) $e['order_id'], 'order_url' => admin_url('admin.php?page=wc-orders&action=edit&id=' . (int) $e['order_id']), 'customer' => $u ? (string) $u->display_name : __('guest', 'ai-eshop-optimizer'), 'reason' => isset($reasons_index[$e['reason_slug']]) ? $reasons_index[$e['reason_slug']]['label'] : (string) $e['reason_slug'], 'note_sub' => $e['reason_note'] ? wp_trim_words((string) $e['reason_note'], 8) : '', 'qty' => $e['qty'] !== null ? (int) $e['qty'] : null, 'amount' => (float) $e['amount'], ); } AIEO_Table::render(array( 'id' => 'aieo-cn-events', 'page_id' => 'cancellations', 'sortable' => false, 'density' => 'compact', 'rows' => $aieo_cne_rows, 'cap' => 500, 'capped' => count($events) >= 500, 'empty' => __('No cancellation or refund events for this product.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'when', 'label' => __('When', 'ai-eshop-optimizer'), 'type' => 'datetime', 'format' => array('tz' => 'utc'), 'nowrap' => true), array('key' => 'kind', 'label' => __('Kind', 'ai-eshop-optimizer'), 'type' => 'text', 'tone_key' => 'kind_tone'), array('key' => 'order_no', 'label' => __('Order', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'order_url')), array('key' => 'customer', 'label' => __('Customer', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'reason', 'label' => __('Reason', 'ai-eshop-optimizer'), 'type' => 'text', 'subline' => 'note_sub'), array('key' => 'qty', 'label' => __('Qty', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'amount', 'label' => __('Amount', 'ai-eshop-optimizer'), 'type' => 'money'), ), )); echo $close; } private function render_customers($base_url) { global $wpdb; $ubm = $wpdb->prefix . 'aieo_dmm_user_behavior_metrics'; $sort = isset($_GET['rl_sort']) ? sanitize_key((string) $_GET['rl_sort']) : 'risk'; $sort_map = array( 'risk' => 'risk_band DESC, cancellation_rate DESC', 'count' => 'total_cancellations DESC, cancellation_rate DESC', 'rate' => 'cancellation_rate DESC, total_cancellations DESC', 'orders' => 'total_orders DESC', 'last' => 'last_cancelled_at DESC', ); $order_by = $sort_map[$sort] ?? $sort_map['risk']; $band = isset($_GET['rl_band']) ? (int) $_GET['rl_band'] : -1; $min_orders = isset($_GET['rl_min']) ? max(0, (int) $_GET['rl_min']) : 0; $where = array('total_cancellations > 0', 'total_orders >= %d'); $params = array($min_orders); if ($band >= 0 && $band <= 2) { $where[] = 'risk_band = %d'; $params[] = $band; } $where_sql = implode(' AND ', $where); $total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$ubm} WHERE {$where_sql}", ...$params)); $per_page = 50; $pageno = isset($_GET['rl_p']) ? max(1, (int) $_GET['rl_p']) : 1; $offset = ($pageno - 1) * $per_page; $rows = $wpdb->get_results($wpdb->prepare( "SELECT user_id, total_orders, total_cancellations, cancellation_rate, last_cancelled_at, top_reason_slug, risk_band FROM {$ubm} WHERE {$where_sql} ORDER BY {$order_by} LIMIT %d OFFSET %d", ...array_merge($params, array($per_page, $offset)) ), ARRAY_A); $close = $this->card_open(array( 'anchor' => 'cancellations-customers-with-returns-s', 'description' => __('Each customer with returns, ranked by return rate, number of returns or risk band, to see who costs the shop most.', 'ai-eshop-optimizer'), 'icon' => 'users', 'title' => __('Customers with returns', 'ai-eshop-optimizer'), 'meta' => AIEO_DMM_UI::title_badge(number_format_i18n($total)), 'flush' => !empty($rows), )); echo '
'; echo ''; echo ''; echo ''; $aieo_ctl = $this->select_html('aieo-cn-f-csort', 'rl_sort', array( 'risk' => __('Risk band (high → low)', 'ai-eshop-optimizer'), 'count' => __('Returns count (high → low)', 'ai-eshop-optimizer'), 'rate' => __('Return rate (high → low)', 'ai-eshop-optimizer'), 'orders' => __('Total purchases (high → low)', 'ai-eshop-optimizer'), 'last' => __('Most recent return', 'ai-eshop-optimizer'), ), $sort); echo $this->filter(__('Sort by', 'ai-eshop-optimizer'), $aieo_ctl, 'aieo-cn-f-csort'); $aieo_ctl = $this->select_html('aieo-cn-f-cband', 'rl_band', array( '-1' => __('— any —', 'ai-eshop-optimizer'), '0' => __('0 normal', 'ai-eshop-optimizer'), '1' => __('1 elevated', 'ai-eshop-optimizer'), '2' => __('2 high', 'ai-eshop-optimizer'), ), (string) $band); echo $this->filter(__('Risk band', 'ai-eshop-optimizer'), $aieo_ctl, 'aieo-cn-f-cband'); echo $this->filter(__('Min purchases', 'ai-eshop-optimizer'), '', 'aieo-cn-f-cmin'); echo '
'; echo '
'; if (empty($rows)) { $any = (bool) $wpdb->get_var("SELECT 1 FROM {$ubm} WHERE total_cancellations > 0 LIMIT 1"); $this->list_state($any, __('No customers match the current filter.', 'ai-eshop-optimizer'), add_query_arg('cn_section', 'customers', $base_url)); echo $close; return; } $reasons_index = class_exists('AIEO_DMM_Cancellation_Reasons_Data') ? AIEO_DMM_Cancellation_Reasons_Data::all() : array(); $aieo_cnc_rows = array(); foreach ($rows as $r) { $u = get_user_by('id', (int) $r['user_id']); $band = (int) $r['risk_band']; $reason_label = isset($r['top_reason_slug'], $reasons_index[$r['top_reason_slug']]) ? $reasons_index[$r['top_reason_slug']]['label'] : (string) ($r['top_reason_slug'] ?? ''); $aieo_cnc_rows[] = array( 'uid' => (int) $r['user_id'], 'customer' => $u ? (string) $u->display_name : ('#' . (int) $r['user_id']), 'user_url' => get_edit_user_link((int) $r['user_id']), 'email' => $u ? (string) $u->user_email : '', 'orders' => (int) $r['total_orders'], 'returns' => (int) $r['total_cancellations'], 'rate' => round((float) $r['cancellation_rate'] * 100, 2), 'reason' => $reason_label, 'band' => 2 === $band ? 'high' : (1 === $band ? 'elevated' : 'normal'), 'band_tone' => 2 === $band ? 'danger' : (1 === $band ? 'warning' : 'neutral'), 'last' => (string) ($r['last_cancelled_at'] ?? ''), ); } AIEO_Table::render(array( 'id' => 'aieo-cn-customers', 'page_id' => 'cancellations', 'row_key' => 'uid', 'sortable' => false, 'density' => 'compact', 'rows' => $aieo_cnc_rows, 'columns' => array( array('key' => 'customer', 'label' => __('Customer', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'user_url')), array('key' => 'email', 'label' => __('Email', 'ai-eshop-optimizer'), 'type' => 'text', 'tone' => 'secondary'), array('key' => 'orders', 'label' => __('Total purchases', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'returns', 'label' => __('Returns', 'ai-eshop-optimizer'), 'type' => 'integer'), array( 'key' => 'rate', 'label' => __('Return rate', 'ai-eshop-optimizer'), 'type' => 'percent', 'format' => array('precision' => 2), 'bands' => array( array('min' => 15, 'tone' => 'danger'), array('min' => 5, 'max' => 14.99, 'tone' => 'warning'), ), ), array('key' => 'reason', 'label' => __('Top reason', 'ai-eshop-optimizer'), 'type' => 'text'), array( 'key' => 'band', 'label' => __('Risk', 'ai-eshop-optimizer'), 'type' => 'badge', 'format' => array( 'tone_key' => 'band_tone', 'labels' => array( 'normal' => __('normal', 'ai-eshop-optimizer'), 'elevated' => __('elevated', 'ai-eshop-optimizer'), 'high' => __('high', 'ai-eshop-optimizer'), ), ), ), array('key' => 'last', 'label' => __('Last return', 'ai-eshop-optimizer'), 'type' => 'datetime', 'format' => array('tz' => 'utc'), 'nowrap' => true), ), )); $pages = (int) ceil($total / $per_page); if ($pages > 1) { echo \Rooster\Admin\Ui::pager($pageno, $pages, static function ($i) use ($sort, $band, $min_orders, $base_url) { $args = array('cn_section' => 'customers', 'rl_p' => $i); if ($sort !== 'risk') { $args['rl_sort'] = $sort; } if ($band !== -1) { $args['rl_band'] = $band; } if ($min_orders !== 0) { $args['rl_min'] = $min_orders; } return add_query_arg($args, $base_url); }, array('total' => $total, 'per_page' => $per_page)); } echo $close; } private function render_operations_card($pbm, $ubm) { global $wpdb; $oc = $wpdb->prefix . 'aieo_dmm_order_cancellations'; $pbm_n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$pbm}"); $ubm_n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$ubm}"); $pbm_stale = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$pbm} WHERE computed_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)"); $ubm_stale = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$ubm} WHERE computed_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)"); $oc_pending = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$oc} WHERE event_kind > 0 AND (outbound_shipping_cost IS NULL OR outbound_shipping_cost = 0)" ); $oc_postship = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$oc} WHERE event_kind > 0"); $oc_total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$oc}"); $oc_kinds = $wpdb->get_results("SELECT event_kind, COUNT(*) AS n FROM {$oc} GROUP BY event_kind", ARRAY_A); $oc_kind_map = array(0 => 0, 1 => 0, 2 => 0); foreach ((array) $oc_kinds as $r) { $oc_kind_map[(int) $r['event_kind']] = (int) $r['n']; } $next_cron = wp_next_scheduled('aieo_dmm_metrics_daily_recompute'); $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $close = $this->card_open(array( 'anchor' => 'cancellations-behavior-metrics-operations', 'icon' => 'activity', 'title' => __('Behavior metrics: operations', 'ai-eshop-optimizer'), 'description' => __( 'Captured at the WooCommerce order edit screen. Per-order reasons + per-line refund logs feed the recommendation engine via the product / user behavior metrics tables.', 'ai-eshop-optimizer' ), 'col' => 2, )); $this->guidance(__('Per-product + per-user aggregates that feed the recommendation engine. Filled write-side as cancellations land; the daily cron catches any rows that drifted (e.g. operator edited a reason after capture).', 'ai-eshop-optimizer'), array('question' => __('What are these figures for?', 'ai-eshop-optimizer'))); $aieo_cn_badge = static function ($text, $tone) { return class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::badge((string) $text, (string) $tone) : '' . esc_html($text) . ''; }; $aieo_cn_facts = array( array('label' => __('Product metric rows', 'ai-eshop-optimizer'), 'html' => esc_html(number_format_i18n($pbm_n)) . ($pbm_stale > 0 ? ' ' . $aieo_cn_badge('(' . number_format_i18n($pbm_stale) . ' stale)', 'warning') : '')), array('label' => __('User metric rows', 'ai-eshop-optimizer'), 'html' => esc_html(number_format_i18n($ubm_n)) . ($ubm_stale > 0 ? ' ' . $aieo_cn_badge('(' . number_format_i18n($ubm_stale) . ' stale)', 'warning') : '')), array('label' => __('Cancellations log', 'ai-eshop-optimizer'), 'html' => esc_html(number_format_i18n($oc_total)) . ' ' . '(' . sprintf( esc_html__('%1$d cancellations · %2$d returns · %3$d refund-only', 'ai-eshop-optimizer'), (int) $oc_kind_map[0], (int) $oc_kind_map[1], (int) $oc_kind_map[2] ) . ')'), array('label' => __('Outbound shipping cost (post-shipment events)', 'ai-eshop-optimizer'), 'html' => sprintf( esc_html__('%1$s of %2$s post-shipment rows have a resolved carrier cost', 'ai-eshop-optimizer'), '' . esc_html(number_format_i18n($oc_postship - $oc_pending)) . '', esc_html(number_format_i18n($oc_postship)) ) . ($oc_pending > 0 ? ' ' . $aieo_cn_badge('(' . number_format_i18n($oc_pending) . ' pending)', 'warning') : '')), array('label' => __('Next daily sweep', 'ai-eshop-optimizer'), 'html' => $next_cron ? esc_html(date_i18n('Y-m-d H:i', $next_cron)) : $aieo_cn_badge(__('not scheduled', 'ai-eshop-optimizer'), 'destructive')), ); if (class_exists('\Rooster\Admin\Ui')) { echo \Rooster\Admin\Ui::facts($aieo_cn_facts); } else { echo '
'; foreach ($aieo_cn_facts as $aieo_cn_fact) { echo '
' . esc_html($aieo_cn_fact['label']) . '
' . $aieo_cn_fact['html'] . '
'; } echo '
'; } echo '

'; echo ' '; echo ' '; echo ''; echo '

'; echo ''; if ($this->chrome_on()) { echo rooster_admin_card_section_html(); echo '

'; } else { echo '

'; } echo ' '; if (0 === $oc_pending) { echo '' . esc_html__('Every post-shipment row already has its carrier cost.', 'ai-eshop-optimizer') . ''; } echo '

'; $this->guidance(__('Runs the carrier-cost resolver (same one used for free-shipping ROI) against every cancellation row that doesn’t have a resolved cost yet. Chunked — safe to leave running.', 'ai-eshop-optimizer'), array('id' => 'aieo-oc-backfill-help', 'question' => __('What does this backfill do?', 'ai-eshop-optimizer'))); echo ''; ?> prefix . 'aieo_dmm_order_cancellations'; $reasons_index = class_exists('AIEO_DMM_Cancellation_Reasons_Data') ? AIEO_DMM_Cancellation_Reasons_Data::all() : array(); $f_reason = isset($_GET['rl_reason']) ? sanitize_key((string) $_GET['rl_reason']) : ''; $f_from = isset($_GET['rl_from']) ? sanitize_text_field((string) wp_unslash( $_GET['rl_from'] )) : ''; $f_to = isset($_GET['rl_to']) ? sanitize_text_field((string) wp_unslash( $_GET['rl_to'] )) : ''; $f_scope = isset($_GET['rl_scope']) ? sanitize_key((string) $_GET['rl_scope']) : ''; $f_kind = isset($_GET['rl_kind']) ? (string) sanitize_text_field( wp_unslash( $_GET['rl_kind'] ) ) : ''; $where = array('1=1'); $params = array(); if ($f_reason !== '') { $where[] = 'reason_slug = %s'; $params[] = $f_reason; } if ($f_from !== '') { $where[] = 'cancelled_at >= %s'; $params[] = $f_from . ' 00:00:00'; } if ($f_to !== '') { $where[] = 'cancelled_at <= %s'; $params[] = $f_to . ' 23:59:59'; } if ($f_scope === 'full') { $where[] = 'is_full_cancel = 1'; } if ($f_scope === 'partial') { $where[] = 'is_full_cancel = 0'; } if ($f_kind !== '' && in_array((int) $f_kind, array(0, 1, 2, 3, 4), true)) { $where[] = 'event_kind = %d'; $params[] = (int) $f_kind; } $where_sql = implode(' AND ', $where); $sum_sql = "SELECT COUNT(*) AS n, SUM(CASE WHEN event_kind = 0 THEN 1 ELSE 0 END) AS n_cancel, SUM(CASE WHEN event_kind = 1 THEN 1 ELSE 0 END) AS n_returned, SUM(CASE WHEN event_kind = 2 THEN 1 ELSE 0 END) AS n_kept, SUM(CASE WHEN event_kind = 3 THEN 1 ELSE 0 END) AS n_refused, SUM(CASE WHEN event_kind = 4 THEN 1 ELSE 0 END) AS n_lost, SUM(order_total) AS revenue_lost, SUM(IFNULL(outbound_shipping_cost,0)) AS outbound_paid, SUM(IFNULL(return_shipping_cost,0)) AS return_paid, SUM(IFNULL(outbound_shipping_cost,0) + IFNULL(return_shipping_cost,0)) AS shipping_total FROM {$oc} WHERE {$where_sql}"; $sum = $params ? $wpdb->get_row($wpdb->prepare($sum_sql, ...$params), ARRAY_A) : $wpdb->get_row($sum_sql, ARRAY_A); $sum = (is_array($sum) ? $sum : array()) + array('n' => 0, 'n_cancel' => 0, 'n_returned' => 0, 'n_kept' => 0, 'n_refused' => 0, 'n_lost' => 0, 'revenue_lost' => 0, 'outbound_paid' => 0, 'return_paid' => 0, 'shipping_total' => 0); $close = $this->card_open(array( 'anchor' => 'cancellations-returns-filters', 'description' => __('Narrow the returns below by type, reason, scope and dates, and see what the matching events cost.', 'ai-eshop-optimizer'), 'icon' => 'list-filter', 'title' => __( 'Filters', 'ai-eshop-optimizer' ), )); $reason_opts = array('' => __('— all —', 'ai-eshop-optimizer')); foreach ($reasons_index as $slug => $r) { $reason_opts[(string) $slug] = (string) $r['label']; } echo '
'; echo ''; echo ''; echo ''; echo $this->filter(__('Reason', 'ai-eshop-optimizer'), $this->select_html('aieo-cn-f-rreason', 'rl_reason', $reason_opts, $f_reason), 'aieo-cn-f-rreason'); echo $this->filter(__('From', 'ai-eshop-optimizer'), AIEO_Kit::input('rl_from', (string) $f_from, array('type' => 'date', 'id' => 'aieo-cn-f-rfrom')), 'aieo-cn-f-rfrom'); echo $this->filter(__('To', 'ai-eshop-optimizer'), AIEO_Kit::input('rl_to', (string) $f_to, array('type' => 'date', 'id' => 'aieo-cn-f-rto')), 'aieo-cn-f-rto'); $aieo_ctl = $this->select_html('aieo-cn-f-rscope', 'rl_scope', array( '' => __('All', 'ai-eshop-optimizer'), 'full' => __('Full', 'ai-eshop-optimizer'), 'partial' => __('Partial', 'ai-eshop-optimizer'), ), $f_scope); echo $this->filter(__('Scope', 'ai-eshop-optimizer'), $aieo_ctl, 'aieo-cn-f-rscope'); $aieo_ctl = $this->select_html('aieo-cn-f-rkind', 'rl_kind', array( '' => __('Any', 'ai-eshop-optimizer'), '0' => __('Cancellation', 'ai-eshop-optimizer'), '1' => __('Returned (after receipt)', 'ai-eshop-optimizer'), '2' => __('Kept (refund only)', 'ai-eshop-optimizer'), '3' => __('Refused (never received)', 'ai-eshop-optimizer'), '4' => __('Lost (shipped, never arrived)', 'ai-eshop-optimizer'), ), $f_kind); echo $this->filter(__('Type', 'ai-eshop-optimizer'), $aieo_ctl, 'aieo-cn-f-rkind'); $filtered = $f_reason || $f_from || $f_to || $f_scope || $f_kind !== ''; echo '
'; if ($filtered) { $aieo_clear = class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::button(__('Clear filters', 'ai-eshop-optimizer'), array('href' => $base_url . '&cn_section=returns', 'icon' => 'x', 'variant' => 'ghost')) : '' . esc_html__('Clear filters', 'ai-eshop-optimizer') . ''; echo ' ' . $aieo_clear; } echo '
'; echo '
'; $this->stats(array( array( __('Events matching filter', 'ai-eshop-optimizer'), number_format_i18n((int) $sum['n']), sprintf(__('%1$d cancellations · %2$d returned · %3$d kept · %4$d refused · %5$d lost', 'ai-eshop-optimizer'), (int) $sum['n_cancel'], (int) $sum['n_returned'], (int) $sum['n_kept'], (int) $sum['n_refused'], (int) $sum['n_lost']), ), array(__('Revenue lost', 'ai-eshop-optimizer'), $this->money_text($sum['revenue_lost'])), array(__('Outbound shipping paid', 'ai-eshop-optimizer'), $this->money_text($sum['outbound_paid'])), array(__('Return shipping paid', 'ai-eshop-optimizer'), $this->money_text($sum['return_paid'])), array(__('Total shipping cost', 'ai-eshop-optimizer'), $this->money_text($sum['shipping_total'])), )); $this->guidance(__('Cancellations: no shipping cost (voided pre-shipment). Returned: outbound + return cost (customer received + sent back). Kept: outbound only (customer received + kept). Refused: outbound + return (carrier returned the parcel — customer never opened it). Lost: outbound, plus the goods unless the carrier pays a claim (shipped, never arrived).', 'ai-eshop-optimizer')); echo $close; $per_page = 50; $pageno = isset($_GET['rl_p']) ? max(1, (int) $_GET['rl_p']) : 1; $offset = ($pageno - 1) * $per_page; $list_sql = "SELECT id, order_id, user_id, reason_slug, reason_note, status_after, order_total, product_ids, is_full_cancel, event_kind, entry_channel, entry_branch_id, outbound_shipping_cost, return_shipping_cost, cancelled_at FROM {$oc} WHERE {$where_sql} ORDER BY cancelled_at DESC LIMIT %d OFFSET %d"; $list_args = array_merge($params, array($per_page, $offset)); $rows = $wpdb->get_results($wpdb->prepare($list_sql, ...$list_args), ARRAY_A); $close = $this->card_open(array( 'anchor' => 'cancellations-returns', 'description' => __('Every cancellation, return, kept item and refused delivery logged, one row each, as the filters above select them.', 'ai-eshop-optimizer'), 'icon' => 'package', 'title' => __('Returns', 'ai-eshop-optimizer'), 'flush' => !empty($rows), )); if (empty($rows)) { $any = !$filtered ? false : (bool) $wpdb->get_var("SELECT 1 FROM {$oc} LIMIT 1"); $this->list_state($any, __('No returns match the current filter.', 'ai-eshop-optimizer'), $base_url . '&cn_section=returns'); echo $close; return; } $kind_meta = array( 0 => array('label' => __('Cancellation', 'ai-eshop-optimizer'), 'tone' => 'neutral'), 1 => array('label' => __('Returned', 'ai-eshop-optimizer'), 'tone' => 'danger'), 2 => array('label' => __('Kept', 'ai-eshop-optimizer'), 'tone' => 'warning'), 3 => array('label' => __('Refused', 'ai-eshop-optimizer'), 'tone' => 'info'), ); $aieo_cn_rows = array(); foreach ($rows as $r) { $kind = (int) ($r['event_kind'] ?? 0); $km = $kind_meta[$kind] ?? $kind_meta[0]; $reason_label = isset($reasons_index[$r['reason_slug']]) ? $reasons_index[$r['reason_slug']]['label'] : $r['reason_slug']; $product_ids = json_decode((string) $r['product_ids'], true); $product_ids = is_array($product_ids) ? $product_ids : array(); $names = array(); foreach (array_slice($product_ids, 0, 4) as $pid) { $title = get_the_title((int) $pid); $names[] = $title ? $title : ('#' . (int) $pid); } if (count($product_ids) > 4) { $names[] = '+' . (count($product_ids) - 4); } $outbound = $r['outbound_shipping_cost'] !== null ? (float) $r['outbound_shipping_cost'] : null; $returnc = $r['return_shipping_cost'] !== null ? (float) $r['return_shipping_cost'] : null; $total_cost = ($outbound ?? 0) + ($returnc ?? 0); $u = (int) $r['user_id'] > 0 ? get_user_by('id', (int) $r['user_id']) : null; $aieo_cn_rows[] = array( 'id' => (int) $r['id'], 'when' => (string) $r['cancelled_at'], 'order_no' => '#' . (int) $r['order_id'], 'order_url' => admin_url('admin.php?page=wc-orders&action=edit&id=' . (int) $r['order_id']), 'status' => (string) $r['status_after'], 'customer' => $u ? (string) $u->display_name : __('guest', 'ai-eshop-optimizer'), 'kind' => $km['label'], 'kind_tone' => $km['tone'], 'reason' => (string) $reason_label, 'note_sub' => $r['reason_note'] ? wp_trim_words((string) $r['reason_note'], 8) : '', 'scope' => 1 === (int) $r['is_full_cancel'] ? __('Full', 'ai-eshop-optimizer') : __('Partial', 'ai-eshop-optimizer'), 'scope_tone' => 1 === (int) $r['is_full_cancel'] ? '' : 'warning', 'items' => $names, 'total' => (float) $r['order_total'], 'outbound' => $outbound, 'return' => $returnc, 'cost' => $total_cost, 'channel' => (string) ($r['entry_channel'] ?? ''), ); } AIEO_Table::render(array( 'id' => 'aieo-cn-returns', 'page_id' => 'cancellations', 'row_key' => 'id', 'sortable' => false, 'density' => 'compact', 'rows' => $aieo_cn_rows, 'columns' => array( array('key' => 'when', 'label' => __('When', 'ai-eshop-optimizer'), 'type' => 'datetime', 'format' => array('tz' => 'utc'), 'nowrap' => true), array('key' => 'order_no', 'label' => __('Order', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'order_url'), 'subline' => 'status'), array('key' => 'customer', 'label' => __('Customer', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'kind', 'label' => __('Type', 'ai-eshop-optimizer'), 'type' => 'badge', 'format' => array('tone_key' => 'kind_tone')), array('key' => 'reason', 'label' => __('Reason', 'ai-eshop-optimizer'), 'type' => 'text', 'subline' => 'note_sub'), array('key' => 'scope', 'label' => __('Scope', 'ai-eshop-optimizer'), 'type' => 'text', 'tone_key' => 'scope_tone'), array('key' => 'items', 'label' => __('Affected items', 'ai-eshop-optimizer'), 'type' => 'list'), array('key' => 'total', 'label' => __('Order total', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'outbound', 'label' => __('Outbound', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'return', 'label' => __('Return', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'cost', 'label' => __('Total cost', 'ai-eshop-optimizer'), 'type' => 'money', 'empty_zero' => true), array('key' => 'channel', 'label' => __('Channel', 'ai-eshop-optimizer'), 'type' => 'text'), ), )); $total = (int) $sum['n']; $pages = (int) ceil($total / $per_page); if ($pages > 1) { $qs = static function ($p) use ($f_reason, $f_from, $f_to, $f_scope, $f_kind, $base_url) { $args = array('cn_section' => 'returns', 'rl_p' => $p); if ($f_reason !== '') { $args['rl_reason'] = $f_reason; } if ($f_from !== '') { $args['rl_from'] = $f_from; } if ($f_to !== '') { $args['rl_to'] = $f_to; } if ($f_scope !== '') { $args['rl_scope'] = $f_scope; } if ($f_kind !== '') { $args['rl_kind'] = $f_kind; } return add_query_arg($args, $base_url); }; echo \Rooster\Admin\Ui::pager($pageno, $pages, $qs, array('total' => $total, 'per_page' => $per_page)); } echo $close; } private function render_overview() { global $wpdb; $oc = $wpdb->prefix . 'aieo_dmm_order_cancellations'; $pbm = $wpdb->prefix . 'aieo_dmm_product_behavior_metrics'; $ubm = $wpdb->prefix . 'aieo_dmm_user_behavior_metrics'; $this->band_open(array('cols' => 2)); $this->render_operator_guide_card(); $this->render_operations_card($pbm, $ubm); $this->render_reco_integration_card(); $total_cancellations = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$oc}"); $cancellations_30d = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$oc} WHERE cancelled_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)"); $revenue_lost_30d = (float) $wpdb->get_var("SELECT IFNULL(SUM(order_total), 0) FROM {$oc} WHERE cancelled_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)"); $high_risk_users = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$ubm} WHERE risk_band = 2"); $high_cancel_products = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$pbm} WHERE cancellation_rate >= 0.10 AND total_orders >= 5"); $close = $this->card_open(array( 'anchor' => 'cancellations-headline-metrics', 'description' => __('The shop\'s cancellations at a glance: the total, the last 30 days, the revenue lost and the high-risk customers and products.', 'ai-eshop-optimizer'), 'icon' => 'hash', 'title' => __('Headline metrics', 'ai-eshop-optimizer'), 'col' => 2, )); if (0 === $total_cancellations) { $this->state_of('first-use', array('title' => __('No cancellations logged yet.', 'ai-eshop-optimizer'))); } else { $this->render_headline_stats($total_cancellations, $cancellations_30d, $revenue_lost_30d, $high_risk_users, $high_cancel_products); } echo $close; $this->band_close(); $this->render_overview_lists($oc); } private function render_headline_stats($total_cancellations, $cancellations_30d, $revenue_lost_30d, $high_risk_users, $high_cancel_products) { $this->stats(array( array(__('Total cancellations logged', 'ai-eshop-optimizer'), number_format_i18n($total_cancellations)), array(__('Cancellations (last 30 days)', 'ai-eshop-optimizer'), number_format_i18n($cancellations_30d)), array(__('Revenue lost (last 30 days)', 'ai-eshop-optimizer'), $this->money_text($revenue_lost_30d)), array(__('High-risk users (risk_band = 2)', 'ai-eshop-optimizer'), number_format_i18n($high_risk_users)), array(__('Products with ≥10% cancellation rate (5+ orders)', 'ai-eshop-optimizer'), number_format_i18n($high_cancel_products)), )); } private function render_overview_lists($oc) { global $wpdb; $this->band_open(array('cols' => 1)); $close = $this->card_open(array( 'anchor' => 'cancellations-top-reasons-last-30-days', 'icon' => 'chart-column', 'title' => __('Top cancellation reasons (30 days)', 'ai-eshop-optimizer'), 'description' => __('The ten most common cancellation reasons of the last 30 days, each with its count and the revenue lost behind it.', 'ai-eshop-optimizer'), 'flush' => true, )); $rows = $wpdb->get_results( "SELECT reason_slug, COUNT(*) AS n, ROUND(SUM(order_total), 2) AS revenue_lost FROM {$oc} WHERE cancelled_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY) GROUP BY reason_slug ORDER BY n DESC LIMIT 10", ARRAY_A ); $reasons_index = class_exists('AIEO_DMM_Cancellation_Reasons_Data') ? AIEO_DMM_Cancellation_Reasons_Data::all() : array(); $reason_rows = array(); foreach ((array) $rows as $r) { $slug = (string) $r['reason_slug']; $reason_rows[] = array( 'slug' => $slug, 'label' => isset($reasons_index[$slug]) ? $reasons_index[$slug]['label'] : $slug, 'n' => (int) $r['n'], 'lost' => (float) $r['revenue_lost'], ); } AIEO_Table::render(array( 'page_id' => $this->get_id(), 'row_key' => 'slug', 'density' => 'compact', 'rows' => $reason_rows, 'caps' => array('search' => true), 'per_page' => 0, 'cap' => 10, 'capped' => count($reason_rows) >= 10, 'empty' => __('No cancellations in the last 30 days.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'label', 'label' => __('Reason', 'ai-eshop-optimizer'), 'type' => 'text', 'subline' => 'slug'), array('key' => 'n', 'label' => __('Count', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'lost', 'label' => __('Revenue lost', 'ai-eshop-optimizer'), 'type' => 'money'), ), )); echo $close; AIEO_Report_Render::card('cancellations_hotspots', array( 'anchor' => 'cancellations-cancellation-hotspots-10-orders', 'heading' => 'h2', 'icon' => 'zap', 'title' => __('Cancellation hotspot products', 'ai-eshop-optimizer'), 'description' => __('Products with at least ten orders, ranked by how many of those were cancelled. Sort by Rate to find the proportionally worst offenders that still clear the ten-order signal threshold.', 'ai-eshop-optimizer'), 'overrides' => array('page_id' => $this->get_id()), )); AIEO_Report_Render::card('cancellations_risk_users', array( 'anchor' => 'cancellations-high-risk-users-risk-band-1', 'heading' => 'h2', 'icon' => 'user-x', 'title' => __('Users at cancellation risk', 'ai-eshop-optimizer'), 'description' => __('Registered customers in the elevated or high risk band, with their orders, cancellations, rate and last cancellation. The band is recomputed from rate and recency, so this list fills and empties on its own.', 'ai-eshop-optimizer'), 'overrides' => array('page_id' => $this->get_id()), )); $close = $this->card_open(array( 'anchor' => 'cancellations-recent-cancellations-last-25', 'description' => __('The latest cancellations logged, with the reason, the order total and where each one was recorded.', 'ai-eshop-optimizer'), 'icon' => 'clock', 'title' => __('Recent cancellations (last 25)', 'ai-eshop-optimizer'), 'flush' => true, )); $rows = $wpdb->get_results( "SELECT order_id, user_id, reason_slug, reason_note, order_total, entry_channel, cancelled_at FROM {$oc} ORDER BY cancelled_at DESC LIMIT 25", ARRAY_A ); $recent = array(); foreach ((array) $rows as $r) { $note = (string) ($r['reason_note'] ?? ''); $recent[] = array( 'order_id' => (int) $r['order_id'], 'when' => $r['cancelled_at'], 'order' => '#' . (int) $r['order_id'], 'edit_url' => admin_url('post.php?action=edit&post=' . (int) $r['order_id']), 'user' => (int) $r['user_id'] > 0 ? '#' . (int) $r['user_id'] : __('guest', 'ai-eshop-optimizer'), 'guest' => ( (int) $r['user_id'] <= 0 ), 'reason' => isset($reasons_index[$r['reason_slug']]) ? $reasons_index[$r['reason_slug']]['label'] : (string) $r['reason_slug'], 'note' => '' !== $note ? wp_trim_words($note, 8) : '', 'total' => (float) $r['order_total'], 'channel' => (string) ($r['entry_channel'] ?? ''), ); } AIEO_Table::render(array( 'page_id' => $this->get_id(), 'row_key' => 'order_id', 'density' => 'compact', 'rows' => $recent, 'caps' => array('search' => true), 'filters' => array( array('key' => 'reason', 'label' => __('Reason', 'ai-eshop-optimizer'), 'kind' => 'values', 'control' => 'select'), array('key' => 'channel', 'label' => __('Channel', 'ai-eshop-optimizer'), 'kind' => 'values', 'control' => 'select'), ), 'per_page' => 0, 'cap' => 25, 'capped' => count($recent) >= 25, 'empty' => __('No cancellations logged yet.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'when', 'label' => __('When', 'ai-eshop-optimizer'), 'type' => 'datetime', 'format' => array('tz' => 'utc')), array( 'key' => 'order', 'label' => __('Order', 'ai-eshop-optimizer'), 'type' => 'link', 'format' => array('href' => 'edit_url'), ), array('key' => 'user', 'label' => __('User', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'reason', 'label' => __('Reason', 'ai-eshop-optimizer'), 'type' => 'text', 'subline' => 'note'), array('key' => 'total', 'label' => __('Total', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'channel', 'label' => __('Channel', 'ai-eshop-optimizer'), 'type' => 'text'), ), )); echo $close; $this->band_close(); } private function render_vocabulary() { $reasons = class_exists('AIEO_DMM_Cancellation_Reasons_Data') ? AIEO_DMM_Cancellation_Reasons_Data::all() : array(); $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $close = $this->card_open(array( 'anchor' => 'cancellations-cancellation-reasons', 'icon' => 'list', 'title' => __('Cancellation reasons', 'ai-eshop-optimizer'), 'description' => __('The dropdown the operator picks from on the order edit screen. Disabled rows are hidden from new picks but kept in the table for historical reports.', 'ai-eshop-optimizer'), 'flush' => true, )); $aieo_cnv_rows = array(); foreach ($reasons as $slug => $r) { $aieo_cnv_rows[] = array_merge((array) $r, array('slug' => (string) $slug)); } AIEO_Table::render(array( 'id' => 'aieo-dmm-cn-vocab', 'page_id' => 'cancellations', 'row_key' => 'slug', 'sortable' => false, 'rows' => $aieo_cnv_rows, 'row_data' => array('slug' => 'slug', 'sort' => 'sort_order'), 'move' => array( 'label' => __('Cancellation reasons', 'ai-eshop-optimizer'), 'save' => array('url' => rest_url('aieo/v1/cancellation-reasons-order'), 'nonce' => wp_create_nonce('wp_rest')), ), 'row_class' => static function ($r) { return 0 === (int) $r['enabled'] ? 'aieo-t-tone--muted' : ''; }, 'columns' => array( array('key' => 'slug', 'label' => __('Slug', 'ai-eshop-optimizer'), 'type' => 'code'), array( 'key' => 'label', 'label' => __('Label', 'ai-eshop-optimizer'), 'type' => 'control', 'format' => array('render' => static function ($r) { return ''; }), ), array( 'key' => 'default_event_kind', 'label' => __('Default type', 'ai-eshop-optimizer'), 'type' => 'control', 'format' => array('render' => static function ($r) { $cur_kind = isset($r['default_event_kind']) && $r['default_event_kind'] !== null ? (string) $r['default_event_kind'] : ''; $out = ''; }), ), array( 'key' => 'is_customer_visible', 'label' => __('Customer-visible', 'ai-eshop-optimizer'), 'type' => 'control', 'align' => 'center', 'format' => array('render' => static function ($r) { return ''; }), ), array( 'key' => 'enabled', 'label' => __('Enabled', 'ai-eshop-optimizer'), 'type' => 'control', 'align' => 'center', 'format' => array('render' => static function ($r) { return ''; }), ), array( 'key' => 'act', 'label' => '', 'type' => 'control', 'nowrap' => true, 'format' => array('render' => static function () { $ask = class_exists('\Rooster\Admin\States') ? ' data-ra-confirm="aieo-cn-disable-confirm" aria-haspopup="dialog"' : ''; return ' ' . ''; }), ), ), )); if ($this->chrome_on()) { echo rooster_admin_card_section_html(__('Add new reason', 'ai-eshop-optimizer')); } else { echo '

' . esc_html__('Add new reason', 'ai-eshop-optimizer') . '

'; } echo '

'; echo ''; echo ' '; echo ' '; echo '

'; echo $close; if (class_exists('\Rooster\Admin\States')) { $aieo_ask = \Rooster\Admin\States::confirm( 'aieo-cn-disable-confirm', 'off', __('Disable this reason?', 'ai-eshop-optimizer'), '

' . esc_html__('It leaves the dropdown on the order edit screen. Orders that already carry it keep it, and the reports still count it. Tick Enabled and save the row to bring it back.', 'ai-eshop-optimizer') . '

', array('confirm' => __('Disable', 'ai-eshop-optimizer')) ); echo $aieo_ask; } ?> card_open(array( 'anchor' => 'cancellations-recommendation-engine-integration', 'icon' => 'sparkles', 'title' => __('Recommendation engine integration', 'ai-eshop-optimizer'), 'description' => __('Cancellation signals are wired into both the product reco engine (high-cancellation products are down-ranked or dropped) and the per-user risk band (band 0 normal / 1 elevated / 2 high). Toggles below opt in to the user-side checkout integrations.', 'ai-eshop-optimizer'), 'col' => 1, )); $library = class_exists('\Rooster\Admin\States'); if ($library) { $aieo_badge = $penalty_enabled ? \Rooster\Admin\States::status('active') : \Rooster\Admin\States::status('off', array('label' => __('Disabled by filter', 'ai-eshop-optimizer'))); } else { $aieo_badge = $penalty_enabled ? '' . esc_html__('Active', 'ai-eshop-optimizer') . '' : '' . esc_html__('Disabled by filter', 'ai-eshop-optimizer') . ''; } $aieo_badge .= ' ' . sprintf( esc_html__('threshold %1$s%%, drop ≤ %2$s', 'ai-eshop-optimizer'), esc_html(round($penalty_threshold * 100, 0)), esc_html(round($drop_threshold, 2)) ) . ''; if ($library) { echo \Rooster\Admin\Ui::facts(array(array('label' => __('Product reco penalty', 'ai-eshop-optimizer'), 'html' => $aieo_badge))); } else { echo '
' . esc_html__('Product reco penalty', 'ai-eshop-optimizer') . '
' . $aieo_badge . '
'; } echo '
'; foreach (array( array('aieo-rg-notice', AIEO_DMM_Risk_Gating::OPT_NOTICE, $notice_on, __('Show checkout warning to elevated/high risk users', 'ai-eshop-optimizer'), __('Render a polite review-your-order banner above checkout', 'ai-eshop-optimizer')), array('aieo-rg-cod', AIEO_DMM_Risk_Gating::OPT_BLOCK_COD, $cod_on, __('Block COD for high-risk (band 2) users', 'ai-eshop-optimizer'), __('Remove cash-on-delivery from the gateway list at checkout for band-2 customers', 'ai-eshop-optimizer')), ) as $aieo_rg) { $attrs = ''; if ($library) { foreach (\Rooster\Admin\States::save_attrs($ajax, array('data' => array('action' => 'aieo_dmm_risk_gating_toggle', 'nonce' => $nonce, 'option' => $aieo_rg[1]))) as $k => $v) { $attrs .= ' ' . esc_attr($k) . '="' . esc_attr($v) . '"'; } } $aieo_rg_box = ''; echo class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::field_row(array('id' => $aieo_rg[0], 'label' => $aieo_rg[3], 'control' => $aieo_rg_box)) : AIEO_Kit::row($aieo_rg[3], $aieo_rg_box, '', array('id' => $aieo_rg[0])); } echo '
'; if (!$library) { echo '

'; } ob_start(); echo '
';
        echo "// In your theme/plugin, gate UX by risk band:\n";
        echo "\$band = AIEO_DMM_User_Behavior_Metrics::risk_band(\$user_id); // 0|1|2\n\n";
        echo "// Adjust the bands per your policy (e.g. tighter on COD-eligible regions):\n";
        echo "add_filter('aieo_dmm_user_risk_thresholds', function (\$t) {\n";
        echo "    return array_merge(\$t, ['min_orders' => 5, 'high_rate' => 0.20]);\n";
        echo "});\n\n";
        echo "// Force an override for a specific user (e.g. VIP whitelisting):\n";
        echo "add_filter('aieo_dmm_user_risk_band', function (\$band, \$uid) {\n";
        echo "    if (in_array(\$uid, [42, 1337], true)) { return 0; }\n";
        echo "    return \$band;\n";
        echo "}, 10, 2);\n\n";
        echo "// Down-rank thresholds for the reco penalty:\n";
        echo "add_filter('aieo_dmm_pbm_penalty_threshold', fn() => 0.15);  // start penalising at 15% rate\n";
        echo "add_filter('aieo_dmm_reco_drop_threshold',   fn() => 0.40);  // drop products with penalty ≤ 0.40\n\n";
        echo "// Per-kind weights for the PRODUCT reco penalty.\n";
        echo "// Defaults: returned=1.0, kept=0.5, refused=0.1\n";
        echo "// (refused tells us nothing about the product — parcel never opened).\n";
        echo "add_filter('aieo_dmm_pbm_kind_weights', fn() => [1 => 1.0, 2 => 0.4, 3 => 0.05]);\n\n";
        echo "// Per-kind weights for the USER risk band.\n";
        echo "// Defaults: returned=0.7, kept=1.0, refused=1.5\n";
        echo "// (refused is the strongest customer-friction signal — they bailed without opening).\n";
        echo "add_filter('aieo_dmm_ubm_kind_weights', fn() => [1 => 0.7, 2 => 1.0, 3 => 2.0]);\n";
        echo '
'; echo AIEO_Kit::fold(__('Theme integration cheat-sheet', 'ai-eshop-optimizer'), (string) ob_get_clean()); ?> 'POST', 'permission_callback' => static function () { return current_user_can(AIEO_CAPABILITY); }, 'callback' => array($this, 'rest_save_order'), )); } public function rest_save_order($request) { $params = (array) $request->get_json_params(); $order = isset($params['order']) && is_array($params['order']) ? array_map('strval', $params['order']) : array(); $known = array_map('strval', array_keys(AIEO_DMM_Cancellation_Reasons_Data::all())); if (count($order) !== count($known) || array_diff($order, $known) || count(array_unique($order)) !== count($order)) { return new WP_Error('aieo_cn_order', __('The list of reasons changed. Reload the page and try again.', 'ai-eshop-optimizer'), array('status' => 409)); } global $wpdb; $table = AIEO_DMM_Cancellation_Reasons_Data::table(); foreach ($order as $i => $slug) { $wpdb->update($table, array('sort_order' => ($i + 1) * 10), array('slug' => $slug)); } AIEO_DMM_Cancellation_Reasons_Data::flush_cache(); return new WP_REST_Response(array('order' => $order), 200); } public function ajax_save_reason() { AIEO_DMM::verify_ajax_request(); $slug = isset($_POST['slug']) ? sanitize_key(wp_unslash($_POST['slug'])) : ''; if ($slug === '') { wp_send_json_error(array('message' => __('Slug required.', 'ai-eshop-optimizer'))); } $patch = array( 'label' => isset($_POST['label']) ? sanitize_text_field(wp_unslash($_POST['label'])) : '', 'is_customer_visible' => !empty($_POST['is_customer_visible']) ? 1 : 0, 'sort_order' => isset($_POST['sort_order']) ? (int) $_POST['sort_order'] : 0, 'enabled' => !empty($_POST['enabled']) ? 1 : 0, ); if (isset($_POST['default_event_kind'])) { $patch['default_event_kind'] = (string) wp_unslash($_POST['default_event_kind']); } $id = AIEO_DMM_Cancellation_Reasons_Data::save($slug, $patch); wp_send_json_success(array('id' => (int) $id)); } public function ajax_disable_reason() { AIEO_DMM::verify_ajax_request(); $slug = isset($_POST['slug']) ? sanitize_key(wp_unslash($_POST['slug'])) : ''; if ($slug === '') { wp_send_json_error(array('message' => __('Slug required.', 'ai-eshop-optimizer'))); } AIEO_DMM_Cancellation_Reasons_Data::disable($slug); wp_send_json_success(); } public function ajax_metrics_backfill() { AIEO_DMM::verify_ajax_request(); @set_time_limit(300); $t0 = microtime(true); $historical = function_exists('aieo_dmm_backfill_order_cancellations') ? (int) aieo_dmm_backfill_order_cancellations() : 0; $products = class_exists('AIEO_DMM_Product_Behavior_Metrics') ? (int) AIEO_DMM_Product_Behavior_Metrics::backfill_all() : 0; $users = class_exists('AIEO_DMM_User_Behavior_Metrics') ? (int) AIEO_DMM_User_Behavior_Metrics::backfill_all() : 0; wp_send_json_success(array( 'historical' => $historical, 'products' => $products, 'users' => $users, 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), )); } public function ajax_metrics_backfill_step() { AIEO_DMM::verify_ajax_request(); @set_time_limit(120); $phase = isset($_POST['phase']) ? sanitize_key(wp_unslash($_POST['phase'])) : 'ingest'; if ($phase === 'aggregate') { $t0 = microtime(true); $products = class_exists('AIEO_DMM_Product_Behavior_Metrics') ? (int) AIEO_DMM_Product_Behavior_Metrics::backfill_all() : 0; $users = class_exists('AIEO_DMM_User_Behavior_Metrics') ? (int) AIEO_DMM_User_Behavior_Metrics::backfill_all() : 0; wp_send_json_success(array( 'phase' => 'aggregate', 'products' => $products, 'users' => $users, 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), 'done' => true, )); } if (!function_exists('aieo_dmm_backfill_order_cancellations_chunked')) { wp_send_json_error(array('message' => __('Chunked backfill function not loaded.', 'ai-eshop-optimizer'))); } $cursor = isset($_POST['cursor']) ? (int) $_POST['cursor'] : 0; $limit = isset($_POST['limit']) ? (int) $_POST['limit'] : 3000; $t0 = microtime(true); $r = aieo_dmm_backfill_order_cancellations_chunked($cursor, $limit); wp_send_json_success(array( 'phase' => 'ingest', 'processed' => (int) $r['processed'], 'chunk_size' => (int) $r['chunk_size'], 'next_cursor' => (int) $r['next_cursor'], 'total' => (int) $r['total'], 'has_more' => (bool) $r['has_more'], 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), )); } public function ajax_metrics_recompute_stale() { AIEO_DMM::verify_ajax_request(); $t0 = microtime(true); $products = class_exists('AIEO_DMM_Product_Behavior_Metrics') ? (int) AIEO_DMM_Product_Behavior_Metrics::recompute_stale(24, 200) : 0; $users = class_exists('AIEO_DMM_User_Behavior_Metrics') ? (int) AIEO_DMM_User_Behavior_Metrics::recompute_stale(24, 200) : 0; wp_send_json_success(array( 'products' => $products, 'users' => $users, 'elapsed_ms' => (int) round((microtime(true) - $t0) * 1000), )); } public function ajax_outbound_cost_step() { AIEO_DMM::verify_ajax_request(); if (!class_exists('AIEO_DMM_Order_Cancellations')) { wp_send_json_error(array('message' => __('Cancellations data class not loaded.', 'ai-eshop-optimizer'))); } @set_time_limit(120); $batch = isset($_POST['batch']) ? max(10, min(200, (int) $_POST['batch'])) : 100; $r = AIEO_DMM_Order_Cancellations::backfill_outbound_costs_batch($batch); wp_send_json_success($r); } public function ajax_risk_gating_toggle() { AIEO_DMM::verify_ajax_request(); $option = isset($_POST['option']) ? sanitize_key(wp_unslash($_POST['option'])) : ''; $allowed = array(AIEO_DMM_Risk_Gating::OPT_NOTICE, AIEO_DMM_Risk_Gating::OPT_BLOCK_COD); if (!in_array($option, $allowed, true)) { wp_send_json_error(array('message' => __('Unknown option.', 'ai-eshop-optimizer'))); } $value = !empty($_POST['value']); update_option($option, $value ? 1 : 0); wp_send_json_success(array('option' => $option, 'value' => $value ? 1 : 0)); } }