&$def) { $def['frequency'] = isset($sched[$id]['frequency']) ? $sched[$id]['frequency'] : 'off'; $def['time'] = isset($sched[$id]['time']) ? $sched[$id]['time'] : $def['default_time']; $def['day'] = isset($sched[$id]['day']) ? (int) $sched[$id]['day'] : -1; } unset($def); return $defs; } public static function erp_missing(array $def) { global $wpdb; if (empty($def['erp'])) { return array(); } $present = array_map('strval', (array) $wpdb->get_col($wpdb->prepare( 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s', $wpdb->esc_like($wpdb->prefix . 's1erp_') . '%' ))); $missing = array(); foreach ((array) $def['erp'] as $table) { if (!in_array($wpdb->prefix . $table, $present, true)) { $missing[] = (string) $table; } } return $missing; } public static function needs_hpos($job_id, array $seen = array()) { $jobs = self::jobs(); if (!isset($jobs[$job_id]) || isset($seen[$job_id])) { return false; } if (!empty($jobs[$job_id]['orders'])) { return true; } $seen[$job_id] = true; foreach ((array) ($jobs[$job_id]['deps'] ?? array()) as $dep) { if (self::needs_hpos((string) $dep, $seen)) { return true; } } return false; } public static function schedule() { $stored = get_option(self::SETTINGS_OPTION, array()); return is_array($stored) ? $stored : array(); } public static function grain() { $g = (string) get_option(self::GRAIN_OPTION, 'both'); return in_array($g, array('variation', 'parent', 'both'), true) ? $g : 'both'; } public static function reference_mode() { $m = (string) get_option(self::REFERENCE_OPTION, 'auto'); return in_array($m, array('today', 'last_sales_day', 'auto'), true) ? $m : 'auto'; } public static function staleness_days() { $d = (int) get_option(self::STALENESS_OPTION, 3); return ($d >= 1 && $d <= 90) ? $d : 3; } public static function resolve_asof() { global $wpdb; $today = current_time('Y-m-d'); $mode = self::reference_mode(); if ($mode === 'today') { return $today; } $opa = $wpdb->prefix . 'aieo_order_products_analytics'; if ((string) $wpdb->get_var("SHOW TABLES LIKE '{$opa}'") !== $opa) { return $today; } $last_gmt = $wpdb->get_var("SELECT MAX(date_created_gmt) FROM `{$opa}`"); if (empty($last_gmt)) { return $today; } $last_ts = strtotime($last_gmt . ' UTC'); $last = $last_ts ? wp_date('Y-m-d', $last_ts) : ''; if (empty($last)) { return $today; } if ($mode === 'last_sales_day') { return (string) $last; } $gap = (int) $wpdb->get_var($wpdb->prepare('SELECT DATEDIFF(%s, %s)', $today, $last)); return ($gap > self::staleness_days()) ? (string) $last : $today; } public function register_hooks() { add_action('admin_init', array($this, 'maybe_install_sprocs')); add_action('plugins_loaded', array($this, 'maybe_schedule'), 27); add_action(self::CRON_HOOK, array($this, 'cron_run_job'), 10, 1); add_filter('cron_schedules', array($this, 'add_cron_schedules')); add_action('wp_ajax_aieo_analytics_save_schedule', array($this, 'ajax_save_schedule')); add_action('wp_ajax_aieo_analytics_run_now', array($this, 'ajax_run_now')); add_action('wp_ajax_aieo_analytics_predictor_step', array($this, 'ajax_predictor_step')); add_action('wp_ajax_aieo_analytics_run_all', array($this, 'ajax_run_all')); add_action('wp_ajax_aieo_analytics_save_pages', array($this, 'ajax_save_pages')); add_action('wp_ajax_aieo_analytics_reinstall', array($this, 'ajax_reinstall')); } public function maybe_install_sprocs() { if (get_option(self::SCHEMA_OPTION, '') === self::SCHEMA_VERSION) { return; } self::install_sprocs(); } public static function install_sprocs() { self::load_sprocs_file(); if (!function_exists('aieo_analytics_jobs_install_sprocs')) { return false; } if (class_exists('AIEO_Analytics_Collation') && !AIEO_Analytics_Collation::converge('page')) { return array('the analytics tables are being converted to one collation'); } if (!AIEO_Schema_Lock::acquire(self::SCHEMA_OPTION)) { return array('another request is installing the analytics procedures'); } $res = aieo_analytics_jobs_install_sprocs(); if ($res === true) { update_option(self::SCHEMA_OPTION, self::SCHEMA_VERSION, false); } return $res; } public static function repoint_wpdatatables() { global $wpdb; $t = $wpdb->prefix . 'wpdatatables'; if ((string) $wpdb->get_var("SHOW TABLES LIKE '{$t}'") !== $t) { return array('updated' => 0, 'note' => 'no wpdatatables table'); } $p = $wpdb->prefix; $map = array( $p . 'pods_order_stats' => $p . 'aieo_order_stats', $p . 'pods_order_products_analytics' => $p . 'aieo_order_products_analytics', $p . 'wiz_cummulative_order_stats' => $p . 'aieo_cumulative_order_stats', $p . 'wiz_order_products_freq_single_item_orders' => $p . 'aieo_order_products_freq_single_item_orders', $p . 'wiz_order_products_freq_entry' => $p . 'aieo_order_products_freq_entry', $p . 'wiz_order_products_freq_exit' => $p . 'aieo_order_products_freq_exit', $p . 'wiz_order_products_single_orders' => $p . 'aieo_order_products_single_orders', $p . 'wiz_order_products_entry' => $p . 'aieo_order_products_entry', $p . 'wiz_order_products_exit' => $p . 'aieo_order_products_exit', $p . 'order_invoice_match_report_current_month' => $p . 'aieo_order_invoice_match_report_current_month', $p . 's1erp_store_only_products_table' => $p . 'aieo_store_only_products', $p . 's1erp_reciprocal_ids_table' => $p . 'aieo_reciprocal_ids', $p . 'pods_product_vitals' => $p . 'aieo_core_product_vitals', $p . 'wiz_product_vitals' => $p . 'aieo_core_product_vitals', $p . 'wiz_order_products_analytics' => $p . 'aieo_order_products_analytics', $p . 'wiz_order_stats' => $p . 'aieo_order_stats', $p . 'pods_store_sales' => $p . 'aieo_dmm_store_sales', $p . 'pods_preorder_vitals' => $p . 'aieo_dmm_preorder_vitals', ); $inv_re = '/' . preg_quote($p . 'order_invoice_match_report', '/') . '(?![_a-zA-Z0-9])/'; $inv_to = $p . 'aieo_order_invoice_match_report'; $qe = static function ($name) use ($p) { return preg_quote($p . $name, '/'); }; $col_remaps = array( '/(' . $qe('aieo_order_stats') . '\.)(?:`OrderTrafficSource`|OrderTrafficSource(?![A-Za-z0-9_]))(?!\s+AS\s+`?OrderTrafficSource`?)/i' => '${1}`ordersource` AS `OrderTrafficSource`', '/(' . $qe('aieo_dmm_preorder_vitals') . '\.)(?:`supplier_name`|supplier_name(?![A-Za-z0-9_]))(?!\s+AS\s+`?supplier_name`?)/i' => '${1}`supplier` AS `supplier_name`', '/(' . $qe('aieo_dmm_store_sales') . '\.)(?:`trend_of_eshop`|trend_of_eshop(?![A-Za-z0-9_]))(?!\s+AS\s+`?trend_of_eshop`?)/i' => '${1}`trend_of_eshop_soft1` AS `trend_of_eshop`', '/(' . preg_quote($p, '/') . 'store_board_[A-Za-z0-9_]+\.)(?:`supplier_name`|supplier_name(?![A-Za-z0-9_]))(?!\s+AS\s+`?supplier_name`?)/i' => '${1}`supplier` AS `supplier_name`', ); $rows = $wpdb->get_results("SELECT id, content, mysql_table_name FROM `{$t}`", ARRAY_A); $updated = 0; $skipped = 0; foreach ((array) $rows as $r) { $c = (string) $r['content']; $mt = (string) $r['mysql_table_name']; $oc = $c; $omt = $mt; if (strpos($c, $p . 'pods_supplier_preorders') !== false) { $c = self::supplier_preorders_aieo_sql($p); $mt = ''; } elseif (preg_match('/(? $new) { $c = str_replace($old, $new, $c); $mt = str_replace($old, $new, $mt); } $c = preg_replace($inv_re, $inv_to, $c); $mt = preg_replace($inv_re, $inv_to, $mt); foreach ($col_remaps as $pat => $rep) { $c = preg_replace($pat, $rep, $c); } if (strpos($c, $p . 'aieo_dmm_preorder_vitals') !== false) { $c = preg_replace('/(?update($t, array('content' => $c, 'mysql_table_name' => $mt), array('id' => (int) $r['id']), array('%s', '%s'), array('%d')); $updated++; } elseif (preg_match('/' . preg_quote($p, '/') . '(pods_|wiz_)/i', $c)) { $skipped++; } } return array('updated' => $updated, 'skipped_no_target' => $skipped); } private static function sales_predictor_products_aieo_sql($p) { $admin = admin_url(); return "SELECT sp.`id`, sp.`product_id`, sp.`parent_product_id`, v.`name` AS `product_name`, v.`image_url`, v.`category_id`, v.`brand`, v.`brand_bias`, v.`menu_id`, v.`menu_category`, v.`head_id`, v.`head_category`, v.`subcat_id`, v.`sub_category`, v.`product_first_sale_date`, v.`product_latest_sale_date`, v.`product_creation_date`, v.`product_age`, sp.`product_availability_last_year_7`, sp.`product_availability_last_year_14`, sp.`product_availability_last_year_28`, sp.`product_availability_last_year_56`, sp.`product_availability_last_year_175`, v.`sku`, sp.`price`, sp.`price_before_adjustment`, sp.`price_adjustment_percentage`, sp.`latest_price_adjustment_date`, sp.`price_update_repetitions`, sp.`current_stock`, sp.`out_of_stock_days`, sp.`out_of_stock_repetitions`, sp.`reorder_date`, sp.`reorder_quantity`, CONCAT('Edit') AS `edit_link`, sp.`total_product_sales`, sp.`total_product_turnover`, sp.`quantity_last_175_days`, sp.`quantity_last_56_days`, sp.`quantity_last_28_days`, sp.`quantity_last_14_days`, sp.`quantity_last_7_days`, sp.`quantity_next_7_days`, sp.`quantity_next_14_days`, sp.`quantity_next_28_days`, sp.`quantity_next_56_days`, sp.`quantity_next_175_days`, sp.`turnover_last_175_days`, sp.`turnover_last_56_days`, sp.`turnover_last_28_days`, sp.`turnover_last_14_days`, sp.`turnover_last_7_days`, sp.`turnover_next_7_days`, sp.`turnover_next_14_days`, sp.`turnover_next_28_days`, sp.`turnover_next_56_days`, sp.`turnover_next_175_days`, NULL AS `actual_10_product_sale_retro_date`, NULL AS `actual_10_product_sale_retro_days`, sp.`items_per_day_last_175_days`, sp.`items_per_day_last_56_days`, sp.`items_per_day_last_28_days`, sp.`items_per_day_last_14_days`, sp.`items_per_day_last_7_days`, sp.`items_per_day_next_7_days`, sp.`items_per_day_next_14_days`, sp.`items_per_day_next_28_days`, sp.`items_per_day_next_56_days`, sp.`items_per_day_next_175_days`, sp.`turnover_per_day_last_175_days`, sp.`turnover_per_day_last_56_days`, sp.`turnover_per_day_last_28_days`, sp.`turnover_per_day_last_14_days`, sp.`turnover_per_day_last_7_days`, sp.`turnover_per_day_next_7_days`, sp.`turnover_per_day_next_14_days`, sp.`turnover_per_day_next_28_days`, sp.`turnover_per_day_next_56_days`, sp.`turnover_per_day_next_175_days`, sp.`days_until_stock_run_out_linear`, NULL AS `days_until_stock_run_out_ai`, sp.`ewma_items_per_day`, NULL AS `diff_days_linear_ai`, NULL AS `diff_percentage_linear_ai`, sp.`days_to_sell_10_units_linear`, sp.`days_to_sell_10_units_ewma`, (sp.`days_to_sell_10_units_linear` - sp.`days_to_sell_10_units_ewma`) AS `diff_days_10_units_linear_ewma`, (CASE WHEN sp.`days_to_sell_10_units_ewma` > 0 THEN ROUND(100.0 * (sp.`days_to_sell_10_units_linear` - sp.`days_to_sell_10_units_ewma`) / sp.`days_to_sell_10_units_ewma`, 2) ELSE NULL END) AS `diff_percentage_10_units_linear_ewma`, sp.`estimated_zero_stock_date`, NULL AS `truelife_zero_stock_date`, sp.`estimated_date_to_sell_10_units`, NULL AS `truelife_date_to_sell_10_units`, sp.`chosen_method` AS `best_match_estimator`, NULL AS `actual_product_daily_sales`, sp.`optimistic_estimator_value`, sp.`optimistic_estimator_name`, sp.`pessimistic_estimator_value`, sp.`pessimistic_estimator_name`, sp.`average_estimator_value`, NULL AS `closest_to_average_estimator_name`, sp.`optimistic_cost`, sp.`pessimistic_cost`, sp.`average_cost`, sp.`prediction_date`, sp.`linear_items_per_day`, NULL AS `ai_items_per_day`, NULL AS `closest_to_ewma_estimator_name`, NULL AS `closest_to_linear_estimator_name`, NULL AS `closest_to_ai_estimator_name`, sp.`linear_estimator_value`, sp.`ewma_estimator_value`, NULL AS `ai_estimator_value` FROM `{$p}aieo_sales_predictor` sp LEFT JOIN `{$p}aieo_core_product_vitals` v ON v.`product_id` = sp.`product_id` WHERE sp.`current_stock` < 1 AND sp.`out_of_stock_days` < 900 AND DATE(sp.`prediction_date`) = (SELECT MAX(DATE(`prediction_date`)) FROM `{$p}aieo_sales_predictor`) AND ( sp.`grain` = 'variation' OR NOT EXISTS ( SELECT 1 FROM `{$p}aieo_sales_predictor` x WHERE x.`grain` = 'variation' AND x.`parent_product_id` = sp.`product_id` AND x.`prediction_date` = sp.`prediction_date` ) ) ORDER BY sp.`linear_estimator_value` DESC"; } private static function sales_predictor_costs_aieo_sql($p) { return "SELECT COUNT(DISTINCT sp.`product_id`) AS `Products`, COUNT(DISTINCT sp.`parent_product_id`) AS `Parent Products`, SUM(sp.`average_cost`) AS `Average Estimator`, SUM(sp.`optimistic_cost`) AS `Optimistic Sales Estimator`, SUM(sp.`pessimistic_cost`) AS `Pessimistic Sales Estimator`, SUM(sp.`linear_estimator_value`) AS `Realistic Sales Estimator`, SUM(sp.`ewma_estimator_value`) AS `Realistic (today bias) Estimator`, NULL AS `Adaptive Estimator` FROM `{$p}aieo_sales_predictor` sp WHERE sp.`current_stock` < 1 AND sp.`out_of_stock_days` < 900 AND DATE(sp.`prediction_date`) = (SELECT MAX(DATE(`prediction_date`)) FROM `{$p}aieo_sales_predictor`) AND ( sp.`grain` = 'variation' OR NOT EXISTS ( SELECT 1 FROM `{$p}aieo_sales_predictor` x WHERE x.`grain` = 'variation' AND x.`parent_product_id` = sp.`product_id` AND x.`prediction_date` = sp.`prediction_date` ) )"; } private static function supplier_preorders_aieo_sql($p) { return "SELECT\n" . " sp.`id` AS `id`,\n" . " concat('', COALESCE(NULLIF(pv.`product_name`, ''), pv.`name`), '') AS `name`,\n" . " sp.`created_at` AS `created`,\n" . " sp.`created_at` AS `modified`,\n" . " COALESCE((SELECT tm.`meta_value` FROM {$p}termmeta tm WHERE tm.`term_id` = sp.`supplier_term_id` AND tm.`meta_key` = 'aieo_supplier_erp_id' LIMIT 1), sp.`supplier_term_id`) AS `supplier_id`,\n" . " t.`name` AS `supplier`,\n" . " sp.`branch_id` AS `shop_id`,\n" . " br.`label` AS `shop_name`,\n" . " pv.`sku` AS `sku`,\n" . " pv.`gtin` AS `barcode`,\n" . " sp.`requested_qty` AS `quantity_requested`,\n" . " sp.`product_id` AS `product_id`,\n" . " sp.`prior_existing` AS `quantity_before_order`,\n" . " sp.`status` AS `status`,\n" . " NULL AS `origin_type`,\n" . " sp.`scanned_user_id` AS `origin_user`,\n" . " pv.`image_url` AS `image_url`,\n" . " NULL AS `parent_image_url`\n" . "FROM {$p}aieo_dmm_supplier_preorders sp\n" . "LEFT JOIN {$p}aieo_core_product_vitals pv ON pv.`product_id` = sp.`product_id`\n" . "LEFT JOIN {$p}aieo_dmm_branches br ON br.`id` = sp.`branch_id`\n" . "LEFT JOIN {$p}terms t ON t.`term_id` = sp.`supplier_term_id`"; } public function add_cron_schedules($schedules) { if (!isset($schedules['weekly'])) { $schedules['weekly'] = array('interval' => WEEK_IN_SECONDS, 'display' => __('Once Weekly', 'ai-eshop-optimizer')); } if (!isset($schedules['aieo_monthly'])) { $schedules['aieo_monthly'] = array('interval' => 30 * DAY_IN_SECONDS, 'display' => __('Once Monthly (~30 days)', 'ai-eshop-optimizer')); } return $schedules; } public function maybe_schedule() { foreach (self::jobs() as $id => $def) { $args = array($id); $next = (int) wp_next_scheduled(self::CRON_HOOK, $args); $sched = wp_get_schedule(self::CRON_HOOK, $args); if ($def['frequency'] === 'off') { if ($next > 0) { wp_unschedule_event($next, self::CRON_HOOK, $args); } continue; } $recurrence_map = array('hourly' => 'hourly', 'daily' => 'daily', 'weekly' => 'weekly', 'monthly' => 'aieo_monthly'); $recurrence = isset($recurrence_map[$def['frequency']]) ? $recurrence_map[$def['frequency']] : 'daily'; $day = (int) ($def['day'] ?? -1); $calendar = in_array($def['frequency'], array('daily', 'weekly', 'monthly'), true); $aligned = !$calendar || self::athens_aligned($next, $def['time'], $def['frequency'], $day); if ($sched === $recurrence && $next > 0 && $aligned) { continue; } if ($next > 0) { wp_unschedule_event($next, self::CRON_HOOK, $args); } $first = ($def['frequency'] === 'hourly') ? (time() + MINUTE_IN_SECONDS) : self::next_athens_at($def['time'], $def['frequency'], $day); wp_schedule_event($first, $recurrence, self::CRON_HOOK, $args); } } private static function next_athens_at($hhmm, $frequency = 'daily', $day = -1) { if (!preg_match('/^\d{2}:\d{2}$/', (string) $hhmm)) { $hhmm = '04:00'; } $day = (int) $day; try { $tz = new \DateTimeZone('Europe/Athens'); $next = new \DateTime('today ' . $hhmm . ':00', $tz); if ('weekly' === $frequency && $day >= 1 && $day <= 7) { $guard = 0; while (((int) $next->format('N') !== $day || $next->getTimestamp() <= time()) && $guard++ < 8) { $next->modify('+1 day'); } return (int) $next->getTimestamp(); } if ('monthly' === $frequency && $day >= 1 && $day <= 31) { list($h, $i) = array_map('intval', explode(':', $hhmm)); for ($m = 0; $m < 3; $m++) { $probe = new \DateTime('first day of this month', $tz); if ($m > 0) { $probe->modify('+' . $m . ' month'); } $probe->setDate((int) $probe->format('Y'), (int) $probe->format('n'), min($day, (int) $probe->format('t'))); $probe->setTime($h, $i, 0); if ($probe->getTimestamp() > time()) { return (int) $probe->getTimestamp(); } } } if ($next->getTimestamp() <= time()) { $next->modify('+1 day'); } return (int) $next->getTimestamp(); } catch (\Throwable $e) { return time() + DAY_IN_SECONDS; } } private static function athens_aligned($ts, $hhmm, $frequency, $day) { if ((int) $ts <= 0) { return false; } try { $at = new \DateTime('@' . (int) $ts); $at->setTimezone(new \DateTimeZone('Europe/Athens')); } catch (\Throwable $e) { return true; } if (preg_match('/^\d{2}:\d{2}$/', (string) $hhmm) && $at->format('H:i') !== $hhmm) { return false; } $day = (int) $day; if ($day < 1) { return true; } if ('weekly' === $frequency) { return (int) $at->format('N') === $day; } if ('monthly' === $frequency) { return (int) $at->format('j') === min($day, (int) $at->format('t')); } return true; } public function cron_run_job($job_id) { $this->run_job((string) $job_id, 'cron'); } public static function after_collation() { if (get_option(self::SCHEMA_OPTION, '') !== self::SCHEMA_VERSION) { self::install_sprocs(); } } public function run_job($job_id, $source = 'manual') { global $wpdb; $jobs = self::jobs(); if (!isset($jobs[$job_id])) { return array('ok' => false, 'error' => 'unknown_job', 'job' => $job_id); } $def = $jobs[$job_id]; $proc = $wpdb->prefix . $def['proc']; $erp = self::erp_missing($def); if ($erp) { return array('ok' => false, 'skipped' => true, 'error' => 'no_erp_data', 'erp' => $erp, 'job' => $job_id); } if (self::needs_hpos($job_id) && !AIEO_Order_Store::uses_hpos()) { return array('ok' => false, 'skipped' => true, 'error' => 'orders_not_in_hpos', 'job' => $job_id); } $lock = 'aieo_analytics_job_' . $job_id; if (get_transient($lock)) { return array('ok' => false, 'skipped' => true, 'error' => 'already_running', 'job' => $job_id); } set_transient($lock, time(), 90 * MINUTE_IN_SECONDS); register_shutdown_function(function () use ($lock) { $e = error_get_last(); $fatal = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR); if ($e && in_array($e['type'], $fatal, true)) { delete_transient($lock); } }); @set_time_limit(0); $exists = (string) $wpdb->get_var($wpdb->prepare( "SELECT routine_name FROM information_schema.routines WHERE routine_schema = DATABASE() AND routine_type = 'PROCEDURE' AND routine_name = %s", $proc )) === $proc; if (!$exists || get_option(self::SCHEMA_OPTION, '') !== self::SCHEMA_VERSION) { self::install_sprocs(); } self::load_sprocs_file(); if (class_exists('AIEO_Analytics_Collation') && AIEO_Analytics_Collation::pending()) { delete_transient($lock); return array('ok' => false, 'skipped' => true, 'error' => 'collation_conversion_pending', 'job' => $job_id); } if ($job_id === 'sales_predictor') { $wpdb->query($wpdb->prepare('SET @aieo_predictor_grain = %s', self::grain())); $wpdb->query($wpdb->prepare('SET @aieo_predictor_asof = %s', self::resolve_asof())); } if (($job_id === 'stock_recovery' || $job_id === 'stock_recovery_outcomes') && class_exists('AIEO_DMM_Physical_Recovery')) { foreach (AIEO_DMM_Physical_Recovery::session_vars() as $var => $val) { $wpdb->query($wpdb->prepare("SET {$var} = %s", $val)); } } if (in_array($job_id, array('store_sales_stats', 'preorder_vitals', 'stock_recovery'), true) && class_exists('AIEO_DMM_Branches')) { AIEO_DMM_Branches::sync(); $wpdb->query($wpdb->prepare('SET @aieo_eshop_it = %s', AIEO_DMM_Branches::eshop_invoice_type())); } $t0 = microtime(true); if ($job_id === 'sales_predictor') { $err = self::run_sales_predictor_staged(); } else { aieo_call_sproc("CALL `{$proc}`()", null); $err = $wpdb->last_error; } if (!$err && in_array($job_id, array('supplier_sku_sync', 'preorder_vitals'), true)) { if (class_exists('AIEO_DMM_Store_Builder')) { if ($job_id === 'supplier_sku_sync') { AIEO_DMM_Store_Builder::sync_supplier_terms(); } else { AIEO_DMM_Store_Builder::upsert_boards(); } $err = $wpdb->last_error; } } if (!$err && $job_id === 'stock_recovery' && class_exists('AIEO_DMM_Physical_Recovery') && AIEO_DMM_Physical_Recovery::auto_apply_enabled()) { AIEO_DMM_Physical_Recovery::run_auto_apply(); $err = $wpdb->last_error; } $dur = round(microtime(true) - $t0, 1); $rows = null; if (!$err && !empty($def['table'])) { $tbl = str_replace('wp_', $wpdb->prefix, $def['table']); $rows = (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$tbl}`"); } $entry = array( 'finished_at' => current_time('mysql', true), 'source' => $source, 'duration' => $dur, 'rows' => $rows, 'ok' => empty($err), 'error' => $err ? (string) $err : '', ); $all = get_option(self::LAST_RUN_OPTION, array()); if (!is_array($all)) { $all = array(); } $all[$job_id] = $entry; update_option(self::LAST_RUN_OPTION, $all, false); if ($err && function_exists('aieo_debug_log')) { aieo_debug_log("AIEO analytics job {$job_id} ({$source}) failed: {$err}"); } delete_transient($lock); $entry['job'] = $job_id; return $entry; } private static function run_sales_predictor_staged() { global $wpdb; $p = $wpdb->prefix; $have = (string) $wpdb->get_var($wpdb->prepare( "SELECT routine_name FROM information_schema.routines WHERE routine_schema = DATABASE() AND routine_name = %s", $p . 'AIEO_SP_Prepare' )) === $p . 'AIEO_SP_Prepare'; if (!$have) { self::install_sprocs(); } $installer = array(__CLASS__, 'install_sprocs'); aieo_call_sproc("CALL `{$p}AIEO_SP_Prepare`()", $installer); if ($wpdb->last_error) { return (string) $wpdb->last_error; } $tb = (int) $wpdb->get_var("SELECT total_batches FROM `{$p}aieo_sp_run` WHERE id = 1"); $guard = $tb + 50; for ($i = 0; $i <= $guard; $i++) { $cur = (int) $wpdb->get_var("SELECT next_batch FROM `{$p}aieo_sp_run` WHERE id = 1"); if ($cur >= $tb) { break; } aieo_call_sproc("CALL `{$p}AIEO_SP_Batch`()", $installer); if ($wpdb->last_error) { return (string) $wpdb->last_error; } } aieo_call_sproc("CALL `{$p}AIEO_SP_Finalize`()", $installer); return (string) $wpdb->last_error; } public function ajax_predictor_step() { AIEO_DMM::verify_ajax_request(); if (self::needs_hpos('sales_predictor') && !AIEO_Order_Store::uses_hpos()) { wp_send_json_error(array('message' => __('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer'))); } global $wpdb; $phase = isset($_POST['phase']) ? sanitize_key((string) wp_unslash($_POST['phase'])) : ''; $p = $wpdb->prefix; $lock = 'aieo_analytics_job_sales_predictor'; @set_time_limit(0); if ($phase === 'prepare') { if (get_transient($lock)) { wp_send_json_error(array('message' => __('The predictor is already running.', 'ai-eshop-optimizer'))); } set_transient($lock, time(), 90 * MINUTE_IN_SECONDS); set_transient('aieo_sp_started', microtime(true), 90 * MINUTE_IN_SECONDS); $have = (string) $wpdb->get_var($wpdb->prepare( "SELECT routine_name FROM information_schema.routines WHERE routine_schema = DATABASE() AND routine_type = 'PROCEDURE' AND routine_name = %s", $p . 'AIEO_SP_Prepare' )) === $p . 'AIEO_SP_Prepare'; if (!$have) { self::install_sprocs(); } self::load_sprocs_file(); if (class_exists('AIEO_Analytics_Collation') && AIEO_Analytics_Collation::pending()) { delete_transient($lock); wp_send_json_error(array('message' => __('The analytics tables are being converted to one collation. Try again in a few minutes.', 'ai-eshop-optimizer'))); } $wpdb->query($wpdb->prepare('SET @aieo_predictor_grain = %s', self::grain())); $wpdb->query($wpdb->prepare('SET @aieo_predictor_asof = %s', self::resolve_asof())); aieo_call_sproc("CALL `{$p}AIEO_SP_Prepare`()", array(__CLASS__, 'install_sprocs')); if ($wpdb->last_error) { delete_transient($lock); wp_send_json_error(array('message' => $wpdb->last_error)); } $row = $wpdb->get_row("SELECT total_batches, total_products FROM `{$p}aieo_sp_run` WHERE id = 1", ARRAY_A); wp_send_json_success(array( 'phase' => 'prepare', 'total_batches' => (int) ($row['total_batches'] ?? 0), 'total_products' => (int) ($row['total_products'] ?? 0), 'next_batch' => 0, )); } if ($phase === 'batch') { if (!get_transient($lock)) { set_transient($lock, time(), 90 * MINUTE_IN_SECONDS); } aieo_call_sproc("CALL `{$p}AIEO_SP_Batch`()", array(__CLASS__, 'install_sprocs')); if ($wpdb->last_error) { delete_transient($lock); wp_send_json_error(array('message' => $wpdb->last_error)); } $row = $wpdb->get_row("SELECT next_batch, total_batches FROM `{$p}aieo_sp_run` WHERE id = 1", ARRAY_A); $nb = (int) ($row['next_batch'] ?? 0); $tb = (int) ($row['total_batches'] ?? 0); wp_send_json_success(array('phase' => 'batch', 'next_batch' => $nb, 'total_batches' => $tb, 'done' => ($nb >= $tb))); } if ($phase === 'finalize') { aieo_call_sproc("CALL `{$p}AIEO_SP_Finalize`()", array(__CLASS__, 'install_sprocs')); $err = $wpdb->last_error; $asof = (string) $wpdb->get_var("SELECT asof FROM `{$p}aieo_sp_run` WHERE id = 1"); $rows = $err ? null : (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM `{$p}aieo_sales_predictor` WHERE prediction_date = %s", $asof )); $started = (float) get_transient('aieo_sp_started'); $dur = $started ? round(microtime(true) - $started, 1) : null; $entry = array( 'finished_at' => current_time('mysql', true), 'source' => 'manual', 'duration' => $dur, 'rows' => $rows, 'ok' => empty($err), 'error' => $err ? (string) $err : '', ); $all = get_option(self::LAST_RUN_OPTION, array()); if (!is_array($all)) { $all = array(); } $all['sales_predictor'] = $entry; update_option(self::LAST_RUN_OPTION, $all, false); delete_transient($lock); delete_transient('aieo_sp_started'); if ($err) { wp_send_json_error(array('message' => $err)); } wp_send_json_success(array( 'phase' => 'finalize', 'rows' => $rows, 'duration' => $dur, 'message' => sprintf(__('Done — %1$s rows in %2$ss.', 'ai-eshop-optimizer'), number_format_i18n((int) $rows), $dur), )); } wp_send_json_error(array('message' => 'unknown phase')); } public function run_all($source = 'manual') { $log = array(); foreach (array_keys(self::jobs()) as $id) { $log[$id] = $this->run_job($id, $source); } return $log; } public function ajax_save_schedule() { AIEO_DMM::verify_ajax_request(); $jobs = self::jobs(); $raw = isset($_POST['schedule_json']) ? json_decode((string) wp_unslash($_POST['schedule_json']), true) : array(); $out = array(); if (is_array($raw)) { foreach ($jobs as $id => $def) { $freq = isset($raw[$id]['frequency']) ? sanitize_key((string) $raw[$id]['frequency']) : 'off'; $time = isset($raw[$id]['time']) ? sanitize_text_field((string) $raw[$id]['time']) : $def['default_time']; if (!in_array($freq, array('off', 'hourly', 'daily', 'weekly', 'monthly'), true)) { $freq = 'off'; } if (!preg_match('/^\d{2}:\d{2}$/', $time)) { $time = $def['default_time']; } $day = isset($raw[$id]['day']) ? (int) $raw[$id]['day'] : -1; $max = ('weekly' === $freq) ? 7 : (('monthly' === $freq) ? 31 : 0); if ($day < 1 || $day > $max) { $day = -1; } $out[$id] = array('frequency' => $freq, 'time' => $time, 'day' => $day); } } update_option(self::SETTINGS_OPTION, $out, false); if (isset($_POST['predictor_grain'])) { $g = sanitize_key((string) wp_unslash($_POST['predictor_grain'])); if (!in_array($g, array('variation', 'parent', 'both'), true)) { $g = 'both'; } update_option(self::GRAIN_OPTION, $g, false); } if (isset($_POST['predictor_reference'])) { $r = sanitize_key((string) wp_unslash($_POST['predictor_reference'])); if (!in_array($r, array('today', 'last_sales_day', 'auto'), true)) { $r = 'auto'; } update_option(self::REFERENCE_OPTION, $r, false); } if (isset($_POST['predictor_staleness'])) { $s = (int) $_POST['predictor_staleness']; if ($s < 1) { $s = 1; } if ($s > 90) { $s = 90; } update_option(self::STALENESS_OPTION, $s, false); } $this->maybe_schedule(); wp_send_json_success(array('message' => __('Schedules saved.', 'ai-eshop-optimizer'))); } public function ajax_run_now() { AIEO_DMM::verify_ajax_request(); $job_id = isset($_POST['job']) ? sanitize_key((string) wp_unslash($_POST['job'])) : ''; $entry = $this->run_job($job_id, 'manual'); if (isset($entry['error']) && 'orders_not_in_hpos' === $entry['error']) { wp_send_json_success(array('entry' => $entry, 'message' => __('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer'))); } if (isset($entry['error']) && 'no_erp_data' === $entry['error']) { wp_send_json_success(array( 'entry' => $entry, 'message' => __('Skipped: no ERP data on this site.', 'ai-eshop-optimizer') . ' ' . sprintf(__('It runs once the ERP sync has created %s.', 'ai-eshop-optimizer'), implode(', ', (array) $entry['erp'])), )); } wp_send_json_success(array( 'entry' => $entry, 'message' => !empty($entry['ok']) ? sprintf(__('Done — %1$s rows in %2$ss.', 'ai-eshop-optimizer'), number_format_i18n((int) ($entry['rows'] ?? 0)), $entry['duration'] ?? '?') : sprintf(__('Failed: %s', 'ai-eshop-optimizer'), $entry['error'] ?? 'error'), )); } public function ajax_run_all() { AIEO_DMM::verify_ajax_request(); $log = $this->run_all('manual'); $okc = 0; $fail = 0; $skip = 0; foreach ($log as $e) { if (!empty($e['ok'])) { $okc++; } elseif (!empty($e['skipped'])) { $skip++; } else { $fail++; } } wp_send_json_success(array( 'log' => $log, 'message' => sprintf(__('Ran all jobs: %1$d ok, %2$d failed, %3$d skipped.', 'ai-eshop-optimizer'), $okc, $fail, $skip), )); } public function ajax_save_pages() { AIEO_DMM::verify_ajax_request(); $csv = isset($_POST['page_ids']) ? sanitize_text_field((string) wp_unslash($_POST['page_ids'])) : ''; $ids = array(); foreach (preg_split('/[\s,]+/', $csv) as $p) { $p = (int) $p; if ($p > 0) { $ids[] = $p; } } $ids = array_values(array_unique($ids)); update_option(self::PAGES_OPTION, $ids, false); wp_send_json_success(array( 'message' => __('Enhancement pages saved.', 'ai-eshop-optimizer'), 'page_ids' => implode(', ', $ids), )); } public function ajax_reinstall() { AIEO_DMM::verify_ajax_request(); delete_option(self::SCHEMA_OPTION); $res = self::install_sprocs(); wp_send_json_success(array( 'message' => ($res === true) ? __('Stored procedures reinstalled.', 'ai-eshop-optimizer') : __('Reinstall reported issues — see debug log.', 'ai-eshop-optimizer'), )); } public function render_subtab() { $this->render_jobs_card( __( 'This screen now lives under Data Pipeline, alongside the substrate schedule it runs beside.', 'ai-eshop-optimizer' ) ); } public function render_jobs_card( $note = '' ) { $jobs = self::jobs(); $last = (array) get_option(self::LAST_RUN_OPTION, array()); $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $footer = ' ' . ' ' . ' ' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-aj-status')); list($open, $close) = AIEO_DMM_UI::card( array( 'anchor' => 'product-orders-analytics-analytics-jobs', 'flush' => true, 'title' => __('Analytics jobs', 'ai-eshop-optimizer'), 'description' => __('Rebuilds the order/product analytics tables that the logistics report pages (wpDataTables) read from — per-order stats, cumulative customer stats, order-products analytics, behaviours, cart frequencies and Woo↔ERP invoice matching. Each job runs a server-side stored procedure; product data is sourced from the Rooster One product vitals table. Set a schedule per job (Europe/Athens) or run on demand.', 'ai-eshop-optimizer'), 'footer' => $footer, 'footnote' => __('Run-now / Run-all edit historical analytics tables and can take a few minutes (the cumulative-stats job is the slowest). Run off-peak.', 'ai-eshop-optimizer'), ) ); echo $open; if ( '' !== (string) $note ) { $pointer = \Rooster\Admin\Ui::notice( esc_html( (string) $note ), 'info', array( 'inline' => true ) ); echo $pointer; } $aj_rows = array(); foreach ($jobs as $id => $def) { $li = isset($last[$id]) && is_array($last[$id]) ? $last[$id] : array(); $deps = !empty($def['deps']) ? implode(', ', $def['deps']) : ''; $job_name = (string) $def['label']; ob_start(); echo ' '; global $wp_locale; $job_day = (int) ($def['day'] ?? -1); echo ''; echo ' '; echo ''; echo ' '; echo ''; if ($id === 'sales_predictor') { $grain = self::grain(); $ref = self::reference_mode(); $stale = self::staleness_days(); echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; if (class_exists('\Rooster\Admin\Guidance')) { echo \Rooster\Admin\Guidance::tip(__('Which day the demand windows treat as “now”. Use the last real sales day when the store goes quiet so velocity is not read as zero.', 'ai-eshop-optimizer'), rtrim(__('Anchor:', 'ai-eshop-optimizer'), ': '), array('id' => 'aieo-aj-reference-tip')); } echo ''; echo ''; echo '' . esc_html__('quiet after', 'ai-eshop-optimizer') . ' ' . '' . esc_html__('days', 'ai-eshop-optimizer') . ''; echo '
'; echo '
'; } $schedule_html = (string) ob_get_clean(); ob_start(); $erp = self::erp_missing($def); if (self::needs_hpos((string) $id) && !AIEO_Order_Store::uses_hpos()) { echo \Rooster\Admin\Ui::badge(__('HPOS', 'ai-eshop-optimizer'), 'warning'); echo '
' . esc_html__('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer') . ''; } elseif ($erp) { echo \Rooster\Admin\Ui::badge(__('No ERP data', 'ai-eshop-optimizer'), 'warning'); echo '
' . esc_html(sprintf(__('It runs once the ERP sync has created %s.', 'ai-eshop-optimizer'), implode(', ', $erp))) . ''; } elseif (empty($li)) { echo '' . esc_html__('never', 'ai-eshop-optimizer') . ''; } else { echo \Rooster\Admin\Ui::badge(!empty($li['ok']) ? 'OK' : 'FAIL', !empty($li['ok']) ? 'success' : 'destructive') . ' '; echo esc_html((isset($li['rows']) && $li['rows'] !== null ? number_format_i18n((int) $li['rows']) . ' rows · ' : '') . ($li['duration'] ?? '?') . 's'); echo '
' . esc_html(($li['finished_at'] ?? '') . ' · ' . ($li['source'] ?? '')) . ''; if (!empty($li['error'])) { echo '
' . \Rooster\Admin\States::outcome(mb_substr((string) $li['error'], 0, 160), 'destructive'); } } $last_run_html = (string) ob_get_clean(); $aj_rows[] = array( 'job' => (string) $id, 'label' => (string) $def['label'], 'sub' => $id . ( '' !== $deps ? ' · ' . __('runs after:', 'ai-eshop-optimizer') . ' ' . $deps : '' ), 'out_table' => isset($def['table']) ? (string) $def['table'] : '', 'schedule' => $schedule_html, 'last_run' => $last_run_html, ); } AIEO_Table::render(array( 'id' => 'aieo-analytics-jobs', 'density' => 'compact', 'sortable' => false, 'columns' => array( array( 'key' => 'label', 'label' => __('Job', 'ai-eshop-optimizer'), 'type' => 'text', 'subline' => 'sub', ), array( 'key' => 'out_table', 'label' => __('Output table', 'ai-eshop-optimizer'), 'type' => 'code', ), array( 'key' => 'schedule', 'label' => __('Schedule (Athens)', 'ai-eshop-optimizer'), 'type' => 'control', 'nowrap' => true, 'format' => array( 'render' => static function ($row) { return $row['schedule']; }, ), ), array( 'key' => 'last_run', 'label' => __('Last run', 'ai-eshop-optimizer'), 'type' => 'control', 'format' => array( 'render' => static function ($row) { return $row['last_run']; }, ), ), array( 'key' => 'run', 'label' => '', 'type' => 'control', 'format' => array( 'render' => static function ($row) { return \Rooster\Admin\Guidance::row_buttons( array( array( 'label' => __('Run now', 'ai-eshop-optimizer'), 'attrs' => array('data-job' => (string) $row['job'], 'data-aieo-aj-run' => '1'), ), ), (string) ( $row['label'] ?? $row['job'] ) ); }, ), ), ), 'rows' => $aj_rows, 'row_key' => 'job', 'row_data' => array('job' => 'job'), 'page_id' => 'product_orders_analytics', )); echo $close; ?>