api = new API(); add_action( 'admin_init', array( $this, 'handle_actions' ) ); } /** * Handle all admin actions * * @return void */ public function handle_actions() { // Handle per page update. if ( isset( $_POST['aistpr_per_page_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aistpr_per_page_nonce'] ) ), 'aistpr_update_per_page' ) ) { if ( current_user_can( 'manage_woocommerce' ) && isset( $_POST['aistpr_per_page'] ) ) { $per_page = absint( $_POST['aistpr_per_page'] ); if ( $per_page > 0 ) { update_user_meta( get_current_user_id(), 'aistpr_predictions_per_page', $per_page ); } wp_safe_redirect( add_query_arg( array( 'page' => 'ai-stock-predictor', 'updated' => '1', ), admin_url( 'admin.php' ) ) ); exit; } } // Handle refresh actions. if ( isset( $_GET['aistpr_refresh'], $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'aistpr_refresh_dashboard' ) ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'ai-stock-predictor' ) ); } // Handle individual product refresh. if ( isset( $_GET['product_id'] ) ) { $product_id = absint( $_GET['product_id'] ); delete_transient( 'aistpr_prediction_' . $product_id ); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-stock-predictor', 'refreshed' => $product_id, ), admin_url( 'admin.php' ) ) ); exit; } // Handle bulk refresh. $this->clear_all_cache(); wp_safe_redirect( add_query_arg( array( 'page' => 'ai-stock-predictor', 'refreshed' => 'all', ), admin_url( 'admin.php' ) ) ); exit; } } /** * Clear all plugin cache * * @return void */ private function clear_all_cache() { global $wpdb; // Clear transients. delete_transient( 'aistpr_recent_predictions' ); delete_transient( 'aistpr_stock_statistics' ); delete_transient( 'aistpr_total_products' ); // Clear all prediction transients - direct query needed for bulk deletion pattern matching. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_aistpr_prediction_%'" ); $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_aistpr_prediction_%'" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Get items per page setting. * * @return int */ private function get_items_per_page() { $saved_value = get_user_meta( get_current_user_id(), 'aistpr_predictions_per_page', true ); return $saved_value ? absint( $saved_value ) : $this->default_per_page; } /** * Render dashboard page. * * @since 1.0.0 * @return void */ public function render() { $current_page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; $per_page = $this->get_items_per_page(); $offset = ( $current_page - 1 ) * $per_page; $recent_predictions = $this->get_recent_predictions( $per_page, $offset ); $total_items = $this->get_total_products(); $total_pages = ceil( $total_items / $per_page ); $stock_stats = $this->get_stock_statistics(); $refresh_url = wp_nonce_url( add_query_arg( array( 'page' => 'ai-stock-predictor', 'aistpr_refresh' => '1', ), admin_url( 'admin.php' ) ), 'aistpr_refresh_dashboard' ); require_once AISTPR_TEMPLATE_PATH_MAIN . 'admin/dashboard.php'; } /** * Get total number of products. * * @return int */ private function get_total_products() { $total = get_transient( 'aistpr_total_products' ); if ( false !== $total ) { return (int) $total; } global $wpdb; // Direct query with JOIN is more efficient than WP_Query for this count operation. // Result is cached in transient for 1 hour. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $total = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}posts p LEFT JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id AND pm.meta_key = '_stock' WHERE p.post_type = 'product' AND p.post_status = 'publish' AND pm.meta_value IS NOT NULL AND pm.meta_value > 0" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching set_transient( 'aistpr_total_products', $total, HOUR_IN_SECONDS ); return (int) $total; } /** * Get stock statistics. * * @return array */ private function get_stock_statistics() { $cached_stats = get_transient( 'aistpr_stock_statistics' ); if ( false !== $cached_stats ) { return $cached_stats; } $stats = array( 'total_products' => 0, 'low_stock' => 0, 'out_of_stock' => 0, 'total_stock_value' => 0, ); $products = wc_get_products( array( 'limit' => -1, 'status' => 'publish', 'type' => array( 'simple', 'variable' ), ) ); foreach ( $products as $product ) { if ( ! $product || ! is_object( $product ) ) { continue; } ++$stats['total_products']; if ( $product->managing_stock() ) { $stock = $product->get_stock_quantity(); $low_stock_amount = get_option( 'woocommerce_notify_low_stock_amount' ); if ( 0 === $stock || '' === $stock ) { ++$stats['out_of_stock']; } elseif ( $stock <= $low_stock_amount ) { ++$stats['low_stock']; } $regular_price = (float) $product->get_regular_price(); if ( $stock > 0 && $regular_price > 0 ) { $stats['total_stock_value'] += $stock * $regular_price; } } } set_transient( 'aistpr_stock_statistics', $stats, HOUR_IN_SECONDS ); return $stats; } /** * Get recent stock predictions. * * @param int $limit Number of predictions to fetch. * @param int $offset Offset for pagination. * @return array */ private function get_recent_predictions( $limit = 20, $offset = 0 ) { global $wpdb; // Direct query with multiple JOINs and custom fields is more efficient than WP_Query. // Results vary by pagination, so not suitable for object caching. // Individual predictions are cached via transients in get_product_prediction(). // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $predictions = $wpdb->get_results( $wpdb->prepare( "SELECT p.*, pm.meta_value as current_stock, pm2.meta_value as regular_price FROM {$wpdb->prefix}posts p LEFT JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id AND pm.meta_key = '_stock' LEFT JOIN {$wpdb->prefix}postmeta pm2 ON p.ID = pm2.post_id AND pm2.meta_key = '_regular_price' WHERE p.post_type = 'product' AND p.post_status = 'publish' AND pm.meta_value IS NOT NULL AND pm.meta_value > 0 ORDER BY p.post_modified DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( empty( $predictions ) ) { return array(); } $formatted_predictions = array(); foreach ( $predictions as $product ) { $product_id = $product['ID']; $wc_product = wc_get_product( $product_id ); if ( ! $wc_product ) { continue; } // Get cached or new prediction. $prediction = $this->get_product_prediction( $product_id ); if ( ! $prediction ) { continue; } $current_stock = (int) $product['current_stock']; $regular_price = (float) $product['regular_price']; $formatted_predictions[] = array( 'id' => $product_id, 'name' => $product['post_title'], 'current_stock' => $current_stock, 'regular_price' => $regular_price, 'stock_value' => $current_stock * $regular_price, 'stockout_date' => $prediction['stockout_date'], 'risk_level' => $prediction['risk_level'], 'sales_velocity' => $this->get_sales_velocity( $product_id ), 'lead_time' => $this->get_lead_time( $product_id ), 'edit_url' => get_edit_post_link( $product_id ), 'view_url' => get_permalink( $product_id ), 'sku' => $wc_product->get_sku(), 'categories' => wp_strip_all_tags( wc_get_product_category_list( $product_id ) ), 'prediction_source' => isset( $prediction['source'] ) ? $prediction['source'] : 'api', ); } return $formatted_predictions; } /** * Get product prediction * * @param int $product_id Product ID. * @return array|false */ private function get_product_prediction( $product_id ) { $cached_prediction = get_transient( 'aistpr_prediction_' . $product_id ); if ( false !== $cached_prediction ) { $cached_prediction['source'] = 'cache'; return $cached_prediction; } $product_data = $this->prepare_product_data( $product_id ); $prediction = $this->api->generate_prediction( $product_data ); if ( is_wp_error( $prediction ) ) { $prediction = $this->api->generate_fallback_prediction( $product_data ); } if ( $prediction ) { $prediction['source'] = 'api'; set_transient( 'aistpr_prediction_' . $product_id, $prediction, HOUR_IN_SECONDS ); } return $prediction; } /** * Get sales velocity for a product * * @param int $product_id Product ID. * @return array */ private function get_sales_velocity( $product_id ) { return array( '30' => round( $this->get_sales_count( $product_id, 30 ) / 30, 2 ), '60' => round( $this->get_sales_count( $product_id, 60 ) / 60, 2 ), '90' => round( $this->get_sales_count( $product_id, 90 ) / 90, 2 ), ); } /** * Get lead time for a product * * @param int $product_id Product ID. * @return int */ private function get_lead_time( $product_id ) { return (int) get_post_meta( $product_id, '_aistpr_lead_time', true ) ?: get_option( 'aistpr_default_lead_time', 7 ); } /** * Prepare product data for prediction * * @param int $product_id Product ID. * @return array */ private function prepare_product_data( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return array(); } return array( 'sales_velocity' => $this->get_sales_velocity( $product_id ), 'current_stock' => $product->get_stock_quantity(), 'lead_time' => $this->get_lead_time( $product_id ), ); } /** * Get sales count for a product in a given period * * @param int $product_id Product ID. * @param int $days Number of days to look back. * @return int */ private function get_sales_count( $product_id, $days ) { global $wpdb; $date = gmdate( 'Y-m-d', strtotime( "-{$days} days" ) ); // Direct query needed to access WooCommerce order tables with complex JOINs. // No WordPress API exists for this specific aggregate query. // Results are cached within get_sales_velocity() via prediction transients. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(order_item_meta.meta_value), 0) FROM {$wpdb->prefix}woocommerce_order_items as order_items LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta ON order_items.order_item_id = order_item_meta.order_item_id LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as product_id_meta ON order_items.order_item_id = product_id_meta.order_item_id LEFT JOIN {$wpdb->posts} as orders ON order_items.order_id = orders.ID WHERE orders.post_type = 'shop_order' AND orders.post_status IN ('wc-completed', 'wc-processing') AND order_item_meta.meta_key = '_qty' AND product_id_meta.meta_key = '_product_id' AND product_id_meta.meta_value = %d AND orders.post_date >= %s", $product_id, $date ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } }