0 ? $cap : self::CAP; } public function register(): void { // Invalide le cache quand le catalogue ou les défauts changent. add_action( 'save_post_product', array( __CLASS__, 'flush' ) ); add_action( 'woocommerce_update_product', array( __CLASS__, 'flush' ) ); add_action( 'woocommerce_new_product', array( __CLASS__, 'flush' ) ); add_action( 'update_option_' . Meta::OPTION_CATEGORY_DEFAULTS, array( __CLASS__, 'flush' ) ); add_action( 'update_option_' . Meta::OPTION_SETTINGS, array( __CLASS__, 'flush' ) ); } /** * Couverture du catalogue (mise en cache). * * @return array{total:int, without:int, scanned:int, capped:bool} */ public static function coverage(): array { $cached = get_transient( Meta::TRANSIENT_CATALOG_SCAN ); if ( is_array( $cached ) && isset( $cached['total'], $cached['without'], $cached['scanned'], $cached['capped'], $cached['at_risk'] ) ) { return $cached; } $result = self::compute(); // Ne pas figer 12 h un scan manifestement incomplet (total connu mais 0 produit scanné = échec // de requête) : on le recalcule au prochain passage plutôt que de cristalliser un faux « tout couvert ». if ( ! ( $result['total'] > 0 && 0 === $result['scanned'] ) ) { set_transient( Meta::TRANSIENT_CATALOG_SCAN, $result, self::TTL ); } return $result; } public static function flush(): void { delete_transient( Meta::TRANSIENT_CATALOG_SCAN ); } /** * État de couverture pour l'affichage (logique pure, unit-testable). Le point clé : on ne déclare * « tout couvert » que si le catalogue **entier** a été scanné — un catalogue plafonné (`scanned < total`) * ou un scan en échec (`scanned === 0`) ne doit jamais afficher le faux vert. * * @param array{total:int, without:int, scanned:int, capped:bool} $cov Résultat de coverage(). * @return 'empty'|'unknown'|'gaps'|'partial'|'covered' */ public static function coverage_state( array $cov ): string { $total = isset( $cov['total'] ) ? (int) $cov['total'] : 0; $without = isset( $cov['without'] ) ? (int) $cov['without'] : 0; $scanned = isset( $cov['scanned'] ) ? (int) $cov['scanned'] : 0; if ( $total <= 0 ) { return 'empty'; } if ( $scanned <= 0 ) { return 'unknown'; // Produits présents mais aucun scanné : couverture inconnue (jamais « tout couvert »). } if ( $without > 0 ) { return 'gaps'; } if ( $scanned < $total ) { return 'partial'; // Les produits scannés sont couverts, mais le catalogue dépasse le plafond. } return 'covered'; } /** * Calcule la couverture (borné + une seule requête d'IDs). * * @return array{total:int, without:int, scanned:int, capped:bool} */ private static function compute(): array { $total = 0; if ( post_type_exists( 'product' ) ) { $counts = wp_count_posts( 'product' ); $total = isset( $counts->publish ) ? (int) $counts->publish : 0; } if ( 0 === $total || ! function_exists( 'wc_get_product' ) ) { return array( 'total' => $total, 'without' => 0, 'scanned' => 0, 'capped' => false, 'at_risk' => 0, 'risk_examples' => array(), ); } $cap = self::cap(); $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => $cap, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'suppress_filters' => false, ) ); if ( ! is_array( $ids ) ) { // Pas d'échec silencieux : une requête qui ne renvoie pas de tableau est une anomalie, // pas « zéro produit ». On la trace (le résultat dégradé ne sera pas mis en cache, cf. coverage()). Log::warning( 'CatalogScan : get_posts(product) n\'a pas renvoyé de tableau.' ); $ids = array(); } $defaults = CategoryDefaults::get(); $without = 0; $at_risk = 0; $examples = array(); foreach ( $ids as $id ) { $product = wc_get_product( $id ); if ( ! $product instanceof \WC_Product ) { continue; } $effective = TriRenderer::resolve( (bool) $product->get_meta( Meta::PRODUCT_TRIMAN ), $product->get_meta( Meta::PRODUCT_INFOTRI ), $product->get_category_ids(), $defaults ); if ( ! $effective['triman'] && empty( $effective['components'] ) ) { ++$without; } // Qualifieur greenwashing (findings-08 #12) : allégations à risque dans le titre/description. $text = $product->get_name() . ' ' . $product->get_short_description() . ' ' . $product->get_description(); $risk = GreenwashingScan::scan_text( wp_strip_all_tags( $text ) ); if ( ! empty( $risk ) ) { ++$at_risk; if ( count( $examples ) < 3 ) { $examples[] = array( 'name' => $product->get_name(), 'term' => $risk[0]['label'], ); } } } return array( 'total' => $total, 'without' => $without, 'scanned' => count( $ids ), 'capped' => $total > $cap, 'at_risk' => $at_risk, 'risk_examples' => $examples, ); } }