' ) ) { return; } if ( get_option( 'storesuit_schema' ) !== '2' || get_option( 'storesuit_money_schema' ) !== '1' || get_option( 'storesuit_order_index_schema' ) !== '3' || get_option( OrderIndex::COLUMNS ) !== '1' || version_compare( STORESUIT_VERSION, '0.24.5', '>=' ) && get_option( 'storesuit_dashboard_facts_schema' ) !== '1' ) { return; } // One request upgrades (Almira, 29 Oct 2026: right after a deploy two requests each recorded 1.2.188 → 1.2.189 in the // same second). A MySQL named lock, freed with the connection should the request die; another request at the same // moment goes on without upgrading, and the version is read again from the table under the lock, so a request // that read it before the winner wrote it does not upgrade a second time. // The version can't be saved, so every request would try again: wait until the host gives the writes back (4 Nov 2026). if ( Housekeeping::writes_refused() ) { return; } global $wpdb; $lock = 'ss-upgrade-' . md5( DB_NAME . $wpdb->prefix ); if ( (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) !== '1' ) { return; } try { $stored = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, 'storesuit_version' ) ); if ( $stored !== $before ) { // This request's copy is stale: the next get_option() reads the table. $all = wp_cache_get( 'alloptions', 'options' ); if ( is_array( $all ) && isset( $all['storesuit_version'] ) ) { unset($all['storesuit_version']); wp_cache_set( 'alloptions', $all, 'options' ); } wp_cache_delete( 'storesuit_version', 'options' ); return; } self::upgrade( $before ); } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** check()'s steps from $before to this version, in the one request that holds the upgrade lock. */ private static function upgrade( string $before ) : void { update_option( 'storesuit_previous_version', $before, false ); update_option( 'storesuit_version', STORESUIT_VERSION, false ); Store::audit( 'settings', 0, 'suite_upgraded', array( 'from' => $before, 'to' => STORESUIT_VERSION, ) ); // 0.61.1: a register sale is no longer open preparation work; its index row is read again once. if ( version_compare( $before, '0.61.1', '<' ) ) { self::reindex_counter_sales(); } // 0.62.12: an abandoned web checkout is no longer payment work; the pending rows flagged unpaid are read again once. if ( version_compare( $before, '0.62.12', '<' ) ) { self::reindex_unpaid_pending(); } // 0.62.13: the index rebuild uses a MySQL named lock; a 0.62.12 option lock left by a request that died is removed. if ( version_compare( $before, '0.62.13', '<' ) ) { delete_option( 'storesuit_lock_' . md5( 'order-index-build' ) ); } // 0.65: quote lines no longer stay in WooCommerce's product lookup (SaleStatus::quote_line()). The earlier ones go once // per store, gated by SaleStatus::LINES_DROPPED rather than by the version (SaleStatus::hooks(); review of 0.65 L1). } /** * Steps that run once per store whatever version it came from and however it was updated, each gated by its own done * flag (review of 0.65 L1: a version gate misses a store already on the version, and an update by deactivating and * activating skips check()). A wp-admin page request of someone who manages the shop, or a cron request, takes them, a few * batches at a time (review of fix-woo R1). */ private static function once() : void { // 1.2.73: a request from WooCommerce's return form counts in the "Return requests" queue (OrderIndex::row()); the orders // that already carry an unreviewed one are read again once. if ( get_option( self::WITHDRAWAL_REREAD ) !== 'yes' ) { add_action( 'admin_init', array(__CLASS__, 'withdrawal_reread') ); } // Admin audit A2 (28 Oct 2026): another plugin's marketplace orders, orders paid by their status and orders in another plugin's // "shipped" status are read again once. if ( get_option( self::MARKET_REREAD ) !== 'yes' ) { add_action( 'admin_init', array(__CLASS__, 'market_reread') ); } // Almira audit SAT-2 (28 Oct 2026): another till's sales are store sales (Orders::store_sale()); the rows still waiting // for preparation are read again once. if ( get_option( self::STORE_SALE_REREAD ) !== 'yes' ) { add_action( 'admin_init', array(__CLASS__, 'store_sale_reread') ); } // Almira audit SAT-7 (28 Oct 2026): rows read before a time zone change keep the old zone's time; every row's is written // again once (OrderIndex::retime()), and again whenever the time zone changes. // A cron request starts it too (Almira on 1.2.175: no one opened wp-admin after the update, so it waited). if ( get_option( self::RETIME_ONCE ) !== 'yes' ) { add_action( 'admin_init', static function () { if ( self::page_request() ) { self::retime_once(); } } ); if ( wp_doing_cron() ) { add_action( 'init', array(__CLASS__, 'retime_once'), 30 ); } } // The site language's date and time formats, once per site language (locale_formats()). if ( get_option( self::LOCALE_FORMATS ) !== get_locale() ) { add_action( 'admin_init', static function () { if ( self::page_request() ) { self::locale_formats(); } } ); } // Both builds (IDX-1): the rows written from a stale copy of their order are read again and the earlier rows get their // amount and cash-on-delivery mark; then, once per rule, the cash-on-delivery rows follow a new cash-on-delivery rule. if ( get_option( self::INDEX_REPAIR ) !== 'yes' || get_option( self::COD_RULE_READ, self::COD_RULE_BEFORE ) !== self::cod_rule() ) { add_action( 'admin_init', array(__CLASS__, 'admin_index_repair') ); if ( wp_doing_cron() ) { add_action( 'init', array(__CLASS__, 'cron_index_repair'), 30 ); } } } /** The site language locale_formats() last ran for. */ public const LOCALE_FORMATS = 'storesuit_locale_formats'; /** Date and time formats of the languages StoreRuler is translated into, by language (PHP date() letters). */ private const FORMATS = array( 'tr' => array('j F Y', 'H:i'), 'de' => array('j. F Y', 'H:i'), 'es' => array('j \\d\\e F \\d\\e Y', 'H:i'), ); /** * WordPress keeps its English date and time formats ("F j, Y", "g:i a") whatever the site language (Almira, 30 Oct 2026: * Settings › General and every plugin wrote "Temmuz 30, 2026" and "4:36 pm"; the StoreRuler theme only changed what it * shows). In Turkish, German or Spanish the stored formats become the language's own while they are still WordPress's * defaults: a format the owner chose stays. Once per site language. Returns what it wrote. */ public static function locale_formats() : array { global $wpdb; $locale = (string) get_locale(); $own = self::FORMATS[substr( $locale, 0, 2 )] ?? null; $written = array(); foreach ( ( $own ? array( 'date_format' => array('F j, Y', $own[0]), 'time_format' => array('g:i a', $own[1]), ) : array() ) as $option => [$english, $format] ) { // The stored value: a theme may show another one through the option's filter (the StoreRuler theme does). $stored = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, $option ) ); if ( $stored !== $english ) { continue; } $raw = static fn() => $stored; add_filter( 'option_' . $option, $raw, PHP_INT_MAX ); try { update_option( $option, $format ); } finally { remove_filter( 'option_' . $option, $raw, PHP_INT_MAX ); } $written[$option] = $format; } if ( $written ) { Store::audit( 'settings', 0, 'locale_formats', array( 'locale' => $locale, 'formats' => $written, ) ); } update_option( self::LOCALE_FORMATS, $locale, true ); return $written; } private const WITHDRAWAL_REREAD = 'storesuit_order_index_withdrawal_reread'; /** admin_init: the orders holding a customer's unreviewed return request, queued for OrderIndex::sync once the index is ready. */ public static function withdrawal_reread() : void { if ( !self::page_request() || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return; } $query = array( 'limit' => -1, 'return' => 'ids', 'type' => 'shop_order', ); if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { $query['meta_query'] = array(array( 'key' => '_order_withdrawal_requested', 'value' => 'yes', ), array( 'key' => '_sr_withdrawal_reviewed', 'compare' => 'NOT EXISTS', )); } else { $query['storeruler_pending_withdrawal'] = true; } $ids = array_map( 'intval', (array) wc_get_orders( $query ) ); if ( $ids ) { OrderIndex::read_again( $ids ); } update_option( self::WITHDRAWAL_REREAD, 'yes', false ); } private const MARKET_REREAD = 'storesuit_order_index_market_reread'; /** * admin_init: the index rows Orders::market(), paid_by_status() and shipped_status() change, queued for OrderIndex::sync * once the index is ready: the web rows waiting for payment or preparation, and the web rows of orders that carry a * marketplace's mark (its source, its customers' address domain, its order number meta). */ public static function market_reread() : void { if ( !self::page_request() || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return; } global $wpdb; $index = OrderIndex::table(); $hpos = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); [$meta, $owner] = ( $hpos ? array($wpdb->prefix . 'wc_orders_meta', 'order_id') : array($wpdb->postmeta, 'post_id') ); $keys = array('_order_source'); foreach ( array_keys( Orders::MARKETS ) as $market ) { $keys[] = '_' . $market . '_order_number'; $keys[] = '_' . $market . '_order_id'; } $ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT order_id FROM %i WHERE channel='web' AND ( needs_payment=1 OR needs_fulfil=1 OR flow_stage='new' )", $index ) ); $ids = array_merge( $ids, (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT i.order_id FROM %i i INNER JOIN %i m ON m.%i=i.order_id WHERE i.channel='web' AND m.meta_key IN (" . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ')', array_merge( array($index, $meta, $owner), $keys ) ) ) ); $ids = array_merge( $ids, (array) (( $hpos ? $wpdb->get_col( $wpdb->prepare( "SELECT i.order_id FROM %i i INNER JOIN %i o ON o.id=i.order_id WHERE i.channel='web' AND o.billing_email LIKE %s", $index, $wpdb->prefix . 'wc_orders', '%@trendyolmail.com' ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT i.order_id FROM %i i INNER JOIN %i m ON m.post_id=i.order_id AND m.meta_key='_billing_email' WHERE i.channel='web' AND m.meta_value LIKE %s", $index, $wpdb->postmeta, '%@trendyolmail.com' ) ) )) ); if ( $wpdb->last_error !== '' ) { return; } if ( $ids ) { OrderIndex::read_again( array_map( 'intval', $ids ) ); } update_option( self::MARKET_REREAD, 'yes', false ); } private const STORE_SALE_REREAD = 'storesuit_order_index_store_sale_reread'; public const RETIME_ONCE = 'storesuit_order_index_retime_once'; /** SAT-7's one rewrite of every index row's time, started once the index is ready (a manager's page or a cron request). */ public static function retime_once() : void { if ( get_option( self::RETIME_ONCE ) === 'yes' || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return; } OrderIndex::retime_start(); update_option( self::RETIME_ONCE, 'yes', false ); } /** admin_init: the store and web rows still counted as preparation work, queued for OrderIndex::sync once the index is ready. */ public static function store_sale_reread() : void { if ( !self::page_request() || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return; } global $wpdb; $ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT order_id FROM %i WHERE channel IN ('pos','web') AND ( needs_fulfil=1 OR flow_stage NOT IN ('done','closed') )", OrderIndex::table() ) ); if ( $wpdb->last_error !== '' ) { return; } if ( $ids ) { OrderIndex::read_again( array_map( 'intval', $ids ) ); } update_option( self::STORE_SALE_REREAD, 'yes', false ); } /** admin_init: the order index steps (index_repair(), then cod_rule_reread()), in a manager's wp-admin page only. */ public static function admin_index_repair() : void { if ( self::page_request() ) { self::index_steps(); } } /** init in a cron request: the order index steps. */ public static function cron_index_repair() : void { self::index_steps(); } /** About a second of the two order index steps, the repair first (the second waits for it). */ private static function index_steps() : void { $until = microtime( true ) + 1.0; self::index_repair( 1.0 ); self::cod_rule_reread( max( 0.0, $until - microtime( true ) ) ); } /** * IDX-1: rows written from a stale copy of their order are read again, once per store. WooCommerce's place-order step * logger saved the checkout's own copy, still 'pending', after the payment gateway had moved the stored order on (to * on-hold for a bank transfer, processing for cash on delivery or a card); the index row took 'pending' from that copy, * so an unpaid order counted as a checkout left before payment and stayed out of the payment queue, the receivables and * the sales facts until something saved it again. OrderIndex::changed() now reads the stored order; this finds the rows * of before: the ones whose status is not the stored order's status. In the same pass the rows written before the table * had OrderIndex::COLUMNS get them: the cash-on-delivery mark from the stored payment method and type, 0 owed where the * row owes nothing, and the rows of the payment queue are read again for their amount. A window of order ids at a time, * oldest first, for at most $seconds per request (index_pass()); it waits while the index is being built (that pass * reads every order itself). Returns how many orders it read. */ public static function index_repair( float $seconds = 1.0 ) : int { $columns = OrderIndex::ensure_columns(); $state = get_option( self::INDEX_REPAIR ); if ( $state === 'yes' || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return 0; } if ( $state === 'status' && !$columns ) { return 0; } // The statuses are checked; the amounts wait for the columns. [$read, $through] = self::index_pass( self::INDEX_REPAIR_CURSOR, static fn( int $after, int $end ): ?array => self::stale_rows( $after, $end, $columns ), $seconds ); if ( $through ) { update_option( self::INDEX_REPAIR, ( $columns ? 'yes' : 'status' ), true ); // Every row so far followed the rule of before (kept with the autoloaded options, so the check of once() reads none). if ( $columns ) { add_option( self::COD_RULE_READ, self::COD_RULE_BEFORE, '', true ); } } return $read; } /** * The rows of ($after, $end] index_repair() reads again, oldest first, at most a batch; null on a database error. With the * columns, the window's earlier rows (no amount yet) first get, with no order read, the cash-on-delivery mark the stored * order's payment method and type give (OrderIndex::cod()) and 0 owed where they owe nothing; the rows of the payment * queue among them are read again for their amount. The statuses are compared byte for byte: the tables of an older * site may have different collations. */ private static function stale_rows( int $after, int $end, bool $columns ) : ?array { global $wpdb; $index = OrderIndex::table(); $hpos = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); if ( $columns ) { if ( $hpos ) { $wpdb->query( $wpdb->prepare( "UPDATE %i i INNER JOIN %i o ON o.id=i.order_id SET i.cod=CASE WHEN o.payment_method='cod' OR EXISTS ( SELECT 1 FROM %i m WHERE m.order_id=o.id AND m.meta_key='_storesuit_payment_mode' AND m.meta_value='cod' ) THEN 1 ELSE 0 END WHERE i.order_id>%d AND i.order_id<=%d AND i.due IS NULL", $index, $wpdb->prefix . 'wc_orders', $wpdb->prefix . 'wc_orders_meta', $after, $end ) ); } else { $wpdb->query( $wpdb->prepare( "UPDATE %i i SET i.cod=CASE WHEN EXISTS ( SELECT 1 FROM %i m WHERE m.post_id=i.order_id AND m.meta_key IN ('_payment_method','_storesuit_payment_mode') AND m.meta_value='cod' ) THEN 1 ELSE 0 END WHERE i.order_id>%d AND i.order_id<=%d AND i.due IS NULL", $index, $wpdb->postmeta, $after, $end ) ); } if ( $wpdb->last_error !== '' ) { return null; } // 0 owed where nothing is owed, and in a row whose order is gone (IndexRecovery's clean pass removes such a row; // until then it adds nothing to a total and leaves none incomplete). if ( $hpos ) { $wpdb->query( $wpdb->prepare( "UPDATE %i i SET i.due=0 WHERE i.order_id>%d AND i.order_id<=%d AND i.due IS NULL AND ( i.needs_payment=0 OR NOT EXISTS ( SELECT 1 FROM %i o WHERE o.id=i.order_id AND o.type='shop_order' ) )", $index, $after, $end, $wpdb->prefix . 'wc_orders' ) ); } else { $wpdb->query( $wpdb->prepare( "UPDATE %i i SET i.due=0 WHERE i.order_id>%d AND i.order_id<=%d AND i.due IS NULL AND ( i.needs_payment=0 OR NOT EXISTS ( SELECT 1 FROM %i p WHERE p.ID=i.order_id AND p.post_type='shop_order' ) )", $index, $after, $end, $wpdb->posts ) ); } if ( $wpdb->last_error !== '' ) { return null; } $ids = ( $hpos ? $wpdb->get_col( $wpdb->prepare( "SELECT i.order_id FROM %i i INNER JOIN %i o ON o.id=i.order_id WHERE i.order_id>%d AND i.order_id<=%d AND ( ( i.due IS NULL AND i.needs_payment=1 ) OR CAST(o.status AS BINARY) NOT IN ( CAST(i.status AS BINARY), CAST(CONCAT('wc-',i.status) AS BINARY) ) ) ORDER BY i.order_id LIMIT %d", $index, $wpdb->prefix . 'wc_orders', $after, $end, self::COD_BATCH ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT i.order_id FROM %i i INNER JOIN %i p ON p.ID=i.order_id WHERE i.order_id>%d AND i.order_id<=%d AND ( ( i.due IS NULL AND i.needs_payment=1 ) OR CAST(p.post_status AS BINARY) NOT IN ( CAST(i.status AS BINARY), CAST(CONCAT('wc-',i.status) AS BINARY) ) ) ORDER BY i.order_id LIMIT %d", $index, $wpdb->posts, $after, $end, self::COD_BATCH ) ) ); } else { $ids = ( $hpos ? $wpdb->get_col( $wpdb->prepare( "SELECT i.order_id FROM %i i INNER JOIN %i o ON o.id=i.order_id WHERE i.order_id>%d AND i.order_id<=%d AND CAST(o.status AS BINARY) NOT IN ( CAST(i.status AS BINARY), CAST(CONCAT('wc-',i.status) AS BINARY) ) ORDER BY i.order_id LIMIT %d", $index, $wpdb->prefix . 'wc_orders', $after, $end, self::COD_BATCH ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT i.order_id FROM %i i INNER JOIN %i p ON p.ID=i.order_id WHERE i.order_id>%d AND i.order_id<=%d AND CAST(p.post_status AS BINARY) NOT IN ( CAST(i.status AS BINARY), CAST(CONCAT('wc-',i.status) AS BINARY) ) ORDER BY i.order_id LIMIT %d", $index, $wpdb->posts, $after, $end, self::COD_BATCH ) ) ); } return ( $wpdb->last_error !== '' || !is_array( $ids ) ? null : array_map( 'intval', $ids ) ); } /** * The cash-on-delivery rule in force: Orders::COD_RULE, which names it, or the rule of before (COD_RULE_BEFORE). A change * of Orders::cod_unreceived() that changes what an order owes changes Orders::COD_RULE with it, and cod_rule_reread() * then reads the cash-on-delivery rows again once (critic note on IDX-1/ORD-2: keyed to the release that brings the rule, * not to this one, so a store updated between the two keeps no row of the old rule). */ public static function cod_rule() : string { $named = Orders::class . '::COD_RULE'; return ( defined( $named ) ? (string) constant( $named ) : self::COD_RULE_BEFORE ); } /** * Once per cash-on-delivery rule: the index rows of the orders paid by cash on delivery (OrderIndex's cod mark) are read * again under the rule in force, a window at a time for at most $seconds per request. It waits for index_repair(), which * marks the earlier rows. Orders saved meanwhile are written under the rule already. Returns how many orders it read. */ public static function cod_rule_reread( float $seconds = 1.0 ) : int { $rule = self::cod_rule(); if ( get_option( self::INDEX_REPAIR ) !== 'yes' || get_option( self::COD_RULE_READ, self::COD_RULE_BEFORE ) === $rule || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return 0; } if ( !OrderIndex::ensure_columns() ) { return 0; } [$read, $through] = self::index_pass( self::COD_RULE_CURSOR, static function ( int $after, int $end ) : ?array { global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT order_id FROM %i WHERE order_id>%d AND order_id<=%d AND cod=1 ORDER BY order_id LIMIT %d', OrderIndex::table(), $after, $end, self::COD_BATCH ) ); return ( $wpdb->last_error !== '' || !is_array( $ids ) ? null : array_map( 'intval', $ids ) ); }, $seconds ); if ( $through ) { update_option( self::COD_RULE_READ, $rule, true ); } return $read; } /** * One bounded pass over the order index, as cod_reread() walks the orders: windows of order ids up to the index's highest, * oldest first, for at most $seconds (one window at least). $window( $after, $end ) gives the ids of that window to read * again, at most a batch (null on a database error); each is read into its row while the row is locked * (IndexRecovery::read_now()). The cursor stays between requests; a read that fails stops the pass there for the next * request, and reading a row twice is harmless. Returns how many it read and whether the pass is through. */ private static function index_pass( string $cursor_key, callable $window, float $seconds ) : array { global $wpdb; $last = $wpdb->get_var( $wpdb->prepare( 'SELECT COALESCE(MAX(order_id),0) FROM %i', OrderIndex::table() ) ); if ( $wpdb->last_error !== '' || null === $last ) { return array(0, false); } // The next admin or cron request tries again. $last = (int) $last; $until = microtime( true ) + max( 0.0, $seconds ); $cursor = (int) get_option( $cursor_key, 0 ); $read = 0; do { if ( $cursor >= $last ) { delete_option( $cursor_key ); return array($read, true); } $end = min( $last, $cursor + self::COD_WINDOW ); $ids = $window( $cursor, $end ); if ( null === $ids ) { return array($read, false); } foreach ( $ids as $id ) { if ( !IndexRecovery::read_now( $id ) ) { update_option( $cursor_key, $cursor, false ); return array($read, false); } $cursor = $id; ++$read; } // Fewer than a batch: the window is through, and the next one starts after it. if ( count( $ids ) < self::COD_BATCH ) { $cursor = $end; } update_option( $cursor_key, $cursor, false ); } while ( microtime( true ) < $until ); return array($read, false); } /** * Whether this request may carry a one-time step (review of fix-woo R1): a wp-admin page request of someone who manages the * shop. WordPress runs admin_init for every admin-ajax.php request too, a visitor's storefront request included, so those * never do; cron requests run the steps from init themselves. */ public static function page_request() : bool { return !wp_doing_ajax() && current_user_can( 'manage_woocommerce' ); } /** admin_init (WordPress passes its hooks an empty argument): the cash-on-delivery step, in a manager's wp-admin page only. */ public static function admin_cod_reread() : void { if ( self::page_request() ) { self::cod_reread(); } } /** init in a cron request: the cash-on-delivery step. */ public static function cron_cod_reread() : void { self::cod_reread(); } /** * Cash on delivery is paid when its money is recorded, not because WooCommerce stored a date (0.65.25). Index rows written * before that rule can still say "nothing due" (review of fix-cod L2: the test site's reconciliation read only the orders * of WooCommerce's cod method). The orders of the cod method and the manual sales of the cod payment type under another or * no method are read again once, oldest first, a batch at a time for at most $seconds per request, with each row locked * as the index's own pass does. It waits while the index is being built (that pass reads every order itself). Orders * created after it started are written under the rule already. Returns how many orders it read. */ public static function cod_reread( float $seconds = 1.0 ) : int { if ( get_option( self::COD_REREAD ) === 'yes' || get_option( 'storesuit_order_index_ready' ) !== '1' ) { return 0; } $last = self::last_order_id(); if ( null === $last ) { return 0; } // A database error: the next admin or cron request tries again. $until = microtime( true ) + max( 0.0, $seconds ); $cursor = (int) get_option( self::COD_CURSOR, 0 ); $read = 0; do { if ( $cursor >= $last ) { update_option( self::COD_REREAD, 'yes', true ); delete_option( self::COD_CURSOR ); return $read; } $end = min( $last, $cursor + self::COD_WINDOW ); $ids = self::cod_orders( $cursor, $end ); if ( null === $ids ) { return $read; } foreach ( $ids as $id ) { if ( !IndexRecovery::read_now( $id ) ) { update_option( self::COD_CURSOR, $cursor, false ); return $read; } $cursor = $id; ++$read; } // Fewer than a batch: the window is through, and the next one starts after it. if ( count( $ids ) < self::COD_BATCH ) { $cursor = $end; } update_option( self::COD_CURSOR, $cursor, false ); } while ( microtime( true ) < $until ); return $read; } /** The highest id of the order table (the primary key's last entry; on the posts storage, of any post type). */ private static function last_order_id() : ?int { global $wpdb; $hpos = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); // One prepared query per storage, each written out in full, so that a code scanner sees both are prepared. $last = ( $hpos ? $wpdb->get_var( $wpdb->prepare( 'SELECT COALESCE(MAX(id),0) FROM %i', $wpdb->prefix . 'wc_orders' ) ) : $wpdb->get_var( $wpdb->prepare( 'SELECT COALESCE(MAX(ID),0) FROM %i', $wpdb->posts ) ) ); return ( $wpdb->last_error !== '' || null === $last ? null : (int) $last ); } /** * The cash-on-delivery orders with an id in ($after, $end], oldest first, at most a batch (WooCommerce's cod method, or * StoreRuler's cod payment type), in either storage: a range of the primary key, each order's meta through its indexed id. */ private static function cod_orders( int $after, int $end ) : ?array { global $wpdb; if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT o.id FROM %i o WHERE o.id>%d AND o.id<=%d AND o.type='shop_order' AND o.status NOT IN ('trash','auto-draft') AND ( o.payment_method='cod' OR EXISTS ( SELECT 1 FROM %i m WHERE m.order_id=o.id AND m.meta_key='_storesuit_payment_mode' AND m.meta_value='cod' ) ) ORDER BY o.id LIMIT %d", $wpdb->prefix . 'wc_orders', $after, $end, $wpdb->prefix . 'wc_orders_meta', self::COD_BATCH ) ); } else { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM %i p WHERE p.ID>%d AND p.ID<=%d AND p.post_type='shop_order' AND p.post_status NOT IN ('trash','auto-draft') AND EXISTS ( SELECT 1 FROM %i m WHERE m.post_id=p.ID AND m.meta_key IN ('_payment_method','_storesuit_payment_mode') AND m.meta_value='cod' ) ORDER BY p.ID LIMIT %d", $wpdb->posts, $after, $end, $wpdb->postmeta, self::COD_BATCH ) ); } return ( $wpdb->last_error !== '' || !is_array( $ids ) ? null : array_map( 'intval', $ids ) ); } private static function reindex_unpaid_pending() : void { global $wpdb; // A rebuild under way starts again under the new rule (rows it already wrote used the old one). if ( get_option( 'storesuit_order_index_ready' ) !== '1' ) { IndexRecovery::restart(); return; } $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT order_id FROM %i WHERE needs_payment=1 AND status IN ('pending','checkout-draft') ORDER BY order_id", OrderIndex::table() ) ) ); // Owner item 40: the former "Shipped" stage is gone; those rows are read again as completed. $ids = array_merge( $ids, array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT order_id FROM %i WHERE flow_stage='dispatched' ORDER BY order_id", OrderIndex::table() ) ) ) ); // The index stays ready; OrderIndex::sync reads these again 150 at a time, never all in this request. if ( $ids ) { OrderIndex::read_again( $ids ); } } private static function reindex_counter_sales() : void { global $wpdb; if ( get_option( 'storesuit_order_index_ready' ) !== '1' ) { return; } foreach ( (array) $wpdb->get_col( $wpdb->prepare( "SELECT order_id FROM %i WHERE channel='pos' AND (needs_fulfil=1 OR flow_stage NOT IN ('done','closed')) LIMIT 2000", OrderIndex::table() ) ) as $id ) { OrderIndex::changed( (int) $id ); } } }