$type, 'id' => $id, 'title' => $title, 'detail' => $detail, 'url' => admin_url( 'admin.php?page=' . $url ), ); } /** Lower case without accents (â → a, ş → s, ı → i): how the database collation compares, for ranking only. */ private static function plain( string $text ) : string { return remove_accents( mb_strtolower( str_replace( array('İ', 'ı'), array('i', 'i'), $text ) ) ); } /** * How closely a record meets the search, for ordering a group (P1-27): the database also matches inside words and * ignores accents ("kâr" finds "Kara" and "Kartlık"), which stays useful for a partial SKU, so those come last instead * of being dropped. 0: a whole text or word is the search with its accents; 1: the same without accents; 2: a text or * word starts with it; 3: the same without accents; 4: it is inside a word. Every word of the search counts; the * weakest decides. */ public static function rank( string $query, string ... $texts ) : int { // "İ" lowers to "i" (mb_strtolower would add a combining dot that splits the word). $lower = static fn( string $text ): string => mb_strtolower( str_replace( 'İ', 'i', trim( $text ) ) ); $terms = ( preg_split( '/[^\\p{L}\\p{N}]+/u', $lower( $query ), -1, PREG_SPLIT_NO_EMPTY ) ?: array() ); if ( !$terms ) { return 4; } $words = array(); foreach ( $texts as $text ) { $text = $lower( $text ); if ( $text === '' ) { continue; } $words[] = $text; // A whole code or number ("SKU-12", "1045") is a word too. array_push( $words, ...( preg_split( '/[^\\p{L}\\p{N}]+/u', $text, -1, PREG_SPLIT_NO_EMPTY ) ?: array() ) ); } $worst = 0; foreach ( $terms as $term ) { $best = 4; $plain = self::plain( $term ); foreach ( $words as $word ) { $folded = self::plain( $word ); $rank = ( $word === $term ? 0 : (( $folded === $plain ? 1 : (( str_starts_with( $word, $term ) ? 2 : (( str_starts_with( $folded, $plain ) ? 3 : 4 )) )) )) ); $best = min( $best, $rank ); if ( $best === 0 ) { break; } } $worst = max( $worst, $best ); } return $worst; } /** The rows best matching first (rank()); rows of the same rank keep the order the source gave them. */ private static function ranked( array $rows, string $query, callable $texts ) : array { $keyed = array(); foreach ( array_values( $rows ) as $i => $row ) { $keyed[] = array(self::rank( $query, ...array_map( 'strval', $texts( $row ) ) ), $i, $row); } usort( $keyed, static fn( $a, $b ) => ( $a[0] <=> $b[0] ?: $a[1] <=> $b[1] ) ); return array_column( $keyed, 2 ); } /** Legacy WooCommerce ignores meta_query for orders; HPOS supports it. */ private static function tracking_orders( string $query ) : array { if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { return wc_get_orders( array( 'type' => 'shop_order', 'limit' => 12, 'return' => 'objects', 'meta_query' => array(array( 'key' => '_storesuit_shipments', 'value' => $query, 'compare' => 'LIKE', )), ) ); } global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID=pm.post_id WHERE pm.meta_key=%s AND pm.meta_value LIKE %s AND p.post_type='shop_order' AND p.post_status NOT IN ('trash','auto-draft') ORDER BY pm.meta_id DESC LIMIT 20", '_storesuit_shipments', '%' . $wpdb->esc_like( $query ) . '%' ) ); return array_values( array_filter( array_map( 'wc_get_order', array_map( 'intval', $ids ) ), static fn( $order ) => $order instanceof \WC_Order ) ); } public static function read( array $data ) : array { Store::require_cap( 'storesuit_orders' ); $query = trim( Store::text( $data, 'search' ) ); if ( mb_strlen( $query ) < 2 ) { return array( 'query' => $query, 'groups' => array(), 'total' => 0, ); } if ( mb_strlen( $query ) > 80 ) { throw new \InvalidArgumentException(I18n::t( 'Search with 80 characters or fewer.' )); } $groups = array(); $seen_orders = array(); $order_rows = array(); // Order numbers are shown with a leading #. Keep a product's literal SKU query intact in the other groups. $order_query = ( str_starts_with( $query, '#' ) ? trim( substr( $query, 1 ) ) : $query ); if ( $order_query === '' ) { $order_query = $query; } if ( get_option( 'storesuit_order_index_ready' ) === '1' ) { $found = OrderIndex::query( array( 'search' => $order_query, 'page' => 1, ) ); $channel_labels = array( 'web' => 'Web store', 'pos' => 'Store / POS', 'instagram' => 'Instagram', 'whatsapp' => 'WhatsApp', 'facebook' => 'Facebook', 'phone' => 'Phone', 'b2b' => 'B2B', 'other' => 'Other', ) + Orders::MARKETS; foreach ( array_slice( self::ranked( $found['orders'], $order_query, static fn( $o ) => array($o['number'] ?? '', $o['name'] ?? '') ), 0, 6 ) as $order ) { $seen_orders[(int) $order['id']] = true; $channel = $channel_labels[(string) $order['channel']] ?? (string) $order['channel']; $order_rows[] = self::result( 'order', (int) $order['id'], I18n::format( 'Order #%s', $order['number'] ), trim( (( $order['name'] ?: I18n::t( 'Guest' ) )) . ' · ' . I18n::t( 'Sales channel' ) . ': ' . I18n::t( $channel ) ), 'storesuit-orders&order=' . (int) $order['id'] ); } } if ( mb_strlen( $query ) >= 3 ) { $tracking_orders = self::tracking_orders( $query ); foreach ( $tracking_orders as $order ) { if ( isset( $seen_orders[$order->get_id()] ) ) { continue; } $tracking = ''; foreach ( (array) $order->get_meta( '_storesuit_shipments' ) as $shipment ) { if ( stripos( (string) ($shipment['tracking'] ?? ''), $query ) !== false ) { $tracking = (string) $shipment['tracking']; break; } } if ( $tracking === '' ) { continue; } $seen_orders[$order->get_id()] = true; $order_rows[] = self::result( 'tracking', $order->get_id(), I18n::format( 'Tracking %s', $tracking ), I18n::format( 'Order #%s', $order->get_order_number() ), 'storesuit-orders&order=' . $order->get_id() ); if ( count( $order_rows ) >= 6 ) { break; } } } if ( $order_rows ) { $groups[] = array( 'key' => 'orders', 'label' => I18n::t( 'Orders and tracking' ), 'items' => $order_rows, ); } $product_rows = array(); $products = Products::search( array( 'search' => $query, 'page' => 1, ) ); foreach ( array_slice( self::ranked( $products['rows'], $query, static fn( $p ) => array($p['name'] ?? '', $p['sku'] ?? '', $p['barcode'] ?? '') ), 0, 6 ) as $product ) { $code = ( $product['sku'] ?: (( $product['barcode'] ?: '#' . $product['id'] )) ); // Without product access: the orders with this product code; the paid build opens a new sale with it. $find = 'storesuit-orders&search=' . rawurlencode( $code ); $url = ( current_user_can( 'manage_woocommerce' ) ? 'storesuit-products&id=' . (int) $product['id'] : $find ); // The record number is no product code a merchant could type or print: say there is none instead of "Product code: #47". $product_rows[] = self::result( 'product', (int) $product['id'], (string) $product['name'], ( (( $product['sku'] ?: $product['barcode'] )) ? I18n::format( 'Product code: %s', $code ) : I18n::t( 'No product code' ) ), $url ); } if ( $product_rows ) { $groups[] = array( 'key' => 'products', 'label' => I18n::t( 'Products, SKU, and barcode' ), 'items' => $product_rows, ); } $customer_rows = array(); foreach ( array_slice( self::ranked( Customers::search( array( 'search' => $query, ) ), $query, static fn( $c ) => array($c['name'] ?? '', $c['email'] ?? '', $c['phone'] ?? '') ), 0, 6 ) as $customer ) { $lookup = ( $customer['phone'] ?: (( $customer['email'] ?: $customer['name'] )) ); $url = ( current_user_can( 'manage_woocommerce' ) ? 'storesuit-customers&id=' . (int) $customer['id'] : 'storesuit-orders&search=' . rawurlencode( $lookup ) ); $customer_rows[] = self::result( 'customer', (int) $customer['id'], (string) $customer['name'], implode( ' · ', array_filter( array($customer['phone'], $customer['email']) ) ), $url ); } if ( $customer_rows ) { $groups[] = array( 'key' => 'customers', 'label' => I18n::t( 'Customers' ), 'items' => $customer_rows, ); } return array( 'query' => $query, 'groups' => $groups, 'total' => array_sum( array_map( static fn( $group ) => count( $group['items'] ), $groups ) ), ); } }