> */ public static function rows( string $search = '' ) : array { $out = array(); foreach ( self::all() as $key => $g ) { if ( self::matches( $g, $search ) ) { $out[] = self::row( $key, $g ); } } return $out; } /** The phone a guest key stands for (its latest order's, or the ledger's); refused when no guest has it. */ public static function phone( string $key ) : string { $g = self::all()[$key] ?? null; if ( !$g || $g['phone'] === '' ) { throw new \InvalidArgumentException(I18n::t( 'Customer not found.' )); } return $g['phone']; } public static function row( string $key, array $g ) : array { return array( 'id' => 'g' . $key, 'guest' => true, 'name' => $g['name'], 'email' => $g['email'], 'phone' => $g['phone'], 'company' => $g['company'], 'team' => false, 'sample' => false, 'orders' => $g['orders'], 'spent' => (object) $g['spent'], ); } /** All guests, read once per request: phone key => name, e-mail, phone, company, orders, spent, last order. */ public static function all() : array { if ( self::$all !== null ) { return self::$all; } $all = array(); $owned = self::owned(); if ( get_option( 'storesuit_order_index_ready' ) === '1' && get_option( 'storesuit_order_index_schema' ) === '3' ) { $places = wc_get_price_decimals(); $last = array(); foreach ( self::bought() as $row ) { $key = (string) $row['k']; if ( isset( $owned[$key] ) ) { continue; } $g = $all[$key] ?? array( 'name' => '', 'email' => '', 'phone' => '', 'company' => '', 'orders' => 0, 'spent' => array(), 'last' => 0, ); $g['orders'] += (int) $row['orders']; $currency = ( (string) $row['currency'] ?: get_woocommerce_currency() ); $g['spent'][$currency] = round( ($g['spent'][$currency] ?? 0) + (float) $row['spent'], $places ); $g['last'] = max( $g['last'], (int) $row['last_id'] ); $all[$key] = $g; } foreach ( $all as $key => $g ) { $last[$g['last']] = $key; } foreach ( self::billing( array_keys( $last ) ) as $order_id => $b ) { $key = $last[$order_id]; $all[$key] = array_merge( $all[$key], array( 'name' => ( trim( $b['first_name'] . ' ' . $b['last_name'] ) ?: $b['company'] ), 'email' => $b['email'], 'phone' => $b['phone'], 'company' => $b['company'], ) ); } } foreach ( $all as $key => $g ) { if ( $g['phone'] === '' ) { unset($all[$key]); } } uasort( $all, static fn( $a, $b ) => $b['last'] <=> $a['last'] ); return self::$all = $all; } private static ?array $all = null; /** Forget what all() read (a list read again in the same request, after an order or a collection). */ public static function forget() : void { self::$all = null; } /** * Phone keys an account owns: the one account with the number as its billing phone, or of several the one the team chose * (Denetim R3-1, the option Finance keeps). Its guest orders and ledger debt are that account's. */ private static function owned() : array { global $wpdb; $holders = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT user_id,meta_value FROM %i WHERE meta_key=%s', $wpdb->usermeta, 'billing_phone' ) ) as $row ) { $phone = Store::normalize_phone( (string) $row->meta_value ); if ( $phone !== '' ) { $holders[$phone][(int) $row->user_id] = true; } } $chosen = (array) get_option( 'storesuit_phone_owner', array() ); $owned = array(); foreach ( $holders as $phone => $ids ) { $phone = (string) $phone; $id = ( count( $ids ) === 1 ? (int) key( $ids ) : (int) ($chosen[$phone] ?? 0) ); if ( $id && isset( $ids[$id] ) ) { $owned[OrderIndex::phone_key( $phone )] = $id; } } return $owned; } /** The guest orders per phone key and currency, by CustomerDirectory::bought()'s rules: count, total after refunds, newest id. */ private static function bought() : array { global $wpdb; $statuses = array_values( array_diff( SaleStatus::orders(), array('wc-cancelled', 'wc-failed', 'wc-checkout-draft') ) ); $web = ( Orders::web_checkout_sources() ?: array('checkout') ); if ( class_exists( '\\Automattic\\WooCommerce\\Utilities\\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { $orders = $wpdb->prefix . 'wc_orders'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT i.phone_key AS k, o.currency AS currency, COUNT(*) AS orders, SUM(GREATEST(0, o.total_amount + COALESCE((SELECT SUM(r.total_amount) FROM %i r WHERE r.parent_order_id=o.id AND r.type='shop_order_refund'),0))) AS spent, MAX(o.id) AS last_id FROM %i i JOIN %i o ON o.id=i.order_id LEFT JOIN %i d ON d.order_id=o.id WHERE i.customer_id=0 AND i.phone_key<>'' AND o.type='shop_order' AND o.customer_id=0 AND o.status IN (" . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ") AND NOT (o.status='wc-pending' AND COALESCE(d.created_via,'') IN (" . implode( ',', array_fill( 0, count( $web ), '%s' ) ) . ")) GROUP BY i.phone_key, o.currency", array_merge( array( $orders, OrderIndex::table(), $orders, $wpdb->prefix . 'wc_order_operational_data' ), $statuses, $web ) ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT i.phone_key AS k, COALESCE(cur.meta_value,'') AS currency, COUNT(*) AS orders, SUM(GREATEST(0, CAST(COALESCE(t.meta_value,'0') AS DECIMAL(26,8)) + COALESCE((SELECT SUM(CAST(rm.meta_value AS DECIMAL(26,8))) FROM %i rp JOIN %i rm ON rm.post_id=rp.ID AND rm.meta_key='_order_total' WHERE rp.post_parent=p.ID AND rp.post_type='shop_order_refund'),0))) AS spent, MAX(p.ID) AS last_id FROM %i i JOIN %i p ON p.ID=i.order_id LEFT JOIN %i t ON t.post_id=p.ID AND t.meta_key='_order_total' LEFT JOIN %i cur ON cur.post_id=p.ID AND cur.meta_key='_order_currency' LEFT JOIN %i v ON v.post_id=p.ID AND v.meta_key='_created_via' LEFT JOIN %i c ON c.post_id=p.ID AND c.meta_key='_customer_user' WHERE i.customer_id=0 AND i.phone_key<>'' AND p.post_type='shop_order' AND COALESCE(c.meta_value,'0') IN ('','0') AND p.post_status IN (" . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ") AND NOT (p.post_status='wc-pending' AND COALESCE(v.meta_value,'') IN (" . implode( ',', array_fill( 0, count( $web ), '%s' ) ) . ")) GROUP BY i.phone_key, currency", array_merge( array( $wpdb->posts, $wpdb->postmeta, OrderIndex::table(), $wpdb->posts, $wpdb->postmeta, $wpdb->postmeta, $wpdb->postmeta, $wpdb->postmeta ), $statuses, $web ) ), ARRAY_A ); } if ( $wpdb->last_error !== '' ) { throw new \RuntimeException(I18n::t( 'Customer accounts could not be read. Try again shortly.' )); } return (array) $rows; } /** The billing name, company, e-mail and phone of these orders, 500 at a time. @return array> */ private static function billing( array $ids ) : array { global $wpdb; $out = array(); $hpos = class_exists( '\\Automattic\\WooCommerce\\Utilities\\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); foreach ( array_chunk( array_values( array_filter( array_map( 'intval', $ids ) ) ), 500 ) as $chunk ) { if ( $hpos ) { foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT order_id,first_name,last_name,company,email,phone FROM %i WHERE address_type='billing' AND order_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', array_merge( array($wpdb->prefix . 'wc_order_addresses'), $chunk ) ), ARRAY_A ) as $row ) { $out[(int) $row['order_id']] = array_map( 'strval', array( 'first_name' => $row['first_name'], 'last_name' => $row['last_name'], 'company' => $row['company'], 'email' => $row['email'], 'phone' => $row['phone'], ) ); } } else { foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT post_id,meta_key,meta_value FROM %i WHERE meta_key IN ('_billing_first_name','_billing_last_name','_billing_company','_billing_email','_billing_phone') AND post_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', array_merge( array($wpdb->postmeta), $chunk ) ), ARRAY_A ) as $row ) { $id = (int) $row['post_id']; $out[$id] = $out[$id] ?? array( 'first_name' => '', 'last_name' => '', 'company' => '', 'email' => '', 'phone' => '', ); $out[$id][substr( (string) $row['meta_key'], 9 )] = (string) $row['meta_value']; } } } return $out; } /** * The directory's search on a guest: every word (two characters or more, at most four) in the name, company or e-mail, or * with three digits or more in the phone; a query of phone characters only matches the number however it is spaced, with * or without its leading zero (CustomerDirectory::filter_args()). */ private static function matches( array $g, string $search ) : bool { $search = trim( $search ); if ( $search === '' ) { return true; } $text = mb_strtolower( $g['name'] . ' ' . $g['company'] . ' ' . $g['email'] ); $phone = (string) preg_replace( '/\\D+/', '', $g['phone'] ); $in_phone = static function ( string $digits ) use($phone) : bool { $national = ltrim( $digits, '0' ); return $digits !== '' && (str_contains( $phone, $digits ) || $national !== $digits && strlen( $national ) >= 7 && str_contains( $phone, $national )); }; $number = (string) preg_replace( '/\\D+/', '', $search ); if ( strlen( $number ) >= 3 && preg_match( '/^[\\d\\s+\\-().\\/\\x{00A0}]+$/u', $search ) && $in_phone( $number ) ) { return true; } $words = array_slice( array_values( array_filter( ( preg_split( '/\\s+/u', $search, -1, PREG_SPLIT_NO_EMPTY ) ?: array() ), static fn( $w ) => mb_strlen( $w ) >= 2 ) ), 0, 4 ); if ( !$words ) { return false; } foreach ( $words as $word ) { $digits = (string) preg_replace( '/\\D+/', '', $word ); if ( !str_contains( $text, mb_strtolower( $word ) ) && !(strlen( $digits ) >= 3 && $in_phone( $digits )) ) { return false; } } return true; } /** * A guest's card: the contact of the latest order, the orders under the number (the latest 20 shown), the totals per * currency and, paid build with the finance permission, the one account Finance::account() keeps for the number. */ public static function card( string $id ) : array { Store::require_cap( 'manage_woocommerce' ); $key = self::key( $id ); $g = self::all()[$key] ?? null; if ( !$g ) { throw new \InvalidArgumentException(I18n::t( 'Customer not found.' )); } global $wpdb; $totals = array(); $recent = array(); $count = 0; $last = ''; $channels = array(); $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT order_id FROM %i WHERE customer_id=0 AND phone_key=%s ORDER BY order_id DESC LIMIT 200', OrderIndex::table(), $key ) ) ); foreach ( $ids as $order_id ) { $order = wc_get_order( $order_id ); if ( !$order instanceof \WC_Order || $order->get_type() !== 'shop_order' || $order->get_customer_id() || SaleStatus::quote( $order ) ) { continue; } $created = ( $order->get_date_created() ? $order->get_date_created()->date( 'c' ) : '' ); $channel = (string) Orders::channel( $order ); $abandoned = Orders::abandoned_checkout( $order ); if ( !$order->has_status( array('cancelled', 'failed', 'trash') ) && !$abandoned ) { $currency = $order->get_currency(); $totals[$currency] = $totals[$currency] ?? array( 'orders' => 0, 'outstanding' => 0, 'collected' => 0, 'sales' => 0, ); ++$totals[$currency]['orders']; ++$count; $totals[$currency]['sales'] += max( 0, (float) $order->get_total() - (float) $order->get_total_refunded() ); $totals[$currency]['outstanding'] += Orders::outstanding( $order ); $totals[$currency]['collected'] += Orders::collected( $order ); $channels[$channel] = ($channels[$channel] ?? 0) + 1; if ( $last === '' ) { $last = $created; } } if ( count( $recent ) < 20 ) { $flow = OrderFlow::read( $order ); $recent[] = array( 'id' => $order->get_id(), 'number' => $order->get_order_number(), 'total' => $order->get_total(), 'currency' => $order->get_currency(), 'status' => $order->get_status(), 'stage' => $flow['stage'], 'stage_label' => $flow['label'], 'awaiting_payment' => !$abandoned && Orders::outstanding( $order ) > 0, 'abandoned' => $abandoned, 'channel' => $channel, 'created' => $created, ); } } foreach ( $totals as &$total ) { foreach ( array('sales', 'outstanding', 'collected') as $f ) { $total[$f] = round( $total[$f], wc_get_price_decimals() ); } } unset($total); $due = array(); foreach ( $totals as $currency => $total ) { if ( $total['outstanding'] > 0 ) { $due[$currency] = $total['outstanding']; } } $account = array( 'user_id' => 0, 'phone' => '', 'ambiguous' => false, 'totals' => $due, 'manual' => 0, 'orders' => array(), ); $finance = current_user_can( 'storesuit_finance' ) || current_user_can( Roles::COLLECT ); $collections = array(); if ( !$finance ) { $account = array( 'user_id' => 0, 'phone' => '', 'ambiguous' => false, 'totals' => array(), 'manual' => 0, 'orders' => array(), ); foreach ( $totals as &$total ) { unset($total['outstanding'], $total['collected']); } unset($total); } return array( 'id' => 'g' . $key, 'guest' => true, 'name' => $g['name'], 'email' => $g['email'], 'phone' => $g['phone'], 'company' => $g['company'], 'finance' => $finance, 'account' => $account, 'collections' => $collections, 'totals' => $totals, 'orders' => $recent, 'count' => $count, 'channels' => $channels, 'last_order' => $last, ); } }