__('Order documents', 'ai-eshop-optimizer'), 'description' => __('Invoices, packing slips, picking lists, proformas and credit notes generated from your orders — singly, or merged across a date range into one PDF or ZIP. Courier setup, vouchers, labels and daily closings are free and stay on the other tabs.', 'ai-eshop-optimizer'), 'bullets' => array( __('Invoice and delivery-note templates you can configure.', 'ai-eshop-optimizer'), __('Print one order, or merge a whole day into a single file.', 'ai-eshop-optimizer'), __('Credit notes and proformas from the same order data.', 'ai-eshop-optimizer'), ), ); } public function __construct() { $this->load_services(); } private function load_services() { $base = AIEO_DIR . 'includes/dmm/fulfillment/'; foreach (array( 'class-aieo-courier-driver-base.php', 'class-aieo-courier-driver-api-base.php', 'class-aieo-order-document-base.php', 'class-aieo-runtime-fulfillment-urls.php', 'class-aieo-runtime-fulfillment-tracking.php', 'class-aieo-fulfillment-settings.php', 'class-aieo-fulfillment-vouchers.php', 'class-aieo-fulfillment-pdf-engine.php', 'class-aieo-fulfillment-templates.php', 'class-aieo-fulfillment-documents.php', 'class-aieo-fulfillment-cron.php', 'class-aieo-fulfillment-closings.php', 'class-aieo-fulfillment-emails.php', 'class-aieo-fulfillment-bulk.php', 'class-aieo-fulfillment-migrator.php', ) as $f) { $path = $base . $f; if (file_exists($path)) { require_once $path; } } if (class_exists('AIEO_Fulfillment_Cron')) { AIEO_Fulfillment_Cron::register(); } if (class_exists('AIEO_Fulfillment_Emails')) { AIEO_Fulfillment_Emails::register(); } if (class_exists('AIEO_Runtime_Fulfillment_Tracking')) { AIEO_Runtime_Fulfillment_Tracking::register(); } $drivers_dir = $base . 'drivers/'; if (is_dir($drivers_dir)) { foreach (glob($drivers_dir . 'class-aieo-courier-driver-*.php') as $driver_path) { require_once $driver_path; } } $docs_dir = $base . 'docs/'; if (is_dir($docs_dir)) { foreach (glob($docs_dir . 'class-aieo-order-document-*.php') as $doc_path) { require_once $doc_path; } } add_action('admin_init', array(__CLASS__, 'seed_tracking_templates')); } public static function seed_tracking_templates() { if (!class_exists('AIEO_Fulfillment_Settings') || !class_exists('AIEO_Runtime_Fulfillment_URLs')) { return; } if ('done' === get_option('aieo_ff_tracking_seeded', '')) { return; } $drivers = apply_filters('aieo_fulfillment_couriers', array()); if (!is_array($drivers) || empty($drivers)) { return; } $seeded = 0; foreach ($drivers as $code => $driver) { $builtin = (string) AIEO_Runtime_Fulfillment_URLs::builtin_pattern($code); if ('' === $builtin) { continue; } $cfg = (array) AIEO_Fulfillment_Settings::get('courier_' . $code, array()); if (!empty($cfg['tracking_url_template'])) { continue; } $cfg['tracking_url_template'] = $builtin; AIEO_Fulfillment_Settings::set('courier_' . $code, $cfg); $seeded++; } update_option('aieo_ff_tracking_seeded', 'done', false); do_action('aieo_ff_tracking_templates_seeded', $seeded); } public function section_headings() { return array( 'documents' => array( __('Order Documents', 'ai-eshop-optimizer'), __('The document types registered on this shop — invoice, packing slip, proforma, credit note — plus bulk generation: merge every document for a date range of orders into one PDF, or download them as a ZIP. The installed document templates are listed at the foot of the page.', 'ai-eshop-optimizer'), ), 'couriers' => array( __('Couriers', 'ai-eshop-optimizer'), __('Carrier configuration. Custom carriers (a name plus a tracking URL, for providers with no API) come first, then one settings card per API driver — ACS, Geniki Taxydromiki, BOX NOW and the rest — each with its credentials, an optional tracking-URL override and a "Test connection" button. Vouchers themselves are issued from the order screen.', 'ai-eshop-optimizer'), ), 'maintenance' => array( __('Courier Daily Jobs', 'ai-eshop-optimizer'), __('The end-of-day carrier routine. Close the day\'s pending vouchers for a chosen carrier — reported to the carrier where an API exists (Geniki), marked dispatched locally with a printed manifest where it does not (Courier Center) — reprint the last manifest, close a back-dated range, and ZIP the day\'s cached carrier labels. The auto-close cron\'s next and last run are shown here.', 'ai-eshop-optimizer'), ), 'runlog' => array( __('Delivery Statistics', 'ai-eshop-optimizer'), __('Delivery-time metrics per postal area, computed from delivered vouchers: averages, the delivery-time distribution, and the shipping cost against gross margin used to sanity-check the free-shipping threshold. Filter by carrier and switch between order→delivery total time and fulfillment lag.', 'ai-eshop-optimizer'), ), 'settings' => array( __('Courier Settings', 'ai-eshop-optimizer'), __('Fulfillment-wide configuration: default carrier for new vouchers, how many postcode digits group the delivery metrics, and the sender line printed on every manifest. Also holds the carrier merge tool (re-points one carrier\'s vouchers, closings and stats onto another), raw activity counters, database schema state, the legacy-data migration and the delivery-date/cost backfills.', 'ai-eshop-optimizer'), ), ); } public function navigator_cards($cards) { if (!is_array($cards)) { return $cards; } foreach ($this->section_headings() as $slug => $pair) { $cards[] = array( 'label' => $pair[0], 'page_slug' => AIEO_DMM::MENU_SLUG, 'dmm_tab' => $this->get_id(), 'query' => array('ff_section' => $slug), ); } return $cards; } public function register_hooks() { add_filter('aieo_dmm_admin_navigator_cards', array($this, 'navigator_cards')); add_action('add_meta_boxes', array($this, 'register_meta_box'), 30, 2); add_action('admin_enqueue_scripts', array($this, 'meta_box_table_assets')); add_action('admin_enqueue_scripts', array($this, 'admin_style')); add_action('wp_ajax_aieo_ff_save_courier', array($this, 'ajax_save_courier')); add_action('wp_ajax_aieo_ff_test_courier', array($this, 'ajax_test_courier')); add_action('wp_ajax_aieo_ff_create_voucher', array($this, 'ajax_create_voucher')); add_action('wp_ajax_aieo_ff_save_manual', array($this, 'ajax_save_manual')); add_action('wp_ajax_aieo_ff_get_label', array($this, 'ajax_get_label')); add_action('wp_ajax_aieo_ff_cancel_voucher', array($this, 'ajax_cancel_voucher')); add_action('wp_ajax_aieo_ff_track_now', array($this, 'ajax_track_now')); add_action('wp_ajax_aieo_ff_backfill_delivered', array($this, 'ajax_backfill_delivered_batch')); add_action('wp_ajax_aieo_ff_backfill_costs', array($this, 'ajax_backfill_costs_batch')); add_action('wp_ajax_aieo_ff_import_geniki_csv', array($this, 'ajax_import_geniki_invoice_csv')); add_action('wp_ajax_aieo_ff_import_carrier_csv', array($this, 'ajax_import_carrier_invoice_csv')); add_action('wp_ajax_aieo_ff_backfill_customer_paid', array($this, 'ajax_backfill_customer_paid_batch')); add_action('wp_ajax_aieo_ff_backfill_detailed', array($this, 'ajax_backfill_detailed_batch')); add_action('wp_ajax_aieo_ff_metrics_drill', array($this, 'ajax_metrics_drill')); add_action('wp_ajax_aieo_ff_metrics_export', array($this, 'ajax_metrics_export')); add_action('wp_ajax_aieo_ff_metrics_export_aggregate', array($this, 'ajax_metrics_export_aggregate')); add_action('wp_ajax_aieo_ff_save_general', array($this, 'ajax_save_general_settings')); add_action('wp_ajax_aieo_ff_delete_voucher', array($this, 'ajax_delete_voucher')); add_action('wp_ajax_aieo_ff_create_document', array($this, 'ajax_create_document')); add_action('wp_ajax_aieo_ff_get_document_pdf',array($this, 'ajax_get_document_pdf')); add_action('wp_ajax_aieo_ff_print_doc_for_order', array($this, 'ajax_print_doc_for_order')); add_action('wp_ajax_aieo_ff_save_manual_voucher', array($this, 'ajax_save_manual_voucher')); add_action('wp_ajax_aieo_ff_save_carrier_map', array($this, 'ajax_save_carrier_map')); add_action('wp_ajax_aieo_ff_delete_document', array($this, 'ajax_delete_document')); add_action('wp_ajax_aieo_ff_bulk_merge', array($this, 'ajax_bulk_merge')); add_action('wp_ajax_aieo_ff_bulk_zip', array($this, 'ajax_bulk_zip')); add_action('wp_ajax_aieo_ff_bulk_zip_labels', array($this, 'ajax_bulk_zip_labels')); add_action('wp_ajax_aieo_ff_close_pending_now', array($this, 'ajax_close_pending_now')); add_action('wp_ajax_aieo_ff_close_pending_by_date', array($this, 'ajax_close_pending_by_date')); add_action('wp_ajax_aieo_ff_toggle_auto_close', array($this, 'ajax_toggle_auto_close')); add_action('wp_ajax_aieo_ff_close_and_print', array($this, 'ajax_close_and_print')); add_action('wp_ajax_aieo_ff_reprint_last_closing', array($this, 'ajax_reprint_last_closing')); add_action('wp_ajax_aieo_ff_download_manifest', array($this, 'ajax_download_manifest')); add_action('wp_ajax_aieo_ff_migrate_audit', array($this, 'ajax_migrate_audit')); add_action('wp_ajax_aieo_ff_migrate_batch', array($this, 'ajax_migrate_batch')); add_action('wp_ajax_aieo_ff_migrate_reset', array($this, 'ajax_migrate_reset')); $hpos_screen = function_exists('wc_get_page_screen_id') ? wc_get_page_screen_id('shop-order') : 'woocommerce_page_wc-orders'; $legacy_screen = 'edit-shop_order'; foreach (array($hpos_screen, $legacy_screen) as $screen) { add_filter("manage_{$screen}_columns", array($this, 'orders_list_columns')); add_action("manage_{$screen}_custom_column", array($this, 'orders_list_column_body'), 10, 2); add_filter("bulk_actions-{$screen}", array($this, 'orders_list_bulk_actions')); add_filter("handle_bulk_actions-{$screen}", array($this, 'orders_list_handle_bulk'), 10, 3); } add_action('admin_notices', array($this, 'orders_list_bulk_notice')); add_action('admin_init', array($this, 'maybe_render_a4x3_print_page')); add_action('woocommerce_order_list_table_restrict_manage_orders', array($this, 'render_channel_filter_dropdown'), 20, 2); add_filter('woocommerce_order_list_table_prepare_items_query_args', array($this, 'apply_channel_filter_to_query')); add_action('restrict_manage_posts', array($this, 'render_channel_filter_dropdown_legacy'), 20); add_action('pre_get_posts', array($this, 'apply_channel_filter_to_legacy_query')); add_action('admin_footer', array($this, 'orders_list_doc_button_js')); add_action('admin_footer', array($this, 'orders_list_voucher_modal')); add_filter('aieo_fulfillment_couriers', array($this, 'register_custom_carriers'), 50); add_action('wp_ajax_aieo_ff_save_custom_carrier', array($this, 'ajax_save_custom_carrier')); add_action('wp_ajax_aieo_ff_delete_custom_carrier', array($this, 'ajax_delete_custom_carrier')); add_action('wp_ajax_aieo_ff_merge_couriers', array($this, 'ajax_merge_couriers')); } public function register_custom_carriers($drivers) { if (!class_exists('AIEO_Courier_Driver_Custom')) { return $drivers; } if (!is_array($drivers)) { $drivers = array(); } foreach ($this->get_custom_carriers() as $slug => $cfg) { $code = AIEO_Courier_Driver_Custom::PREFIX . $slug; if (!isset($drivers[$code])) { $drivers[$code] = new AIEO_Courier_Driver_Custom( $slug, isset($cfg['label']) ? (string) $cfg['label'] : '', isset($cfg['tracking_url_template']) ? (string) $cfg['tracking_url_template'] : '' ); } } return $drivers; } public function orders_list_doc_button_js() { $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen) { return; } $hpos = function_exists('wc_get_page_screen_id') ? wc_get_page_screen_id('shop-order') : ''; if (!in_array($screen->id, array($hpos, 'edit-shop_order'), true)) { return; } $nonce = wp_create_nonce('aieo_fulfillment_admin'); $ajaxurl = admin_url('admin-ajax.php'); ?> id, array('shop_order', 'edit-shop_order'), true) || (function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop-order') === $screen->id)); if (!$on_page && !$on_orders) { return; } $css = 'assets/css/aieo-ff-admin.css'; wp_enqueue_style('aieo-ff-admin', AIEO_URI . $css, array(), file_exists(AIEO_DIR . $css) ? (string) filemtime(AIEO_DIR . $css) : '1'); if ($on_orders && class_exists('\Rooster\Admin\Library')) { \Rooster\Admin\Library::island_style(); } } public function meta_box_table_assets() { if (!function_exists('rooster_admin_table_assets') || !function_exists('get_current_screen')) { return; } $screen = get_current_screen(); if (!$screen) { return; } $hpos_screen = function_exists('wc_get_page_screen_id') ? wc_get_page_screen_id('shop-order') : 'woocommerce_page_wc-orders'; if ($screen->id === $hpos_screen || $screen->id === 'shop_order') { rooster_admin_table_assets(); } } private function meta_box_notice($html, $tone) { return AIEO_DMM_UI::notice($html, $tone); } public function render_meta_box($post_or_order) { if (!current_user_can('manage_woocommerce')) { echo '
' . esc_html__('Insufficient permissions.', 'ai-eshop-optimizer') . '
'; return; } $order = ($post_or_order instanceof WC_Order) ? $post_or_order : (function_exists('wc_get_order') ? wc_get_order($post_or_order) : null); if (!$order) { echo '' . esc_html__('Order not found.', 'ai-eshop-optimizer') . '
'; return; } $order_id = (int) $order->get_id(); $vouchers = AIEO_Fulfillment_Vouchers::get_for_order($order_id); $couriers = $this->couriers(); $nonce = wp_create_nonce('aieo_fulfillment_admin'); echo ''; $this->print_meta_box_js(); } private function print_meta_box_js() { $ajax = admin_url('admin-ajax.php'); ?> 'permission_denied'), 403); } check_ajax_referer('aieo_fulfillment_admin', 'nonce'); } public function ajax_create_voucher() { $this->verify_admin_ajax(); $order_id = isset($_POST['order_id']) ? (int) $_POST['order_id'] : 0; $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; if ($order_id <= 0 || $carrier === '') { wp_send_json_error(array('message' => 'order_id and carrier required')); } $order = function_exists('wc_get_order') ? wc_get_order($order_id) : null; if (!$order) { wp_send_json_error(array('message' => 'order_not_found')); } $couriers = $this->couriers(); if (!isset($couriers[$carrier])) { wp_send_json_error(array('message' => 'unknown_carrier')); } $driver = $couriers[$carrier]; $create_mode = method_exists($driver, 'create_mode') ? (string) $driver->create_mode() : 'api'; $typed_vno = isset($_POST['manual_voucher_no']) ? sanitize_text_field((string) wp_unslash($_POST['manual_voucher_no'])) : ''; $use_manual_entry = ($create_mode === 'manual') || ($typed_vno !== ''); if ($use_manual_entry) { $vno = $typed_vno; if ($vno === '') { wp_send_json_error(array('message' => 'tracking_number_required')); } $shipping_paid = (float) $order->get_shipping_total(); $row = array( 'order_id' => $order_id, 'carrier' => $carrier, 'voucher_no' => $vno, 'status' => 'created', 'status_at' => current_time('mysql', true), 'customer_shipping_paid' => $shipping_paid, ); if ($carrier === 'manual') { $row['carrier_account'] = isset($_POST['carrier_account']) ? sanitize_text_field((string) wp_unslash($_POST['carrier_account'])) : ''; } $stored = AIEO_Fulfillment_Vouchers::create($row); if (empty($stored['ok'])) { wp_send_json_error(array('message' => 'db_insert_failed: ' . (isset($stored['error']) ? $stored['error'] : ''))); } $message = sprintf( __('Tracking number %s saved.', 'ai-eshop-optimizer'), $vno ); $marked_complete = false; if (!empty($_POST['mark_completed'])) { $current = (string) $order->get_status(); if (!in_array($current, array('completed', 'refunded', 'cancelled', 'failed', 'trash'), true)) { $order->update_status('completed', __('Marked completed by Rooster One after saving tracking number.', 'ai-eshop-optimizer')); $marked_complete = true; $message .= ' ' . __('Order marked completed; customer notification sent.', 'ai-eshop-optimizer'); } else { $message .= ' ' . sprintf( __('Order already %s — skipped status change.', 'ai-eshop-optimizer'), $current ); } } wp_send_json_success(array( 'voucher_id' => (int) $stored['id'], 'voucher_no' => $vno, 'marked_complete' => $marked_complete, 'message' => $message, )); } $payload = $this->build_voucher_payload($order); if ($carrier === 'manual') { $payload['manual_voucher_no'] = isset($_POST['manual_voucher_no']) ? sanitize_text_field((string) wp_unslash($_POST['manual_voucher_no'])) : ''; $payload['carrier_account'] = isset($_POST['carrier_account']) ? sanitize_text_field((string) wp_unslash($_POST['carrier_account'])) : ''; } else { $weight_raw = isset($_POST['weight']) ? (string) wp_unslash($_POST['weight']) : ''; $cod_raw = isset($_POST['cod_amount']) ? (string) wp_unslash($_POST['cod_amount']) : ''; $payload['pieces'] = isset($_POST['pieces']) ? max(1, (int) $_POST['pieces']) : 1; $payload['weight'] = self::parse_locale_decimal($weight_raw, 0.5); $payload['cod_amount'] = self::parse_locale_decimal($cod_raw, 0.0); $payload['services_csv'] = isset($_POST['services_csv']) ? sanitize_text_field((string) wp_unslash($_POST['services_csv'])) : ''; if (isset($_POST['received_date'])) { $rd = sanitize_text_field((string) wp_unslash($_POST['received_date'])); if ($rd !== '') { $payload['received_date'] = $rd; $payload['pickup_date'] = substr($rd, 0, 10); } } } $result = $driver->create_voucher($payload); if (empty($result['ok'])) { wp_send_json_error(array('message' => isset($result['error']) ? $result['error'] : 'create_failed')); } $voucher_no = (string) ($result['voucher_no'] ?? ''); if ($voucher_no === '') { wp_send_json_error(array('message' => 'no_voucher_number_returned')); } $row = array( 'order_id' => $order_id, 'carrier' => $carrier, 'voucher_no' => $voucher_no, 'pieces' => $payload['pieces'] ?? 1, 'weight' => $payload['weight'] ?? null, 'cod_amount' => $payload['cod_amount'] ?? null, 'services_csv' => $payload['services_csv'] ?? '', 'reference_1' => $payload['reference_1'] ?? '', 'job_id' => isset($result['job_id']) ? (int) $result['job_id'] : null, 'status' => 'created', 'customer_shipping_paid' => isset($payload['customer_shipping_paid']) ? (float) $payload['customer_shipping_paid'] : null, ); if ($carrier === 'manual' && !empty($result['carrier_account'])) { $row['carrier_account'] = (string) $result['carrier_account']; } if (method_exists($driver, 'has_capability') && $driver->has_capability('calculate_cost')) { $cost_r = $driver->calculate_cost($payload); if (!empty($cost_r['ok'])) { $row['carrier_cost'] = isset($cost_r['cost']) ? (float) $cost_r['cost'] : null; $row['carrier_vat'] = isset($cost_r['vat']) ? (float) $cost_r['vat'] : null; $row['cost_source'] = 'quoted'; } } $stored = AIEO_Fulfillment_Vouchers::create($row); if (empty($stored['ok'])) { wp_send_json_error(array('message' => 'db_insert_failed: ' . (isset($stored['error']) ? $stored['error'] : ''))); } if (!empty($result['subvouchers']) && is_array($result['subvouchers'])) { foreach ($result['subvouchers'] as $sub) { AIEO_Fulfillment_Vouchers::create(array( 'order_id' => $order_id, 'carrier' => $carrier, 'voucher_no' => (string) $sub, 'parent_voucher_no' => $voucher_no, 'status' => 'created', )); } } $message = sprintf( __('Voucher %s created.', 'ai-eshop-optimizer'), $voucher_no ); $marked_complete = false; if (!empty($_POST['mark_completed'])) { $current = (string) $order->get_status(); if (!in_array($current, array('completed', 'refunded', 'cancelled', 'failed', 'trash'), true)) { $order->update_status('completed', __('Marked completed by Rooster One after voucher creation.', 'ai-eshop-optimizer')); $marked_complete = true; $message .= ' ' . __('Order marked completed; customer notification sent.', 'ai-eshop-optimizer'); } else { $message .= ' ' . sprintf( __('Order already %s — skipped status change.', 'ai-eshop-optimizer'), $current ); } } wp_send_json_success(array( 'voucher_id' => (int) $stored['id'], 'voucher_no' => $voucher_no, 'marked_complete' => $marked_complete, 'message' => $message, )); } public function ajax_save_manual() { $_POST['carrier'] = 'manual'; $this->ajax_create_voucher(); } public function ajax_get_label() { $this->verify_admin_ajax(); $voucher_id = isset($_POST['voucher_id']) ? (int) $_POST['voucher_id'] : 0; $voucher = AIEO_Fulfillment_Vouchers::get($voucher_id); if (!$voucher) { wp_send_json_error(array('message' => 'voucher_not_found')); } $couriers = $this->couriers(); $carrier = (string) $voucher['carrier']; $driver = isset($couriers[$carrier]) ? $couriers[$carrier] : null; if (!$driver || !$driver->has_capability('print')) { wp_send_json_error(array('message' => 'driver_no_print_capability')); } $r = $driver->get_label_pdf((string) $voucher['voucher_no'], 'A4'); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'print_failed')); } if (!empty($r['pdf_url'])) { AIEO_Fulfillment_Vouchers::mark_printed($voucher_id); wp_send_json_success(array('pdf_url' => $r['pdf_url'])); } if (!empty($r['pdf']) && is_string($r['pdf'])) { $cached = $this->cache_label_pdf((int) $voucher['order_id'], $voucher_id, $carrier, (string) $voucher['voucher_no'], $r['pdf']); if ($cached) { AIEO_Fulfillment_Vouchers::mark_printed($voucher_id); wp_send_json_success(array('pdf_url' => $cached)); } } wp_send_json_error(array('message' => 'no_pdf_returned')); } public function ajax_cancel_voucher() { $this->verify_admin_ajax(); $voucher_id = isset($_POST['voucher_id']) ? (int) $_POST['voucher_id'] : 0; $voucher = AIEO_Fulfillment_Vouchers::get($voucher_id); if (!$voucher) { wp_send_json_error(array('message' => 'voucher_not_found')); } $couriers = $this->couriers(); $carrier = (string) $voucher['carrier']; $driver = isset($couriers[$carrier]) ? $couriers[$carrier] : null; if (!$driver || !$driver->has_capability('cancel')) { wp_send_json_error(array('message' => 'driver_no_cancel_capability')); } $r = ($carrier === 'geniki' && method_exists($driver, 'cancel_by_job') && !empty($voucher['job_id'])) ? $driver->cancel_by_job((int) $voucher['job_id']) : $driver->cancel((string) $voucher['voucher_no']); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'cancel_failed')); } AIEO_Fulfillment_Vouchers::update($voucher_id, array( 'status' => 'canceled', 'canceled_at' => current_time('mysql', true), )); wp_send_json_success(array('message' => __('Canceled.', 'ai-eshop-optimizer'))); } public function ajax_track_now() { $this->verify_admin_ajax(); $voucher_id = isset($_POST['voucher_id']) ? (int) $_POST['voucher_id'] : 0; $voucher = AIEO_Fulfillment_Vouchers::get($voucher_id); if (!$voucher) { wp_send_json_error(array('message' => 'voucher_not_found')); } $couriers = $this->couriers(); $carrier = (string) $voucher['carrier']; $driver = isset($couriers[$carrier]) ? $couriers[$carrier] : null; if (!$driver || !$driver->has_capability('track')) { wp_send_json_error(array('message' => 'driver_no_track_capability')); } $mode = isset($_POST['mode']) && sanitize_key((string) wp_unslash($_POST['mode'])) === 'simple' ? 'simple' : 'full'; $r = ($mode === 'simple') ? $driver->track_simple((string) $voucher['voucher_no']) : $driver->track((string) $voucher['voucher_no']); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'track_failed')); } $code_str = (string) ($r['status_code'] ?? ''); $unified_status = $driver->map_status($code_str); if ($unified_status === 'unknown' && !empty($r['delivered_at'])) { $unified_status = 'delivered'; } AIEO_Fulfillment_Vouchers::update_status( $voucher_id, $unified_status !== 'unknown' ? $unified_status : (string) $voucher['status'], $code_str, isset($r['delivered_at']) ? $r['delivered_at'] : null, isset($r['consignee']) ? $r['consignee'] : null, isset($r['arrived_at']) ? $r['arrived_at'] : null, isset($r['arrived_status']) ? $r['arrived_status'] : null ); $checkpoint_count = 0; if (!empty($r['checkpoints'])) { AIEO_Fulfillment_Vouchers::record_checkpoints($voucher_id, $r['checkpoints']); $checkpoint_count = count($r['checkpoints']); } wp_send_json_success(array( 'status' => $unified_status, 'mode' => $mode, 'delivered_at' => !empty($r['delivered_at']) ? gmdate('Y-m-d H:i:s', (int) $r['delivered_at']) : null, 'consignee' => isset($r['consignee']) ? $r['consignee'] : null, 'checkpoints' => $checkpoint_count, 'message' => $mode === 'simple' ? sprintf(__('Status: %s · delivery date refreshed', 'ai-eshop-optimizer'), $unified_status) : sprintf(__('Status: %1$s · checkpoints: %2$d', 'ai-eshop-optimizer'), $unified_status, $checkpoint_count), )); } public function ajax_backfill_delivered_batch() { $this->verify_admin_ajax(); $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : 'geniki'; $offset = isset($_POST['offset']) ? max(0, (int) $_POST['offset']) : 0; $batch = isset($_POST['batch']) ? max(1, min(50, (int) $_POST['batch'])) : 10; $couriers = $this->couriers(); if (!isset($couriers[$carrier])) { wp_send_json_error(array('message' => 'unknown_carrier')); } $driver = $couriers[$carrier]; if (!$driver->has_capability('track')) { wp_send_json_error(array('message' => 'driver_no_track_capability')); } global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $terminal_not_found_codes = array(9); $consec_in = isset($_POST['consecutive_errors']) ? max(0, (int) $_POST['consecutive_errors']) : 0; $consec_max = 5; $consec = $consec_in; $aborted = false; $sweep_mode = !empty($_POST['sweep']); $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier = %s AND delivered_at IS NULL AND status NOT IN ('canceled','returned','untrackable')", $carrier )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT id, voucher_no FROM {$table} WHERE carrier = %s AND delivered_at IS NULL AND status NOT IN ('canceled','returned','untrackable') ORDER BY id DESC LIMIT %d", $carrier, $batch ), ARRAY_A); $delivered = 0; $still_pending = 0; $marked_untrackable = 0; $errors = array(); foreach ((array) $rows as $row) { if (!$sweep_mode && $consec >= $consec_max) { $aborted = true; break; } $vid = (int) $row['id']; $vno = (string) $row['voucher_no']; if ($vno === '') { continue; } $r = $driver->track_simple($vno); if (empty($r['ok'])) { $code_int = isset($r['code']) ? (int) $r['code'] : 0; $is_terminal = in_array($code_int, $terminal_not_found_codes, true); if ($is_terminal) { AIEO_Fulfillment_Vouchers::update($vid, array( 'status' => 'untrackable', 'status_code' => 'CARRIER_NOT_FOUND_' . $code_int, 'status_at' => current_time('mysql', true), 'last_polled_at' => current_time('mysql', true), )); $marked_untrackable++; } else { $errors[] = $vno . ': ' . (isset($r['error']) ? (string) $r['error'] : 'track_failed'); AIEO_Fulfillment_Vouchers::update($vid, array( 'last_polled_at' => current_time('mysql', true), )); } $consec++; if (!$sweep_mode && $consec >= $consec_max) { $aborted = true; break; } continue; } $consec = 0; if (!empty($r['delivered_at'])) { AIEO_Fulfillment_Vouchers::update_status( $vid, 'delivered', (string) ($r['status_code'] ?? ''), (int) $r['delivered_at'], isset($r['consignee']) ? (string) $r['consignee'] : null ); $delivered++; } else { AIEO_Fulfillment_Vouchers::update($vid, array( 'last_polled_at' => current_time('mysql', true), )); $still_pending++; } } $processed_this_batch = is_array($rows) ? count($rows) : 0; $complete = ($processed_this_batch === 0) || $aborted; wp_send_json_success(array( 'processed' => $processed_this_batch, 'delivered' => $delivered, 'pending' => $still_pending, 'untrackable' => $marked_untrackable, 'errors' => array_slice($errors, 0, 10), 'remaining' => max(0, $total - $processed_this_batch), 'complete' => $complete, 'consecutive_errors' => $consec, 'aborted_consecutive' => $aborted, )); } private static function bucket_where_sql($bucket) { switch ((string) $bucket) { case '2': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 48'; case '3': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 48 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 72'; case '4': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 72 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 96'; case '5': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 96 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 120'; case '6': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 120 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 144'; case '7': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 144 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 168'; case '8': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 168 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 192'; case '9': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 192 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 216'; case '10': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 216 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) <= 240'; case 'over_10': return 'TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) > 240'; } return ''; } public function ajax_metrics_drill() { $this->verify_admin_ajax(); if (!AIEO_Order_Store::hpos_readable()) { wp_send_json_error(array('message' => __('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer'))); } $prefix = isset($_POST['prefix']) ? (string) wp_unslash($_POST['prefix']) : ''; $prefix_len = isset($_POST['prefix_len']) ? (int) $_POST['prefix_len'] : 2; if ($prefix_len < 1 || $prefix_len > 5) { $prefix_len = 2; } $prefix = preg_replace('/[^0-9A-Za-z]/', '', $prefix); if ($prefix !== '' && strlen($prefix) > $prefix_len) { wp_send_json_error(array('message' => 'invalid prefix')); } $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; $bucket = isset($_POST['bucket']) ? sanitize_key((string) wp_unslash($_POST['bucket'])) : ''; if ($prefix === '' && $bucket === '') { wp_send_json_error(array('message' => 'either prefix or bucket required')); } global $wpdb; $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $ot = $wpdb->prefix . 'wc_orders'; $at = $wpdb->prefix . 'wc_order_addresses'; $where_clauses = array(); $params = array(); if ($prefix !== '') { $where_clauses[] = "LEFT(REPLACE(CAST(a.postcode AS CHAR), ' ', ''), {$prefix_len}) = %s"; $params[] = $prefix; } $bucket_sql = self::bucket_where_sql($bucket); if ($bucket_sql !== '') { $where_clauses[] = $bucket_sql; } $where_carrier = ''; if ($carrier !== '') { $where_carrier = 'AND v.carrier = %s'; $params[] = $carrier; } $where_extra = empty($where_clauses) ? '' : ' AND (' . implode(') AND (', $where_clauses) . ')'; $sanity_where = self::delivery_metrics_sanity_where(); $sql = " SELECT v.order_id, v.carrier, v.voucher_no, v.delivered_at, a.first_name, a.last_name, a.address_1, a.address_2, a.city, a.postcode, a.country, o.total_amount, TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) AS h_order_to_delivery FROM {$vt} v INNER JOIN {$ot} o ON o.id = v.order_id INNER JOIN {$at} a ON a.order_id = v.order_id AND a.address_type = 'shipping' WHERE v.delivered_at IS NOT NULL AND a.postcode IS NOT NULL AND a.postcode <> '' {$sanity_where} {$where_extra} {$where_carrier} ORDER BY v.delivered_at DESC, v.id DESC LIMIT 250 "; $rows = empty($params) ? $wpdb->get_results($sql, ARRAY_A) : $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); $couriers = $this->couriers(); $carrier_labels = array(); foreach ($couriers as $code => $driver) { $carrier_labels[$code] = $driver->label(); } $fmt_money = static function ($v) { if ($v === null || $v === '') { return ''; } return html_entity_decode(wp_strip_all_tags(wc_price((float) $v)), ENT_QUOTES, 'UTF-8'); }; $fmt_hours_days = static function ($v) { if ($v === null || $v === '') { return ''; } $h = (float) $v; return sprintf('%.1f h (%.1f d)', $h, $h / 24); }; $out = array(); foreach ((array) $rows as $r) { $vno = (string) $r['voucher_no']; $carrier_code = (string) $r['carrier']; $driver = isset($couriers[$carrier_code]) ? $couriers[$carrier_code] : null; $tracking_url = $driver ? $driver->tracking_url($vno) : ''; $addr_parts = array_filter(array( trim((string) $r['address_1']), trim((string) $r['address_2']), trim((string) $r['city']), trim((string) $r['postcode']), trim((string) $r['country']), ), 'strlen'); $name = trim(((string) $r['first_name']) . ' ' . ((string) $r['last_name'])); $out[] = array( 'order_id' => (int) $r['order_id'], 'order_admin_url' => admin_url('admin.php?page=wc-orders&action=edit&id=' . (int) $r['order_id']), 'carrier' => $carrier_code, 'carrier_label' => isset($carrier_labels[$carrier_code]) ? $carrier_labels[$carrier_code] : strtoupper($carrier_code), 'voucher_no' => $vno, 'tracking_url' => $tracking_url, 'customer_name' => $name, 'address' => implode(', ', $addr_parts), 'order_total' => $fmt_money($r['total_amount']), 'order_to_delivery' => $fmt_hours_days($r['h_order_to_delivery']), ); } wp_send_json_success(array( 'prefix' => $prefix, 'carrier' => $carrier, 'count' => count($out), 'capped' => count($out) >= 250, 'rows' => $out, )); } public function ajax_metrics_export_aggregate() { $this->verify_admin_ajax(); if (!AIEO_Order_Store::hpos_readable()) { wp_die(esc_html__('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer')); } if (!class_exists('\Rooster\Admin\Export') && function_exists('rooster_admin_builders')) { rooster_admin_builders(); } if (!class_exists('\Rooster\Admin\Export')) { wp_die(esc_html__('XLSX writer unavailable.', 'ai-eshop-optimizer')); } $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; $prefix_len = (int) AIEO_Fulfillment_Settings::get('metrics_postcode_prefix_length', 2); if ($prefix_len < 1 || $prefix_len > 5) { $prefix_len = 2; } global $wpdb; $where_carrier = ''; $params = array(); if ($carrier !== '') { $where_carrier = 'AND v.carrier = %s'; $params[] = $carrier; } $margin = AIEO_DMM::get_gross_margin(); $parts = self::delivery_metrics_sql_parts($where_carrier); $sql = " SELECT LEFT(REPLACE(CAST(a.postcode AS CHAR), ' ', ''), {$prefix_len}) AS prefix, {$parts['select']} {$parts['from']} GROUP BY prefix HAVING shipments > 0 ORDER BY shipments DESC, prefix ASC "; $rows = empty($params) ? $wpdb->get_results($sql, ARRAY_A) : $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); $totals_sql = "SELECT {$parts['select']} {$parts['from']}"; $totals = empty($params) ? $wpdb->get_row($totals_sql, ARRAY_A) : $wpdb->get_row($wpdb->prepare($totals_sql, $params), ARRAY_A); $h = static function ($v) { return $v === null ? '' : round((float) $v, 1); }; $m = static function ($v) { return $v === null ? '' : round((float) $v, 2); }; $couriers = $this->couriers(); $carrier_label = ($carrier !== '' && isset($couriers[$carrier])) ? $couriers[$carrier]->label() : __('all carriers', 'ai-eshop-optimizer'); $hour = array('format' => array('decimals' => 1)); $whole = array('format' => array('decimals' => 0)); $money = array('format' => 'money'); $columns = array( 'prefix' => array('label' => __('Postal prefix', 'ai-eshop-optimizer'), 'type' => 'text'), 'shipments' => array('label' => __('Shipments', 'ai-eshop-optimizer')) + $whole, 'ov_avg' => array('label' => __('Order → voucher AVG (h)', 'ai-eshop-optimizer')) + $hour, 'ov_min' => array('label' => __('Order → voucher MIN (h)', 'ai-eshop-optimizer')) + $whole, 'ov_max' => array('label' => __('Order → voucher MAX (h)', 'ai-eshop-optimizer')) + $whole, 'vd_avg' => array('label' => __('Voucher → delivery AVG (h)', 'ai-eshop-optimizer')) + $hour, 'vd_min' => array('label' => __('Voucher → delivery MIN (h)', 'ai-eshop-optimizer')) + $whole, 'vd_max' => array('label' => __('Voucher → delivery MAX (h)', 'ai-eshop-optimizer')) + $whole, 'od_avg' => array('label' => __('Order → delivery AVG (h)', 'ai-eshop-optimizer')) + $hour, 'od_min' => array('label' => __('Order → delivery MIN (h)', 'ai-eshop-optimizer')) + $whole, 'od_max' => array('label' => __('Order → delivery MAX (h)', 'ai-eshop-optimizer')) + $whole, 'paid' => array('label' => __('Avg paid', 'ai-eshop-optimizer')) + $money, 'cost' => array('label' => __('Avg cost', 'ai-eshop-optimizer')) + $money, 'net' => array('label' => __('Avg net', 'ai-eshop-optimizer')) + $money, 'profit' => array('label' => __('Avg profit', 'ai-eshop-optimizer')) + $money, 'min_order' => array('label' => __('Min order', 'ai-eshop-optimizer')) + $money, 'free_ship' => array('label' => __('Free-ship threshold', 'ai-eshop-optimizer')) + $money, ); $fs_threshold = AIEO_DMM::get_free_shipping_threshold(); $hi = static function ($v) { return $v === null || $v === '' ? '' : (int) round((float) $v); }; $line = static function (array $r, $prefix) use ($h, $hi, $m, $margin, $fs_threshold) { $cost = $r['avg_carrier_cost']; return array( 'prefix' => $prefix, 'shipments' => (int) $r['shipments'], 'ov_avg' => $h($r['avg_hours_order_to_voucher']), 'ov_min' => $hi($r['min_hours_order_to_voucher']), 'ov_max' => $hi($r['max_hours_order_to_voucher']), 'vd_avg' => $h($r['avg_hours_voucher_to_delivery']), 'vd_min' => $hi($r['min_hours_voucher_to_delivery']), 'vd_max' => $hi($r['max_hours_voucher_to_delivery']), 'od_avg' => $h($r['avg_hours_order_to_delivery']), 'od_min' => $hi($r['min_hours_order_to_delivery']), 'od_max' => $hi($r['max_hours_order_to_delivery']), 'paid' => $m($r['avg_customer_paid']), 'cost' => $m($cost), 'net' => $m($r['avg_net']), 'profit' => $m($r['avg_net_products'] !== null ? (float) $r['avg_net_products'] * $margin : null), 'min_order' => $m(($cost !== null && $margin > 0) ? (float) $cost / $margin : null), 'free_ship' => $m(($cost !== null && $margin > 0) ? $fs_threshold + (float) $cost / $margin : null), ); }; $lines = array(); foreach ((array) $rows as $r) { $lines[] = $line($r, (string) $r['prefix']); } if (is_array($totals)) { $lines[] = $line($totals, __('TOTAL', 'ai-eshop-optimizer')); } $filename = sprintf( 'metrics-postal-areas-%s-%s.xlsx', ($carrier !== '' ? $carrier : 'all'), gmdate('Ymd-His') ); \Rooster\Admin\Export::xlsx($filename, array(array('name' => sprintf('Postal-area metrics — %s', $carrier_label), 'columns' => $columns, 'rows' => $lines))); exit; } public function ajax_metrics_export() { $this->verify_admin_ajax(); if (!AIEO_Order_Store::hpos_readable()) { wp_die(esc_html__('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer')); } if (!class_exists('\Rooster\Admin\Export') && function_exists('rooster_admin_builders')) { rooster_admin_builders(); } if (!class_exists('\Rooster\Admin\Export')) { wp_die(esc_html__('XLSX writer unavailable.', 'ai-eshop-optimizer')); } $prefix = isset($_POST['prefix']) ? (string) wp_unslash($_POST['prefix']) : ''; $prefix_len = isset($_POST['prefix_len']) ? (int) $_POST['prefix_len'] : 2; if ($prefix_len < 1 || $prefix_len > 5) { $prefix_len = 2; } $prefix = preg_replace('/[^0-9A-Za-z]/', '', $prefix); if ($prefix !== '' && strlen($prefix) > $prefix_len) { wp_die(esc_html__('invalid prefix', 'ai-eshop-optimizer')); } $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; $bucket = isset($_POST['bucket']) ? sanitize_key((string) wp_unslash($_POST['bucket'])) : ''; if ($prefix === '' && $bucket === '') { wp_die(esc_html__('either prefix or bucket required', 'ai-eshop-optimizer')); } global $wpdb; $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $ot = $wpdb->prefix . 'wc_orders'; $at = $wpdb->prefix . 'wc_order_addresses'; $where_clauses = array(); $params = array(); if ($prefix !== '') { $where_clauses[] = "LEFT(REPLACE(CAST(a.postcode AS CHAR), ' ', ''), {$prefix_len}) = %s"; $params[] = $prefix; } $bucket_sql = self::bucket_where_sql($bucket); if ($bucket_sql !== '') { $where_clauses[] = $bucket_sql; } $where_carrier = ''; if ($carrier !== '') { $where_carrier = 'AND v.carrier = %s'; $params[] = $carrier; } $where_extra = empty($where_clauses) ? '' : ' AND (' . implode(') AND (', $where_clauses) . ')'; $sanity_where = self::delivery_metrics_sanity_where(); $sql = " SELECT v.order_id, v.carrier, v.voucher_no, v.delivered_at, a.first_name, a.last_name, a.address_1, a.address_2, a.city, a.postcode, a.country, o.total_amount, TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at) AS h_order_to_delivery FROM {$vt} v INNER JOIN {$ot} o ON o.id = v.order_id INNER JOIN {$at} a ON a.order_id = v.order_id AND a.address_type = 'shipping' WHERE v.delivered_at IS NOT NULL AND a.postcode IS NOT NULL AND a.postcode <> '' {$sanity_where} {$where_extra} {$where_carrier} ORDER BY v.delivered_at DESC, v.id DESC "; $rows = empty($params) ? $wpdb->get_results($sql, ARRAY_A) : $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); $couriers = $this->couriers(); $carrier_labels = array(); foreach ($couriers as $code => $driver) { $carrier_labels[$code] = $driver->label(); } $sheet_name = sprintf('Postal %s', $prefix); if ($carrier !== '' && isset($carrier_labels[$carrier])) { $sheet_name = sprintf('%s — %s', $sheet_name, $carrier_labels[$carrier]); } $columns = array( 'order' => array('label' => __('Order ID', 'ai-eshop-optimizer'), 'type' => 'number'), 'carrier' => __('Carrier', 'ai-eshop-optimizer'), 'voucher' => array('label' => __('Voucher', 'ai-eshop-optimizer'), 'type' => 'text'), 'delivered' => array('label' => __('Delivered at', 'ai-eshop-optimizer'), 'type' => 'date'), 'customer' => __('Customer', 'ai-eshop-optimizer'), 'address_1' => __('Address 1', 'ai-eshop-optimizer'), 'address_2' => __('Address 2', 'ai-eshop-optimizer'), 'city' => __('City', 'ai-eshop-optimizer'), 'postcode' => array('label' => __('Postcode', 'ai-eshop-optimizer'), 'type' => 'text'), 'country' => __('Country', 'ai-eshop-optimizer'), 'hours' => array('label' => __('Order → delivery (h)', 'ai-eshop-optimizer'), 'format' => array('decimals' => 0)), 'days' => array('label' => __('Order → delivery (d)', 'ai-eshop-optimizer'), 'format' => array('decimals' => 1)), 'value' => array('label' => __('Order value', 'ai-eshop-optimizer'), 'format' => 'money'), ); $lines = array(); foreach ((array) $rows as $r) { $carrier_code = (string) $r['carrier']; $h = $r['h_order_to_delivery']; $lines[] = array( 'order' => (int) $r['order_id'], 'carrier' => isset($carrier_labels[$carrier_code]) ? $carrier_labels[$carrier_code] : strtoupper($carrier_code), 'voucher' => (string) $r['voucher_no'], 'delivered' => (string) $r['delivered_at'], 'customer' => trim(((string) $r['first_name']) . ' ' . ((string) $r['last_name'])), 'address_1' => (string) $r['address_1'], 'address_2' => (string) $r['address_2'], 'city' => (string) $r['city'], 'postcode' => (string) $r['postcode'], 'country' => (string) $r['country'], 'hours' => ($h === null || $h === '') ? '' : (int) round((float) $h), 'days' => ($h === null || $h === '') ? '' : round(((float) $h) / 24, 1), 'value' => (float) $r['total_amount'], ); } $scope = ($prefix !== '' ? 'p' . $prefix : ''); if ($bucket !== '') { $scope = ($scope === '' ? '' : $scope . '-') . 'b' . $bucket; } $filename = sprintf( 'shipments-%s-%s-%s.xlsx', ($carrier !== '' ? $carrier : 'all'), $scope, gmdate('Ymd-His') ); \Rooster\Admin\Export::xlsx($filename, array(array('name' => $sheet_name, 'columns' => $columns, 'rows' => $lines))); exit; } public function ajax_backfill_detailed_batch() { $this->verify_admin_ajax(); $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : 'geniki'; $offset = isset($_POST['offset']) ? max(0, (int) $_POST['offset']) : 0; $batch = isset($_POST['batch']) ? max(1, min(50, (int) $_POST['batch'])) : 10; $couriers = $this->couriers(); if (!isset($couriers[$carrier])) { wp_send_json_error(array('message' => 'unknown_carrier')); } $driver = $couriers[$carrier]; if (!$driver->has_capability('track')) { wp_send_json_error(array('message' => 'driver_no_track_capability')); } $consec_in = isset($_POST['consecutive_errors']) ? max(0, (int) $_POST['consecutive_errors']) : 0; $consec_max = 5; $consec = $consec_in; $aborted = false; $sweep_mode = !empty($_POST['sweep']); $terminal_not_found_codes = array(9); global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier = %s AND delivered_at IS NOT NULL AND arrived_at IS NULL AND status NOT IN ('canceled','returned','untrackable')", $carrier )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT id, voucher_no FROM {$table} WHERE carrier = %s AND delivered_at IS NOT NULL AND arrived_at IS NULL AND status NOT IN ('canceled','returned','untrackable') ORDER BY id DESC LIMIT %d", $carrier, $batch ), ARRAY_A); $populated_arrived = 0; $no_c_a3 = 0; $marked_untrackable = 0; $errors = array(); foreach ((array) $rows as $row) { if (!$sweep_mode && $consec >= $consec_max) { $aborted = true; break; } $vid = (int) $row['id']; $vno = (string) $row['voucher_no']; if ($vno === '') { continue; } $r = $driver->track($vno); if (empty($r['ok'])) { $code_int = isset($r['code']) ? (int) $r['code'] : 0; $is_terminal = in_array($code_int, $terminal_not_found_codes, true); if ($is_terminal) { AIEO_Fulfillment_Vouchers::update($vid, array( 'status' => 'untrackable', 'status_code' => 'CARRIER_NOT_FOUND_' . $code_int, 'status_at' => current_time('mysql', true), 'last_polled_at' => current_time('mysql', true), )); $marked_untrackable++; } else { $errors[] = $vno . ': ' . (isset($r['error']) ? (string) $r['error'] : 'track_failed'); AIEO_Fulfillment_Vouchers::update($vid, array( 'last_polled_at' => current_time('mysql', true), )); } $consec++; if (!$sweep_mode && $consec >= $consec_max) { $aborted = true; break; } continue; } $consec = 0; if (!empty($r['checkpoints'])) { AIEO_Fulfillment_Vouchers::record_checkpoints($vid, $r['checkpoints']); } $patch = array( 'last_polled_at' => current_time('mysql', true), ); if (!empty($r['arrived_at'])) { $patch['arrived_at'] = gmdate('Y-m-d H:i:s', (int) $r['arrived_at']); $patch['arrived_status'] = isset($r['arrived_status']) ? sanitize_key((string) $r['arrived_status']) : 'C_A3'; $populated_arrived++; } else { $no_c_a3++; } if (!empty($r['delivered_at'])) { $patch['delivered_at'] = gmdate('Y-m-d H:i:s', (int) $r['delivered_at']); } if (!empty($r['consignee'])) { $patch['consignee'] = sanitize_text_field((string) $r['consignee']); } AIEO_Fulfillment_Vouchers::update($vid, $patch); } $processed_this_batch = is_array($rows) ? count($rows) : 0; $complete = ($processed_this_batch === 0) || $aborted; wp_send_json_success(array( 'processed' => $processed_this_batch, 'arrived_populated' => $populated_arrived, 'no_c_a3' => $no_c_a3, 'untrackable' => $marked_untrackable, 'errors' => array_slice($errors, 0, 10), 'remaining' => max(0, $total - $processed_this_batch), 'complete' => $complete, 'consecutive_errors' => $consec, 'aborted_consecutive' => $aborted, )); } public function backfill_customer_shipping_paid($batch = 500) { global $wpdb; if (!function_exists('wc_get_order')) { return array('processed' => 0, 'updated' => 0, 'remaining' => 0, 'error' => 'wc_get_order unavailable'); } $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $batch = max(1, min(2000, (int) $batch)); $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE customer_shipping_paid IS NULL AND order_id > 0" ); $rows = $wpdb->get_results($wpdb->prepare( "SELECT id, order_id FROM {$table} WHERE customer_shipping_paid IS NULL AND order_id > 0 ORDER BY id DESC LIMIT %d", $batch ), ARRAY_A); $updated = 0; foreach ((array) $rows as $row) { $order = wc_get_order((int) $row['order_id']); if (!$order instanceof WC_Order) { continue; } $paid = (float) $order->get_shipping_total(); AIEO_Fulfillment_Vouchers::update((int) $row['id'], array( 'customer_shipping_paid' => $paid, )); $updated++; } return array( 'processed' => count((array) $rows), 'updated' => $updated, 'remaining' => max(0, $total - count((array) $rows)), ); } public function ajax_backfill_customer_paid_batch() { $this->verify_admin_ajax(); $batch = isset($_POST['batch']) ? max(1, min(2000, (int) $_POST['batch'])) : 500; $result = $this->backfill_customer_shipping_paid($batch); $result['complete'] = ($result['remaining'] === 0); wp_send_json_success($result); } public function ajax_backfill_costs_batch() { $this->verify_admin_ajax(); $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : 'geniki'; $batch = isset($_POST['batch']) ? max(1, min(50, (int) $_POST['batch'])) : 10; $couriers = $this->couriers(); if (!isset($couriers[$carrier])) { wp_send_json_error(array('message' => 'unknown_carrier')); } $driver = $couriers[$carrier]; if (!$driver->has_capability('calculate_cost')) { wp_send_json_error(array('message' => 'driver_no_calculate_cost_capability')); } $cost_disabled_codes = array(12); global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier = %s AND carrier_cost IS NULL AND status NOT IN ('canceled','returned','untrackable')", $carrier )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT id, voucher_no, weight, services_csv FROM {$table} WHERE carrier = %s AND carrier_cost IS NULL AND status NOT IN ('canceled','returned','untrackable') ORDER BY id DESC LIMIT %d", $carrier, $batch ), ARRAY_A); $costed = 0; $errors = array(); $account_disabled = false; foreach ((array) $rows as $row) { $vid = (int) $row['id']; $vno = (string) $row['voucher_no']; if ($vno === '') { continue; } $weight = isset($row['weight']) ? (float) $row['weight'] : 0; if ($weight <= 0) { $weight = 0.5; } $cost_r = $driver->calculate_cost(array( 'weight' => $weight, 'services_csv' => (string) ($row['services_csv'] ?? ''), 'recipient' => array('country' => 'Greece'), )); if (!empty($cost_r['ok'])) { AIEO_Fulfillment_Vouchers::update($vid, array( 'carrier_cost' => isset($cost_r['cost']) ? (float) $cost_r['cost'] : null, 'carrier_vat' => isset($cost_r['vat']) ? (float) $cost_r['vat'] : null, 'cost_source' => 'estimate_current_tariff', )); $costed++; continue; } $code_int = isset($cost_r['code']) ? (int) $cost_r['code'] : 0; if (in_array($code_int, $cost_disabled_codes, true)) { $account_disabled = true; $errors[] = sprintf( 'CalculateCost returned code %d — likely not enabled on this account. Run halted; ask Geniki to enable it, then re-run.', $code_int ); break; } $errors[] = $vno . ': ' . (isset($cost_r['error']) ? (string) $cost_r['error'] : 'cost_failed'); } $processed_this_batch = is_array($rows) ? count($rows) : 0; $complete = ($processed_this_batch === 0) || $account_disabled; wp_send_json_success(array( 'processed' => $processed_this_batch, 'costed' => $costed, 'errors' => array_slice($errors, 0, 10), 'remaining' => max(0, $total - $processed_this_batch), 'complete' => $complete, 'account_disabled' => $account_disabled, )); } public function ajax_import_geniki_invoice_csv() { $this->verify_admin_ajax(); if (empty($_FILES['file']) || !is_array($_FILES['file'])) { wp_send_json_error(array('message' => __('No file uploaded.', 'ai-eshop-optimizer'))); } $f = $_FILES['file']; if ((int) ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { wp_send_json_error(array('message' => sprintf( __('Upload error %d. Check upload_max_filesize / post_max_size.', 'ai-eshop-optimizer'), (int) ($f['error'] ?? 0) ))); } $tmp = (string) ($f['tmp_name'] ?? ''); if ($tmp === '' || !is_uploaded_file($tmp)) { wp_send_json_error(array('message' => __('Invalid upload.', 'ai-eshop-optimizer'))); } $raw = (string) file_get_contents($tmp); if ($raw === '') { wp_send_json_error(array('message' => __('Empty file.', 'ai-eshop-optimizer'))); } $raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw); if (function_exists('mb_check_encoding') && !mb_check_encoding($raw, 'UTF-8')) { if (function_exists('iconv')) { $converted = @iconv('WINDOWS-1253', 'UTF-8//TRANSLIT', $raw); if ($converted !== false) { $raw = $converted; } } elseif (function_exists('mb_convert_encoding')) { $raw = mb_convert_encoding($raw, 'UTF-8', 'Windows-1253'); } } $raw = str_replace(array("\r\n", "\r"), "\n", $raw); $lines = explode("\n", $raw); if (empty($lines)) { wp_send_json_error(array('message' => __('No rows parsed.', 'ai-eshop-optimizer'))); } $header_seen = false; $rows_by_voucher = array(); $row_errors = array(); $parsed_rows = 0; foreach ($lines as $idx => $line) { if ($line === '') { continue; } $cols = str_getcsv($line, ';'); if (count($cols) < 25) { continue; } $voucher = trim((string) ($cols[2] ?? '')); if (!$header_seen && !ctype_digit($voucher)) { $header_seen = true; continue; } if (!ctype_digit($voucher) || strlen($voucher) < 6) { continue; } $parsed_rows++; $total_raw = trim((string) ($cols[24] ?? '')); $total_raw = str_replace(array('.', ' '), '', $total_raw); $total_raw = str_replace(',', '.', $total_raw); if (!is_numeric($total_raw)) { $row_errors[] = sprintf('row %d voucher=%s: cost not numeric (%s)', $idx + 1, $voucher, (string) ($cols[24] ?? '')); continue; } $cost = (float) $total_raw; if ($cost < 0) { $row_errors[] = sprintf('row %d voucher=%s: negative cost (%.2f)', $idx + 1, $voucher, $cost); continue; } $weight_raw = trim((string) ($cols[12] ?? '')); $weight_raw = str_replace(',', '.', str_replace(' ', '', $weight_raw)); $weight = is_numeric($weight_raw) ? (float) $weight_raw : null; $rows_by_voucher[$voucher] = array( 'cost' => $cost, 'pieces' => max(1, (int) trim((string) ($cols[11] ?? '1'))), 'weight' => $weight, 'consignee' => mb_substr(trim((string) ($cols[10] ?? '')), 0, 120), 'invoice_no' => mb_substr(trim((string) ($cols[1] ?? '')), 0, 80), 'pickup_date' => mb_substr(trim((string) ($cols[3] ?? '')), 0, 80), ); } if (empty($rows_by_voucher)) { wp_send_json_error(array( 'message' => __('No valid voucher rows found in file.', 'ai-eshop-optimizer'), 'parsed_rows' => $parsed_rows, 'errors' => array_slice($row_errors, 0, 10), )); } global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $existing_set = array(); foreach (array_chunk(array_keys($rows_by_voucher), 500) as $chunk) { $ph = implode(',', array_fill(0, count($chunk), '%s')); $found = $wpdb->get_col($wpdb->prepare( "SELECT voucher_no FROM {$table} WHERE carrier='geniki' AND voucher_no IN ({$ph})", $chunk )); foreach ((array) $found as $v) { $existing_set[$v] = true; } } $matched_count = count($existing_set); $updated_count = 0; $matched_keys = array_keys($existing_set); foreach (array_chunk($matched_keys, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '%s')); $when_sql = ''; $params = array(); foreach ($chunk as $v) { $when_sql .= ' WHEN voucher_no = %s THEN %f'; $params[] = $v; $params[] = $rows_by_voucher[$v]['cost']; } $update_sql = "UPDATE {$table} SET carrier_cost = CASE {$when_sql} END, cost_source = 'invoice_csv' WHERE carrier='geniki' AND voucher_no IN ({$placeholders})"; $params = array_merge($params, $chunk); $r = $wpdb->query($wpdb->prepare($update_sql, $params)); if ($r !== false) { $updated_count += (int) $r; } } $orphan_keys = array_diff_key($rows_by_voucher, $existing_set); $orphans_inserted = 0; $orphans_updated = 0; foreach (array_chunk($orphan_keys, 200, true) as $chunk) { $values_sql = array(); $params = array(); foreach ($chunk as $voucher_no => $r) { $values_sql[] = '(0, %s, %s, %d, ' . ($r['weight'] !== null ? '%f' : 'NULL') . ', %f, %s, %s, %s, %s, %s)'; $params[] = 'geniki'; $params[] = (string) $voucher_no; $params[] = (int) $r['pieces']; if ($r['weight'] !== null) { $params[] = (float) $r['weight']; } $params[] = (float) $r['cost']; $params[] = 'invoice_csv'; $params[] = 'orphan_invoice'; $params[] = (string) $r['consignee']; $params[] = (string) $r['invoice_no']; $params[] = (string) $r['pickup_date']; } $sql = "INSERT INTO {$table} (order_id, carrier, voucher_no, pieces, weight, carrier_cost, cost_source, status, consignee, reference_1, reference_2) VALUES " . implode(',', $values_sql) . " ON DUPLICATE KEY UPDATE carrier_cost = VALUES(carrier_cost), cost_source = VALUES(cost_source), consignee = VALUES(consignee), reference_1 = VALUES(reference_1), reference_2 = VALUES(reference_2)"; $r = $wpdb->query($wpdb->prepare($sql, $params)); if ($r !== false) { $orphans_inserted += count($chunk); } } $orphan_count = count($orphan_keys); wp_send_json_success(array( 'parsed_rows' => $parsed_rows, 'unique_vouchers' => count($rows_by_voucher), 'matched' => $matched_count, 'updated' => $updated_count, 'unmatched' => $orphan_count, 'orphans_stored' => $orphans_inserted, 'errors' => array_slice($row_errors, 0, 10), )); } public function ajax_import_carrier_invoice_csv() { $this->verify_admin_ajax(); $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; if ($carrier === '') { wp_send_json_error(array('message' => __('Pick a carrier.', 'ai-eshop-optimizer'))); } $map = $this->invoice_csv_map($carrier); $override = array(); foreach (array('delimiter', 'encoding', 'decimal') as $k) { if (isset($_POST[$k]) && $_POST[$k] !== '') { $override[$k] = sanitize_text_field((string) wp_unslash($_POST[$k])); } } foreach (array('min_cols', 'voucher_col', 'cost_col', 'weight_col', 'pieces_col', 'consignee_col', 'invoice_col', 'date_col', 'voucher_min_len', 'voucher_digits_only') as $k) { if (isset($_POST[$k]) && $_POST[$k] !== '') { $override[$k] = (int) $_POST[$k]; } } if (!empty($override)) { $map = array_merge($map, $override); if (class_exists('AIEO_Fulfillment_Settings')) { AIEO_Fulfillment_Settings::set('invoice_csv_map_' . $carrier, $map); } } $raw = $this->read_uploaded_csv(); $res = $this->import_invoice_csv($carrier, $raw, $map); if (empty($res['ok'])) { wp_send_json_error($res); } unset($res['ok']); wp_send_json_success($res); } private function read_uploaded_csv() { if (empty($_FILES['file']) || !is_array($_FILES['file'])) { wp_send_json_error(array('message' => __('No file uploaded.', 'ai-eshop-optimizer'))); } $f = $_FILES['file']; if ((int) ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { wp_send_json_error(array('message' => sprintf( __('Upload error %d. Check upload_max_filesize / post_max_size.', 'ai-eshop-optimizer'), (int) ($f['error'] ?? 0) ))); } $tmp = (string) ($f['tmp_name'] ?? ''); if ($tmp === '' || !is_uploaded_file($tmp)) { wp_send_json_error(array('message' => __('Invalid upload.', 'ai-eshop-optimizer'))); } $raw = (string) file_get_contents($tmp); if ($raw === '') { wp_send_json_error(array('message' => __('Empty file.', 'ai-eshop-optimizer'))); } return $raw; } private function invoice_csv_map($carrier) { $carrier = sanitize_key((string) $carrier); $builtin = array( 'geniki' => array( 'delimiter' => ';', 'encoding' => 'Windows-1253', 'decimal' => 'comma', 'min_cols' => 25, 'voucher_col' => 2, 'cost_col' => 24, 'weight_col' => 12, 'pieces_col' => 11, 'consignee_col' => 10, 'invoice_col' => 1, 'date_col' => 3, 'voucher_min_len' => 6, 'voucher_digits_only' => 1, ), ); $default = isset($builtin[$carrier]) ? $builtin[$carrier] : array( 'delimiter' => ',', 'encoding' => 'UTF-8', 'decimal' => 'dot', 'min_cols' => 2, 'voucher_col' => 0, 'cost_col' => 1, 'weight_col' => -1, 'pieces_col' => -1, 'consignee_col' => -1, 'invoice_col' => -1, 'date_col' => -1, 'voucher_min_len' => 4, 'voucher_digits_only' => 0, ); $stored = class_exists('AIEO_Fulfillment_Settings') ? (array) AIEO_Fulfillment_Settings::get('invoice_csv_map_' . $carrier, array()) : array(); return array_merge($default, $stored); } private function import_invoice_csv($carrier, $raw, array $map) { $carrier = sanitize_key((string) $carrier); $delim = (string) ($map['delimiter'] ?? ';'); if (strtolower($delim) === 'tab' || $delim === '\\t') { $delim = "\t"; } $delim = ($delim === '') ? ';' : substr($delim, 0, 1); $enc = (string) ($map['encoding'] ?? 'UTF-8'); $decimal = (string) ($map['decimal'] ?? 'dot'); $min_cols = max(1, (int) ($map['min_cols'] ?? 1)); $vc = (int) ($map['voucher_col'] ?? 0); $costc = (int) ($map['cost_col'] ?? 1); $wcix = (int) ($map['weight_col'] ?? -1); $pcix = (int) ($map['pieces_col'] ?? -1); $ncix = (int) ($map['consignee_col'] ?? -1); $icix = (int) ($map['invoice_col'] ?? -1); $dcix = (int) ($map['date_col'] ?? -1); $vmin = max(1, (int) ($map['voucher_min_len'] ?? 4)); $vdigits = !empty($map['voucher_digits_only']); $raw = preg_replace('/^\xEF\xBB\xBF/', '', (string) $raw); if (strtoupper($enc) !== 'UTF-8' && function_exists('mb_check_encoding') && !mb_check_encoding($raw, 'UTF-8')) { if (function_exists('iconv')) { $conv = @iconv($enc, 'UTF-8//TRANSLIT', $raw); if ($conv !== false) { $raw = $conv; } } elseif (function_exists('mb_convert_encoding')) { $raw = mb_convert_encoding($raw, 'UTF-8', $enc); } } $raw = str_replace(array("\r\n", "\r"), "\n", $raw); $lines = explode("\n", $raw); $to_num = static function ($s) use ($decimal) { $s = trim((string) $s); if ($s === '') { return ''; } if ($decimal === 'comma') { $s = str_replace(array('.', ' '), '', $s); $s = str_replace(',', '.', $s); } else { $s = str_replace(array(',', ' '), '', $s); } return $s; }; $header_seen = false; $rows_by_voucher = array(); $row_errors = array(); $parsed_rows = 0; foreach ($lines as $idx => $line) { if ($line === '') { continue; } $cols = str_getcsv($line, $delim); if (count($cols) < $min_cols) { continue; } $voucher = trim((string) ($cols[$vc] ?? '')); $is_voucher = $vdigits ? (ctype_digit($voucher) && strlen($voucher) >= $vmin) : ($voucher !== '' && strlen($voucher) >= $vmin); if (!$header_seen && !$is_voucher) { $header_seen = true; continue; } if (!$is_voucher) { continue; } $parsed_rows++; $total_raw = $to_num($cols[$costc] ?? ''); if (!is_numeric($total_raw)) { $row_errors[] = sprintf('row %d voucher=%s: cost not numeric (%s)', $idx + 1, $voucher, (string) ($cols[$costc] ?? '')); continue; } $cost = (float) $total_raw; if ($cost < 0) { $row_errors[] = sprintf('row %d voucher=%s: negative cost (%.2f)', $idx + 1, $voucher, $cost); continue; } $weight = null; if ($wcix >= 0) { $w = $to_num($cols[$wcix] ?? ''); if (is_numeric($w)) { $weight = (float) $w; } } $rows_by_voucher[$voucher] = array( 'cost' => $cost, 'pieces' => $pcix >= 0 ? max(1, (int) trim((string) ($cols[$pcix] ?? '1'))) : 1, 'weight' => $weight, 'consignee' => $ncix >= 0 ? mb_substr(trim((string) ($cols[$ncix] ?? '')), 0, 120) : '', 'invoice_no' => $icix >= 0 ? mb_substr(trim((string) ($cols[$icix] ?? '')), 0, 80) : '', 'pickup_date' => $dcix >= 0 ? mb_substr(trim((string) ($cols[$dcix] ?? '')), 0, 80) : '', ); } if (empty($rows_by_voucher)) { return array( 'ok' => false, 'message' => __('No valid voucher rows found in file. Check the column mapping (delimiter / voucher column / cost column).', 'ai-eshop-optimizer'), 'parsed_rows' => $parsed_rows, 'errors' => array_slice($row_errors, 0, 10), ); } global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $existing_set = array(); foreach (array_chunk(array_keys($rows_by_voucher), 500) as $chunk) { $ph = implode(',', array_fill(0, count($chunk), '%s')); $args = array_merge(array($carrier), $chunk); $found = $wpdb->get_col($wpdb->prepare( "SELECT voucher_no FROM {$table} WHERE carrier=%s AND voucher_no IN ({$ph})", $args )); foreach ((array) $found as $v) { $existing_set[$v] = true; } } $matched_count = count($existing_set); $updated_count = 0; foreach (array_chunk(array_keys($existing_set), 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '%s')); $when_sql = ''; $params = array(); foreach ($chunk as $v) { $when_sql .= ' WHEN voucher_no = %s THEN %f'; $params[] = $v; $params[] = $rows_by_voucher[$v]['cost']; } $update_sql = "UPDATE {$table} SET carrier_cost = CASE {$when_sql} END, cost_source = 'invoice_csv' WHERE carrier=%s AND voucher_no IN ({$placeholders})"; $params[] = $carrier; $params = array_merge($params, $chunk); $r = $wpdb->query($wpdb->prepare($update_sql, $params)); if ($r !== false) { $updated_count += (int) $r; } } $orphan_keys = array_diff_key($rows_by_voucher, $existing_set); $orphans_inserted = 0; foreach (array_chunk($orphan_keys, 200, true) as $chunk) { $values_sql = array(); $params = array(); foreach ($chunk as $voucher_no => $r) { $values_sql[] = '(0, %s, %s, %d, ' . ($r['weight'] !== null ? '%f' : 'NULL') . ', %f, %s, %s, %s, %s, %s)'; $params[] = $carrier; $params[] = (string) $voucher_no; $params[] = (int) $r['pieces']; if ($r['weight'] !== null) { $params[] = (float) $r['weight']; } $params[] = (float) $r['cost']; $params[] = 'invoice_csv'; $params[] = 'orphan_invoice'; $params[] = (string) $r['consignee']; $params[] = (string) $r['invoice_no']; $params[] = (string) $r['pickup_date']; } $sql = "INSERT INTO {$table} (order_id, carrier, voucher_no, pieces, weight, carrier_cost, cost_source, status, consignee, reference_1, reference_2) VALUES " . implode(',', $values_sql) . " ON DUPLICATE KEY UPDATE carrier_cost = VALUES(carrier_cost), cost_source = VALUES(cost_source), consignee = VALUES(consignee), reference_1 = VALUES(reference_1), reference_2 = VALUES(reference_2)"; $r = $wpdb->query($wpdb->prepare($sql, $params)); if ($r !== false) { $orphans_inserted += count($chunk); } } return array( 'ok' => true, 'parsed_rows' => $parsed_rows, 'unique_vouchers' => count($rows_by_voucher), 'matched' => $matched_count, 'updated' => $updated_count, 'unmatched' => count($orphan_keys), 'orphans_stored' => $orphans_inserted, 'errors' => array_slice($row_errors, 0, 10), ); } public function ajax_delete_voucher() { $this->verify_admin_ajax(); $voucher_id = isset($_POST['voucher_id']) ? (int) $_POST['voucher_id'] : 0; $ok = AIEO_Fulfillment_Vouchers::delete($voucher_id); if (!$ok) { wp_send_json_error(array('message' => 'delete_failed')); } wp_send_json_success(); } public function ajax_create_document() { $this->verify_admin_ajax(); $order_id = isset($_POST['order_id']) ? (int) $_POST['order_id'] : 0; $type = isset($_POST['doc_type']) ? sanitize_key((string) wp_unslash($_POST['doc_type'])) : ''; if ($order_id <= 0 || $type === '') { wp_send_json_error(array('message' => 'order_id and doc_type required')); } $order = function_exists('wc_get_order') ? wc_get_order($order_id) : null; if (!$order) { wp_send_json_error(array('message' => 'order_not_found')); } $docs = $this->documents(); if (!isset($docs[$type])) { wp_send_json_error(array('message' => 'unknown_doc_type')); } $r = $docs[$type]->generate($order, array('force_regenerate' => true)); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'generate_failed')); } $upload = wp_upload_dir(null, false); $pdf_url = !empty($r['pdf_path']) ? $upload['baseurl'] . '/' . ltrim((string) $r['pdf_path'], '/') : ''; wp_send_json_success(array( 'document_id' => (int) $r['document_id'], 'pdf_url' => $pdf_url, 'message' => sprintf(__('%1$s %2$s generated.', 'ai-eshop-optimizer'), $docs[$type]->label(), isset($r['number']) ? '#' . $r['number'] : ''), )); } public function ajax_get_document_pdf() { $this->verify_admin_ajax(); $document_id = isset($_POST['document_id']) ? (int) $_POST['document_id'] : 0; $regenerate = !empty($_POST['regenerate']); $document = AIEO_Fulfillment_Documents::get($document_id); if (!$document) { wp_send_json_error(array('message' => 'document_not_found')); } $docs = $this->documents(); $type = (string) $document['type']; if (!isset($docs[$type])) { wp_send_json_error(array('message' => 'unknown_doc_type')); } if ($regenerate || empty($document['pdf_path'])) { $order = wc_get_order((int) $document['order_id']); if (!$order) { wp_send_json_error(array('message' => 'order_not_found')); } $r = $docs[$type]->generate($order, array('force_regenerate' => true)); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'regenerate_failed')); } $document = AIEO_Fulfillment_Documents::get($document_id); } $upload = wp_upload_dir(null, false); $pdf_url = !empty($document['pdf_path']) ? $upload['baseurl'] . '/' . ltrim((string) $document['pdf_path'], '/') : ''; if ($pdf_url === '') { wp_send_json_error(array('message' => 'no_pdf_path')); } AIEO_Fulfillment_Documents::mark_printed($document_id); wp_send_json_success(array('pdf_url' => $pdf_url)); } public function ajax_print_doc_for_order() { $this->verify_admin_ajax(); $order_id = isset($_POST['order_id']) ? (int) $_POST['order_id'] : 0; $type = isset($_POST['doc_type']) ? sanitize_key((string) wp_unslash($_POST['doc_type'])) : ''; if ($order_id <= 0 || $type === '') { wp_send_json_error(array('message' => 'order_id and doc_type required')); } $docs = $this->documents(); if (!isset($docs[$type])) { wp_send_json_error(array('message' => 'unknown_doc_type')); } $order = wc_get_order($order_id); if (!$order) { wp_send_json_error(array('message' => 'order_not_found')); } $r = $docs[$type]->generate($order, array('force_regenerate' => true)); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'generate_failed')); } $document_id = (int) $r['document_id']; $document = AIEO_Fulfillment_Documents::get($document_id); if (!$document) { wp_send_json_error(array('message' => 'document_not_found_after_generate')); } $upload = wp_upload_dir(null, false); $pdf_url = !empty($document['pdf_path']) ? $upload['baseurl'] . '/' . ltrim((string) $document['pdf_path'], '/') : ''; if ($pdf_url === '') { wp_send_json_error(array('message' => 'no_pdf_path')); } AIEO_Fulfillment_Documents::mark_printed($document_id); wp_send_json_success(array( 'pdf_url' => $pdf_url, 'document_id' => $document_id, 'already_printed' => true, )); } public function ajax_save_manual_voucher() { $this->verify_admin_ajax(); $order_id = isset($_POST['order_id']) ? (int) $_POST['order_id'] : 0; $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; $vno_raw = isset($_POST['voucher_no']) ? (string) wp_unslash($_POST['voucher_no']) : ''; $vno = sanitize_text_field($vno_raw); $notify = !empty($_POST['notify']); if ($order_id <= 0 || $carrier === '' || $vno === '') { wp_send_json_error(array('message' => 'order_id, carrier, voucher_no required')); } $couriers = $this->manual_entry_couriers(); if (!isset($couriers[$carrier])) { wp_send_json_error(array('message' => 'carrier_not_supported')); } $customer_shipping_paid = null; $wc_order = function_exists('wc_get_order') ? wc_get_order($order_id) : null; if ($wc_order instanceof WC_Order) { $customer_shipping_paid = (float) $wc_order->get_shipping_total(); } $r = AIEO_Fulfillment_Vouchers::create(array( 'order_id' => $order_id, 'carrier' => $carrier, 'voucher_no' => $vno, 'status' => 'created', 'status_at' => current_time('mysql', true), 'customer_shipping_paid' => $customer_shipping_paid, )); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'create_failed')); } $email_sent = false; $email_err = ''; if ($notify) { try { $email_sent = $this->send_customer_tracking_email($order_id, $couriers[$carrier], $vno); } catch (\Throwable $e) { $email_err = $e->getMessage(); } } ob_start(); $this->orders_list_column_body('aieo_ff_voucher', $order_id); $cell_html = ob_get_clean(); wp_send_json_success(array( 'voucher_id' => (int) $r['id'], 'cell_html' => $cell_html, 'email_sent' => (bool) $email_sent, 'email_err' => (string) $email_err, )); } public function ajax_save_carrier_map() { $this->verify_admin_ajax(); $code = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; $title = isset($_POST['title']) ? trim((string) wp_unslash($_POST['title'])) : ''; if ($code === '') { wp_send_json_error(array('message' => 'carrier required')); } if (!isset($this->manual_entry_couriers()[$code])) { wp_send_json_error(array('message' => 'unsupported_carrier')); } if (!class_exists('AIEO_Fulfillment_Settings')) { wp_send_json_error(array('message' => 'settings_unavailable')); } $map = $this->get_carrier_map(); foreach ($map as $existing_title => $existing_code) { if ($existing_code === $code) { $this->set_carrier_map_entry($existing_title, ''); } } if ($title !== '') { $this->set_carrier_map_entry($title, $code); } wp_send_json_success(array('mapped' => $title)); } public function ajax_save_custom_carrier() { $this->verify_admin_ajax(); $slug_raw = isset($_POST['slug']) ? (string) wp_unslash($_POST['slug']) : ''; $label = isset($_POST['label']) ? (string) wp_unslash($_POST['label']) : ''; $tpl = isset($_POST['tracking_url_template']) ? (string) wp_unslash($_POST['tracking_url_template']) : ''; $title = isset($_POST['linked_method_title']) ? trim((string) wp_unslash($_POST['linked_method_title'])) : ''; $slug = $this->save_custom_carrier($slug_raw, $label, $tpl); if ($slug === '') { wp_send_json_error(array('message' => 'invalid_slug_or_label_or_collides_with_builtin')); } $code = AIEO_Courier_Driver_Custom::PREFIX . $slug; if ($title !== '') { $map = $this->get_carrier_map(); foreach ($map as $t => $c) { if ($c === $code) { $this->set_carrier_map_entry($t, ''); } } $this->set_carrier_map_entry($title, $code); } wp_send_json_success(array( 'slug' => $slug, 'code' => $code, )); } public function ajax_delete_custom_carrier() { $this->verify_admin_ajax(); $slug = isset($_POST['slug']) ? sanitize_key((string) wp_unslash($_POST['slug'])) : ''; if ($slug === '') { wp_send_json_error(array('message' => 'slug required')); } $ok = $this->delete_custom_carrier($slug); if (!$ok) { wp_send_json_error(array('message' => 'not_found')); } wp_send_json_success(array('slug' => $slug)); } private function render_link_title_options($current_carrier_code) { $titles_idx = $this->get_distinct_shipping_titles(); $map = $this->get_carrier_map(); $couriers = $this->couriers(); $linked_title_for_current = $this->get_linked_title_for_carrier((string) $current_carrier_code); $html = ''; $html .= ''; foreach ($titles_idx as $t => $info) { $is_enabled_wc = !empty($info['enabled']); $zones_csv = implode(', ', (array) $info['zones']); $taken_by_code = isset($map[$t]) ? (string) $map[$t] : ''; $taken_by_other = ($taken_by_code !== '' && $taken_by_code !== (string) $current_carrier_code); $taken_by_label = ''; if ($taken_by_other) { $taken_by_label = isset($couriers[$taken_by_code]) && method_exists($couriers[$taken_by_code], 'label') ? (string) $couriers[$taken_by_code]->label() : strtoupper($taken_by_code); } $is_disabled = (!$is_enabled_wc) || $taken_by_other; $suffix = ''; if (!$is_enabled_wc) { $suffix = ' ' . esc_html__('(disabled in WC)', 'ai-eshop-optimizer'); } elseif ($taken_by_other) { $suffix = ' ' . sprintf(esc_html__('(linked to %s)', 'ai-eshop-optimizer'), esc_html($taken_by_label)); } $label = esc_html($t) . $suffix; if ($zones_csv !== '') { $label .= ' — ' . esc_html($zones_csv); } $is_selected = ((string) $current_carrier_code !== '' && $linked_title_for_current === $t); $html .= ''; } return $html; } public function get_linked_title_for_carrier($carrier_code) { $code = sanitize_key((string) $carrier_code); if ($code === '') { return ''; } foreach ($this->get_carrier_map() as $title => $existing) { if ($existing === $code) { return $title; } } return ''; } public function get_custom_carriers() { $raw = class_exists('AIEO_Fulfillment_Settings') ? AIEO_Fulfillment_Settings::get('custom_carriers', array()) : array(); if (!is_array($raw)) { return array(); } $out = array(); foreach ($raw as $slug => $cfg) { $slug = sanitize_key((string) $slug); if ($slug === '' || !is_array($cfg)) { continue; } $out[$slug] = array( 'label' => isset($cfg['label']) ? (string) $cfg['label'] : '', 'tracking_url_template' => isset($cfg['tracking_url_template']) ? (string) $cfg['tracking_url_template'] : '', ); } $filtered = apply_filters('aieo_custom_carriers', $out); if (is_array($filtered)) { foreach ($filtered as $slug => $cfg) { $slug = sanitize_key((string) $slug); if ($slug === '' || !is_array($cfg)) { continue; } $out[$slug] = array( 'label' => isset($cfg['label']) ? (string) $cfg['label'] : '', 'tracking_url_template' => isset($cfg['tracking_url_template']) ? (string) $cfg['tracking_url_template'] : '', ); } } return $out; } public function save_custom_carrier($slug, $label, $tracking_url_template = '') { if (!class_exists('AIEO_Fulfillment_Settings')) { return ''; } $slug = AIEO_Courier_Driver_Custom::slugify_user_input($slug); if ($slug === '') { return ''; } $label = trim((string) $label); if ($label === '') { return ''; } $template = trim((string) $tracking_url_template); $reg = $this->get_custom_carriers(); $reg[$slug] = array( 'label' => $label, 'tracking_url_template' => $template, ); AIEO_Fulfillment_Settings::set('custom_carriers', $reg); return $slug; } public function delete_custom_carrier($slug) { $slug = sanitize_key((string) $slug); if ($slug === '' || !class_exists('AIEO_Fulfillment_Settings')) { return false; } $reg = $this->get_custom_carriers(); if (!isset($reg[$slug])) { return false; } unset($reg[$slug]); AIEO_Fulfillment_Settings::set('custom_carriers', $reg); $code = AIEO_Courier_Driver_Custom::PREFIX . $slug; $map = $this->get_carrier_map(); foreach ($map as $title => $existing) { if ($existing === $code) { $this->set_carrier_map_entry($title, ''); } } return true; } private function send_customer_tracking_email($order_id, $driver, $voucher_no) { $order = wc_get_order((int) $order_id); if (!$order) { return false; } if (apply_filters('aieo_suppress_order_customer_email', false, $order, 'tracking')) { return false; } $to = (string) $order->get_billing_email(); if ($to === '') { return false; } $carrier_lbl =method_exists($driver, 'label') ? (string) $driver->label() : ''; $track_url = method_exists($driver, 'tracking_url') ? (string) $driver->tracking_url($voucher_no) : ''; $first_name = (string) $order->get_billing_first_name(); $store_name = (string) get_bloginfo('name'); $order_no = (string) $order->get_order_number(); $subject = sprintf(__('%1$s — your order #%2$s has shipped', 'ai-eshop-optimizer'), $store_name, $order_no); $body_lines = array(); $body_lines[] = trim(sprintf(__('Hello %s,', 'ai-eshop-optimizer'), $first_name)); $body_lines[] = ''; $body_lines[] = sprintf(__('Your order #%1$s has been dispatched via %2$s.', 'ai-eshop-optimizer'), $order_no, $carrier_lbl !== '' ? $carrier_lbl : __('our courier', 'ai-eshop-optimizer')); $body_lines[] = ''; $body_lines[] = sprintf(__('Tracking number: %s', 'ai-eshop-optimizer'), $voucher_no); if ($track_url !== '') { $body_lines[] = sprintf(__('Track your shipment: %s', 'ai-eshop-optimizer'), $track_url); } $body_lines[] = ''; $body_lines[] = __('Thank you for your order.', 'ai-eshop-optimizer'); $body_lines[] = $store_name; $body = implode("\n", $body_lines); $body = (string) apply_filters( 'aieo_fulfillment_tracking_email_body', $body, $order, $driver, $voucher_no ); $from_name = $store_name; $from_email = ''; if (function_exists('WC') && WC()->mailer()) { $mailer = WC()->mailer(); $from_name = $mailer->get_from_name() ?: $from_name; $from_email = $mailer->get_from_address() ?: ''; } if ($from_email === '') { $from_email = (string) get_option('admin_email'); } $headers = array( 'From: ' . $from_name . ' <' . $from_email . '>', 'Content-Type: text/plain; charset=UTF-8', ); $order->add_order_note(sprintf( __('Tracking number %1$s (%2$s) sent to customer at %3$s.', 'ai-eshop-optimizer'), $voucher_no, $carrier_lbl, $to )); return (bool) wp_mail($to, $subject, $body, $headers); } public function ajax_delete_document() { $this->verify_admin_ajax(); $document_id = isset($_POST['document_id']) ? (int) $_POST['document_id'] : 0; $ok = AIEO_Fulfillment_Documents::delete($document_id); if (!$ok) { wp_send_json_error(array('message' => 'delete_failed')); } wp_send_json_success(); } private function read_bulk_payload() { $type = isset($_POST['doc_type']) ? sanitize_key((string) wp_unslash($_POST['doc_type'])) : ''; $date_from = isset($_POST['date_from']) ? sanitize_text_field((string) wp_unslash($_POST['date_from'])) : ''; $date_to = isset($_POST['date_to']) ? sanitize_text_field((string) wp_unslash($_POST['date_to'])) : ''; $statuses_csv = isset($_POST['statuses']) ? sanitize_text_field((string) wp_unslash($_POST['statuses'])) : 'wc-completed,wc-processing'; $statuses = array_filter(array_map('sanitize_key', explode(',', $statuses_csv))); if (empty($statuses)) { $statuses = array('wc-completed', 'wc-processing'); } return compact('type', 'date_from', 'date_to', 'statuses'); } public function ajax_bulk_merge() { $this->verify_admin_ajax(); $p = $this->read_bulk_payload(); if ($p['type'] === '') { wp_send_json_error(array('message' => 'doc_type required')); } $order_ids = AIEO_Fulfillment_Bulk::order_ids_in_range($p['date_from'], $p['date_to'], $p['statuses']); if (empty($order_ids)) { wp_send_json_error(array('message' => 'no_orders_in_range')); } $r = AIEO_Fulfillment_Bulk::merge_documents($order_ids, $p['type']); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'merge_failed')); } foreach ($order_ids as $oid) { $existing = AIEO_Fulfillment_Documents::get_for_order_by_type((int) $oid, $p['type']); if ($existing && !empty($existing['id'])) { AIEO_Fulfillment_Documents::mark_printed((int) $existing['id']); } } wp_send_json_success(array( 'pdf_url' => isset($r['pdf_url']) ? (string) $r['pdf_url'] : '', 'merged' => isset($r['merged']) ? (int) $r['merged'] : 0, 'message' => sprintf(__('Merged %d documents.', 'ai-eshop-optimizer'), (int) ($r['merged'] ?? 0)), )); } public function ajax_bulk_zip() { $this->verify_admin_ajax(); $p = $this->read_bulk_payload(); if ($p['type'] === '') { wp_send_json_error(array('message' => 'doc_type required')); } $order_ids = AIEO_Fulfillment_Bulk::order_ids_in_range($p['date_from'], $p['date_to'], $p['statuses']); if (empty($order_ids)) { wp_send_json_error(array('message' => 'no_orders_in_range')); } $r = AIEO_Fulfillment_Bulk::zip_documents($order_ids, $p['type']); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'zip_failed')); } foreach ($order_ids as $oid) { $existing = AIEO_Fulfillment_Documents::get_for_order_by_type((int) $oid, $p['type']); if ($existing && !empty($existing['id'])) { AIEO_Fulfillment_Documents::mark_printed((int) $existing['id']); } } wp_send_json_success(array( 'zip_url' => (string) $r['zip_url'], 'count' => (int) $r['count'], )); } public function ajax_bulk_zip_labels() { $this->verify_admin_ajax(); $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : ''; $date_from = isset($_POST['date_from']) ? sanitize_text_field((string) wp_unslash($_POST['date_from'])) : ''; $date_to = isset($_POST['date_to']) ? sanitize_text_field((string) wp_unslash($_POST['date_to'])) : ''; $voucher_ids = AIEO_Fulfillment_Bulk::voucher_ids_with_labels_in_range($date_from, $date_to, $carrier); if (empty($voucher_ids)) { wp_send_json_error(array('message' => 'no_labels_cached')); } $r = AIEO_Fulfillment_Bulk::zip_voucher_labels($voucher_ids); if (empty($r['ok'])) { wp_send_json_error(array('message' => isset($r['error']) ? $r['error'] : 'zip_failed')); } wp_send_json_success(array('zip_url' => (string) $r['zip_url'], 'count' => (int) $r['count'])); } public function ajax_migrate_audit() { $this->verify_admin_ajax(); wp_send_json_success(AIEO_Fulfillment_Migrator::audit()); } public function ajax_migrate_batch() { $this->verify_admin_ajax(); $step = isset($_POST['step']) ? sanitize_key((string) wp_unslash($_POST['step'])) : ''; $batch_size = isset($_POST['batch_size']) ? (int) $_POST['batch_size'] : 500; if ($step === '') { wp_send_json_error(array('message' => 'step required')); } $r = AIEO_Fulfillment_Migrator::run_batch($step, $batch_size); wp_send_json_success($r); } public function ajax_migrate_reset() { $this->verify_admin_ajax(); $step = isset($_POST['step']) ? sanitize_key((string) wp_unslash($_POST['step'])) : null; AIEO_Fulfillment_Migrator::reset_progress($step ?: null); wp_send_json_success(); } public function orders_list_columns($columns) { if (!is_array($columns)) { return $columns; } $new = array(); foreach ($columns as $key => $label) { $new[$key] = $label; if ($key === 'order_status') { $new['aieo_ff_voucher'] = __('Voucher', 'ai-eshop-optimizer'); } } if (!isset($new['aieo_ff_voucher'])) { $new['aieo_ff_voucher'] = __('Voucher', 'ai-eshop-optimizer'); } return $new; } public function orders_list_column_body($column, $order_or_post_id) { if ($column !== 'aieo_ff_voucher') { return; } $order_id = is_object($order_or_post_id) && method_exists($order_or_post_id, 'get_id') ? (int) $order_or_post_id->get_id() : (int) $order_or_post_id; if ($order_id <= 0) { echo '—'; return; } echo '
' . esc_html($vno_short) . '';
} else {
echo '' . esc_html($vno_short) . '';
}
echo '';
}
}
public function render_channel_filter_dropdown($order_type = 'shop_order', $which = 'top') {
if ($which !== 'top') { return; }
if ($order_type !== 'shop_order') { return; }
$this->render_channel_filter_select();
}
public function render_channel_filter_dropdown_legacy() {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || $screen->id !== 'edit-shop_order') { return; }
$this->render_channel_filter_select();
}
private function render_channel_filter_select() {
$current = isset($_GET['aieo_channel']) ? sanitize_text_field(wp_unslash((string) $_GET['aieo_channel'])) : '';
$marketplaces = $this->list_marketplace_filter_options();
$couriers = $this->list_courier_filter_options();
$pickups = $this->list_pickup_filter_options();
echo '';
}
private function list_pickup_filter_options() {
$raw = get_option('pickup_location_pickup_locations', array());
$out = array();
if (is_array($raw)) {
foreach ($raw as $l) {
if (is_array($l) && !empty($l['name'])) {
$name = (string) $l['name'];
$out[$name] = $name;
}
}
}
return $out;
}
private function list_marketplace_filter_options() {
global $wpdb;
$vendor_slugs = $wpdb->get_col(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE meta_key = '_aieo_moi_vendor_slug' AND meta_value <> ''
ORDER BY meta_value ASC"
);
if (empty($vendor_slugs) && class_exists('AIEO_MOI_Vendor_Manager') && method_exists('AIEO_MOI_Vendor_Manager', 'list_all')) {
$vendors = (array) AIEO_MOI_Vendor_Manager::list_all();
foreach ($vendors as $v) {
if (!empty($v['slug'])) { $vendor_slugs[] = $v['slug']; }
}
}
$out = array();
foreach ((array) $vendor_slugs as $slug) {
$slug = (string) $slug;
if ($slug === '') { continue; }
$out[$slug] = ucwords(str_replace(array('-', '_'), ' ', $slug));
}
return $out;
}
private function list_courier_filter_options() {
$out = array();
foreach ($this->active_couriers() as $slug => $entry) {
$driver = isset($entry['driver']) ? $entry['driver'] : null;
$label = ($driver && method_exists($driver, 'label')) ? (string) $driver->label() : ucwords(str_replace('_', ' ', (string) $slug));
$out[(string) $slug] = $label;
}
global $wpdb;
$used = (array) $wpdb->get_col("SELECT DISTINCT carrier FROM {$wpdb->prefix}aieo_dmm_fulfillment_vouchers WHERE carrier <> '' AND carrier <> 'manual'");
$all_drivers = $this->couriers();
foreach ($used as $slug) {
$slug = (string) $slug;
if ($slug === '' || isset($out[$slug])) { continue; }
$label = isset($all_drivers[$slug]) && method_exists($all_drivers[$slug], 'label')
? (string) $all_drivers[$slug]->label()
: ucwords(str_replace('_', ' ', $slug));
$out[$slug] = $label;
}
ksort($out);
return $out;
}
private function resolve_orders_for_courier($courier_slug) {
global $wpdb;
$courier_slug = (string) $courier_slug;
if ($courier_slug === '') { return array(); }
$vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers';
$oi = $wpdb->prefix . 'woocommerce_order_items';
$oim = $wpdb->prefix . 'woocommerce_order_itemmeta';
$name_parts = array();
foreach ($this->courier_name_patterns_for_slug($courier_slug) as $p) {
$name_parts[] = $wpdb->prepare("oi.order_item_name LIKE %s", '%' . $wpdb->esc_like($p) . '%');
}
$name_clause = !empty($name_parts) ? '(' . implode(' OR ', $name_parts) . ')' : '0';
$map_raw = (string) $wpdb->get_var($wpdb->prepare(
"SELECT setting_value FROM {$wpdb->prefix}aieo_dmm_fulfillment_settings WHERE setting_key = %s",
'shipping_method_carrier_map'
));
$map = $map_raw !== '' ? json_decode($map_raw, true) : array();
$instance_ids = array();
if (is_array($map)) {
foreach ($map as $label => $entry) {
if (is_array($entry) && (string) ($entry['carrier'] ?? '') === $courier_slug && !empty($entry['instance_id'])) {
$instance_ids[] = (int) $entry['instance_id'];
}
}
}
$inst_clause = '0';
if (!empty($instance_ids)) {
$placeholders = implode(',', array_fill(0, count($instance_ids), '%d'));
$inst_clause = $wpdb->prepare(
"EXISTS (SELECT 1 FROM {$oim} m
WHERE m.order_item_id = oi.order_item_id
AND m.meta_key = 'instance_id'
AND m.meta_value IN ({$placeholders}))",
...$instance_ids
);
}
$sql = "SELECT order_id FROM (
SELECT v.order_id
FROM {$vt} v
WHERE v.carrier = %s AND v.order_id > 0
UNION
SELECT oi.order_id
FROM {$oi} oi
WHERE oi.order_item_type = 'shipping'
AND ({$name_clause} OR {$inst_clause})
) AS x
ORDER BY order_id DESC
LIMIT 50000";
$ids = $wpdb->get_col($wpdb->prepare($sql, $courier_slug));
return array_values(array_map('intval', (array) $ids));
}
private function courier_name_patterns_for_slug($slug) {
$map = array(
'geniki' => array('Γενικ', 'Geniki', 'Ταχυδρομικ', 'Taxydromik'),
'elta' => array('ELTA', 'ΕΛΤΑ', 'Ταχυμεταφορες ΕΛΤΑ'),
'acs' => array('ACS'),
'speedex' => array('Speedex', 'SPEEDEX'),
'boxnow' => array('Box Now', 'BoxNow', 'BOX NOW'),
'courrier_centre' => array('Courier Center'),
);
$map = apply_filters('aieo_courier_name_patterns', $map);
$slug = (string) $slug;
return isset($map[$slug]) && is_array($map[$slug]) ? $map[$slug] : array();
}
public function apply_channel_filter_to_query($query_args) {
$filter = isset($_GET['aieo_channel']) ? sanitize_text_field(wp_unslash((string) $_GET['aieo_channel'])) : '';
if ($filter === '' || strpos($filter, ':') === false) { return $query_args; }
list($type, $slug) = explode(':', $filter, 2);
if ($type === 'm') {
if (!isset($query_args['meta_query']) || !is_array($query_args['meta_query'])) {
$query_args['meta_query'] = array();
}
$query_args['meta_query'][] = array(
'key' => '_aieo_moi_vendor_slug',
'value' => $slug,
);
} elseif ($type === 'c') {
$ids = $this->resolve_orders_for_courier($slug);
$query_args['post__in'] = !empty($ids) ? $ids : array(0);
} elseif ($type === 'p') {
if (!isset($query_args['meta_query']) || !is_array($query_args['meta_query'])) {
$query_args['meta_query'] = array();
}
$query_args['meta_query'][] = array(
'key' => '_aieo_pickup_location',
'value' => $slug,
);
}
return $query_args;
}
public function apply_channel_filter_to_legacy_query($query) {
if (!is_admin() || !function_exists('get_current_screen')) { return; }
$screen = get_current_screen();
if (!$screen || $screen->id !== 'edit-shop_order') { return; }
if (!$query->is_main_query()) { return; }
$filter = isset($_GET['aieo_channel']) ? sanitize_text_field(wp_unslash((string) $_GET['aieo_channel'])) : '';
if ($filter === '' || strpos($filter, ':') === false) { return; }
list($type, $slug) = explode(':', $filter, 2);
if ($type === 'm') {
$mq = (array) $query->get('meta_query');
$mq[] = array('key' => '_aieo_moi_vendor_slug', 'value' => $slug);
$query->set('meta_query', $mq);
} elseif ($type === 'c') {
$ids = $this->resolve_orders_for_courier($slug);
$query->set('post__in', !empty($ids) ? $ids : array(0));
} elseif ($type === 'p') {
$mq = (array) $query->get('meta_query');
$mq[] = array('key' => '_aieo_pickup_location', 'value' => $slug);
$query->set('meta_query', $mq);
}
}
public function orders_list_bulk_actions($actions) {
if (!is_array($actions)) { $actions = array(); }
$actions['aieo_ff_print_packing_slips'] = __('Print packing slips (merged PDF)', 'ai-eshop-optimizer');
$actions['aieo_ff_print_invoices'] = __('Print invoices (merged PDF)', 'ai-eshop-optimizer');
$actions['aieo_ff_print_labels'] = __('Print picking labels (ZIP)', 'ai-eshop-optimizer');
$actions['aieo_ff_print_labels_a4x3'] = __('Print labels (A4×3 portrait, 3 stacked)', 'ai-eshop-optimizer');
$actions['aieo_ff_generate_vouchers'] = __('Generate vouchers for selected (no print)', 'ai-eshop-optimizer');
$actions['aieo_ff_generate_print_complete'] = __('Generate + Print A4×3 + Mark Completed', 'ai-eshop-optimizer');
$actions['aieo_ff_mark_completed'] = __('Mark orders Completed', 'ai-eshop-optimizer');
return $actions;
}
public function maybe_render_a4x3_print_page() {
if (empty($_GET['aieo_ff_print_a4x3'])) { return; }
if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('You do not have permission to view this page.', 'ai-eshop-optimizer'), 403); }
$token = sanitize_text_field(wp_unslash((string) $_GET['aieo_ff_print_a4x3']));
if ($token === '' || !preg_match('/^[A-Za-z0-9]{8,40}$/', $token)) { wp_die(esc_html__('Invalid print token.', 'ai-eshop-optimizer'), 400); }
$bundle = get_transient('aieo_ff_a4x3_' . $token);
if (!is_array($bundle) || empty($bundle['urls'])) { wp_die(esc_html__('Print bundle expired or not found. Re-run the bulk action.', 'ai-eshop-optimizer'), 410); }
$urls = array_values(array_filter((array) $bundle['urls']));
$missing = (array) ($bundle['missing'] ?? array());
nocache_headers();
header('Content-Type: text/html; charset=UTF-8');
$title = sprintf(
esc_html__('Print %d labels (A4×3 portrait)', 'ai-eshop-optimizer'),
count($urls)
);
$btn_print = esc_html__('Print', 'ai-eshop-optimizer');
$loading = esc_html__('Loading PDFs…', 'ai-eshop-optimizer');
$done_msg = esc_html__('All loaded — printing.', 'ai-eshop-optimizer');
$missing_n = count($missing);
$missing_t = $missing_n > 0
? sprintf(
esc_html__('%1$d order(s) had no voucher PDF and were skipped: %2$s', 'ai-eshop-optimizer'),
$missing_n,
esc_html(implode(', ', array_map('intval', $missing)))
)
: '';
$urls_json = wp_json_encode($urls);
?>
' . sprintf(esc_html__('Labels ZIP ready (%d files).', 'ai-eshop-optimizer'), $n) . ' ' . '' . esc_html__('Download ZIP', 'ai-eshop-optimizer') . '' . '
' . sprintf(esc_html__('Merged %1$d %2$s ready to print.', 'ai-eshop-optimizer'), $n, $type_label) . ' ' . '' . esc_html__('Open PDF in new tab', 'ai-eshop-optimizer') . '' . '
' . esc_html__('None of the selected orders have cached carrier labels yet. Generate the vouchers first (the API drivers will fetch labels automatically).', 'ai-eshop-optimizer') . '
'; return; } if ($msg === 'zip_failed') { echo ''
. esc_html__('Could not build the labels ZIP.', 'ai-eshop-optimizer')
. ($err !== '' ? ' ' . esc_html($err) . '' : '')
. '
'
. esc_html__('Could not build the merged PDF.', 'ai-eshop-optimizer')
. ($err !== '' ? ' ' . esc_html($err) . '' : '')
. '
' . esc_html($headings[$section][1]) . '
'; } private function section_intro_text($section) { $headings = $this->section_headings(); return isset($headings[$section][1]) ? (string) $headings[$section][1] : ''; } private static function with_attrs(array $card, array $attrs) { list($open, $close) = $card; $extra = ''; foreach ($attrs as $name => $value) { $extra .= ' ' . sanitize_key($name) . '="' . esc_attr((string) $value) . '"'; } $open = (string) preg_replace_callback('/^\s*<(?:section|div)\b/', static function ($m) use ($extra) { return $m[0] . $extra; }, $open, 1); return array($open, $close); } private static function band_open(array $opts = array()) { if (function_exists('rooster_admin_band_open')) { rooster_admin_band_open($opts); } } private static function band_close() { if (function_exists('rooster_admin_band_close')) { rooster_admin_band_close(); } } private static function empty_html($text, $kind = 'not-set-up') { return class_exists('\Rooster\Admin\States') ? \Rooster\Admin\States::state((string) $kind, array('title' => (string) $text, 'compact' => true)) : '' . esc_html($text) . '
'; } private static function stats_html(array $figures) { $out = ''; foreach ($figures as $label => $value) { $out .= class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::stat((string) $label, (string) $value) : '' . esc_html($code) . '';
if ($configured) {
$aieo_ff_meta .= $aieo_ff_badge(__('configured', 'ai-eshop-optimizer'), 'success');
}
$rmode = $driver->returns_mode($cfg);
$offered = class_exists('AIEO_Withdrawal_Returns')
? AIEO_Withdrawal_Returns::return_courier_options()
: array();
if (isset($offered[$code])) {
$aieo_ff_meta .= $aieo_ff_badge('api' === $rmode ? __('returns via API', 'ai-eshop-optimizer') : __('returns, booked directly', 'ai-eshop-optimizer'), 'info');
}
if ($code === $default_carrier_code) {
$aieo_ff_meta .= $aieo_ff_badge(__('default', 'ai-eshop-optimizer'), 'warning');
}
ob_start();
?>
' . \Rooster\Admin\Ui::fold($driver->label(), (string) ob_get_clean(), array('meta' => $aieo_ff_meta)) . ''; } echo $aieo_ff_close; } $this->render_custom_carriers_section(); ?> couriers() as $slug => $driver) { $slug = (string) $slug; $label = (is_object($driver) && method_exists($driver, 'label')) ? (string) $driver->label() : ucwords(str_replace(array('-', '_'), ' ', $slug)); $out[$slug] = array('label' => $label, 'count' => 0, 'custom' => (strpos($slug, $prefix) === 0)); } $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $rows = $wpdb->get_results("SELECT carrier, COUNT(*) c FROM {$vt} WHERE carrier <> '' GROUP BY carrier", ARRAY_A); foreach ((array) $rows as $r) { $slug = (string) $r['carrier']; if ($slug === '') { continue; } if (!isset($out[$slug])) { $out[$slug] = array('label' => ucwords(str_replace(array('-', '_'), ' ', $slug)), 'count' => 0, 'custom' => (strpos($slug, $prefix) === 0)); } $out[$slug]['count'] = (int) $r['c']; } ksort($out); return $out; } private function render_merge_couriers_section() { $options = $this->merge_carrier_options(); if (count($options) < 2) { return; } $ff_nonce = wp_create_nonce('aieo_fulfillment_admin'); $ajax = admin_url('admin-ajax.php'); $aieo_ff_merge = array('' => '— —'); foreach ($options as $slug => $meta) { $aieo_ff_merge[(string) $slug] = sprintf('%1$s — %2$s (%3$d %4$s)', $meta['label'], $slug, $meta['count'], _n('voucher', 'vouchers', $meta['count'], 'ai-eshop-optimizer')); } list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-consolidate-merge-couriers', 'icon' => 'arrow-right-left', 'title' => __('Consolidate / merge couriers', 'ai-eshop-optimizer'), 'description' => __('Merge two carriers into one when the same courier ended up with duplicate entries (e.g. a built-in driver and a custom carrier). Everything keyed on the source carrier is re-pointed to the target: voucher history (your courier statistics), closing manifests, the shipping-method → carrier links, and the default carrier. If the source is a custom carrier it is then removed. Edits historical data — not reversible from the UI.', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => ' ' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-merge-status')), )); echo $aieo_ff_open; echo AIEO_Kit::row(__('Merge FROM (source — emptied)', 'ai-eshop-optimizer'), AIEO_Kit::select('', $aieo_ff_merge, '', array('id' => 'aieo-merge-from')), '', array('id' => 'aieo-merge-from')); echo AIEO_Kit::row(__('Merge INTO (target — keeps everything)', 'ai-eshop-optimizer'), AIEO_Kit::select('', $aieo_ff_merge, '', array('id' => 'aieo-merge-to')), '', array('id' => 'aieo-merge-to')); echo $aieo_ff_close; ?> verify_admin_ajax(); if (function_exists('set_time_limit')) { @set_time_limit(0); } global $wpdb; $from = isset($_POST['from']) ? sanitize_text_field(wp_unslash((string) $_POST['from'])) : ''; $to = isset($_POST['to']) ? sanitize_text_field(wp_unslash((string) $_POST['to'])) : ''; if ($from === '' || $to === '' || $from === $to) { wp_send_json_error(array('message' => __('Pick a source carrier and a different target carrier.', 'ai-eshop-optimizer'))); } $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $ct = $wpdb->prefix . 'aieo_dmm_fulfillment_closings'; $vouchers = (int) $wpdb->query($wpdb->prepare("UPDATE {$vt} SET carrier = %s WHERE carrier = %s", $to, $from)); $closings = (int) $wpdb->query($wpdb->prepare("UPDATE {$ct} SET carrier = %s WHERE carrier = %s", $to, $from)); $map_moved = 0; if (class_exists('AIEO_Fulfillment_Settings')) { $raw = AIEO_Fulfillment_Settings::get('shipping_method_carrier_map', array()); if (is_array($raw)) { $changed = false; foreach ($raw as $title => $entry) { if (is_array($entry)) { if (isset($entry['carrier']) && (string) $entry['carrier'] === $from) { $raw[$title]['carrier'] = $to; $map_moved++; $changed = true; } } elseif ((string) $entry === $from) { $raw[$title] = $to; $map_moved++; $changed = true; } } if ($changed) { AIEO_Fulfillment_Settings::set('shipping_method_carrier_map', $raw); } } if ((string) AIEO_Fulfillment_Settings::get('default_carrier', '') === $from) { AIEO_Fulfillment_Settings::set('default_carrier', $to); } } $custom_removed = false; $prefix = class_exists('AIEO_Courier_Driver_Custom') ? AIEO_Courier_Driver_Custom::PREFIX : 'custom__'; if (strpos($from, $prefix) === 0) { $custom_removed = (bool) $this->delete_custom_carrier(substr($from, strlen($prefix))); } $extra = $custom_removed ? ' · ' . __('source custom carrier removed', 'ai-eshop-optimizer') : ''; wp_send_json_success(array( 'vouchers' => $vouchers, 'closings' => $closings, 'map' => $map_moved, 'custom_removed' => $custom_removed, 'message' => sprintf( __('Merged "%1$s" → "%2$s". Vouchers moved: %3$d · closings: %4$d · method links: %5$d%6$s.', 'ai-eshop-optimizer'), $from, $to, $vouchers, $closings, $map_moved, $extra ), )); } public function courier_setup_state($code, $driver) { $cfg = class_exists('AIEO_Fulfillment_Settings') ? (array) AIEO_Fulfillment_Settings::get('courier_' . $code, array()) : array(); $mode = method_exists($driver, 'create_mode') ? (string) $driver->create_mode() : 'api'; if ('manual' === $mode) { if (class_exists('AIEO_Courier_Driver_Custom') && strpos($code, AIEO_Courier_Driver_Custom::PREFIX) === 0) { return 'manual'; } return $this->courier_has_own_settings($cfg) ? 'manual' : 'available'; } $required = array(); if (method_exists($driver, 'settings_schema')) { foreach ((array) $driver->settings_schema() as $field => $meta) { if (!empty($meta['required'])) { $required[] = $field; } } } foreach ($required as $field) { if (!isset($cfg[$field]) || trim((string) $cfg[$field]) === '') { return 'available'; } } if (empty($required)) { return $this->courier_has_own_settings($cfg) ? 'api' : 'available'; } return 'api'; } private function courier_has_own_settings(array $cfg) { $shared = array('supports_returns', 'returns_mode', 'default_charge_party', 'tracking_url_template'); foreach ($cfg as $k => $v) { if (in_array($k, $shared, true)) { continue; } if (is_scalar($v) && trim((string) $v) !== '') { return true; } } return false; } private function render_custom_carriers_section() { $custom = $this->get_custom_carriers(); $ff_nonce = wp_create_nonce('aieo_fulfillment_admin'); $titles_idx = $this->get_distinct_shipping_titles(); list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-add-a-manual-carrier', 'icon' => 'plus', 'title' => __('Add a manual carrier', 'ai-eshop-optimizer'), 'description' => __('Add the carriers you book by hand, with the tracking link customers receive, and link each to a shipping method.', 'ai-eshop-optimizer'), 'class' => 'aieo-custom-carriers-card', )); echo $aieo_ff_open; ob_start(); echo \Rooster\Admin\Guidance::text(__('Add a carrier here when it has no API integration (e.g. Courier Center) — or when you simply prefer to paste tracking numbers manually for any other carrier. Each provider needs a label + a public tracking-URL template that uses {voucher_no} where the number goes (e.g. https://courier-center.gr/track?id={voucher_no}). Customers click this URL from their order notification email.', 'ai-eshop-optimizer')); $cc_rows = array(); foreach ($custom as $slug => $cfg) { $cc_rows[] = array( 'slug' => (string) $slug, 'code' => AIEO_Courier_Driver_Custom::PREFIX . $slug, 'label' => (string) $cfg['label'], 'label_lc' => strtolower((string) $cfg['label']), 'tpl' => (string) $cfg['tracking_url_template'], 'tpl_lc' => strtolower((string) $cfg['tracking_url_template']), ); } AIEO_Table::render(array( 'class' => 'aieo-cc-sortable', 'columns' => array( array( 'key' => 'label', 'label' => __('Label', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '18%', 'sort_key' => 'label_lc', 'format' => array( 'render' => static function ($row) { return '' . esc_html($row['label']) . ''; }, ), ), array( 'key' => 'slug', 'label' => __('Slug', 'ai-eshop-optimizer'), 'type' => 'code', 'width' => '12%', ), array( 'key' => 'tpl', 'label' => __('Tracking URL template', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '36%', 'sort_key' => 'tpl_lc', 'format' => array( 'render' => static function ($row) { return '' . '
'
. esc_html($row['tpl']) . '';
},
),
),
array(
'key' => 'code',
'label' => __('Linked WC shipping method', 'ai-eshop-optimizer'),
'type' => 'control',
'width' => '24%',
'sortable' => false,
'format' => array(
'render' => function ($row) use ($ff_nonce) {
return ''
. \Rooster\Admin\States::outcome('', '', array('attrs' => array('data-aieo-courier-link-status' => '')));
},
),
),
array(
'key' => 'actions',
'label' => __('Actions', 'ai-eshop-optimizer'),
'type' => 'control',
'width' => '10%',
'format' => array(
'render' => static function ($row) use ($ff_nonce) {
return ''
. ' '
. '';
},
),
),
),
'rows' => $cc_rows,
'row_key' => 'slug',
'row_data' => array('slug' => 'slug'),
'empty' => __('No custom carriers yet — add one below.', 'ai-eshop-optimizer'),
'page_id' => 'fulfillment',
));
echo '';
echo \Rooster\Admin\Ui::fold(__('Add or edit a carrier you enter tracking numbers for by hand', 'ai-eshop-optimizer'), (string) ob_get_clean());
echo $aieo_ff_close;
$ajax = admin_url('admin-ajax.php');
?>
__('Fulfillment settings store unavailable.', 'ai-eshop-optimizer')));
}
$courier = isset($_POST['courier']) ? sanitize_key(wp_unslash($_POST['courier'])) : '';
$couriers = $this->couriers();
if ($courier === '' || empty($couriers[$courier])) {
wp_send_json_error(array('message' => __('Unknown courier.', 'ai-eshop-optimizer')));
}
$driver = $couriers[$courier];
$schema = method_exists($driver, 'settings_schema_full')
? (array) $driver->settings_schema_full()
: (method_exists($driver, 'settings_schema') ? (array) $driver->settings_schema() : array());
if (!isset($schema['tracking_url_template'])) {
$schema['tracking_url_template'] = array('type' => 'url');
}
$raw = isset($_POST['settings']) ? (string) wp_unslash($_POST['settings']) : '';
$incoming = $raw !== '' ? json_decode($raw, true) : array();
if (!is_array($incoming)) {
wp_send_json_error(array('message' => __('The settings could not be read. Nothing was saved.', 'ai-eshop-optimizer')));
}
$clean = array();
foreach ($schema as $field => $meta) {
$type = (string) ($meta['type'] ?? 'text');
$val = $incoming[$field] ?? '';
switch ($type) {
case 'checkbox': $clean[$field] = !empty($val) ? 1 : 0; break;
case 'number': $clean[$field] = $val === '' ? '' : (float) $val; break;
case 'password':
if ('' !== trim(str_replace("\u{2022}", '', (string) $val))) {
$clean[$field] = (string) $val;
}
break;
case 'url': $clean[$field] = esc_url_raw((string) $val); break;
default: $clean[$field] = sanitize_text_field((string) $val);
}
}
$stored = (array) AIEO_Fulfillment_Settings::get('courier_' . $courier, array());
AIEO_Fulfillment_Settings::set('courier_' . $courier, array_merge($stored, $clean));
wp_send_json_success(array('message' => __('Saved.', 'ai-eshop-optimizer')));
}
public function ajax_test_courier() {
AIEO_DMM::verify_ajax_request();
$courier = isset($_POST['courier']) ? sanitize_key(wp_unslash($_POST['courier'])) : '';
$couriers = $this->couriers();
if ($courier === '' || empty($couriers[$courier])) {
wp_send_json_error(array('message' => __('Unknown courier.', 'ai-eshop-optimizer')));
}
$driver = $couriers[$courier];
if (!method_exists($driver, 'authenticate')) {
wp_send_json_error(array('message' => __('Driver does not implement authenticate().', 'ai-eshop-optimizer')));
}
$raw = isset($_POST['settings']) ? (string) wp_unslash($_POST['settings']) : '';
$overrides = $raw !== '' ? json_decode($raw, true) : array();
if (!is_array($overrides)) { $overrides = array(); }
$schema = method_exists($driver, 'settings_schema_full')
? (array) $driver->settings_schema_full()
: (method_exists($driver, 'settings_schema') ? (array) $driver->settings_schema() : array());
foreach ($schema as $field => $meta) {
if ('password' === (string) ($meta['type'] ?? '') && isset($overrides[$field]) && '' === trim(str_replace("\u{2022}", '', (string) $overrides[$field]))) {
unset($overrides[$field]);
}
}
$r = $driver->authenticate($overrides);
if (is_array($r) && !empty($r['ok'])) {
$cached = !empty($r['cached']) ? ' (cached)' : '';
wp_send_json_success(array('message' => __('Authenticated successfully', 'ai-eshop-optimizer') . $cached));
}
$msg = is_array($r) && !empty($r['error']) ? $r['error'] : 'Authentication failed';
wp_send_json_error(array('message' => $msg));
}
private function render_documents() {
$docs = $this->documents();
$nonce = wp_create_nonce('aieo_fulfillment_admin');
$today = current_time('Y-m-d');
$week_ago = wp_date('Y-m-d', time() - 7 * DAY_IN_SECONDS);
self::band_open(array('cols' => 1));
$aieo_ffd_rows = array();
foreach ($docs as $type => $doc) {
$aieo_ffd_rows[] = array(
'type' => (string) $type,
'label' => $doc->label(),
'emails' => implode(', ', $doc->default_attached_to_emails()),
);
}
AIEO_Table::card(array(
'anchor' => 'fulfillment-registered-document-types',
'icon' => 'scroll-text',
'heading' => 'h2',
'title' => __('Registered document types', 'ai-eshop-optimizer'),
'description' => $this->section_intro_text('documents'),
'id' => 'aieo-ff-doc-types',
'page_id' => 'fulfillment',
'row_key' => 'type',
'sortable' => false,
'rows' => $aieo_ffd_rows,
'empty' => __('No document type registered.', 'ai-eshop-optimizer'),
'columns' => array(
array('key' => 'type', 'label' => __('Type', 'ai-eshop-optimizer'), 'type' => 'code'),
array('key' => 'label', 'label' => __('Label', 'ai-eshop-optimizer'), 'type' => 'text'),
array('key' => 'emails', 'label' => __('Default email attachment', 'ai-eshop-optimizer'), 'type' => 'code'),
),
));
self::band_close();
self::band_open(array('cols' => 2));
list($aieo_ff_open, $aieo_ff_close) = self::with_attrs(AIEO_DMM_UI::card(array(
'anchor' => 'fulfillment-bulk-operations',
'icon' => 'list-checks',
'title' => __('Bulk operations', 'ai-eshop-optimizer'),
'description' => __('Generate, merge, or ZIP documents for a date range of orders.', 'ai-eshop-optimizer'),
'col' => 1,
'footer' => ' '
. ' '
. \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-bulk-status')),
)), array('data-aieo-ff-bulk' => '', 'data-nonce' => $nonce));
echo $aieo_ff_open;
$aieo_ff_types = array();
foreach ($docs as $type => $doc) {
$aieo_ff_types[(string) $type] = $doc->label();
}
echo AIEO_Kit::row(__('Document type', 'ai-eshop-optimizer'), AIEO_Kit::select('', $aieo_ff_types, '', array('id' => 'aieo-ff-bulk-type')), __('An order that has no document of this type yet gets one made first.', 'ai-eshop-optimizer'), array('id' => 'aieo-ff-bulk-type'));
echo AIEO_Kit::row(__('Date from', 'ai-eshop-optimizer'), AIEO_Kit::input('', $week_ago, array('id' => 'aieo-ff-bulk-from', 'type' => 'date')), '', array('id' => 'aieo-ff-bulk-from'));
echo AIEO_Kit::row(__('Date to', 'ai-eshop-optimizer'), AIEO_Kit::input('', $today, array('id' => 'aieo-ff-bulk-to', 'type' => 'date')), __('Up to 1,000 orders placed in these days, the oldest first.', 'ai-eshop-optimizer'), array('id' => 'aieo-ff-bulk-to'));
echo AIEO_Kit::row(
__('Order statuses', 'ai-eshop-optimizer'),
'' . $aieo_ff_auto . '
'; echo $aieo_ff_auto; $close_couriers = $this->couriers(); $cc_default = isset($close_couriers['geniki']) ? 'geniki' : (count($close_couriers) ? (string) array_key_first($close_couriers) : ''); $aieo_ff_close_choices = array(); foreach ($close_couriers as $code => $driver) { $api_close = method_exists($driver, 'has_capability') && $driver->has_capability('close_pending'); $aieo_ff_close_choices[(string) $code] = $driver->label() . ($api_close ? '' : ' — ' . __('manifest only', 'ai-eshop-optimizer')); } echo AIEO_Kit::row(__('Carrier', 'ai-eshop-optimizer'), AIEO_Kit::select('', $aieo_ff_close_choices, $cc_default, array('id' => 'aieo-ff-close-carrier')), '', array('id' => 'aieo-ff-close-carrier')); echo '' . sprintf( esc_html__('Average courier cost is %1$s. At a %2$d%% gross margin an order must carry %3$s of products just to cover that — the "Min order" break-even. Because you absorb the courier cost on a free-shipping order, raise your current %4$s free-shipping bar to ≥ %5$s: the extra %3$s of turnover above the bar earns ~%1$s of margin, which offsets the courier cost. The per-area "Free-ship ≥" column gives this per postal prefix (pricier regions need a higher bar).', 'ai-eshop-optimizer'), esc_html($fmt_money($totals['avg_carrier_cost'])), (int) round($margin_pct), '' . esc_html($fmt_money($aieo_ds_break_even['min'])) . '', esc_html($fmt_money($fs_threshold)), '' . esc_html($fmt_money($aieo_ds_break_even['fs'])) . '' ) . '
'; $aieo_ds_be_note = \Rooster\Admin\Guidance::text(sprintf( __('“Avg profit” = avg net product value × %d%% margin. Set the current free-shipping bar in Tools. Rough planning estimates from a single margin figure, not per-product costs.', 'ai-eshop-optimizer'), (int) round($margin_pct) )); echo $aieo_ds_be_note; echo $aieo_ds_close; } if (is_array($dist)) { $bucket_defs = array( '2' => array('label' => __('Within 2 days', 'ai-eshop-optimizer'), 'tone' => 'success'), '3' => array('label' => __('3 days', 'ai-eshop-optimizer'), 'tone' => 'success'), '4' => array('label' => __('4 days', 'ai-eshop-optimizer'), 'tone' => 'success'), '5' => array('label' => __('5 days', 'ai-eshop-optimizer'), 'tone' => 'warning'), '6' => array('label' => __('6 days', 'ai-eshop-optimizer'), 'tone' => 'warning'), '7' => array('label' => __('7 days', 'ai-eshop-optimizer'), 'tone' => 'warning'), '8' => array('label' => __('8 days', 'ai-eshop-optimizer'), 'tone' => 'destructive'), '9' => array('label' => __('9 days', 'ai-eshop-optimizer'), 'tone' => 'destructive'), '10' => array('label' => __('10 days', 'ai-eshop-optimizer'), 'tone' => 'destructive'), 'over_10' => array('label' => __('Over 10 days', 'ai-eshop-optimizer'), 'tone' => 'destructive'), ); $max_count = 0; foreach ($bucket_defs as $key => $_) { $c = (int) ($dist['b_' . $key] ?? 0); if ($c > $max_count) { $max_count = $c; } } $total_count = (int) (is_array($totals) ? $totals['shipments'] : 0); $desc = $metrics_basis === 'fulfillment' ? __('Showing how long our half of the journey took (order placed → parcel out for delivery), excluding recipient response time. Only counts shipments with a captured "out for delivery" (C_A3) checkpoint — historical rows tracked only via the lightweight call do not have this and are excluded. New shipments populate it automatically via the nightly poll.', 'ai-eshop-optimizer') : __('Showing the total customer-perceived wait time (order placed → recipient took possession). Mixes our fulfillment lag with any recipient-response delay (absences, voluntary pickup-after-attempt). Switch to "fulfillment lag" above for the operational view.', 'ai-eshop-optimizer'); list($aieo_ds_open, $aieo_ds_close) = AIEO_DMM_UI::card(array( 'anchor' => 'delivery-stats-delivery-time-distribution', 'icon' => 'chart-column', 'title' => __('Delivery-time distribution', 'ai-eshop-optimizer'), 'description' => $desc . ' ' . __('Click any bar to see the orders in that bucket. Buckets are exclusive: "3 days" = strictly more than 2 days and at most 3 days, etc. Respects the active carrier filter.', 'ai-eshop-optimizer'), )); echo $aieo_ds_open; $base_url = admin_url('admin.php?page=' . AIEO_DMM::MENU_SLUG . '&dmm_tab=delivery_stats'); $url_total = add_query_arg(array('metrics_basis' => 'total', 'ff_metrics_carrier' => $filter_carrier), $base_url); $url_fulfill = add_query_arg(array('metrics_basis' => 'fulfillment', 'ff_metrics_carrier' => $filter_carrier), $base_url); echo '' . esc_html__('All vouchers have a customer_shipping_paid value.', 'ai-eshop-optimizer') . '
'; echo $aieo_ff_done; } echo $aieo_ff_close; $ajax = admin_url('admin-ajax.php'); ?> couriers(); $candidates = array(); foreach ($couriers as $code => $driver) { if ($driver->has_capability('calculate_cost')) { $candidates[$code] = $driver->label(); } } global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; list($aieo_ff_open, $aieo_ff_close) = self::with_attrs(AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-backfill-estimated-costs', 'icon' => 'scale', 'title' => __('Backfill estimated costs', 'ai-eshop-optimizer'), 'description' => __('Polls the carrier\'s CalculateCost endpoint for every voucher missing a cost and writes the result with cost_source="estimate_current_tariff". Returns today\'s tariff (not the historical billed amount). Re-runnable any time. If the carrier returns "feature not enabled" (Geniki: result code 12) we abort cleanly — fix is on their side.', 'ai-eshop-optimizer'), 'col' => 2, 'flush' => true, )), array('data-aieo-ff-cost-backfill' => '', 'data-nonce' => $nonce)); echo $aieo_ff_open; if (empty($candidates)) { echo self::empty_html(__('No carriers advertise CalculateCost.', 'ai-eshop-optimizer')); echo $aieo_ff_close; return; } $aieo_cb_rows = array(); foreach ($candidates as $code => $label) { $aieo_cb_rows[] = array( 'carrier_code' => (string) $code, 'carrier' => (string) $label, 'c1' => (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier=%s AND carrier_cost IS NULL AND status NOT IN ('canceled','returned','untrackable')", $code )), 'c2' => (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier=%s AND carrier_cost IS NOT NULL", $code )), 'c1_class' => 'aieo-ff-cb-missing', 'c2_class' => 'aieo-ff-cb-costed', 'run_class' => 'aieo-ff-cb-run', 'run_label' => __('Backfill costs', 'ai-eshop-optimizer'), 'idle_label' => __('all costed', 'ai-eshop-optimizer'), ); } $this->ff_backfill_table('aieo-ff-backfill-costs', $aieo_cb_rows, __('Missing', 'ai-eshop-optimizer'), __('Costed', 'ai-eshop-optimizer')); echo ''; echo ' '; echo \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-migrate-audit-out')) . '
'; $aieo_ffm_rows = array(); foreach ($steps as $key => $label) { $p = isset($progress[$key]) && is_array($progress[$key]) ? $progress[$key] : array(); $aieo_ffm_rows[] = array( 'step' => (string) $key, 'label' => (string) $label, 'created' => (int) ($p['created'] ?? 0), 'skipped' => (int) ($p['skipped'] ?? 0), 'total' => (int) ($p['total'] ?? 0), 'done' => !empty($p['done']), ); } AIEO_Table::render(array( 'id' => 'aieo-ff-migrate-table', 'page_id' => 'fulfillment', 'row_key' => 'step', 'sortable' => false, 'rows' => $aieo_ffm_rows, 'row_data' => array('step' => 'step'), 'columns' => array( array('key' => 'label', 'label' => __('Step', 'ai-eshop-optimizer'), 'type' => 'text', 'width' => '42%'), array( 'key' => 'created', 'label' => __('Created', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '14%', 'format' => array('render' => static function ($r) { return '' . (int) $r['created'] . ''; }), ), array( 'key' => 'skipped', 'label' => __('Skipped', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '14%', 'format' => array('render' => static function ($r) { return '' . (int) $r['skipped'] . ''; }), ), array( 'key' => 'progress', 'label' => __('Progress', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '18%', 'format' => array('render' => static function ($r) { $proc = $r['created'] + $r['skipped']; $pct = ($r['total'] > 0) ? min(100, (int) floor(100 * $proc / $r['total'])) : ($r['done'] ? 100 : 0); return \Rooster\Admin\Ui::progress((float) $pct, 100, array('tone' => 'success')) . '' . esc_html(sprintf('%d / %d', $proc, $r['total'])) . ''; }), ), array( 'key' => 'act', 'label' => __('Action', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '12%', 'format' => array('render' => static function ($r) { return ''; }), ), ), )); echo '' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-migrate-status')) . '
'; echo \Rooster\Admin\Ui::fold( __('Advanced — batch size', 'ai-eshop-optimizer'), AIEO_Kit::row( __('Rows per batch (50–5000)', 'ai-eshop-optimizer'), AIEO_Kit::input('', '500', array('id' => 'aieo-ff-migrate-batch-size', 'type' => 'number', 'xs' => true, 'min' => '50', 'max' => '5000')), __('Larger batches finish faster but use more memory per request. 500 is safe at default memory limits.', 'ai-eshop-optimizer'), array('id' => 'aieo-ff-migrate-batch-size') ) ); echo $aieo_ff_close; $this->print_migration_js($nonce); } private function print_migration_js($nonce) { $ajax = admin_url('admin-ajax.php'); ?>