Products with options whose option had its stock status written in this request. */ private static array $touched = array(); private static bool $settling = false; public static function hooks(): void { add_action( 'added_post_meta', array( __CLASS__, 'touched' ), 10, 3 ); add_action( 'updated_post_meta', array( __CLASS__, 'touched' ), 10, 3 ); } /** * An option's stock status (or its own-stock switch) was written, by whatever path: a web or marketplace order, the till, * a count, a delivery, an import, a quick edit, another plugin writing the field straight. Its product is checked once, * when the request ends. WordPress calls this: untyped arguments. */ public static function touched( $meta_id, $object_id, $meta_key ): void { if ( ( '_stock_status' !== $meta_key && '_manage_stock' !== $meta_key ) || self::$settling ) { return; } $post = get_post( (int) $object_id ); if ( ! $post || 'product_variation' !== $post->post_type || ! $post->post_parent ) { return; } if ( ! self::$touched ) { add_action( 'shutdown', array( __CLASS__, 'settle' ), 20 ); } self::$touched[ (int) $post->post_parent ] = true; } /** The products touched in this request, settled one by one; a failure is logged, never shown to the visitor. */ public static function settle(): void { $ids = array_keys( self::$touched ); self::$touched = array(); self::$settling = true; try { // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Not debug code: runs only when a stock update fails, so the store can find out why; visitors see nothing. foreach ( $ids as $id ) { try { self::settle_one( $id ); } catch ( \Throwable $e ) { error_log( 'StoreRuler stock status of product ' . $id . ': ' . $e->getMessage() ); } } // phpcs:enable WordPress.PHP.DevelopmentFunctions.error_log_error_log } finally { self::$settling = false; } } /** * A product with options whose stock status disagrees with its options' (a live store, 29 Oct 2026: two products "In * stock" with every size sold out). WooCommerce's rule: in stock while one option is, else on backorder while one is, * else out of stock. Two causes, two repairs, only on a disagreement: * - it keeps a stock of its own while every option keeps its own, and WooCommerce judges it by that leftover figure: * the figure is switched off as Health's repair does (noted). A product whose options draw from it is left alone. * - its options' status was written around WooCommerce (straight into the field): WooCommerce's lookup table, which * its sync reads, still has the old one. Those rows take the fields' status and WooCommerce syncs the product. * @return string '' (nothing to do), 'own_stock_off' or 'synced'. */ public static function settle_one( int $id ): string { $product = wc_get_product( $id ); if ( ! $product instanceof \WC_Product_Variable ) { return ''; } $want = self::options_status( $product ); if ( $want === null || $want === $product->get_stock_status( 'edit' ) ) { return ''; } self::refresh_lookup( $product->get_children() ); if ( $product->get_manage_stock( 'edit' ) ) { return self::options_own_stock( $product ) && self::stock_off( $product ) ? 'own_stock_off' : ''; } \WC_Product_Variable::sync_stock_status( $product ); return 'synced'; } /** The status WooCommerce gives a product with options, read from its options' own fields; null without options. */ private static function options_status( \WC_Product_Variable $product ): ?string { $seen = array(); foreach ( $product->get_children() as $child ) { $seen[ (string) get_post_meta( (int) $child, '_stock_status', true ) ] = true; } if ( ! $seen ) { return null; } return isset( $seen['instock'] ) ? 'instock' : ( isset( $seen['onbackorder'] ) ? 'onbackorder' : 'outofstock' ); } /** WooCommerce's lookup rows of these options take the stock status in their own fields (its sync reads the rows). */ private static function refresh_lookup( array $ids ): void { global $wpdb; foreach ( array_map( 'intval', $ids ) as $id ) { $status = (string) get_post_meta( $id, '_stock_status', true ); if ( $status !== '' ) { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET stock_status = %s WHERE product_id = %d AND stock_status <> %s', $wpdb->wc_product_meta_lookup, $status, $id, $status ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- WooCommerce's own lookup row, kept in step with the product's field. } } /** The product's own stock switched off (its figure noted) and its status taken from its options. */ private static function stock_off( \WC_Product_Variable $product ): bool { $id = $product->get_id(); $product->update_meta_data( self::NOTE, array( 'stock'=>$product->get_stock_quantity( 'edit' ), 'time'=>time() ) ); $product->set_manage_stock( false ); $product->set_stock_quantity( null ); $product->save(); \WC_Product_Variable::sync( $id ); $fresh = wc_get_product( $id ); return $fresh && $fresh->get_manage_stock( 'edit' ) !== true; } /** A product with options whose every published option manages its own stock (none draws from the product). */ public static function options_own_stock( $product ): bool { if ( ! $product instanceof \WC_Product || ! $product->is_type( 'variable' ) ) { return false; } $children = $product->get_children(); if ( ! $children ) { return false; } foreach ( $children as $id ) { if ( get_post_meta( (int) $id, '_manage_stock', true ) !== 'yes' ) { return false; } } return true; } /** * Whether a product's own stock counts in a total (admin audit A1, 28 Oct 2026: a live store's stock value read ₺1.03M, * about twice the real ₺560k, as 635 products with options kept a leftover stock of 2,242 next to their options'): a * line that keeps its own stock, except a product with options whose every option keeps its own. Stock value, the stock * report and the counters read every line through this. */ public static function holds_stock( $product ): bool { if ( ! $product instanceof \WC_Product || (int) $product->get_stock_managed_by_id() !== $product->get_id() ) { return false; } return ! self::options_own_stock( $product ); } /** Fetches, in one query, the options of the products among $ids, so holds_stock() on a long list reads them from the cache. */ public static function prime( array $ids ): void { global $wpdb; $ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); if ( ! $ids ) { return; } $children = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM %i WHERE post_type = 'product_variation' AND post_parent IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( $wpdb->posts ), $ids ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- admin read, placeholders only. if ( $children ) { _prime_post_caches( $children, false, true ); } } /** The refusal StoreRuler's stock tools give for such a product. */ public static function refusal(): string { return I18n::t( 'Each option of this product keeps its own stock, so the product itself holds none. Count or set the stock of its options.' ); } /** Published products with options that keep a stock of their own while every published option keeps its own. @return int[] */ public static function conflicts( int $limit = 500 ): array { global $wpdb; return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM %i p INNER JOIN %i m ON m.post_id = p.ID AND m.meta_key = '_manage_stock' AND m.meta_value = 'yes' WHERE p.post_type = 'product' AND p.post_status = 'publish' AND EXISTS ( SELECT 1 FROM %i c WHERE c.post_parent = p.ID AND c.post_type = 'product_variation' AND c.post_status = 'publish' ) AND NOT EXISTS ( SELECT 1 FROM %i c LEFT JOIN %i cm ON cm.post_id = c.ID AND cm.meta_key = '_manage_stock' WHERE c.post_parent = p.ID AND c.post_type = 'product_variation' AND c.post_status = 'publish' AND ( cm.meta_value IS NULL OR cm.meta_value <> 'yes' ) ) LIMIT %d", $wpdb->posts, $wpdb->postmeta, $wpdb->posts, $wpdb->posts, $wpdb->postmeta, $limit ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin read. } /** Health's repair: each such product's own stock is switched off (its figure noted) and its status taken from its options, up to $limit. */ public static function repair( int $limit = 200 ): int { $done = 0; foreach ( self::conflicts( $limit ) as $id ) { $product = wc_get_product( $id ); if ( ! $product instanceof \WC_Product_Variable || ! self::options_own_stock( $product ) ) { continue; } if ( self::stock_off( $product ) ) { $done++; } } return $done; } }