self::SLUG ), $args ), admin_url( 'admin.php' ) ); } public static function menu() { add_submenu_page( 'woocommerce', 'OrderArc', 'OrderArc', self::CAP, self::SLUG, array( __CLASS__, 'render' ) ); } public static function assets( $hook ) { if ( false === strpos( (string) $hook, self::SLUG ) ) { return; } wp_enqueue_style( 'orderarc-admin', plugins_url( 'assets/admin.css', ORDERARC_FILE ), array(), ORDERARC_VERSION ); wp_enqueue_script( 'orderarc-admin', plugins_url( 'assets/admin.js', ORDERARC_FILE ), array(), ORDERARC_VERSION, true ); wp_localize_script( 'orderarc-admin', 'orderarc', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'orderarc_ajax' ), /* translators: %s: seconds */ 'took' => __( 'Searching orders took %s s', 'orderarc' ), /* translators: %s: seconds */ 'nofin' => __( 'Search did not finish after %s s', 'orderarc' ), ) ); } /* ---------- Enlace en la lista de pedidos y redirección ---------- */ public static function views( $views ) { if ( ! current_user_can( self::CAP ) ) { return $views; } $n = OrderArc_Engine::archived_count(); if ( $n > 0 ) { $views['orderarc'] = sprintf( '%s (%s)', esc_url( self::url( array( 'tab' => 'archived' ) ) ), esc_html__( 'Archived', 'orderarc' ), esc_html( number_format_i18n( $n ) ) ); } return $views; } /** Un pedido archivado abierto desde un enlace viejo va a su vista de archivado. */ public static function maybe_redirect() { global $pagenow; if ( wp_doing_ajax() || ! current_user_can( self::CAP ) ) { return; } $id = 0; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- solo lectura de la URL para redirigir. $page = sanitize_key( wp_unslash( $_GET['page'] ?? '' ) ); $action = sanitize_key( wp_unslash( $_GET['action'] ?? '' ) ); if ( 'admin.php' === $pagenow && 'wc-orders' === $page && 'edit' === $action ) { $id = absint( $_GET['id'] ?? 0 ); if ( $id && function_exists( 'wc_get_order' ) && wc_get_order( $id ) ) { return; } } elseif ( 'post.php' === $pagenow && 'edit' === $action ) { $id = absint( $_GET['post'] ?? 0 ); $type = $id ? get_post_type( $id ) : false; if ( $type && OrderArc_Engine::PLACEHOLDER !== $type ) { return; } } // phpcs:enable if ( $id && OrderArc_Archived::exists( $id ) ) { wp_safe_redirect( self::url( array( 'tab' => 'archived', 'order' => $id ) ) ); exit; } } /* ---------- Acciones (formularios) ---------- */ private static function guard( $action ) { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'orderarc' ), 403 ); } check_admin_referer( $action ); } private static function back( $tab, $msg = '', $extra = array() ) { if ( $msg ) { set_transient( 'orderarc_msg_' . get_current_user_id(), $msg, 120 ); } wp_safe_redirect( self::url( array_merge( array( 'tab' => $tab ), $extra ) ) ); exit; } private static function ack_from_post() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verificado en guard(). if ( ! empty( $_POST['orderarc_backup'] ) && ! get_option( 'orderarc_backup_ack' ) ) { update_option( 'orderarc_backup_ack', array( 'user' => get_current_user_id(), 'time' => time() ), false ); } return (bool) get_option( 'orderarc_backup_ack' ); } public static function post_save() { self::guard( 'orderarc_save' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verificado en guard(). $s = OrderArc_Runner::settings(); $allowed = array_keys( self::status_choices() ); // La regla básica solo se cambia si vino en el formulario (con la versión Pro esos campos no se muestran). if ( isset( $_POST['older_than_days'] ) ) { $statuses = array_values( array_intersect( array_map( 'sanitize_key', (array) wp_unslash( $_POST['statuses'] ?? array() ) ), $allowed ) ); $s['statuses'] = $statuses ? $statuses : OrderArc_Runner::defaults()['statuses']; $s['older_than_days'] = max( 30, min( 36500, absint( $_POST['older_than_days'] ) ) ); } $s['window_start'] = min( 23, absint( $_POST['window_start'] ?? 1 ) ); $s['window_end'] = min( 23, absint( $_POST['window_end'] ?? 6 ) ); $s['catch_up'] = 'wait' === sanitize_key( wp_unslash( $_POST['catch_up'] ?? '' ) ) ? 'wait' : 'gentle'; $s['auto'] = ! empty( $_POST['auto'] ) && self::ack_from_post(); // phpcs:enable $s = (array) apply_filters( 'orderarc_save_settings', $s ); update_option( 'orderarc_settings', $s, false ); do_action( 'orderarc_settings_saved', $s ); OrderArc_Engine::flush_counts(); OrderArc_Runner::schedule_daily(); self::back( 'archive', __( 'Settings saved.', 'orderarc' ) ); } public static function post_preview() { self::guard( 'orderarc_preview' ); $s = OrderArc_Runner::settings(); $prev = OrderArc_Engine::preview( $s ); // Se guarda con la huella de las reglas: si cambian (ajustes, licencia Pro), la vista previa vieja no se muestra. $prev['rules_key'] = md5( wp_json_encode( OrderArc_Engine::rules( $s ) ) ); set_transient( 'orderarc_preview_' . get_current_user_id(), $prev, 600 ); self::back( 'archive' ); } public static function post_start() { self::guard( 'orderarc_start' ); if ( ! self::ack_from_post() ) { self::back( 'archive' ); } OrderArc_Runner::start( 'archive', true ); self::back( 'archive' ); } public static function post_pause() { self::guard( 'orderarc_pause' ); OrderArc_Runner::pause(); self::back( 'archive' ); } public static function post_resume() { self::guard( 'orderarc_resume' ); OrderArc_Runner::resume(); self::back( 'archive' ); } public static function post_restore_all() { self::guard( 'orderarc_restore_all' ); $s = OrderArc_Runner::settings(); if ( $s['auto'] ) { // si no, la noche siguiente vuelve a archivar lo restaurado. $s['auto'] = false; update_option( 'orderarc_settings', $s, false ); } OrderArc_Runner::start( 'restore', true ); self::back( 'archive' ); } /** Restaurar uno o varios pedidos desde la pestaña de archivados. */ public static function post_restore() { self::guard( 'orderarc_restore' ); $ids = array_slice( array_filter( array_map( 'absint', (array) wp_unslash( $_POST['ids'] ?? array() ) ) ), 0, 200 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verificado en guard(). $msgs = array(); $st = OrderArc_Runner::job()['status'] ?? ''; if ( in_array( $st, array( 'running', 'waiting' ), true ) || ! OrderArc_Runner::lock() ) { self::back( 'archived', __( 'An archive or restore job is running. Pause it first.', 'orderarc' ) ); } foreach ( $ids as $id ) { if ( ! OrderArc_Archived::exists( $id ) ) { continue; // solo pedidos archivados (no reembolsos sueltos ni IDs inventados). } $r = OrderArc_Engine::move( array( $id ), 'restore', true ); if ( $r['ok'] && $r['orders'] ) { /* translators: %s: order number */ $msgs[] = sprintf( __( 'Order #%s restored.', 'orderarc' ), $id ); } elseif ( ! $r['ok'] ) { /* translators: 1: first order number, 2: last order number, 3: reason */ $msgs[] = sprintf( __( 'Orders %1$s–%2$s not moved: %3$s. Nothing was changed for these orders.', 'orderarc' ), $id, $id, $r['error'] ); } } OrderArc_Runner::unlock(); self::back( 'archived', implode( "\n", $msgs ) ); } /* ---------- AJAX ---------- */ public static function ajax_status() { check_ajax_referer( 'orderarc_ajax' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( null, 403 ); } $job = OrderArc_Runner::job(); wp_send_json_success( array( 'status' => $job['status'] ?? '', 'html' => self::status_html( $job ) ) ); } /** Mide una búsqueda real de WooCommerce (la misma que hace el buscador de pedidos). */ public static function ajax_measure() { check_ajax_referer( 'orderarc_ajax' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( null, 403 ); } global $wpdb; $hpos = OrderArc_Engine::storage()['hpos']; $total = self::live_orders(); $email = $hpos // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- un email de muestra para la búsqueda de prueba; no hay API. ? $wpdb->get_var( $wpdb->prepare( "SELECT billing_email FROM %i WHERE type='shop_order' AND billing_email<>'' ORDER BY id LIMIT 1 OFFSET %d", OrderArc_Engine::live( 'wc_orders' ), (int) ( $total / 2 ) ) ) : $wpdb->get_var( $wpdb->prepare( "SELECT m.meta_value FROM %i m JOIN %i o ON o.ID=m.post_id AND o.post_type='shop_order' WHERE m.meta_key='_billing_email' AND m.meta_value<>'' ORDER BY m.post_id LIMIT 1 OFFSET %d", OrderArc_Engine::live( 'postmeta' ), OrderArc_Engine::live( 'posts' ), (int) ( $total / 2 ) ) ); if ( ! $email ) { $email = 'orderarc-test@example.com'; } $t = microtime( true ); if ( $hpos ) { wc_get_orders( array( 's' => $email, 'limit' => 20, 'return' => 'ids', 'type' => 'shop_order' ) ); } else { wc_order_search( $email ); } $ms = (int) round( ( microtime( true ) - $t ) * 1000 ); update_option( 'orderarc_search', array( 'ms' => $ms, 'time' => time(), 'orders' => $total ), false ); wp_send_json_success( array( 'ms' => $ms ) ); } /* ---------- Datos ---------- */ public static function live_orders() { global $wpdb; return OrderArc_Engine::storage()['hpos'] // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- conteo de pedidos para el resumen; no hay API sin cargar los pedidos. ? (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE type='shop_order'", OrderArc_Engine::live( 'wc_orders' ) ) ) : (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_type='shop_order'", OrderArc_Engine::live( 'posts' ) ) ); } /** Estados que se pueden archivar: todos menos los que siguen en curso. */ public static function status_choices() { $all = function_exists( 'wc_get_order_statuses' ) ? wc_get_order_statuses() : array(); foreach ( array( 'wc-pending', 'wc-processing', 'wc-on-hold', 'wc-checkout-draft' ) as $k ) { unset( $all[ $k ] ); } return $all; } private static function storage_label( $st = null ) { $st = $st ? $st : OrderArc_Engine::storage(); return ( $st['hpos'] ? 'HPOS' : 'Legacy' ) . ( $st['sync'] ? ' + sync' : '' ); } private static function fmt_date( $gmt ) { if ( ! $gmt || '0000-00-00 00:00:00' === $gmt ) { return ''; } return wp_date( get_option( 'date_format' ), strtotime( $gmt . ' UTC' ) ); // en la zona horaria del sitio. } private static function status_label( $status ) { $all = function_exists( 'wc_get_order_statuses' ) ? wc_get_order_statuses() : array(); $key = 0 === strpos( $status, 'wc-' ) ? $status : 'wc-' . $status; return $all[ $key ] ?? $status; } private static function money( $amount, $currency ) { return function_exists( 'wc_price' ) ? wc_price( (float) $amount, array( 'currency' => $currency ) ) : esc_html( $amount ); } /* ---------- Pantalla ---------- */ public static function render() { if ( ! current_user_can( self::CAP ) ) { return; } $tab = sanitize_key( $_GET['tab'] ?? 'overview' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- navegación. $tabs = array( 'overview' => __( 'Overview', 'orderarc' ), 'archive' => __( 'Archive', 'orderarc' ), 'archived' => __( 'Archived orders', 'orderarc' ), ); // Pestañas extra (versión Pro): clave => título; se dibujan con la acción orderarc_tab_{clave}. $tabs = (array) apply_filters( 'orderarc_admin_tabs', $tabs ); if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'overview'; } echo '
' . nl2br( esc_html( $msg ) ) . '
' . esc_html( OrderArc_Engine::archive_blocked() ) . '
' . esc_html__( 'OrderArc needs a MySQL or MariaDB database. This site uses a different database, so archiving is turned off.', 'orderarc' ) . '
' . esc_html( sprintf( __( 'Orders were archived while the store used %1$s. The store now uses %2$s.', 'orderarc' ), $label[ $was ], $label[ $now ] ) ) . '
' . esc_html__( 'OrderArc Pro adds archive rules, WP-CLI, a monthly report, an archive in another database, and retention and GDPR tools.', 'orderarc' ) . ' ' . esc_html__( 'Learn more', 'orderarc' ) . '
'; } } /** En los ajustes la casilla de backup solo hace falta si se activa el archivado automático. */ private static function backup_box_optional() { if ( ! get_option( 'orderarc_backup_ack' ) ) { echo ''; } } /** Etiquetas de formulario permitidas al imprimir los campos de ajustes. */ private static function form_tags() { return array( 'input' => array( 'type' => true, 'name' => true, 'id' => true, 'min' => true, 'max' => true, 'class' => true, 'value' => true ), 'select' => array( 'name' => true ), 'option' => array( 'value' => true, 'selected' => true ), ); } private static function hour_select( $name, $val ) { $h = ''; } /** Línea de estado del trabajo (también la usa el refresco por AJAX). */ public static function status_html( $job ) { $st = $job['status'] ?? ''; if ( ! $st ) { return ''; } $count = (int) ( $job['orders'] ?? 0 ); $n = number_format_i18n( $count ); $errs = (array) ( $job['errors'] ?? array() ); $dir = 'restore' === ( $job['direction'] ?? '' ); switch ( $st ) { case 'running': $line = $dir /* translators: 1: number of orders, 2: number of errors */ ? sprintf( _n( 'Running — %1$s order restored · %2$s errors', 'Running — %1$s orders restored · %2$s errors', $count, 'orderarc' ), $n, number_format_i18n( count( $errs ) ) ) /* translators: 1: number of orders, 2: number of errors */ : sprintf( _n( 'Running — %1$s order archived · %2$s errors', 'Running — %1$s orders archived · %2$s errors', $count, 'orderarc' ), $n, number_format_i18n( count( $errs ) ) ); break; case 'waiting': /* translators: %s: time, e.g. 01:00 */ $line = sprintf( __( 'Waiting for the time window (opens at %s)', 'orderarc' ), sprintf( '%02d:00', (int) OrderArc_Runner::settings()['window_start'] ) ); break; case 'paused': $line = __( 'Paused', 'orderarc' ); break; case 'done': $mins = max( 1, (int) round( ( ( $job['finished'] ?? time() ) - ( $job['started'] ?? time() ) ) / 60 ) ); $line = $dir /* translators: 1: number of orders, 2: minutes */ ? sprintf( _n( 'Done: %1$s order restored in %2$s min', 'Done: %1$s orders restored in %2$s min', $count, 'orderarc' ), $n, number_format_i18n( $mins ) ) /* translators: 1: number of orders, 2: minutes */ : sprintf( _n( 'Done: %1$s order in %2$s min', 'Done: %1$s orders in %2$s min', $count, 'orderarc' ), $n, number_format_i18n( $mins ) ); break; default: $line = ''; } $html = '' . esc_html( $line ) . '
'; if ( $errs ) { $html .= '| '; foreach ( array( __( 'Order', 'orderarc' ), __( 'Date', 'orderarc' ), __( 'Status', 'orderarc' ), __( 'Customer', 'orderarc' ), __( 'Email', 'orderarc' ), __( 'Total', 'orderarc' ), '' ) as $c ) { echo ' | ' . esc_html( $c ) . ' | '; } echo '||||||
|---|---|---|---|---|---|---|---|
| ' . esc_html__( 'No archived orders found.', 'orderarc' ) . ' | |||||||
| '; echo ' | #' . esc_html( $id ) . ' | '; echo '' . esc_html( self::fmt_date( $o['date'] ) ) . ' | ' . esc_html( self::status_label( $o['status'] ) ) . ' | '; echo '' . esc_html( $o['customer'] ) . ' | ' . esc_html( $o['email'] ) . ' | ' . wp_kses_post( self::money( $o['total'], $o['currency'] ) ) . ' | '; echo '' . esc_html__( 'View', 'orderarc' ) . ' '; echo ' |
← ' . esc_html__( 'Archived orders', 'orderarc' ) . '
'; if ( ! $o ) { echo '' . esc_html__( 'No archived orders found.', 'orderarc' ) . '
'; return; } $cur = $o['currency']; echo '' . esc_html( self::fmt_date( $o['date'] ) ) . ' · ' . esc_html( self::status_label( $o['status'] ) ) . ( $o['payment'] ? ' · ' . esc_html( $o['payment'] ) : '' ) . '
'; echo '' . wp_kses_post( $o['billing'] ) . '
' . esc_html( $o['email'] ) . ( $o['phone'] ? '
' . esc_html( $o['phone'] ) : '' ) . '
' . wp_kses_post( $o['shipping'] ) . '
' . esc_html( $o['note'] ) . '
'; } echo '| ' . esc_html__( 'Item', 'orderarc' ) . ' | ' . esc_html__( 'Qty', 'orderarc' ) . ' | ' . esc_html__( 'Total', 'orderarc' ) . ' |
|---|---|---|
| ' . esc_html( $it['name'] ) . ' | ' . esc_html( 'line_item' === $it['type'] ? $it['qty'] : '' ) . ' | ' . wp_kses_post( self::money( (float) $it['shown'], $cur ) ) . ' |
| ' . esc_html( $label . $extra ) . ' | ' . wp_kses_post( self::money( $v, $cur ) ) . ' | |
| ' . esc_html__( 'Refunded', 'orderarc' ) . ' · ' . esc_html( self::fmt_date( $r['date'] ) ) . ( $r['reason'] ? ' · ' . esc_html( $r['reason'] ) : '' ) . ' | ' . wp_kses_post( self::money( -1 * abs( (float) $r['amount'] ), $cur ) ) . ' | |