__('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 '
' . 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'))); ?>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')); ?>
' . $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 ''; 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 ''; echo ' '; echo ' '; echo ''; echo '
'; 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 ''; 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 ''; } 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));
}
}