__('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 '
'; echo '

' . esc_html__('Vouchers', 'ai-eshop-optimizer') . '

'; if (empty($vouchers)) { echo '

' . esc_html__('No vouchers yet for this order.', 'ai-eshop-optimizer') . '

'; } else { $vrows = array(); foreach ($vouchers as $v) { $carrier_code = (string) $v['carrier']; $driver = isset($couriers[$carrier_code]) ? $couriers[$carrier_code] : null; $caps = $driver ? $driver->capabilities() : array(); $vno = (string) $v['voucher_no']; $tracking_url = $driver ? $driver->tracking_url($vno) : ''; $carrier_label = $driver ? $driver->label() : strtoupper($carrier_code); $status = (string) $v['status']; $items = array(); if (in_array('print', $caps, true)) { $items[] = array('label' => __('Print', 'ai-eshop-optimizer'), 'attrs' => array('data-aieo-ff-action' => 'print')); } if (in_array('track', $caps, true)) { $items[] = array('label' => __('Track', 'ai-eshop-optimizer'), 'attrs' => array('data-aieo-ff-action' => 'track', 'data-mode' => 'simple')); $items[] = array('label' => __('Detailed', 'ai-eshop-optimizer'), 'description' => __('Full checkpoint history', 'ai-eshop-optimizer'), 'attrs' => array('data-aieo-ff-action' => 'track', 'data-mode' => 'full')); } if (in_array('cancel', $caps, true)) { $items[] = array('label' => __('Cancel', 'ai-eshop-optimizer'), 'attrs' => array('data-aieo-ff-action' => 'cancel')); } $items[] = array('label' => __('Remove', 'ai-eshop-optimizer'), 'destructive' => true, 'attrs' => array('data-aieo-ff-action' => 'delete')); $vrows[] = array( '_attrs' => array('data-voucher-id' => (int) $v['id']), '_name' => $carrier_label . ' ' . $vno, 'carrier' => !empty($v['carrier_account']) ? array('title' => $carrier_label, 'sub' => (string) $v['carrier_account']) : $carrier_label, 'voucher' => '' !== $tracking_url ? array('html' => '' . esc_html($vno) . '') : $vno, 'status' => array('label' => $status, 'tone' => 'delivered' === $status ? 'success' : (in_array($status, array('cancelled', 'canceled', 'failed', 'error'), true) ? 'destructive' : 'mono')), 'created' => !empty($v['created_at']) ? mysql2date(get_option('date_format') . ' ' . get_option('time_format'), $v['created_at']) : '', 'actions' => \Rooster\Admin\Guidance::row_buttons($items, $carrier_label . ' ' . $vno), ); } echo \Rooster\Admin\Ui::table( array( array('key' => 'carrier', 'label' => __('Carrier', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'voucher', 'label' => __('Voucher', 'ai-eshop-optimizer'), 'type' => 'code', 'width' => 'sm'), array('key' => 'status', 'label' => __('Status', 'ai-eshop-optimizer'), 'type' => 'status'), array('key' => 'created', 'label' => __('Created', 'ai-eshop-optimizer'), 'type' => 'date'), array('key' => 'actions', 'label' => __('Actions', 'ai-eshop-optimizer'), 'type' => 'actions'), ), $vrows, array('id' => 'aieo-ff-vouchers-table', 'caption' => __('Vouchers', 'ai-eshop-optimizer')) ); } echo '
' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-status')) . '
'; echo '

' . esc_html__('Add a voucher', 'ai-eshop-optimizer') . '

'; $default_carrier = (string) $this->get_carrier_for_order($order); if ($default_carrier === '' || !isset($couriers[$default_carrier])) { $default_carrier = (string) AIEO_Fulfillment_Settings::get('default_carrier', ''); } if ($default_carrier === '' || !isset($couriers[$default_carrier])) { $default_carrier = (string) array_key_first($couriers); } $dropdown_couriers = array(); $active = $this->active_couriers(); foreach ($couriers as $code => $driver) { if (defined('AIEO_Courier_Driver_Custom::PREFIX') && strpos((string) $code, AIEO_Courier_Driver_Custom::PREFIX) === 0) { $dropdown_couriers[$code] = $driver; continue; } if (isset($active[$code])) { $dropdown_couriers[$code] = $driver; } } if (empty($dropdown_couriers)) { $dropdown_couriers = $couriers; } if ($default_carrier === '' || !isset($dropdown_couriers[$default_carrier])) { $default_carrier = (string) array_key_first($dropdown_couriers); } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; $cod_prefill = '0'; if ($order instanceof WC_Order && !$order->get_date_paid()) { $cod_prefill = (string) apply_filters( 'aieo_ff_cod_prefill_amount', (float) $order->get_total(), $order ); $cod_prefill = number_format((float) $cod_prefill, 2, '.', ''); } echo ''; echo ''; $today_local = wp_date('Y-m-d'); echo ''; echo '
' . esc_html__('Carrier', 'ai-eshop-optimizer') . ''; echo ''; echo ' ' . esc_html__('Manage carriers…', 'ai-eshop-optimizer') . ''; echo '
' . esc_html__('Tracking number', 'ai-eshop-optimizer') . ''; echo ''; echo '

' . esc_html__('Optional — leave blank to auto-generate via the carrier API. Fill in if you already have a voucher number issued elsewhere (we will skip the API call and just record it).', 'ai-eshop-optimizer') . '

'; echo ''; echo '
' . esc_html__('Pieces', 'ai-eshop-optimizer') . '
' . esc_html__('Weight (kg)', 'ai-eshop-optimizer') . '
' . esc_html__('COD amount', 'ai-eshop-optimizer') . ''; echo '

' . esc_html__('Order total — includes shipping and any COD fee. Set to 0 for prepaid orders.', 'ai-eshop-optimizer') . '

' . esc_html__('Services', 'ai-eshop-optimizer') . '
' . esc_html__('Pickup date', 'ai-eshop-optimizer') . '' . '' . ' ' . esc_html__('When the courier picks the parcel up from you. Defaults to today — change only to pre-schedule.', 'ai-eshop-optimizer') . '
'; echo '
'; echo \Rooster\Admin\Ui::button( __('Generate voucher', 'ai-eshop-optimizer'), array('variant' => 'primary', 'icon' => 'truck', 'attrs' => array('id' => 'aieo-ff-create-btn', 'data-label-api' => __('Generate voucher', 'ai-eshop-optimizer'), 'data-label-manual' => __('Save tracking', 'ai-eshop-optimizer'))) ); echo \Rooster\Admin\Ui::button( __('Generate voucher + mark order completed', 'ai-eshop-optimizer'), array('icon' => 'circle-check', 'attrs' => array('id' => 'aieo-ff-create-complete-btn', 'data-label-api' => __('Generate voucher + mark order completed', 'ai-eshop-optimizer'), 'data-label-manual' => __('Save tracking + mark order completed', 'ai-eshop-optimizer'))) ); echo \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-create-status')) . '
'; if (class_exists('AIEO_Fulfillment_Documents')) { $docs = AIEO_Fulfillment_Documents::get_for_order($order_id); $doc_classes = $this->documents(); echo '
'; echo '

' . esc_html__('Documents', 'ai-eshop-optimizer') . '

'; if (!empty($docs)) { $drows = array(); foreach ($docs as $d) { $label = isset($doc_classes[$d['type']]) ? $doc_classes[$d['type']]->label() : $d['type']; $has_pdf = !empty($d['pdf_path']); $drows[] = array( '_attrs' => array('data-document-id' => (int) $d['id']), '_name' => $label . ' ' . (string) $d['number'], 'type' => $label, 'number' => (string) $d['number'], 'issued' => !empty($d['issued_at']) ? mysql2date(get_option('date_format'), $d['issued_at']) : '', 'actions' => \Rooster\Admin\Guidance::row_buttons(array( array('label' => $has_pdf ? __('Open PDF', 'ai-eshop-optimizer') : __('Generate PDF', 'ai-eshop-optimizer'), 'attrs' => array('data-aieo-ff-doc-action' => 'open')), array('label' => __('Regenerate', 'ai-eshop-optimizer'), 'attrs' => array('data-aieo-ff-doc-action' => 'regenerate')), array('label' => __('Delete', 'ai-eshop-optimizer'), 'destructive' => true, 'attrs' => array('data-aieo-ff-doc-action' => 'delete')), ), $label . ' ' . (string) $d['number']), ); } echo \Rooster\Admin\Ui::table( array( array('key' => 'type', 'label' => __('Type', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'number', 'label' => __('Number', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'issued', 'label' => __('Issued', 'ai-eshop-optimizer'), 'type' => 'date'), array('key' => 'actions', 'label' => __('Actions', 'ai-eshop-optimizer'), 'type' => 'actions'), ), $drows, array('id' => 'aieo-ff-documents-table', 'caption' => __('Documents', 'ai-eshop-optimizer')) ); } else { echo '

' . esc_html__('No documents generated yet.', 'ai-eshop-optimizer') . '

'; } $issued = array(); foreach ((array) $docs as $d) { $issued[(string) $d['type']] = true; } $ordered = array(); foreach (array('packing-slip', 'invoice', 'proforma', 'credit-note') as $type) { if (isset($doc_classes[$type])) { $ordered[$type] = $doc_classes[$type]; } } $ordered += $doc_classes; echo '
'; echo '' . esc_html__('Issue a document', 'ai-eshop-optimizer') . ''; foreach ($ordered as $type => $doc) { $attrs = array('data-aieo-ff-create-doc' => $type); if (isset($issued[$type])) { $attrs['title'] = sprintf(__('Already issued for this order: %s. Generating again issues a new one.', 'ai-eshop-optimizer'), $doc->label()); } echo \Rooster\Admin\Ui::button($doc->label(), array('icon' => self::doc_icon($type), 'done' => isset($issued[$type]), 'attrs' => $attrs)); } echo \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-doc-status')); echo '
'; $engine_class = AIEO_Fulfillment_PDF_Engine::available_class(); if ($engine_class === '') { echo $this->meta_box_notice(esc_html__('Warning: no DOMPDF library detected. Document generation will fail until Phase 10 vendors a copy. (On Vultr today this is auto-resolved via the WPO core plugin.)', 'ai-eshop-optimizer'), 'warning'); } } 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 '
'; $ps_printed = class_exists('AIEO_Fulfillment_Documents') && AIEO_Fulfillment_Documents::has_printed_for_order($order_id, 'packing-slip'); $this->render_ff_doc_button($order_id, 'packing-slip', __('Packing slip', 'ai-eshop-optimizer'), $ps_printed); $this->render_ff_voucher_block($order_id); $inv_printed = class_exists('AIEO_Fulfillment_Documents') && AIEO_Fulfillment_Documents::has_printed_for_order($order_id, 'invoice'); $this->render_ff_doc_button($order_id, 'invoice', __('Invoice', 'ai-eshop-optimizer'), $inv_printed); echo '
'; } private function render_ff_doc_button($order_id, $type, $label, $printed) { $again = sprintf(__('%s already printed — click to re-print', 'ai-eshop-optimizer'), $label); echo \Rooster\Admin\Ui::button( $label, array( 'size' => 'sm', 'block' => true, 'icon' => self::doc_icon($type), 'done' => (bool) $printed, 'class' => 'aieo-ff-print-doc', 'attrs' => array( 'data-order-id' => (int) $order_id, 'data-doc-type' => $type, 'data-title-done' => $again, 'title' => $printed ? $again : sprintf(__('Generate + print %s', 'ai-eshop-optimizer'), $label), ), ) ); } private static function doc_icon($type) { $icons = array( 'packing-slip' => 'package', 'invoice' => 'receipt', 'proforma' => 'file-text', 'credit-note' => 'rotate-ccw', ); return isset($icons[$type]) ? $icons[$type] : 'file-text'; } public function orders_list_voucher_modal() { $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; } $couriers = $this->linked_carriers(); if (empty($couriers)) { return; } ?> linked_carriers(); if (empty($couriers)) { return; } $preselect = $this->get_carrier_for_order($order_id); $attrs = array('data-order-id' => (int) $order_id); if ($preselect !== '') { $attrs['data-preselect-carrier'] = $preselect; } echo \Rooster\Admin\Ui::button(__('Voucher', 'ai-eshop-optimizer'), array('size' => 'sm', 'block' => true, 'icon' => 'truck', 'class' => 'aieo-ff-voucher-trigger', 'attrs' => $attrs)); return; } $couriers = $this->couriers(); $upload = wp_upload_dir(null, false); $base_url = (string) $upload['baseurl']; $order = function_exists('wc_get_order') ? wc_get_order($order_id) : null; $edit_url = ($order instanceof WC_Order ? $order->get_edit_order_url() : admin_url('post.php?post=' . (int) $order_id . '&action=edit')) . '#aieo_fulfillment_meta_box'; foreach ($vouchers as $v) { $code = (string) $v['carrier']; $vno = (string) $v['voucher_no']; $driver = isset($couriers[$code]) ? $couriers[$code] : null; $carrier_lbl = $driver ? $driver->label() : strtoupper($code); $tracking = $driver ? $driver->tracking_url($vno) : ''; $label_pdf = !empty($v['label_pdf_path']) ? $base_url . '/' . ltrim((string) $v['label_pdf_path'], '/') : ''; $vno_short = strlen($vno) > 12 ? '…' . substr($vno, -8) : $vno; $does = $label_pdf !== '' ? __('Print label', 'ai-eshop-optimizer') : ($driver && $driver->has_capability('print') ? __('Fetch + print', 'ai-eshop-optimizer') : ''); $attrs = array('title' => $carrier_lbl . ' ' . $vno . ('' !== $does ? ': ' . $does : '')); if ($label_pdf !== '') { $attrs += array('target' => '_blank', 'rel' => 'noopener'); } echo \Rooster\Admin\Ui::button(__('Voucher', 'ai-eshop-optimizer'), array('size' => 'sm', 'block' => true, 'icon' => 'truck', 'done' => true, 'href' => $label_pdf !== '' ? $label_pdf : $edit_url, 'attrs' => $attrs)); $status_tone = ($v['status'] === 'delivered') ? 'ok' : (($v['status'] === 'returned' || $v['status'] === 'canceled' || $v['status'] === 'failed') ? 'err' : 'muted'); echo ''; echo ''; echo esc_html($carrier_lbl) . ' '; if ($tracking !== '') { 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); ?> <?php echo $title; ?>
get_status(), $skip_states, true)) { $skipped++; continue; } $o->update_status('completed', __('Bulk-marked completed from orders list.', 'ai-eshop-optimizer')); $changed++; unset($o); if (function_exists('wp_cache_flush_runtime')) { wp_cache_flush_runtime(); } } return add_query_arg(array('aieo_ff_bulk_msg' => 'completed', 'aieo_ff_bulk_n' => $changed, 'aieo_ff_bulk_skipped' => $skipped), $redirect_to); } if ($action === 'aieo_ff_generate_vouchers' || $action === 'aieo_ff_generate_print_complete') { AIEO_Fulfillment_Bulk::raise_limits(600); $channel = isset($_GET['aieo_channel']) ? sanitize_text_field(wp_unslash((string) $_GET['aieo_channel'])) : ''; if (strpos($channel, 'c:') !== 0) { return add_query_arg(array('aieo_ff_bulk_msg' => 'no_carrier_filter'), $redirect_to); } $carrier_slug = substr($channel, 2); $couriers = $this->couriers(); if (!isset($couriers[$carrier_slug])) { return add_query_arg(array('aieo_ff_bulk_msg' => 'unknown_carrier', 'aieo_ff_bulk_carrier' => $carrier_slug), $redirect_to); } $driver = $couriers[$carrier_slug]; $create_mode = method_exists($driver, 'create_mode') ? (string) $driver->create_mode() : 'api'; if ($create_mode !== 'api') { return add_query_arg(array('aieo_ff_bulk_msg' => 'carrier_no_api', 'aieo_ff_bulk_carrier' => $carrier_slug), $redirect_to); } global $wpdb; $cfg_raw = (string) $wpdb->get_var($wpdb->prepare( "SELECT setting_value FROM {$wpdb->prefix}aieo_dmm_fulfillment_settings WHERE setting_key = %s", 'courier_' . $carrier_slug )); $cfg = $cfg_raw !== '' ? json_decode($cfg_raw, true) : array(); $cfg = is_array($cfg) ? $cfg : array(); $def_pieces = isset($cfg['default_pieces']) ? max(1, (int) $cfg['default_pieces']) : 1; $def_weight = isset($cfg['default_weight']) ? (float) $cfg['default_weight'] : 0.5; $def_services = isset($cfg['default_services']) ? (string) $cfg['default_services'] : ''; $created = 0; $skipped = 0; $errors = array(); $print_voucher_ids = array(); foreach ($order_ids as $oid) { $order = wc_get_order($oid); if (!$order) { $skipped++; continue; } $has_existing = false; foreach (AIEO_Fulfillment_Vouchers::get_for_order($oid) as $v) { if ((string) $v['carrier'] === $carrier_slug) { $has_existing = true; $print_voucher_ids[] = (int) $v['id']; break; } } if ($has_existing) { $skipped++; continue; } $payload = $this->build_voucher_payload($order); $payload['pieces'] = $def_pieces; $payload['weight'] = $def_weight; $payload['cod_amount'] = 0.0; $payload['services_csv'] = $def_services; $result = $driver->create_voucher($payload); if (empty($result['ok'])) { $errors[] = $oid . ':' . (isset($result['error']) ? substr((string) $result['error'], 0, 60) : 'err'); continue; } $vno = (string) ($result['voucher_no'] ?? ''); if ($vno === '') { $errors[] = $oid . ':no_vno'; continue; } $stored = AIEO_Fulfillment_Vouchers::create(array( 'order_id' => $oid, 'carrier' => $carrier_slug, 'voucher_no' => $vno, 'pieces' => $def_pieces, 'weight' => $def_weight, 'services_csv'=> $def_services, 'status' => 'created', 'status_at' => current_time('mysql', true), 'customer_shipping_paid' => (float) $order->get_shipping_total(), )); if (!empty($stored['ok'])) { $created++; $print_voucher_ids[] = (int) $stored['id']; } unset($order, $result, $stored, $payload); if (function_exists('wp_cache_flush_runtime')) { wp_cache_flush_runtime(); } } if ($action === 'aieo_ff_generate_vouchers') { return add_query_arg(array( 'aieo_ff_bulk_msg' => 'vouchers_generated', 'aieo_ff_bulk_n' => $created, 'aieo_ff_bulk_skipped' => $skipped, 'aieo_ff_bulk_errors' => count($errors), 'aieo_ff_bulk_carrier' => $carrier_slug, ), $redirect_to); } $upload_dir = wp_upload_dir(); $base_url = trailingslashit($upload_dir['baseurl']); $pdf_urls = array(); $missing = array(); $skip_status = array('completed', 'refunded', 'cancelled', 'failed', 'trash'); $completed_n = 0; foreach ($order_ids as $oid) { $order = wc_get_order($oid); if (!$order) { continue; } $found_pdf = false; foreach (AIEO_Fulfillment_Vouchers::get_for_order($oid) as $v) { if ((string) $v['carrier'] !== $carrier_slug) { continue; } if (!empty($v['label_pdf_path']) && file_exists(trailingslashit($upload_dir['basedir']) . ltrim((string) $v['label_pdf_path'], '/'))) { $pdf_urls[] = $base_url . ltrim((string) $v['label_pdf_path'], '/'); $found_pdf = true; continue; } $r = $driver->get_label_pdf((string) $v['voucher_no'], 'A4'); if (empty($r['ok']) || empty($r['pdf'])) { continue; } $cached = $this->cache_label_pdf((int) $v['order_id'], (int) $v['id'], $carrier_slug, (string) $v['voucher_no'], (string) $r['pdf']); unset($r); if ($cached) { $pdf_urls[] = $cached; $found_pdf = true; } } if (!$found_pdf) { $missing[] = (int) $oid; continue; } if (!in_array((string) $order->get_status(), $skip_status, true)) { $order->update_status('completed', __('Bulk-generated voucher + printed + completed.', 'ai-eshop-optimizer')); $completed_n++; } unset($order); if (function_exists('wp_cache_flush_runtime')) { wp_cache_flush_runtime(); } } if (empty($pdf_urls)) { return add_query_arg(array( 'aieo_ff_bulk_msg' => 'no_labels_after_generate', 'aieo_ff_bulk_n' => $created, 'aieo_ff_bulk_carrier' => $carrier_slug, ), $redirect_to); } $token = wp_generate_password(20, false); set_transient('aieo_ff_a4x3_' . $token, array( 'urls' => $pdf_urls, 'voucher_ids' => array_values(array_unique($print_voucher_ids)), 'missing' => $missing, ), 10 * MINUTE_IN_SECONDS); foreach ($print_voucher_ids as $vid) { AIEO_Fulfillment_Vouchers::mark_printed((int) $vid); } return admin_url('admin.php?aieo_ff_print_a4x3=' . $token); } if ($action === 'aieo_ff_print_labels_a4x3') { AIEO_Fulfillment_Bulk::raise_limits(300); $upload_dir = wp_upload_dir(); $base_url = trailingslashit($upload_dir['baseurl']); $pdf_urls = array(); $voucher_ids = array(); $missing = array(); $couriers = $this->couriers(); foreach ($order_ids as $oid) { $vouchers = AIEO_Fulfillment_Vouchers::get_for_order($oid); if (empty($vouchers)) { $order = wc_get_order($oid); if ($order) { $items = $order->get_meta('_wc_shipment_tracking_items'); if (is_array($items)) { foreach ($items as $item) { if (!is_array($item)) { continue; } $tn = trim((string) ($item['tracking_number'] ?? '')); if ($tn === '') { continue; } $provider = trim((string) ($item['tracking_provider'] ?? ($item['custom_tracking_provider'] ?? ''))); $carrier = 'manual'; $pl = mb_strtolower($provider); if (strpos($pl, 'geniki') !== false || strpos($pl, 'γενικ') !== false || strpos($pl, 'ταχυδρομικ') !== false || strpos($pl, 'taxydromik') !== false) { $carrier = 'geniki'; } $existing = AIEO_Fulfillment_Vouchers::find_by_voucher_no($carrier, $tn); if ($existing) { continue; } $shipped_ts = (int) ($item['date_shipped'] ?? 0); AIEO_Fulfillment_Vouchers::create(array( 'order_id' => (int) $oid, 'carrier' => $carrier, 'voucher_no' => $tn, 'carrier_account' => $carrier === 'manual' ? $provider : '', 'status' => 'in_transit', 'pieces' => 1, 'services_csv' => '', 'created_at' => $shipped_ts > 0 ? gmdate('Y-m-d H:i:s', $shipped_ts) : current_time('mysql', true), )); } $vouchers = AIEO_Fulfillment_Vouchers::get_for_order($oid); } } } $found = false; foreach ($vouchers as $v) { if (!empty($v['label_pdf_path'])) { $abs = trailingslashit($upload_dir['basedir']) . ltrim((string) $v['label_pdf_path'], '/'); if (file_exists($abs)) { $pdf_urls[] = $base_url . ltrim((string) $v['label_pdf_path'], '/'); $voucher_ids[] = (int) $v['id']; $found = true; continue; } } $carrier = (string) $v['carrier']; $driver = isset($couriers[$carrier]) ? $couriers[$carrier] : null; if (!$driver || !$driver->has_capability('print')) { continue; } $r = $driver->get_label_pdf((string) $v['voucher_no'], 'A4'); if (empty($r['ok']) || empty($r['pdf'])) { continue; } $cached = $this->cache_label_pdf((int) $v['order_id'], (int) $v['id'], $carrier, (string) $v['voucher_no'], (string) $r['pdf']); unset($r); if ($cached) { $pdf_urls[] = $cached; $voucher_ids[] = (int) $v['id']; $found = true; } } if (!$found) { $missing[] = (int) $oid; } if (function_exists('wp_cache_flush_runtime')) { wp_cache_flush_runtime(); } } if (empty($pdf_urls)) { return add_query_arg(array('aieo_ff_bulk_msg' => 'no_labels', 'aieo_ff_bulk_n' => 0), $redirect_to); } $token = wp_generate_password(20, false); set_transient('aieo_ff_a4x3_' . $token, array( 'urls' => $pdf_urls, 'voucher_ids' => $voucher_ids, 'missing' => $missing, ), 10 * MINUTE_IN_SECONDS); foreach ($voucher_ids as $vid) { AIEO_Fulfillment_Vouchers::mark_printed($vid); } return admin_url('admin.php?aieo_ff_print_a4x3=' . $token); } if ($action === 'aieo_ff_print_labels') { AIEO_Fulfillment_Bulk::raise_limits(300); $voucher_ids = array(); foreach ($order_ids as $oid) { foreach (AIEO_Fulfillment_Vouchers::get_for_order($oid) as $v) { if (!empty($v['label_pdf_path'])) { $voucher_ids[] = (int) $v['id']; } } } if (empty($voucher_ids)) { return add_query_arg(array('aieo_ff_bulk_msg' => 'no_labels', 'aieo_ff_bulk_n' => 0), $redirect_to); } $r = AIEO_Fulfillment_Bulk::zip_voucher_labels($voucher_ids); if (empty($r['ok']) || empty($r['zip_url'])) { return add_query_arg(array('aieo_ff_bulk_msg' => 'zip_failed', 'aieo_ff_bulk_err' => isset($r['error']) ? rawurlencode((string) $r['error']) : ''), $redirect_to); } foreach ($voucher_ids as $vid) { AIEO_Fulfillment_Vouchers::mark_printed((int) $vid); } return add_query_arg(array( 'aieo_ff_bulk_msg' => 'zip_ready', 'aieo_ff_bulk_n' => (int) $r['count'], 'aieo_ff_zip_url' => rawurlencode((string) $r['zip_url']), ), $redirect_to); } $type = null; if ($action === 'aieo_ff_print_packing_slips') { $type = 'packing-slip'; } if ($action === 'aieo_ff_print_invoices') { $type = 'invoice'; } if ($type !== null) { AIEO_Fulfillment_Bulk::raise_limits(600); $r = AIEO_Fulfillment_Bulk::merge_documents($order_ids, $type); if (empty($r['ok']) || empty($r['pdf_url'])) { return add_query_arg(array( 'aieo_ff_bulk_msg' => 'merge_failed', 'aieo_ff_bulk_type' => $type, 'aieo_ff_bulk_err' => isset($r['error']) ? rawurlencode((string) $r['error']) : '', ), $redirect_to); } foreach ($order_ids as $oid) { $existing = AIEO_Fulfillment_Documents::get_for_order_by_type((int) $oid, $type); if ($existing && !empty($existing['id'])) { AIEO_Fulfillment_Documents::mark_printed((int) $existing['id']); } } return add_query_arg(array( 'aieo_ff_bulk_msg' => 'merge_ready', 'aieo_ff_bulk_type' => $type, 'aieo_ff_bulk_n' => (int) ($r['merged'] ?? 0), 'aieo_ff_pdf_url' => rawurlencode((string) $r['pdf_url']), ), $redirect_to); } return $redirect_to; } public function orders_list_bulk_notice() { if (empty($_GET['aieo_ff_bulk_msg'])) { return; } $msg = sanitize_key((string) wp_unslash( $_GET['aieo_ff_bulk_msg'] )); $n = isset($_GET['aieo_ff_bulk_n']) ? (int) sanitize_text_field( wp_unslash( $_GET['aieo_ff_bulk_n'] ) ) : 0; $zip_url = isset($_GET['aieo_ff_zip_url']) ? esc_url_raw(urldecode((string) wp_unslash( $_GET['aieo_ff_zip_url'] ))) : ''; $pdf_url = isset($_GET['aieo_ff_pdf_url']) ? esc_url_raw(urldecode((string) wp_unslash( $_GET['aieo_ff_pdf_url'] ))) : ''; $type = isset($_GET['aieo_ff_bulk_type']) ? sanitize_key((string) $_GET['aieo_ff_bulk_type']) : ''; $err = isset($_GET['aieo_ff_bulk_err']) ? sanitize_text_field(urldecode((string) wp_unslash( $_GET['aieo_ff_bulk_err'] ))) : ''; if ($msg === 'zip_ready' && $zip_url !== '') { echo '

' . sprintf(esc_html__('Labels ZIP ready (%d files).', 'ai-eshop-optimizer'), $n) . ' ' . '' . esc_html__('Download ZIP', 'ai-eshop-optimizer') . '' . '

'; return; } if ($msg === 'merge_ready' && $pdf_url !== '') { $type_label = $type === 'packing-slip' ? __('packing slips', 'ai-eshop-optimizer') : ($type === 'invoice' ? __('invoices', 'ai-eshop-optimizer') : __('documents', 'ai-eshop-optimizer')); echo '

' . 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) . '' : '') . '

'; return; } if ($msg === 'merge_failed') { echo '

' . esc_html__('Could not build the merged PDF.', 'ai-eshop-optimizer') . ($err !== '' ? ' ' . esc_html($err) . '' : '') . '

'; return; } } public function ajax_close_pending_now() { $this->verify_admin_ajax(); $couriers = $this->couriers(); $results = array(); foreach ($couriers as $code => $driver) { $has_close = method_exists($driver, 'has_capability') && $driver->has_capability('close_pending'); $has_manifest = method_exists($driver, 'has_capability') && $driver->has_capability('manifest'); if (!$has_close && !$has_manifest) { continue; } try { $r = class_exists('AIEO_Fulfillment_Closings') ? AIEO_Fulfillment_Closings::close_and_log($driver, 'now') : ($has_close ? $driver->close_pending() : array('ok' => false, 'error' => 'no closings helper')); $results[$code] = !empty($r['ok']) ? 'ok' : ('err: ' . (isset($r['error']) ? $r['error'] : 'unknown')); } catch (\Throwable $e) { $results[$code] = 'throw: ' . $e->getMessage(); } } if (empty($results)) { wp_send_json_error(array('message' => __('No carrier has an end-of-day API close.', 'ai-eshop-optimizer'))); } $msg = array(); foreach ($results as $code => $r) { $msg[] = $code . '=' . $r; } wp_send_json_success(array('message' => implode(' · ', $msg))); } public function ajax_toggle_auto_close() { $this->verify_admin_ajax(); $enabled = !empty($_POST['enabled']); AIEO_Fulfillment_Settings::set('auto_close_enabled', $enabled); if (class_exists('AIEO_Fulfillment_Cron') && 'show' === get_option(AIEO_Fulfillment_Cron::NOTICE_OPTION, '')) { update_option(AIEO_Fulfillment_Cron::NOTICE_OPTION, 'dismissed', false); } wp_send_json_success(array( 'enabled' => $enabled, 'message' => $enabled ? __('Auto-close enabled. The daily cron reconciles every carrier — an end-of-day API close for those that have one (Geniki), a local dispatch manifest for those that don\'t (e.g. Courier Center).', 'ai-eshop-optimizer') : __('Auto-close disabled. You will need to close pending jobs manually each day.', 'ai-eshop-optimizer'), )); } public function ajax_close_pending_by_date() { $this->verify_admin_ajax(); $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'])) : ''; if ($date_from === '' || $date_to === '') { wp_send_json_error(array('message' => __('date_from and date_to are required.', 'ai-eshop-optimizer'))); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to)) { wp_send_json_error(array('message' => __('Dates must be YYYY-MM-DD.', 'ai-eshop-optimizer'))); } if (strcmp($date_from, $date_to) > 0) { wp_send_json_error(array('message' => __('date_from must be on or before date_to.', 'ai-eshop-optimizer'))); } $from_iso = $date_from . 'T00:00:00'; $to_iso = $date_to . 'T23:59:59'; $couriers = $this->couriers(); $results = array(); foreach ($couriers as $code => $driver) { if (!$driver->has_capability('close_pending_by_date')) { continue; } try { $r = $driver->close_pending_by_date($from_iso, $to_iso); $results[$code] = !empty($r['ok']) ? 'ok' : ('err: ' . (isset($r['error']) ? $r['error'] : 'unknown')); } catch (\Throwable $e) { $results[$code] = 'throw: ' . $e->getMessage(); } } if (empty($results)) { wp_send_json_error(array('message' => __('No driver advertises close_pending_by_date.', 'ai-eshop-optimizer'))); } $msg = array(); foreach ($results as $code => $r) { $msg[] = $code . '=' . $r; } wp_send_json_success(array( 'message' => sprintf(__('Closed range %1$s → %2$s · %3$s', 'ai-eshop-optimizer'), $date_from, $date_to, implode(' · ', $msg)), 'date_from' => $date_from, 'date_to' => $date_to, )); } public function ajax_close_and_print() { $this->verify_admin_ajax(); if (!class_exists('AIEO_Fulfillment_Closings')) { wp_send_json_error(array('message' => 'closings class unavailable')); } $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : 'geniki'; $mode = isset($_POST['mode']) ? sanitize_key((string) wp_unslash($_POST['mode'])) : 'now'; $date_from = isset($_POST['date_from']) ? sanitize_text_field((string) wp_unslash($_POST['date_from'])) : null; $date_to = isset($_POST['date_to']) ? sanitize_text_field((string) wp_unslash($_POST['date_to'])) : null; if ($mode === 'by_date') { if (!$date_from || !$date_to || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) || strcmp($date_from, $date_to) > 0) { wp_send_json_error(array('message' => __('Pick a valid date range (YYYY-MM-DD).', 'ai-eshop-optimizer'))); } } else { $mode = 'now'; $date_from = null; $date_to = null; } $couriers = $this->couriers(); if (empty($couriers[$carrier])) { wp_send_json_error(array('message' => sprintf(__('Unknown carrier: %s.', 'ai-eshop-optimizer'), $carrier))); } $r = AIEO_Fulfillment_Closings::close_and_log($couriers[$carrier], $mode, $date_from, $date_to); if (empty($r['ok']) || empty($r['id'])) { wp_send_json_error(array( 'message' => sprintf( __('Carrier %1$s close failed: %2$s', 'ai-eshop-optimizer'), $carrier, isset($r['error']) ? $r['error'] : 'unknown' ), )); } $pdf_rel = AIEO_Fulfillment_Closings::ensure_pdf((int) $r['id']); if (is_wp_error($pdf_rel)) { wp_send_json_error(array( 'message' => sprintf( __('Closed %s, but manifest PDF failed:', 'ai-eshop-optimizer'), $carrier ) . ' ' . $pdf_rel->get_error_message(), )); } wp_send_json_success(array( 'message' => sprintf( __('Closed %1$d voucher(s) for %2$s.', 'ai-eshop-optimizer'), (int) $r['voucher_count'], $carrier ), 'closing_id' => (int) $r['id'], 'pdf_url' => $this->build_manifest_url((int) $r['id']), 'voucher_count' => (int) $r['voucher_count'], )); } public function ajax_reprint_last_closing() { $this->verify_admin_ajax(); if (!class_exists('AIEO_Fulfillment_Closings')) { wp_send_json_error(array('message' => 'closings class unavailable')); } $carrier = isset($_POST['carrier']) ? sanitize_key((string) wp_unslash($_POST['carrier'])) : 'geniki'; $row = AIEO_Fulfillment_Closings::get_last_for_carrier($carrier); if (!$row) { wp_send_json_error(array( 'message' => sprintf( __('No prior closing found for %s.', 'ai-eshop-optimizer'), $carrier ), )); } $pdf_rel = AIEO_Fulfillment_Closings::ensure_pdf((int) $row['id']); if (is_wp_error($pdf_rel)) { wp_send_json_error(array('message' => $pdf_rel->get_error_message())); } wp_send_json_success(array( 'message' => sprintf( __('Reprinting last %1$s closing (%2$d vouchers, %3$s).', 'ai-eshop-optimizer'), $carrier, (int) $row['voucher_count'], (string) $row['closed_at'] ), 'closing_id' => (int) $row['id'], 'pdf_url' => $this->build_manifest_url((int) $row['id']), )); } public function ajax_download_manifest() { if (!current_user_can('manage_woocommerce')) { status_header(403); echo 'forbidden'; exit; } $closing_id = isset($_GET['closing_id']) ? (int) $_GET['closing_id'] : 0; if ($closing_id <= 0) { status_header(400); echo 'closing_id required'; exit; } if (!class_exists('AIEO_Fulfillment_Closings')) { status_header(500); echo 'closings class unavailable'; exit; } $pdf_rel = AIEO_Fulfillment_Closings::ensure_pdf($closing_id); if (is_wp_error($pdf_rel)) { status_header(500); echo esc_html($pdf_rel->get_error_message()); exit; } $abs = AIEO_Fulfillment_Closings::abs_from_rel($pdf_rel); if (!file_exists($abs)) { status_header(404); echo 'manifest pdf missing'; exit; } nocache_headers(); header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="' . basename($abs) . '"'); header('Content-Length: ' . filesize($abs)); readfile($abs); exit; } private function build_manifest_url($closing_id) { return add_query_arg( array( 'action' => 'aieo_ff_download_manifest', 'closing_id' => (int) $closing_id, ), admin_url('admin-ajax.php') ); } private function build_voucher_payload(WC_Order $order) { $customer_shipping_paid = (float) $order->get_shipping_total(); return array( 'order_id' => $order->get_id(), 'recipient' => array( 'name' => trim($order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name()) ?: trim($order->get_billing_first_name() . ' ' . $order->get_billing_last_name()), 'address' => trim($order->get_shipping_address_1() . ' ' . $order->get_shipping_address_2()) ?: trim($order->get_billing_address_1() . ' ' . $order->get_billing_address_2()), 'city' => $order->get_shipping_city() ?: $order->get_billing_city(), 'zip' => $order->get_shipping_postcode() ?: $order->get_billing_postcode(), 'country' => $order->get_shipping_country() ?: $order->get_billing_country() ?: 'GR', 'phone' => $order->get_billing_phone(), 'email' => $order->get_billing_email(), ), 'reference_1' => (string) $order->get_order_number(), 'cod_amount' => 0, 'customer_shipping_paid' => $customer_shipping_paid, 'charge_party' => AIEO_Courier_Driver_Base::CHARGE_SENDER, ); } private function cache_label_pdf($order_id, $voucher_id, $carrier, $voucher_no, $pdf_bytes) { $upload_dir = wp_upload_dir(null, false); $base = $upload_dir['basedir'] . '/aieo-dmm/fulfillment/labels/' . gmdate('Y/m'); if (!wp_mkdir_p($base)) { return ''; } $filename = sanitize_file_name($carrier . '-' . $voucher_no . '.pdf'); $abs = $base . '/' . $filename; file_put_contents($abs, $pdf_bytes); $rel = 'aieo-dmm/fulfillment/labels/' . gmdate('Y/m') . '/' . $filename; AIEO_Fulfillment_Vouchers::update($voucher_id, array('label_pdf_path' => $rel)); return $upload_dir['baseurl'] . '/' . $rel; } public function couriers() { $drivers = array(); return apply_filters('aieo_fulfillment_couriers', $drivers); } public function active_couriers() { $out = array(); foreach ($this->couriers() as $code => $driver) { $cfg = class_exists('AIEO_Fulfillment_Settings') ? (array) AIEO_Fulfillment_Settings::get('courier_' . $code, array()) : array(); if (!method_exists($driver, 'in_use') ? empty($cfg) : !$driver->in_use($cfg)) { continue; } if (!method_exists($driver, 'has_capability') || !$driver->has_capability('create')) { continue; } $mode = method_exists($driver, 'create_mode') ? (string) $driver->create_mode() : 'api'; $out[$code] = array('driver' => $driver, 'mode' => $mode); } return $out; } public function manual_entry_couriers() { $out = array(); foreach ($this->couriers() as $code => $driver) { if (method_exists($driver, 'has_capability') && $driver->has_capability('create')) { $out[$code] = $driver; } } return $out; } public function has_api_courier_configured() { foreach ($this->active_couriers() as $a) { if ($a['mode'] === 'api') { return true; } } return false; } public function get_carrier_map() { $full = $this->get_carrier_map_full(); $out = array(); foreach ($full as $title => $entry) { if (!empty($entry['carrier'])) { $out[$title] = $entry['carrier']; } } return $out; } public function get_carrier_map_full() { $raw = class_exists('AIEO_Fulfillment_Settings') ? AIEO_Fulfillment_Settings::get('shipping_method_carrier_map', array()) : array(); if (!is_array($raw)) { return array(); } $titles_by_id = array(); $ids_by_title = array(); foreach ($this->get_zone_shipping_methods(true) as $m) { $iid = (int) $m['instance_id']; $t = trim((string) $m['title']); if ($iid > 0) { $titles_by_id[$iid] = $t; } if ($t !== '') { $ids_by_title[$t][] = $iid; } } $out = array(); $needs_resave = false; foreach ($raw as $title => $value) { if (is_int($title) || ctype_digit((string) $title)) { continue; } $title = trim((string) $title); if ($title === '') { continue; } if (!is_array($value)) { $code = sanitize_key((string) $value); if ($code === '') { continue; } $iid = isset($ids_by_title[$title]) && !empty($ids_by_title[$title]) ? (int) $ids_by_title[$title][0] : 0; $value = array( 'carrier' => $code, 'instance_id' => $iid, 'method_id' => '', ); $needs_resave = true; } $carrier = sanitize_key((string) ($value['carrier'] ?? '')); $instance_id = (int) ($value['instance_id'] ?? 0); $method_id = (string) ($value['method_id'] ?? ''); if ($carrier === '') { continue; } $title_exists = isset($ids_by_title[$title]); $iid_exists = $instance_id > 0 && isset($titles_by_id[$instance_id]); if (!$title_exists && $iid_exists) { $new_title = trim($titles_by_id[$instance_id]); if ($new_title !== '' && $new_title !== $title) { $entry = $value; $entry['renamed'] = true; $entry['broken'] = false; $entry['current_title'] = $new_title; $out[$new_title] = $entry; $needs_resave = true; continue; } } $value['renamed'] = false; $value['broken'] = !$title_exists && !$iid_exists; $value['current_title'] = $title_exists ? $title : ($iid_exists ? $titles_by_id[$instance_id] : ''); $out[$title] = $value; } if ($needs_resave && class_exists('AIEO_Fulfillment_Settings')) { $persist = array(); foreach ($out as $title => $entry) { $persist[$title] = array( 'carrier' => (string) $entry['carrier'], 'instance_id' => (int) $entry['instance_id'], 'method_id' => (string) $entry['method_id'], ); } AIEO_Fulfillment_Settings::set('shipping_method_carrier_map', $persist); } return $out; } public function set_carrier_map_entry($title, $carrier_code) { if (!class_exists('AIEO_Fulfillment_Settings')) { return false; } $title = trim((string) $title); if ($title === '') { return false; } $code = sanitize_key((string) $carrier_code); $raw = AIEO_Fulfillment_Settings::get('shipping_method_carrier_map', array()); if (!is_array($raw)) { $raw = array(); } if ($code === '') { unset($raw[$title]); return (bool) AIEO_Fulfillment_Settings::set('shipping_method_carrier_map', $raw); } if (!isset($this->manual_entry_couriers()[$code])) { return false; } $instance_id = 0; $method_id = ''; foreach ($this->get_zone_shipping_methods(true) as $m) { if (trim((string) $m['title']) === $title) { $instance_id = (int) $m['instance_id']; $method_id = (string) $m['method_id']; break; } } $raw[$title] = array( 'carrier' => $code, 'instance_id' => $instance_id, 'method_id' => $method_id, ); return (bool) AIEO_Fulfillment_Settings::set('shipping_method_carrier_map', $raw); } public function get_carrier_for_order($order_id_or_order) { $order = is_object($order_id_or_order) ? $order_id_or_order : (function_exists('wc_get_order') ? wc_get_order((int) $order_id_or_order) : null); if (!$order || !method_exists($order, 'get_shipping_methods')) { return ''; } $map = $this->get_carrier_map(); if (empty($map)) { return ''; } foreach ($order->get_shipping_methods() as $item) { if (!is_object($item)) { continue; } $title = method_exists($item, 'get_method_title') ? (string) $item->get_method_title() : ''; if ($title === '' && method_exists($item, 'get_name')) { $title = (string) $item->get_name(); } $title = trim($title); if ($title !== '' && isset($map[$title])) { return $map[$title]; } } return ''; } public function get_distinct_shipping_titles() { $methods = $this->get_zone_shipping_methods(true); $by_title = array(); foreach ($methods as $m) { $t = trim((string) $m['title']); if ($t === '') { continue; } if (!isset($by_title[$t])) { $by_title[$t] = array( 'title' => $t, 'zones' => array(), 'enabled' => false, 'disabled_count' => 0, ); } $zone_name = (string) $m['zone_name']; if ($zone_name !== '' && !in_array($zone_name, $by_title[$t]['zones'], true)) { $by_title[$t]['zones'][] = $zone_name; } if (!empty($m['enabled'])) { $by_title[$t]['enabled'] = true; } else { $by_title[$t]['disabled_count']++; } } ksort($by_title, SORT_NATURAL | SORT_FLAG_CASE); return $by_title; } public function get_zone_shipping_methods($include_disabled = false) { if (!class_exists('WC_Shipping_Zones')) { return array(); } $out = array(); $zones = WC_Shipping_Zones::get_zones(); $rest = new WC_Shipping_Zone(0); $rest_methods = $rest->get_shipping_methods(false); $rest_zone = array('zone_name' => __('Rest of the world', 'ai-eshop-optimizer'), 'shipping_methods' => $rest_methods); $all = $zones + array(0 => $rest_zone); foreach ($all as $zid => $zone) { $methods = isset($zone['shipping_methods']) ? $zone['shipping_methods'] : array(); $zone_name = (string) ($zone['zone_name'] ?? ''); foreach ($methods as $m) { if (!is_object($m)) { continue; } $enabled = method_exists($m, 'is_enabled') ? (bool) $m->is_enabled() : true; if (!$include_disabled && !$enabled) { continue; } $instance_id = isset($m->instance_id) ? (int) $m->instance_id : (method_exists($m, 'get_instance_id') ? (int) $m->get_instance_id() : 0); if ($instance_id <= 0) { continue; } $title = method_exists($m, 'get_title') ? (string) $m->get_title() : ''; if ($title === '') { $title = isset($m->method_title) ? (string) $m->method_title : ''; } $out[] = array( 'instance_id' => $instance_id, 'method_id' => isset($m->id) ? (string) $m->id : '', 'title' => $title, 'zone_id' => (int) $zid, 'zone_name' => $zone_name, 'enabled' => $enabled, ); } } return $out; } public function linked_carriers() { $map = $this->get_carrier_map(); if (empty($map)) { return $this->manual_entry_couriers(); } $titles_index = $this->get_distinct_shipping_titles(); $live_codes = array(); foreach ($map as $title => $code) { if (!isset($titles_index[$title])) { continue; } if (empty($titles_index[$title]['enabled'])) { continue; } $live_codes[(string) $code] = true; } $all = $this->manual_entry_couriers(); $out = array(); foreach ($live_codes as $code => $_) { if (isset($all[$code])) { $out[$code] = $all[$code]; } } return $out; } public function documents() { $docs = array(); return apply_filters('aieo_fulfillment_documents', $docs); } private function current_section() { $section = isset($_GET['ff_section']) ? sanitize_key((string) $_GET['ff_section']) : 'couriers'; if ($section === 'templates') { $section = 'documents'; } if (!in_array($section, array('couriers', 'documents', 'maintenance', 'runlog', 'settings'), true)) { $section = 'couriers'; } return $section; } public function page_head_id() { $ids = array( 'documents' => 'fulfillment_documents', 'couriers' => 'fulfillment_couriers', 'settings' => 'fulfillment_settings', 'maintenance' => 'fulfillment_maintenance', 'runlog' => 'delivery_stats', ); return $ids[$this->current_section()]; } public function board_key() { return 'fulfillment/' . $this->current_section(); } private function section_intro_html($section) { $headings = $this->section_headings(); if (!isset($headings[$section][1]) || '' === $headings[$section][1]) { return ''; } if (class_exists('\Rooster\Admin\Guidance')) { return \Rooster\Admin\Guidance::text((string) $headings[$section][1], 'guidance', array('question' => __('What do these statistics cover?', 'ai-eshop-optimizer'))); } return '

' . 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($label . ': ' . $value) . '
  • '; } return class_exists('\Rooster\Admin\Ui') ? '
    ' . $out . '
    ' : ''; } public function render_subtab() { $section = $this->current_section(); switch ($section) { case 'couriers': $this->render_couriers(); break; case 'documents': $this->render_documents(); break; case 'maintenance': $this->render_maintenance(); break; case 'runlog': $this->render_runlog($this->section_intro_html('runlog')); break; case 'settings': $this->render_settings(); break; } } private function render_couriers() { $couriers = $this->couriers(); if (empty($couriers)) { list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-registered-carriers', 'icon' => 'truck', 'title' => __('Registered carriers', 'ai-eshop-optimizer'), 'description' => $this->section_intro_text('couriers'), )); echo $aieo_ff_open . self::empty_html(__('No courier driver is registered.', 'ai-eshop-optimizer')) . $aieo_ff_close; return; } $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $default_carrier_code = (string) AIEO_Fulfillment_Settings::get('default_carrier', ''); $aieo_ff_badge = static function ($label, $tone) { return ' ' . \Rooster\Admin\Ui::badge($label, $tone, array('sm' => true, 'class' => 'aieo-ff-tag')); }; $aieo_ff_width = static function ($field, $type) { if ('password' === $type) { return array('code' => true); } if ('url' === $type) { return array(); } if ('number' === $type) { return preg_match('/(pieces|weight|voucher_type)/', $field) ? array('xs' => true) : array('short' => true); } if (preg_match('/(zip|postcode|country|phone|incoterm|participation|house|sub_code|rate_indicator|account_number)$/', $field)) { return array('short' => true); } if (preg_match('/(client_id|api_key|base_url|url_template|credential_value|_key)$/', $field)) { return array('code' => true); } return array('medium' => true); }; $groups = array('api' => array(), 'manual' => array(), 'available' => array()); foreach ($couriers as $code => $driver) { if (!method_exists($driver, 'settings_schema')) { continue; } $groups[$this->courier_setup_state($code, $driver)][$code] = $driver; } $group_labels = array( 'api' => array( __('Connected couriers', 'ai-eshop-optimizer'), __('Set up with credentials — these create vouchers and fetch labels themselves.', 'ai-eshop-optimizer'), ), 'manual' => array( __('Couriers you handle by hand', 'ai-eshop-optimizer'), __('In use, but without an API connection: you book at their portal and record the number here.', 'ai-eshop-optimizer'), ), 'available' => array( __('Available, not set up', 'ai-eshop-optimizer'), __('Supported by the plugin and waiting for credentials. Nothing here affects your shop until you fill one in.', 'ai-eshop-optimizer'), ), ); $group_ids = array('api' => 'connected', 'manual' => 'by-hand', 'available' => 'available'); $first_group = true; foreach ($groups as $group_key => $group) { if (empty($group)) { continue; } list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-couriers-' . $group_ids[$group_key], 'title' => $group_labels[$group_key][0], 'description' => $first_group ? $this->section_intro_text('couriers') : $group_labels[$group_key][1], 'tools' => \Rooster\Admin\Ui::badge(number_format_i18n(count($group)), 'mono', array('sm' => true)), )); echo $aieo_ff_open; if ($first_group) { echo \Rooster\Admin\Guidance::text($group_labels[$group_key][1]); $first_group = false; } foreach ($group as $code => $driver) { $schema = method_exists($driver, 'settings_schema_full') ? (array) $driver->settings_schema_full() : (array) $driver->settings_schema(); $builtin = class_exists('AIEO_Runtime_Fulfillment_URLs') ? (string) AIEO_Runtime_Fulfillment_URLs::builtin_pattern($code) : ''; if (isset($schema['tracking_url_template'])) { if (empty($schema['tracking_url_template']['placeholder'])) { $schema['tracking_url_template']['placeholder'] = '' !== $builtin ? $builtin : 'https://...'; } if ('' === $builtin) { $schema['tracking_url_template']['help'] = trim((string) ($schema['tracking_url_template']['help'] ?? '')) . ' ' . __('We hold no built-in URL for this carrier, so customers get no tracking link in their order emails until you enter one here.', 'ai-eshop-optimizer'); } } if (!isset($schema['tracking_url_template'])) { $schema['tracking_url_template'] = array( 'type' => 'url', 'label' => __('Tracking URL template (optional)', 'ai-eshop-optimizer'), 'placeholder' => '' !== $builtin ? $builtin : 'https://...', 'help' => '' !== $builtin ? sprintf( __('Customer-facing tracking link emitted in order emails. Leave it blank to use our built-in one, shown greyed in the field: %s. To send customers somewhere else, enter your own and put {voucher_no} where the tracking number goes.', 'ai-eshop-optimizer'), $builtin ) : __('Customer-facing tracking link emitted in order emails. We hold no built-in URL for this carrier, so customers get no tracking link until you enter one here. Put {voucher_no} where the tracking number goes.', 'ai-eshop-optimizer'), ); } $cfg = class_exists('AIEO_Fulfillment_Settings') ? (array) AIEO_Fulfillment_Settings::get('courier_' . $code, array()) : array(); $caps = method_exists($driver, 'capabilities') ? (array) $driver->capabilities() : array(); $configured = method_exists($driver, 'in_use') ? $driver->in_use($cfg) : !empty($cfg); $aieo_ff_meta = ' ' . 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(); ?>

    $meta) { $type = (string) ($meta['type'] ?? 'text'); $label = (string) ($meta['label'] ?? $field); $help = (string) ($meta['help'] ?? ''); $required = !empty($meta['required']); $value = $cfg[$field] ?? ($meta['default'] ?? ''); if ('returns_mode' === $field && '' === (string) $value) { $value = $driver->returns_mode($cfg); } if ('tracking_url_template' === $field && '' === (string) $value && method_exists($driver, 'registry_tracking_url_template')) { $value = $driver->registry_tracking_url_template(); } $aieo_ff_fid = 'aieo-courier-' . $code . '-' . $field; $aieo_ff_row = array('id' => $aieo_ff_fid, 'required' => $required); if ('checkbox' === $type) { $aieo_ff_control = AIEO_Kit::check((string) $field, '' !== $help ? $help : $label, !empty($value), array('id' => $aieo_ff_fid)); $help = ''; $aieo_ff_row['group'] = true; } elseif ('password' === $type) { $aieo_ff_control = \Rooster\Admin\Fields::secret((string) $field, '' !== (string) ($cfg[$field] ?? ''), array('id' => $aieo_ff_fid, 'label' => $label)); } elseif ('select' === $type) { $aieo_ff_choices = array(); if (empty($meta['no_empty'])) { $aieo_ff_choices[''] = '— ' . __('default', 'ai-eshop-optimizer') . ' —'; } foreach ((array) ($meta['options'] ?? array()) as $opt_val => $opt_label) { $aieo_ff_choices[(string) $opt_val] = (string) $opt_label; } $aieo_ff_control = AIEO_Kit::select((string) $field, $aieo_ff_choices, (string) $value, array('id' => $aieo_ff_fid)); } else { $aieo_ff_opts = array('id' => $aieo_ff_fid) + $aieo_ff_width((string) $field, $type); if ('number' === $type) { $aieo_ff_opts += array('type' => 'number', 'step' => 'any'); } elseif ('url' === $type) { $aieo_ff_opts += array('type' => 'url', 'placeholder' => !empty($meta['placeholder']) ? (string) $meta['placeholder'] : 'https://...'); } $aieo_ff_control = AIEO_Kit::input((string) $field, (string) $value, $aieo_ff_opts); } $aieo_ff_rows .= AIEO_Kit::row($label, $aieo_ff_control, $help, $aieo_ff_row); } $aieo_ff_lid = 'aieo-courier-' . $code . '-linked-title'; $aieo_ff_rows .= AIEO_Kit::row( __('Linked WC shipping method', 'ai-eshop-optimizer'), '' . \Rooster\Admin\States::outcome('', '', array('attrs' => array('data-aieo-courier-link-status' => ''))), __('Saves on change. Methods already linked to another carrier are greyed out — pick one of those means choosing it would steal the link, so we block it.', 'ai-eshop-optimizer'), array('id' => $aieo_ff_lid) ); echo $aieo_ff_rows; ?>

    array('data-aieo-courier-status' => ''))); ?>

    ' . \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 '' . esc_html__('Add a custom carrier', 'ai-eshop-optimizer') . ''; echo AIEO_Kit::row(__('Label', 'ai-eshop-optimizer'), AIEO_Kit::input('', '', array('id' => 'aieo-cc-new-label', 'medium' => true, 'placeholder' => __('e.g. Courier Center', 'ai-eshop-optimizer'))), '', array('id' => 'aieo-cc-new-label', 'required' => true)); echo AIEO_Kit::row(__('Slug', 'ai-eshop-optimizer'), AIEO_Kit::input('', '', array('id' => 'aieo-cc-new-slug', 'medium' => true, 'code' => true, 'placeholder' => __('auto from label — letters / numbers / dashes', 'ai-eshop-optimizer'))), __('Leave blank to auto-generate. Must be unique. Cannot be acs / geniki / boxnow / elta / manual / speedex.', 'ai-eshop-optimizer'), array('id' => 'aieo-cc-new-slug')); echo AIEO_Kit::row(__('Tracking URL template', 'ai-eshop-optimizer'), AIEO_Kit::input('', '', array('id' => 'aieo-cc-new-tpl', 'type' => 'url', 'placeholder' => 'https://courier-center.gr/track?id={voucher_no}')), __('Use {voucher_no} where the tracking number goes. Customers click this link from their order notification email.', 'ai-eshop-optimizer'), array('id' => 'aieo-cc-new-tpl')); echo AIEO_Kit::row(__('Linked WC shipping method', 'ai-eshop-optimizer'), '', '', array('id' => 'aieo-cc-new-title')); echo '

    ' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-cc-add-status')) . '

    '; 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'), '
    ' . '' . '
    ', '', array('id' => 'aieo-ff-bulk-statuses', 'group' => true) ); echo $aieo_ff_close; $this->print_documents_tab_js($nonce); self::band_close(); } private function print_documents_tab_js($nonce) { $ajax = admin_url('admin-ajax.php'); ?> render_templates(); } private function render_maintenance() { $nonce = wp_create_nonce('aieo_fulfillment_admin'); list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-close-manifest', 'icon' => 'clipboard-check', 'title' => __('Close and manifest', 'ai-eshop-optimizer'), 'description' => __('End-of-day reconciliation. Carriers with a "close pending" API (e.g. Geniki Taxydromiki) report the day\'s vouchers to the carrier; carriers without one (e.g. Courier Center) get a local dispatch manifest and have the day\'s vouchers marked dispatched. The cron runs daily for every carrier; pick a carrier below to trigger it manually.', 'ai-eshop-optimizer'), )); echo $aieo_ff_open; $auto_enabled = AIEO_Fulfillment_Settings::flag('auto_close_enabled', true); $next_ts = wp_next_scheduled('aieo_fulfillment_close_pending_tick'); $last_run = (string) AIEO_Fulfillment_Settings::get('auto_close_last_run_at', ''); $last_results = (array) AIEO_Fulfillment_Settings::get('auto_close_last_run_results', array()); $next_human = $next_ts ? esc_html(mysql2date(get_option('date_format') . ' ' . get_option('time_format'), gmdate('Y-m-d H:i:s', (int) $next_ts))) : '' . esc_html__('not scheduled', 'ai-eshop-optimizer') . ''; $last_human = $last_run !== '' ? esc_html(mysql2date(get_option('date_format') . ' ' . get_option('time_format'), $last_run)) : '' . esc_html__('never', 'ai-eshop-optimizer') . ''; $last_summary = ''; if (!empty($last_results)) { $parts = array(); foreach ($last_results as $code => $r) { $parts[] = esc_html($code . '=' . $r); } $last_summary = ' · ' . implode(' · ', $parts); } $aieo_ff_auto = ' · ' . esc_html__('Next run:', 'ai-eshop-optimizer') . ' ' . $next_human . ' · ' . esc_html__('Last run:', 'ai-eshop-optimizer') . ' ' . $last_human . '' . $last_summary . ' ' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-auto-close-status')); $aieo_ff_auto = class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::notice($aieo_ff_auto, 'mono', array('inline' => true, 'icon' => 'clock')) : '

    ' . $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 '
    '; echo ' '; echo ' '; echo \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-close-status')) . '
    '; echo \Rooster\Admin\Guidance::text(__('"Close and print manifest" closes the selected carrier\'s pending vouchers for today and also opens the dispatcher PDF. For couriers with an end-of-day API (Geniki) it also reports the close to the carrier; for couriers without one (e.g. Courier Center) it produces the manifest and marks the vouchers dispatched locally. "Reprint last closing" re-renders the most recent successful manifest for the selected carrier.', 'ai-eshop-optimizer'), 'guidance', array('question' => __('What do these buttons do?', 'ai-eshop-optimizer'))); $today_local = wp_date('Y-m-d'); echo \Rooster\Admin\Ui::field_row(array( 'id' => 'aieo-ff-close-range', 'label' => __('Close pending by date range', 'ai-eshop-optimizer'), 'joiner' => '–', 'parts' => array( array('label' => __('From', 'ai-eshop-optimizer'), 'id' => 'aieo-ff-close-from', 'control' => AIEO_Kit::input('', $today_local, array('id' => 'aieo-ff-close-from', 'type' => 'date'))), array('label' => __('To', 'ai-eshop-optimizer'), 'id' => 'aieo-ff-close-to', 'control' => AIEO_Kit::input('', $today_local, array('id' => 'aieo-ff-close-to', 'type' => 'date'))), ), )); echo '
    '; echo \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-close-by-date-status')) . '
    '; echo \Rooster\Admin\Guidance::text(__('Use this to close vouchers from a specific past day (e.g. yesterday\'s jobs if today\'s pickup was missed). Ranges are inclusive — the end date is treated as 23:59:59.', 'ai-eshop-optimizer'), 'guidance', array('question' => __('When should I close a range?', 'ai-eshop-optimizer'))); echo $aieo_ff_close; list($aieo_ff_lopen, $aieo_ff_lclose) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-carrier-labels', 'icon' => 'tags', 'title' => __('Carrier labels', 'ai-eshop-optimizer'), 'description' => __('ZIP-bundle every cached carrier label (ACS, Geniki, Speedex partner) within a date range — the end-of-day print run.', 'ai-eshop-optimizer'), 'footer' => ' ' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-ff-bulk-labels-status')), )); echo $aieo_ff_lopen; $aieo_ff_label_carriers = array('' => __('All carriers', 'ai-eshop-optimizer')); foreach ($this->couriers() as $code => $driver) { $aieo_ff_label_carriers[(string) $code] = $driver->label(); } echo AIEO_Kit::row(__('Carrier (optional filter)', 'ai-eshop-optimizer'), AIEO_Kit::select('', $aieo_ff_label_carriers, '', array('id' => 'aieo-ff-bulk-carrier')), '', array('id' => 'aieo-ff-bulk-carrier')); echo AIEO_Kit::row(__('Date from', 'ai-eshop-optimizer'), AIEO_Kit::input('', $today_local, array('id' => 'aieo-ff-bulk-label-from', 'type' => 'date')), '', array('id' => 'aieo-ff-bulk-label-from')); echo AIEO_Kit::row(__('Date to', 'ai-eshop-optimizer'), AIEO_Kit::input('', $today_local, array('id' => 'aieo-ff-bulk-label-to', 'type' => 'date')), '', array('id' => 'aieo-ff-bulk-label-to')); echo $aieo_ff_lclose; $this->print_maintenance_tab_js($nonce); } private function print_maintenance_tab_js($nonce) { $ajax = admin_url('admin-ajax.php'); ?> 'fulfillment-document-templates', 'icon' => 'layout-template', 'title' => __('Document templates', 'ai-eshop-optimizer'), 'description' => __('The layouts your invoices, packing slips and other order documents are printed with, and the one in use.', 'ai-eshop-optimizer'), 'col' => 2, )); echo $aieo_ff_open . self::empty_html(__('No bundled templates.', 'ai-eshop-optimizer')) . $aieo_ff_close; } else { $aieo_fft_rows = array(); foreach ($tpl_list as $id => $label) { $aieo_fft_rows[] = array( 'tpl' => $id . ' — ' . $label, 'active' => ($active === $id), ); } AIEO_Table::card(array( 'anchor' => 'fulfillment-document-templates', 'icon' => 'layout-template', 'heading' => 'h2', 'title' => __('Document templates', 'ai-eshop-optimizer'), 'description' => __('The layouts your invoices, packing slips and other order documents are printed with, and the one in use.', 'ai-eshop-optimizer'), 'footnote' => __('Active template is changed via Settings → templates.active. Theme override: drop a copy at {theme}/aieo/fulfillment/{template}/{doc}.php.', 'ai-eshop-optimizer'), 'col' => 2, 'id' => 'aieo-ff-doc-templates', 'page_id' => 'fulfillment', 'sortable' => false, 'narrow' => true, 'rows' => $aieo_fft_rows, 'columns' => array( array('key' => 'tpl', 'label' => __('Template', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'active', 'label' => __('Active', 'ai-eshop-optimizer'), 'type' => 'boolean'), ), )); } } public function render_runlog($intro = '') { $this->render_delivery_metrics_card((string) $intro); } private function render_activity_counters_card() { global $wpdb; $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $ct = $wpdb->prefix . 'aieo_dmm_fulfillment_checkpoints'; $dt = $wpdb->prefix . 'aieo_dmm_fulfillment_documents'; $voucher_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$vt}"); $checkpoint_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$ct}"); $document_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$dt}"); list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-activity-counters', 'icon' => 'hash', 'title' => __('Activity counters', 'ai-eshop-optimizer'), 'description' => __('How many vouchers, tracking checkpoints and order documents this shop has recorded so far.', 'ai-eshop-optimizer'), 'col' => 2, )); $aieo_ff_stats = self::stats_html(array( __('Vouchers', 'ai-eshop-optimizer') => number_format_i18n($voucher_count), __('Checkpoints', 'ai-eshop-optimizer') => number_format_i18n($checkpoint_count), __('Documents', 'ai-eshop-optimizer') => number_format_i18n($document_count), )); echo $aieo_ff_open . $aieo_ff_stats . $aieo_ff_close; } private static function parse_locale_decimal($raw, $fallback = 0.0) { $s = preg_replace('/\s+/', '', (string) $raw); if ($s === '') { return (float) $fallback; } $last_comma = strrpos($s, ','); $last_dot = strrpos($s, '.'); if ($last_comma !== false && ($last_dot === false || $last_comma > $last_dot)) { $s = str_replace('.', '', $s); $s = str_replace(',', '.', $s); } elseif ($last_dot !== false) { $s = str_replace(',', '', $s); } return is_numeric($s) ? (float) $s : (float) $fallback; } private static function delivery_metrics_sanity_where() { return "AND v.status = 'delivered' AND v.delivered_at > v.created_at AND v.delivered_at > o.date_created_gmt AND v.delivered_at < (UTC_TIMESTAMP() + INTERVAL 1 DAY)"; } private static function delivery_metrics_sql_parts($where_carrier) { global $wpdb; $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $ot = $wpdb->prefix . 'wc_orders'; $at = $wpdb->prefix . 'wc_order_addresses'; $select = " COUNT(*) AS shipments, AVG(TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.created_at)) AS avg_hours_order_to_voucher, MIN(TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.created_at)) AS min_hours_order_to_voucher, MAX(TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.created_at)) AS max_hours_order_to_voucher, AVG(TIMESTAMPDIFF(HOUR, v.created_at, v.delivered_at)) AS avg_hours_voucher_to_delivery, MIN(TIMESTAMPDIFF(HOUR, v.created_at, v.delivered_at)) AS min_hours_voucher_to_delivery, MAX(TIMESTAMPDIFF(HOUR, v.created_at, v.delivered_at)) AS max_hours_voucher_to_delivery, AVG(TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at)) AS avg_hours_order_to_delivery, MIN(TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at)) AS min_hours_order_to_delivery, MAX(TIMESTAMPDIFF(HOUR, o.date_created_gmt, v.delivered_at)) AS max_hours_order_to_delivery, AVG(v.customer_shipping_paid) AS avg_customer_paid, SUM(v.customer_shipping_paid) AS sum_customer_paid, AVG(v.carrier_cost) AS avg_carrier_cost, SUM(v.carrier_cost) AS sum_carrier_cost, AVG(CASE WHEN v.carrier_cost IS NOT NULL THEN v.customer_shipping_paid - v.carrier_cost ELSE NULL END) AS avg_net, SUM(CASE WHEN v.carrier_cost IS NOT NULL THEN v.customer_shipping_paid - v.carrier_cost ELSE 0 END) AS sum_net, SUM(CASE WHEN v.carrier_cost IS NOT NULL THEN 1 ELSE 0 END) AS shipments_with_cost, AVG(GREATEST(o.total_amount - COALESCE(v.customer_shipping_paid, 0), 0)) AS avg_net_products "; $sanity_where = self::delivery_metrics_sanity_where(); $from = " 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 v.created_at IS NOT NULL AND a.postcode IS NOT NULL AND a.postcode <> '' {$sanity_where} {$where_carrier} "; return array( 'select' => $select, 'from' => $from ); } private function render_delivery_metrics_card($intro = '') { global $wpdb; $vt = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $ot = $wpdb->prefix . 'wc_orders'; $at = $wpdb->prefix . 'wc_order_addresses'; $filter_carrier = isset($_GET['ff_metrics_carrier']) ? sanitize_key((string) wp_unslash($_GET['ff_metrics_carrier'])) : ''; $prefix_len = (int) AIEO_Fulfillment_Settings::get('metrics_postcode_prefix_length', 2); if ($prefix_len < 1 || $prefix_len > 5) { $prefix_len = 2; } $margin = AIEO_DMM::get_gross_margin(); $margin_pct = AIEO_DMM::get_gross_margin_pct(); $fs_threshold = AIEO_DMM::get_free_shipping_threshold(); list($aieo_ds_open, $aieo_ds_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-delivery-time-metrics-by-postal-area', 'icon' => 'map-pin', 'title' => __('Delivery times by postcode area', 'ai-eshop-optimizer'), 'description' => sprintf( __('Per-postal-area averages computed across delivered vouchers. Group key = first %d digit(s) of the shipping postcode after stripping spaces. Configurable under Settings → General.', 'ai-eshop-optimizer'), (int) $prefix_len ), 'flush' => true, )); echo $aieo_ds_open; echo $intro; if (method_exists('AIEO_DMM', 'pnl_basis_notice')) { $pnl_notice = AIEO_DMM::pnl_basis_notice(); if ($pnl_notice !== '') { echo \Rooster\Admin\Ui::notice(esc_html($pnl_notice), 'info', array('inline' => true, 'attrs' => array('data-state' => 'pnl-basis-default'))); } } $hpos_orders = AIEO_Order_Store::hpos_readable(); $hpos_addr = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $at)); if (!$hpos_orders || !$hpos_addr) { echo \Rooster\Admin\States::state('not-set-up', array('title' => __('Requires WooCommerce HPOS (high-performance order storage). Activate it under WooCommerce → Settings → Advanced → Features.', 'ai-eshop-optimizer'))); echo $aieo_ds_close; return; } $where_carrier = ''; $params = array(); if ($filter_carrier !== '') { $where_carrier = 'AND v.carrier = %s'; $params[] = $filter_carrier; } $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); $couriers = $this->couriers(); $base_url = admin_url('admin.php?page=' . AIEO_DMM::MENU_SLUG . '&dmm_tab=delivery_stats'); $current_metrics_basis = isset($_GET['metrics_basis']) && sanitize_key((string) wp_unslash($_GET['metrics_basis'])) === 'fulfillment' ? 'fulfillment' : 'total'; echo '
    '; echo ''; echo ''; echo ''; $aieo_ds_carrier_options = array('' => __('All carriers', 'ai-eshop-optimizer')); foreach ($couriers as $code => $driver) { $aieo_ds_carrier_options[(string) $code] = $driver->label(); } $aieo_ds_carriers = \Rooster\Admin\Ui::select('ff_metrics_carrier', $aieo_ds_carrier_options, $filter_carrier, array('id' => 'aieo-ff-metrics-carrier')); echo \Rooster\Admin\Ui::filter(__('Carrier:', 'ai-eshop-optimizer'), $aieo_ds_carriers, 'aieo-ff-metrics-carrier'); echo '
    '; echo ''; if ($filter_carrier !== '') { echo '' . esc_html__('Clear', 'ai-eshop-optimizer') . ''; } echo '
    '; echo '
    '; if (empty($rows)) { echo ''; echo '' . esc_html__('Nothing to export yet.', 'ai-eshop-optimizer') . ''; } else { echo ''; echo '' . esc_html__('Same rows + totals as below, with the active carrier filter applied.', 'ai-eshop-optimizer') . ''; } echo '
    '; echo '
    '; $export_nonce = wp_create_nonce('aieo_fulfillment_admin'); echo ''; $metrics_basis = isset($_GET['metrics_basis']) && sanitize_key((string) wp_unslash($_GET['metrics_basis'])) === 'fulfillment' ? 'fulfillment' : 'total'; $end_col = $metrics_basis === 'fulfillment' ? 'v.arrived_at' : 'v.delivered_at'; $end_where = $metrics_basis === 'fulfillment' ? 'v.arrived_at IS NOT NULL' : 'v.delivered_at IS NOT NULL'; $dist_sql = " SELECT SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 48 THEN 1 ELSE 0 END) AS b_2, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 48 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 72 THEN 1 ELSE 0 END) AS b_3, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 72 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 96 THEN 1 ELSE 0 END) AS b_4, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 96 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 120 THEN 1 ELSE 0 END) AS b_5, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 120 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 144 THEN 1 ELSE 0 END) AS b_6, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 144 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 168 THEN 1 ELSE 0 END) AS b_7, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 168 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 192 THEN 1 ELSE 0 END) AS b_8, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 192 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 216 THEN 1 ELSE 0 END) AS b_9, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 216 AND TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) <= 240 THEN 1 ELSE 0 END) AS b_10, SUM(CASE WHEN TIMESTAMPDIFF(HOUR, o.date_created_gmt, {$end_col}) > 240 THEN 1 ELSE 0 END) AS b_over_10 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 {$end_where} AND v.created_at IS NOT NULL AND a.postcode IS NOT NULL AND a.postcode <> '' {$where_carrier} "; $dist = empty($params) ? $wpdb->get_row($dist_sql, ARRAY_A) : $wpdb->get_row($wpdb->prepare($dist_sql, $params), ARRAY_A); if (empty($rows)) { echo '' !== $filter_carrier ? \Rooster\Admin\States::state('no-results', array('text' => '', 'clear' => add_query_arg('metrics_basis', $current_metrics_basis, $base_url))) : \Rooster\Admin\States::state('first-use', array('title' => __('No delivered vouchers yet (or run the Backfill delivery dates first).', 'ai-eshop-optimizer'))); echo $aieo_ds_close; return; } $fmt_hours = static function ($hours) { if ($hours === null) { return '—'; } $h = (float) $hours; $d = $h / 24; return sprintf('%.1f h (%.1f d)', $h, $d); }; $fmt_money = static function ($amount, $with_zero = true) { if ($amount === null) { return '—'; } $f = (float) $amount; if (!$with_zero && $f == 0) { return '—'; } return function_exists('wc_price') ? wp_strip_all_tags(wc_price($f)) : number_format_i18n($f, 2); }; $fmt_timing_cell = static function ($avg, $min, $max) use ($fmt_hours) { $line = '
    ' . esc_html($fmt_hours($avg)) . '
    '; if ($min !== null && $max !== null) { $range = sprintf( '%s – %s', is_numeric($min) ? sprintf('%dh', (int) round((float) $min)) : '—', is_numeric($max) ? sprintf('%dh', (int) round((float) $max)) : '—' ); $line .= '
    ' . esc_html($range) . '
    '; } return $line; }; $excluded_sql = " SELECT COUNT(*) AS n 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 v.created_at IS NOT NULL AND a.postcode IS NOT NULL AND a.postcode <> '' AND NOT (v.status = 'delivered' AND v.delivered_at > v.created_at AND v.delivered_at > o.date_created_gmt AND v.delivered_at < (UTC_TIMESTAMP() + INTERVAL 1 DAY)) {$where_carrier} "; $aieo_ds_excluded_note = ''; $excluded_count = (int) (empty($params) ? $wpdb->get_var($excluded_sql) : $wpdb->get_var($wpdb->prepare($excluded_sql, $params))); if ($excluded_count > 0) { $aieo_ds_excluded_note = \Rooster\Admin\Guidance::text(sprintf( _n( '%d voucher with a recorded delivery date was excluded from these averages because its timestamps are inconsistent (delivery date earlier than the order or voucher creation date, status no longer "delivered", or future-dated). Most often these are still-in-transit shipments mis-flagged by the lightweight tracking call.', '%d vouchers with recorded delivery dates were excluded from these averages because their timestamps are inconsistent (delivery date earlier than the order or voucher creation date, status no longer "delivered", or future-dated). Most often these are still-in-transit shipments mis-flagged by the lightweight tracking call.', $excluded_count, 'ai-eshop-optimizer' ), $excluded_count )); } $nonce_for_drill = wp_create_nonce('aieo_fulfillment_admin'); $to_secs = static function ($hours) { return ($hours === null || !is_numeric($hours)) ? null : (float) $hours * HOUR_IN_SECONDS; }; $span_sub = static function ($min, $max) { if ($min === null || $max === null || !is_numeric($min) || !is_numeric($max)) { return ''; } return sprintf(__('%1$dh – %2$dh', 'ai-eshop-optimizer'), (int) round((float) $min), (int) round((float) $max)); }; $ff_rows = array(); foreach ((array) $rows as $r) { $prefix = (string) ($r['prefix'] ?? ''); if ($prefix === '') { continue; } $min_order = (isset($r['avg_carrier_cost']) && $r['avg_carrier_cost'] !== null && $margin > 0) ? (float) $r['avg_carrier_cost'] / $margin : null; $ff_rows[] = array( 'prefix' => $prefix, 'shipments' => (int) $r['shipments'], 't_ov' => $to_secs($r['avg_hours_order_to_voucher']), 't_ov_sub' => $span_sub($r['min_hours_order_to_voucher'], $r['max_hours_order_to_voucher']), 't_vd' => $to_secs($r['avg_hours_voucher_to_delivery']), 't_vd_sub' => $span_sub($r['min_hours_voucher_to_delivery'], $r['max_hours_voucher_to_delivery']), 't_od' => $to_secs($r['avg_hours_order_to_delivery']), 't_od_sub' => $span_sub($r['min_hours_order_to_delivery'], $r['max_hours_order_to_delivery']), 'avg_paid' => $r['avg_customer_paid'], 'avg_cost' => $r['avg_carrier_cost'], 'avg_net' => $r['avg_net'], 'avg_profit' => (isset($r['avg_net_products']) && $r['avg_net_products'] !== null) ? (float) $r['avg_net_products'] * $margin : null, 'min_order' => $min_order, 'free_ship' => ($min_order !== null) ? $fs_threshold + $min_order : null, ); } $ff_totals = array(); if (is_array($totals)) { $t_min_order = (isset($totals['avg_carrier_cost']) && $totals['avg_carrier_cost'] !== null && $margin > 0) ? (float) $totals['avg_carrier_cost'] / $margin : null; $ff_totals = array( 'shipments' => (int) $totals['shipments'], 't_ov' => $to_secs($totals['avg_hours_order_to_voucher']), 't_vd' => $to_secs($totals['avg_hours_voucher_to_delivery']), 't_od' => $to_secs($totals['avg_hours_order_to_delivery']), 'avg_paid' => $totals['avg_customer_paid'], 'avg_cost' => $totals['avg_carrier_cost'], 'avg_net' => $totals['avg_net'], 'avg_profit' => (isset($totals['avg_net_products']) && $totals['avg_net_products'] !== null) ? (float) $totals['avg_net_products'] * $margin : null, 'min_order' => $t_min_order, 'free_ship' => ($t_min_order !== null) ? $fs_threshold + $t_min_order : null, ); } $aieo_ds_shipments = isset($ff_totals['shipments']) ? (int) $ff_totals['shipments'] : 0; $aieo_ds_facts = array(sprintf( _n('%s shipment', '%s shipments', $aieo_ds_shipments, 'ai-eshop-optimizer'), number_format_i18n($aieo_ds_shipments) )); if ('' !== $filter_carrier) { $aieo_ds_facts[] = __('Carrier:', 'ai-eshop-optimizer') . ' ' . (isset($couriers[$filter_carrier]) ? $couriers[$filter_carrier]->label() : $filter_carrier); } \Rooster\Admin\States::enqueue(); $aieo_ds_head = \Rooster\Admin\States::result_head( sprintf( _n('%s postcode area', '%s postcode areas', count($ff_rows), 'ai-eshop-optimizer'), number_format_i18n(count($ff_rows)) ), array('facts' => $aieo_ds_facts) ); echo $aieo_ds_head; $hours = array('format' => 'hours'); echo ''; AIEO_Table::render(array( 'id' => 'aieo-ff-metrics-table', 'page_id' => 'delivery_stats', 'class' => 'aieo-ff-metrics', 'row_key' => 'prefix', 'rows' => $ff_rows, 'row_class' => static function () { return 'aieo-ff-metrics-row'; }, 'totals' => $ff_totals, 'totals_at' => 'foot', 'totals_label' => __('All postal areas', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'prefix', 'label' => __('Postal prefix', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'shipments', 'label' => __('Shipments', 'ai-eshop-optimizer'), 'type' => 'integer', 'width' => '90px'), array('key' => 't_ov', 'label' => __('Order → voucher', 'ai-eshop-optimizer'), 'type' => 'duration', 'format' => $hours, 'subline' => 't_ov_sub', 'total' => 'given'), array('key' => 't_vd', 'label' => __('Voucher → delivery', 'ai-eshop-optimizer'), 'type' => 'duration', 'format' => $hours, 'subline' => 't_vd_sub', 'total' => 'given'), array('key' => 't_od', 'label' => __('Order → delivery', 'ai-eshop-optimizer'), 'type' => 'duration', 'format' => $hours, 'subline' => 't_od_sub', 'total' => 'given'), array('key' => 'avg_paid', 'label' => __('Avg paid', 'ai-eshop-optimizer'), 'type' => 'money', 'total' => 'given'), array('key' => 'avg_cost', 'label' => __('Avg cost', 'ai-eshop-optimizer'), 'type' => 'money', 'total' => 'given'), array('key' => 'avg_net', 'label' => __('Avg net', 'ai-eshop-optimizer'), 'type' => 'money', 'total' => 'given'), array( 'key' => 'avg_profit', 'total' => 'given', 'label' => __('Avg profit', 'ai-eshop-optimizer'), 'type' => 'money', 'header_tip' => sprintf(__('Average gross profit per order = avg net product value × %d%% margin (set in Tools).', 'ai-eshop-optimizer'), (int) round($margin_pct)), ), array( 'key' => 'min_order', 'total' => 'given', 'label' => __('Min order ≥', 'ai-eshop-optimizer'), 'type' => 'money', 'header_tip' => __('Minimum-order break-even (order subtotal): avg courier cost ÷ margin. Below this an order does not even cover its own courier cost.', 'ai-eshop-optimizer'), ), array( 'key' => 'free_ship', 'total' => 'given', 'label' => __('Free-ship ≥', 'ai-eshop-optimizer'), 'type' => 'money', 'header_tip' => sprintf( __('Recommended free-shipping threshold = current bar (%s) + courier cost ÷ margin, so the EXTRA turnover above the bar covers the courier cost you absorb on a free-shipping order.', 'ai-eshop-optimizer'), wp_strip_all_tags(function_exists('wc_price') ? wc_price($fs_threshold, array('decimals' => 0)) : (string) round($fs_threshold)) ), ), ), )); $aieo_ds_break_even = null; if (is_array($totals) && isset($totals['avg_carrier_cost']) && $totals['avg_carrier_cost'] !== null && $margin > 0) { $overall_min = (float) $totals['avg_carrier_cost'] / $margin; $aieo_ds_break_even = array('min' => $overall_min, 'fs' => $fs_threshold + $overall_min); } echo ''; $aieo_ds_tpl = array( 'loading' => \Rooster\Admin\States::state('loading'), 'none' => \Rooster\Admin\States::state('no-results', array('title' => __('No shipments found for this row.', 'ai-eshop-optimizer'), 'text' => '')), 'failed' => \Rooster\Admin\States::state('failed', array('text' => __('Failed to load shipments.', 'ai-eshop-optimizer'))), ); foreach ($aieo_ds_tpl as $aieo_ds_key => $aieo_ds_state) { echo ''; } \Rooster\Admin\Icons::svg('download', 15); ?> 'fulfillment-free-shipping-break-even', 'icon' => 'scale', 'title' => __('Free-shipping break-even', 'ai-eshop-optimizer'), )); echo $aieo_ds_open; 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 '
    '; echo '' . esc_html__('Time basis:', 'ai-eshop-optimizer') . ''; foreach (array( array('total', $url_total, __('Order → delivery (total)', 'ai-eshop-optimizer')), array('fulfillment', $url_fulfill, __('Order → arrived at delivery zone (fulfillment lag)', 'ai-eshop-optimizer')), ) as $aieo_ds_basis) { $aieo_ds_basis_button = \Rooster\Admin\Ui::button($aieo_ds_basis[2], array( 'href' => $aieo_ds_basis[1], 'size' => 'sm', 'variant' => $metrics_basis === $aieo_ds_basis[0] ? 'secondary' : 'outline', 'attrs' => $metrics_basis === $aieo_ds_basis[0] ? array('aria-current' => 'true') : array(), )); echo $aieo_ds_basis_button; } echo '
    '; if (0 === $max_count) { echo \Rooster\Admin\States::state('first-use', array('compact' => true, 'title' => __('No delivered shipments with this time basis yet.', 'ai-eshop-optimizer'))); } echo '
    '; foreach ($bucket_defs as $key => $def) { $count = (int) ($dist['b_' . $key] ?? 0); $pct = $total_count > 0 ? round(100 * $count / $total_count, 1) : 0; $bar_pct = $max_count > 0 ? round(100 * $count / $max_count, 1) : 0; $disabled = $count === 0; echo '
    '; echo '
    ' . esc_html($def['label']) . '
    '; echo '
    ' . \Rooster\Admin\Ui::progress((float) $bar_pct, 100, array('tone' => $def['tone'])) . '
    '; echo '
    ' . '' . number_format($count) . '' . ' (' . number_format($pct, 1) . '%)' . '
    '; echo '
    '; } echo '
    '; echo $aieo_ds_close; } $costed_rows = array(); foreach ((array) $rows as $r) { if (!empty($r['shipments_with_cost']) && (int) $r['shipments_with_cost'] >= 5) { $costed_rows[] = $r; } } if (!empty($costed_rows)) { usort($costed_rows, static function ($a, $b) { return ((float) $b['avg_net']) <=> ((float) $a['avg_net']); }); $best = array_slice($costed_rows, 0, 10); $worst = array_slice($costed_rows, -10); $worst = array_reverse($worst); if (function_exists('rooster_admin_band_open')) { rooster_admin_band_open(array( 'title' => __('Best vs worst postal areas (by avg net per shipment)', 'ai-eshop-optimizer'), 'description' => __('Only prefixes with at least 5 cost-captured shipments are ranked, so a single low-cost outlier can\'t skew the lists. Negative net = we paid more for shipping than the customer paid us.', 'ai-eshop-optimizer'), 'cols' => 2, )); } $costed_n = count($costed_rows); $render_list = static function ($anchor, $icon, $title, $list, $costed_n) { AIEO_Table::card(array( 'anchor' => $anchor, 'icon' => $icon, 'title' => $title, 'heading' => 'h2', 'page_id' => 'delivery_stats', 'row_key' => 'prefix', 'rows' => array_map(static function ($r) { return array( 'prefix' => (string) $r['prefix'], 'shipments' => (int) $r['shipments'], 'with_cost' => (int) $r['shipments_with_cost'], 'avg_net' => $r['avg_net'], 'sum_net' => $r['sum_net'], ); }, $list), 'sortable' => false, 'narrow' => true, 'density' => 'compact', 'cap' => 10, 'capped' => $costed_n > 10, 'total' => $costed_n, 'columns' => array( array('key' => 'prefix', 'label' => __('Prefix', 'ai-eshop-optimizer'), 'type' => 'code'), array( 'key' => 'shipments', 'label' => __('Shipments (with cost)', 'ai-eshop-optimizer'), 'type' => 'integer', 'suffix' => static function ($r) { return '(' . number_format_i18n($r['with_cost']) . ')'; }, ), array('key' => 'avg_net', 'label' => __('Avg net', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'sum_net', 'label' => __('Total net', 'ai-eshop-optimizer'), 'type' => 'money'), ), )); }; $render_list('delivery-stats-most-profitable-top-10', 'trending-up', __('Most profitable (top 10)', 'ai-eshop-optimizer'), $best, $costed_n); $render_list('delivery-stats-least-profitable-bottom-10', 'trending-down', __('Least profitable (bottom 10)', 'ai-eshop-optimizer'), $worst, $costed_n); if (function_exists('rooster_admin_band_close')) { rooster_admin_band_close(); } } else { list($aieo_ds_open, $aieo_ds_close) = AIEO_DMM_UI::card(array( 'anchor' => 'delivery-stats-best-vs-worst-postal-areas', 'icon' => 'scale', 'title' => __('Best vs worst postal areas (by avg net per shipment)', 'ai-eshop-optimizer'), )); echo $aieo_ds_open; echo \Rooster\Admin\States::state('first-use', array('compact' => true, 'title' => __('Best/worst ranking will appear here once enough vouchers have a captured carrier cost (≥5 per prefix). Cost is captured automatically for new vouchers from now on.', 'ai-eshop-optimizer'))); echo $aieo_ds_close; } } public function ajax_save_general_settings() { $this->verify_admin_ajax(); $couriers = $this->couriers(); $default_carrier = isset($_POST['default_carrier']) ? sanitize_key((string) wp_unslash($_POST['default_carrier'])) : ''; if ($default_carrier !== '' && !isset($couriers[$default_carrier])) { $default_carrier = ''; } $prefix_len = isset($_POST['metrics_postcode_prefix_length']) ? (int) $_POST['metrics_postcode_prefix_length'] : 2; if ($prefix_len < 1 || $prefix_len > 5) { $prefix_len = 2; } $manifest_footer = isset($_POST['manifest_footer']) ? sanitize_text_field((string) wp_unslash($_POST['manifest_footer'])) : ''; AIEO_Fulfillment_Settings::set_many(array( 'default_carrier' => $default_carrier, 'metrics_postcode_prefix_length' => $prefix_len, 'manifest_footer' => $manifest_footer, )); wp_send_json_success(array( 'default_carrier' => $default_carrier, 'metrics_postcode_prefix_length' => $prefix_len, 'manifest_footer' => $manifest_footer, 'message' => __('Settings saved.', 'ai-eshop-optimizer'), )); } private function ff_backfill_table($id, array $rows, $l1, $l2) { AIEO_Table::render(array( 'id' => $id, 'page_id' => 'fulfillment', 'row_key' => 'carrier_code', 'sortable' => false, 'narrow' => true, 'rows' => $rows, 'row_data' => array('carrier' => 'carrier_code'), 'columns' => array( array('key' => 'carrier', 'label' => __('Carrier', 'ai-eshop-optimizer'), 'type' => 'text'), array( 'key' => 'c1', 'label' => $l1, 'type' => 'control', 'width' => '140px', 'format' => array('render' => static function ($r) { return '' . (int) $r['c1'] . ''; }), ), array( 'key' => 'c2', 'label' => $l2, 'type' => 'control', 'width' => '140px', 'format' => array('render' => static function ($r) { return '' . (int) $r['c2'] . ''; }), ), array( 'key' => 'act', 'label' => __('Action', 'ai-eshop-optimizer'), 'type' => 'control', 'width' => '160px', 'format' => array('render' => static function ($r) { if ((int) $r['c1'] > 0) { return ''; } return '' . esc_html($r['idle_label']) . ''; }), ), ), )); } private function render_settings() { self::band_open(array('cols' => 2)); $this->render_general_settings_card(); $this->render_activity_counters_card(); $this->render_merge_couriers_section(); self::band_close(); self::band_open(array('cols' => 1)); $all = AIEO_Fulfillment_Settings::all(); global $wpdb; $expected = array( 'aieo_dmm_fulfillment_vouchers', 'aieo_dmm_fulfillment_checkpoints', 'aieo_dmm_fulfillment_documents', 'aieo_dmm_fulfillment_sequences', 'aieo_dmm_fulfillment_settings', 'aieo_dmm_fulfillment_closings', ); $aieo_ffs_rows = array(); foreach ($expected as $name) { $full = $wpdb->prefix . $name; $exists = (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $full)); $aieo_ffs_rows[] = array( 'tbl' => $full, 'state' => $exists ? 'present' : 'missing', 'state_tone' => $exists ? 'success' : 'danger', ); } $aieo_ffs_facts = array(); foreach ($aieo_ffs_rows as $aieo_ffs_row) { $aieo_ffs_facts[] = array( 'label' => $aieo_ffs_row['tbl'], 'value' => 'present' === $aieo_ffs_row['state'] ? __('present', 'ai-eshop-optimizer') : __('missing', 'ai-eshop-optimizer'), 'tone' => 'present' === $aieo_ffs_row['state'] ? 'success' : 'destructive', ); } list($aieo_ffs_open, $aieo_ffs_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-schema-state', 'icon' => 'database', 'title' => __('Shipping tables: schema state', 'ai-eshop-optimizer'), 'description' => __('Read-only list of the six wp_aieo_dmm_fulfillment_* tables (vouchers, checkpoints, documents, sequences, settings, closings), each marked present or missing.', 'ai-eshop-optimizer'), )); echo $aieo_ffs_open; echo '
    ' . \Rooster\Admin\Ui::facts($aieo_ffs_facts) . '
    '; echo $aieo_ffs_close; $aieo_ffk_rows = array(); foreach ($all as $k => $v) { $serialized = is_scalar($v) ? (string) $v : wp_json_encode($v); $aieo_ffk_rows[] = array( 'k' => (string) $k, 'v' => mb_strlen($serialized) > 200 ? mb_substr($serialized, 0, 200) . '…' : $serialized, ); } AIEO_Table::card(array( 'anchor' => 'fulfillment-settings-rows', 'icon' => 'sliders-horizontal', 'heading' => 'h2', 'title' => __('Settings rows', 'ai-eshop-optimizer'), 'description' => __('Every stored fulfillment setting with its value, for checking what is saved when something behaves unexpectedly.', 'ai-eshop-optimizer'), 'id' => 'aieo-ff-settings-rows', 'page_id' => 'fulfillment', 'row_key' => 'k', 'sortable' => false, 'density' => 'compact', 'rows' => $aieo_ffk_rows, 'empty' => __('No settings yet. The settings save UI lands in later phases — for now use AIEO_Fulfillment_Settings::set() from wp-cli.', 'ai-eshop-optimizer'), 'columns' => array( array('key' => 'k', 'label' => __('Key', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'v', 'label' => __('Value (truncated)', 'ai-eshop-optimizer'), 'type' => 'code'), ), )); $this->render_migration_card(); self::band_close(); self::band_open(array('cols' => 2)); $this->render_backfill_card(); self::band_close(); } private function render_general_settings_card() { $couriers = $this->couriers(); $current_default = (string) AIEO_Fulfillment_Settings::get('default_carrier', ''); $prefix_len = (int) AIEO_Fulfillment_Settings::get('metrics_postcode_prefix_length', 2); if ($prefix_len < 1 || $prefix_len > 5) { $prefix_len = 2; } $manifest_footer = (string) AIEO_Fulfillment_Settings::get('manifest_footer', ''); $nonce = wp_create_nonce('aieo_fulfillment_admin'); $form_id = 'aieo-card-fulfillment-general-form'; list($aieo_ff_open, $aieo_ff_close) = self::with_attrs(AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-general', 'icon' => 'settings', 'title' => __('General shipping settings', 'ai-eshop-optimizer'), 'description' => $this->section_intro_text('settings'), 'col' => 1, 'footer' => \Rooster\Admin\States::save_button($form_id), )), array('data-aieo-ff-general' => '')); echo $aieo_ff_open; ob_start(); $aieo_ff_defaults = array('' => '— ' . __('first in list', 'ai-eshop-optimizer') . ' —'); foreach ($couriers as $code => $driver) { $aieo_ff_defaults[(string) $code] = $driver->label(); } echo AIEO_Kit::row( __('Default carrier', 'ai-eshop-optimizer'), AIEO_Kit::select('default_carrier', $aieo_ff_defaults, $current_default, array('id' => 'aieo-ff-default-carrier')), __('Pre-selected when creating a voucher. Set to "Geniki" if 90% of your shipments go through Geniki — it saves a click per order.', 'ai-eshop-optimizer'), array('id' => 'aieo-ff-default-carrier') ); $labels = array( 1 => __('1 digit (very coarse)', 'ai-eshop-optimizer'), 2 => __('2 digits (nomos / county — robust averages)', 'ai-eshop-optimizer'), 3 => __('3 digits (town / zone — finer, may be sparse for low-volume areas)', 'ai-eshop-optimizer'), 4 => __('4 digits', 'ai-eshop-optimizer'), 5 => __('5 digits (each unique postcode)', 'ai-eshop-optimizer'), ); echo AIEO_Kit::row( __('Metrics postcode grouping', 'ai-eshop-optimizer'), AIEO_Kit::select('metrics_postcode_prefix_length', $labels, (string) $prefix_len, array('id' => 'aieo-ff-prefix-len')), __('Used by the Delivery Statistics → "Delivery time metrics by postal area" table. Spaces in postcodes are stripped before extracting the prefix.', 'ai-eshop-optimizer'), array('id' => 'aieo-ff-prefix-len') ); echo AIEO_Kit::row( __('Manifest footer', 'ai-eshop-optimizer'), AIEO_Kit::input('manifest_footer', $manifest_footer, array('id' => 'aieo-ff-manifest-footer', 'placeholder' => __('On behalf of … company name', 'ai-eshop-optimizer'))), __('Sender line printed at the bottom of every end-of-day courier manifest (e.g. "On behalf of … company name"). Leave blank to fall back to the shop name.', 'ai-eshop-optimizer'), array('id' => 'aieo-ff-manifest-footer') ); echo AIEO_DMM_UI::card_form('aieo_ff_save_general', 'fulfillment_general', $form_id, (string) ob_get_clean(), array('nonce' => $nonce, 'nonce_name' => 'nonce')); echo $aieo_ff_close; } private function render_backfill_card() { $nonce = wp_create_nonce('aieo_fulfillment_admin'); $couriers = $this->couriers(); $candidates = array(); foreach ($couriers as $code => $driver) { if ($driver->has_capability('track')) { $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-delivery-dates', 'icon' => 'calendar-days', 'title' => __('Backfill delivery dates', 'ai-eshop-optimizer'), 'description' => __('Polls the carrier for every voucher missing a delivery date and writes it back. Retrospective — covers historical shipments. Uses the lightweight tracking call (one round-trip per voucher, no checkpoint history).', 'ai-eshop-optimizer'), 'col' => 1, 'flush' => true, )), array('data-aieo-ff-backfill' => '', 'data-nonce' => $nonce)); echo $aieo_ff_open; if (empty($candidates)) { echo self::empty_html(__('No trackable carriers configured.', 'ai-eshop-optimizer')); echo $aieo_ff_close; return; } $aieo_bf_rows = array(); foreach ($candidates as $code => $label) { $aieo_bf_rows[] = array( 'carrier_code' => (string) $code, 'carrier' => (string) $label, 'c1' => (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier=%s AND delivered_at IS NULL AND status NOT IN ('canceled','returned')", $code )), 'c2' => (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier=%s AND delivered_at IS NOT NULL", $code )), 'c1_class' => 'aieo-ff-bf-pending', 'c2_class' => 'aieo-ff-bf-delivered', 'run_class' => 'aieo-ff-bf-run', 'run_label' => __('Backfill', 'ai-eshop-optimizer'), 'idle_label' => __('all caught up', 'ai-eshop-optimizer'), ); } $this->ff_backfill_table('aieo-ff-backfill-dates', $aieo_bf_rows, __('Pending', 'ai-eshop-optimizer'), __('Delivered', 'ai-eshop-optimizer')); echo '
    '; echo '
    '; echo ''; ?> render_customer_paid_backfill_card(); $this->render_cost_backfill_card(); $this->render_carrier_invoice_import_card(); $this->render_orphan_invoice_lines_card(); $this->render_detailed_backfill_card(); } private function render_detailed_backfill_card() { $nonce = wp_create_nonce('aieo_fulfillment_admin'); $couriers = $this->couriers(); $candidates = array(); foreach ($couriers as $code => $driver) { if ($driver->has_capability('track')) { $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-detailed-stats', 'icon' => 'scroll-text', 'title' => __('Backfill checkpoint history', 'ai-eshop-optimizer'), 'description' => __('Re-polls already-tracked vouchers via the full TrackAndTrace method so we capture the checkpoint timeline and the first "out for delivery" (arrived_at) timestamp. Use this to populate fulfillment-lag analytics for historical shipments. Targets only rows that already have a delivery date but no arrived_at — the lightweight backfill never collected this. ~600ms per voucher; runs in batches of 10.', 'ai-eshop-optimizer'), 'col' => 1, 'flush' => true, )), array('data-aieo-ff-detailed-backfill' => '', 'data-nonce' => $nonce)); echo $aieo_ff_open; if (empty($candidates)) { echo self::empty_html(__('No carriers advertise tracking.', 'ai-eshop-optimizer')); echo $aieo_ff_close; return; } $aieo_db_rows = array(); foreach ($candidates as $code => $label) { $aieo_db_rows[] = array( 'carrier_code' => (string) $code, 'carrier' => (string) $label, 'c1' => (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')", $code )), 'c2' => (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier=%s AND arrived_at IS NOT NULL", $code )), 'c1_class' => 'aieo-ff-db-missing', 'c2_class' => 'aieo-ff-db-populated', 'run_class' => 'aieo-ff-db-run', 'run_label' => __('Backfill detailed', 'ai-eshop-optimizer'), 'idle_label' => __('all caught up', 'ai-eshop-optimizer'), ); } $this->ff_backfill_table('aieo-ff-backfill-detailed', $aieo_db_rows, __('Missing arrived_at', 'ai-eshop-optimizer'), __('Already deep-tracked', 'ai-eshop-optimizer')); echo '
    '; echo '
    '; echo ''; ?> prefix . 'aieo_dmm_fulfillment_vouchers'; $missing = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE customer_shipping_paid IS NULL AND order_id > 0" ); $populated = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE customer_shipping_paid IS NOT NULL" ); list($aieo_ff_open, $aieo_ff_close) = self::with_attrs(AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-backfill-customer-shipping-paid', 'icon' => 'banknote', 'title' => __('Backfill customer shipping paid', 'ai-eshop-optimizer'), 'description' => __('For every voucher with a missing customer_shipping_paid value, reads $order->get_shipping_total() and writes it. Lights up the AVG PAID column for manual-paste vouchers from no-API carriers (Courier Centre, Stoferno, etc.). Different from "Free-shipping cost — retrospective analysis" — that estimates what the carrier would have cost; this captures what the customer actually paid.', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => $missing > 0 ? ' ' . \Rooster\Admin\States::outcome('', '', array('attrs' => array('data-aieo-ff-cpb-status' => ''))) : '', )), array('data-aieo-ff-cpb' => '', 'data-nonce' => $nonce)); echo $aieo_ff_open; $aieo_ff_stats = self::stats_html(array( __('Missing', 'ai-eshop-optimizer') => number_format_i18n($missing), __('Populated', 'ai-eshop-optimizer') => number_format_i18n($populated), )); echo $aieo_ff_stats; if ($missing <= 0) { $aieo_ff_done = class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::notice(esc_html__('All vouchers have a customer_shipping_paid value.', 'ai-eshop-optimizer'), 'success', array('inline' => true)) : '

    ' . 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 ''; ?> couriers(); if (empty($couriers)) { return; } $nonce = wp_create_nonce('aieo_fulfillment_admin'); global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; $maps = array(); $imported = array(); foreach ($couriers as $code => $driver) { $maps[$code] = $this->invoice_csv_map($code); $imported[$code] = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE carrier=%s AND cost_source='invoice_csv'", $code )); } $default_carrier = isset($couriers['geniki']) ? 'geniki' : (string) array_key_first($couriers); list($aieo_ff_open, $aieo_ff_close) = self::with_attrs(AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-import-carrier-invoice-csv-per-courier-cost', 'icon' => 'table', 'title' => __('Import carrier invoice CSV', 'ai-eshop-optimizer'), 'description' => __('Couriers bill the FINAL per-voucher cost in a month-end invoice file (after volume discounts + surcharges) that no price API returns. Upload that file to write carrier_cost for the matched vouchers — this feeds the Delivery statistics and the cost reports. Choose the carrier, map its CSV columns once (Geniki is pre-configured), then upload. Re-uploading a later file overwrites prior values for the same voucher.', 'ai-eshop-optimizer'), 'col' => 2, )), array('data-aieo-ff-inv-csv' => '', 'data-nonce' => $nonce)); echo $aieo_ff_open; $aieo_ff_inv_carriers = array(); foreach ($couriers as $code => $driver) { $aieo_ff_inv_carriers[(string) $code] = $driver->label(); } echo AIEO_Kit::row( __('Carrier', 'ai-eshop-optimizer'), AIEO_Kit::select('', $aieo_ff_inv_carriers, $default_carrier, array('id' => 'aieo-ff-inv-carrier')) . ' ', '', array('id' => 'aieo-ff-inv-carrier') ); ob_start(); echo \Rooster\Admin\Guidance::text(__('Column numbers are 0-based (first column = 0); -1 = absent. Decimal: "comma" for 1.234,56 — "dot" for 1,234.56. Delimiter accepts ; , or the word tab.', 'ai-eshop-optimizer')); $aieo_ff_map = static function ($k) { return array('id' => 'aieo-ff-inv-map-' . $k, 'attrs' => array('data-k' => $k, 'data-aieo-ff-inv-map' => '')); }; echo AIEO_Kit::row(__('Delimiter', 'ai-eshop-optimizer'), AIEO_Kit::input('', '', array('xs' => true, 'maxlength' => '3') + $aieo_ff_map('delimiter')), '', array('id' => 'aieo-ff-inv-map-delimiter')); echo AIEO_Kit::row(__('Encoding', 'ai-eshop-optimizer'), AIEO_Kit::input('', '', array('short' => true) + $aieo_ff_map('encoding')), '', array('id' => 'aieo-ff-inv-map-encoding')); echo AIEO_Kit::row(__('Decimal', 'ai-eshop-optimizer'), AIEO_Kit::select('', array('dot' => 'dot', 'comma' => 'comma'), '', $aieo_ff_map('decimal')), '', array('id' => 'aieo-ff-inv-map-decimal')); $int_fields = array( 'voucher_col' => __('Voucher # column', 'ai-eshop-optimizer'), 'cost_col' => __('Total cost column', 'ai-eshop-optimizer'), 'weight_col' => __('Weight column', 'ai-eshop-optimizer'), 'pieces_col' => __('Pieces column', 'ai-eshop-optimizer'), 'consignee_col' => __('Consignee column', 'ai-eshop-optimizer'), 'invoice_col' => __('Invoice # column', 'ai-eshop-optimizer'), 'date_col' => __('Date column', 'ai-eshop-optimizer'), 'min_cols' => __('Min columns per row', 'ai-eshop-optimizer'), 'voucher_min_len' => __('Min voucher # length', 'ai-eshop-optimizer'), ); foreach ($int_fields as $k => $lbl) { echo AIEO_Kit::row($lbl, AIEO_Kit::input('', '', array('type' => 'number', 'xs' => true) + $aieo_ff_map($k)), '', array('id' => 'aieo-ff-inv-map-' . $k)); } echo AIEO_Kit::row( __('Voucher # is digits-only', 'ai-eshop-optimizer'), '', '', array('id' => 'aieo-ff-inv-map-voucher_digits_only', 'group' => true) ); echo \Rooster\Admin\Ui::fold(__('CSV column mapping', 'ai-eshop-optimizer'), (string) ob_get_clean()); echo AIEO_Kit::row(__('CSV file', 'ai-eshop-optimizer'), '', '', array('id' => 'aieo-ff-inv-file')); echo '
    '; echo ''; echo '
    '; echo \Rooster\Admin\States::outcome('', '', array('attrs' => array('data-aieo-ff-inv-status' => '', 'hidden' => true))); echo ''; $json_opts = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; ?> prefix . 'aieo_dmm_fulfillment_vouchers'; $rollup = $wpdb->get_results( "SELECT carrier, COUNT(*) AS n, SUM(carrier_cost) AS total_cost FROM {$table} WHERE order_id = 0 AND status = 'orphan_invoice' GROUP BY carrier ORDER BY total_cost DESC", ARRAY_A ); list($aieo_ff_open, $aieo_ff_close) = AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-orphan-invoice-lines-cost-without-a-matching', 'icon' => 'file-question-mark', 'title' => __('Invoice lines with no voucher', 'ai-eshop-optimizer'), 'description' => __( 'Lines from monthly carrier invoices that we could not match to ' . 'any voucher in our database. Common causes: a voucher generated ' . 'outside the Rooster One flow, a voucher created before the fulfillment ' . 'module existed, a voucher belonging to a different store account ' . 'under the same carrier login, or a typo on the carrier\'s side. ' . 'These rows still carry a real billed cost — kept here so the ' . 'monthly total reconciles to the carrier\'s invoice total.', 'ai-eshop-optimizer' ), 'col' => 2, 'flush' => true, )); echo $aieo_ff_open; if (empty($rollup)) { echo self::empty_html(__('No orphan invoice lines on file.', 'ai-eshop-optimizer'), 'all-done'); echo $aieo_ff_close; return; } $grand_n = 0; $grand_c = 0.0; $aieo_orph_rows = array(); $couriers = $this->couriers(); foreach ($rollup as $r) { $aieo_orph_rows[] = array( 'carrier' => isset($couriers[$r['carrier']]) ? $couriers[$r['carrier']]->label() : (string) $r['carrier'], 'orphans' => (int) $r['n'], 'cost' => (float) $r['total_cost'], ); $grand_n += (int) $r['n']; $grand_c += (float) $r['total_cost']; } AIEO_Table::render(array( 'id' => 'aieo-ff-orphan-rollup', 'page_id' => 'fulfillment', 'sortable' => false, 'narrow' => true, 'rows' => $aieo_orph_rows, 'totals_at' => 'foot', 'totals_label' => __('Total', 'ai-eshop-optimizer'), 'totals' => count($rollup) > 1 ? array('orphans' => $grand_n, 'cost' => $grand_c) : array(), 'columns' => array( array('key' => 'carrier', 'label' => __('Carrier', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'orphans', 'label' => __('Orphans', 'ai-eshop-optimizer'), 'type' => 'integer', 'width' => '120px'), array('key' => 'cost', 'label' => __('Cost', 'ai-eshop-optimizer'), 'type' => 'money', 'width' => '160px'), ), )); $details = $wpdb->get_results( "SELECT carrier, voucher_no, carrier_cost, consignee, postal_code, reference_1 AS invoice_no, reference_2 AS pickup_date, pieces, weight, created_at FROM {$table} WHERE order_id = 0 AND status = 'orphan_invoice' ORDER BY carrier_cost DESC, voucher_no LIMIT 50", ARRAY_A ); ob_start(); $aieo_orphd_rows = array(); foreach ($details as $r) { $aieo_orphd_rows[] = array( 'carrier' => (string) $r['carrier'], 'voucher' => (string) $r['voucher_no'], 'cost' => (float) $r['carrier_cost'], 'consignee' => (string) $r['consignee'], 'tk' => (string) ($r['postal_code'] ?? ''), 'invoice' => (string) $r['invoice_no'], 'pickup' => (string) $r['pickup_date'], 'pieces' => (int) $r['pieces'], 'weight' => $r['weight'], 'imported' => (string) $r['created_at'], ); } AIEO_Table::render(array( 'id' => 'aieo-ff-orphan-details', 'page_id' => 'fulfillment', 'row_key' => 'voucher', 'sortable' => false, 'density' => 'compact', 'rows' => $aieo_orphd_rows, 'cap' => 50, 'capped' => count($details) >= 50, 'columns' => array( array('key' => 'carrier', 'label' => __('Carrier', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'voucher', 'label' => __('Voucher', 'ai-eshop-optimizer'), 'type' => 'code'), array('key' => 'cost', 'label' => __('Cost', 'ai-eshop-optimizer'), 'type' => 'money'), array('key' => 'consignee', 'label' => __('Consignee', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'tk', 'label' => __('TK', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'invoice', 'label' => __('Invoice #', 'ai-eshop-optimizer'), 'type' => 'id', 'align' => 'left'), array('key' => 'pickup', 'label' => __('Pickup date', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'pieces', 'label' => __('Pieces', 'ai-eshop-optimizer'), 'type' => 'integer'), array('key' => 'weight', 'label' => __('Weight (kg)', 'ai-eshop-optimizer'), 'type' => 'decimal', 'format' => array('precision' => 3)), array('key' => 'imported', 'label' => __('Imported', 'ai-eshop-optimizer'), 'type' => 'text', 'nowrap' => true), ), )); echo \Rooster\Admin\Ui::fold( sprintf( __('Top %d orphans by cost', 'ai-eshop-optimizer'), count($details) ), (string) ob_get_clean() ); echo $aieo_ff_close; } private function render_migration_card() { if (!class_exists('AIEO_Fulfillment_Migrator')) { return; } $nonce = wp_create_nonce('aieo_fulfillment_admin'); $steps = AIEO_Fulfillment_Migrator::steps(); $progress = AIEO_Fulfillment_Migrator::progress(); list($aieo_ff_open, $aieo_ff_close) = self::with_attrs(AIEO_DMM_UI::card(array( 'anchor' => 'fulfillment-legacy-data-migration', 'icon' => 'import', 'title' => __('Legacy data migration', 'ai-eshop-optimizer'), 'description' => __('Permanent, idempotent, re-runnable tooling. Lifts data from the legacy plugins (WPO PDF Invoices, WC Shipment Tracking, ACS Voucher) into our own tables. Safe on satellite stores and clones — re-running never duplicates.', 'ai-eshop-optimizer'), )), array('data-aieo-ff-migrate' => '', 'data-nonce' => $nonce)); echo $aieo_ff_open; 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'); ?>