prefix . 'aiforya_wcs_' . $name; } public function install_schema(): void { global $wpdb; $charset = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $p = $wpdb->prefix . 'aiforya_wcs_'; dbDelta( "CREATE TABLE {$p}product_seo ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, seo_title VARCHAR(255) NOT NULL DEFAULT '', meta_description VARCHAR(320) NOT NULL DEFAULT '', ai_description LONGTEXT NULL, schema_json LONGTEXT NULL, alt_text_done TINYINT(1) NOT NULL DEFAULT 0, focus_keyword VARCHAR(190) NOT NULL DEFAULT '', source_hash VARCHAR(40) NOT NULL DEFAULT '', generated_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uq_product (product_id), KEY idx_alt (alt_text_done) ) {$charset};" ); dbDelta( "CREATE TABLE {$p}gsc_signals ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, sales_30d INT NOT NULL DEFAULT 0, sales_total INT NOT NULL DEFAULT 0, weight DECIMAL(6,3) NOT NULL DEFAULT 0.000, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uq_product (product_id), KEY idx_weight (weight) ) {$charset};" ); } // ===================================================================== // Réglages // ===================================================================== public function get_settings(): array { $stored = get_option( 'aiforya_wcs_settings', array() ); return is_array( $stored ) ? $stored : array(); } public function save_settings( array $settings ): bool { return update_option( 'aiforya_wcs_settings', $settings, false ); } public function get_setting( string $section, string $key, $default = null ) { $settings = $this->get_settings(); return $settings[ $section ][ $key ] ?? $default; } public function feature_enabled( string $key ): bool { return 'on' === $this->get_setting( 'features', $key, 'on' ); } // ===================================================================== // Cache SEO produit // ===================================================================== /** * ⭐ LE NOM DE TABLE PASSE PAR `%i`, JAMAIS PAR INTERPOLATION — et ce n'est pas * cosmétique. Un `{$this->t('…')}` collé dans la chaîne SQL fait remonter à Plugin * Check deux avertissements de la famille SÉCURITÉ, `UnescapedDBParameter` et * `InterpolatedNotPrepared` : le juge ne peut pas savoir que la valeur interpolée vient * d'une méthode sûre, il voit une variable dans du SQL. `%i` (WordPress 6.2, que cette * extension exige déjà dans son en-tête) fait échapper l'identifiant par `wpdb`. * * ⛔ LE PIÈGE À NE PAS REFAIRE, et il nous a coûté un bug LIVRÉ le 17/08 dans * aiforya-redirect-manager : `wpdb::prepare()` n'aplatit un tableau QUE s'il est le SEUL * argument (`is_array( $args[0] ) && 1 === count( $args )`). Avec deux arguments dont un * tableau, `prepare()` rend une chaîne VIDE, la requête ne part pas, le résultat est * vide, et RIEN ne plante — ni `php -l`, ni le juge. Ici les deux arguments sont des * scalaires : le cas dangereux n'est pas atteint. */ public function get_product_seo( int $product_id ): ?array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre à l'extension, hors de toute API WordPress ; lecture ponctuelle sur clé primaire. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE product_id = %d', $this->t( 'product_seo' ), $product_id ), ARRAY_A ); return is_array( $row ) ? $row : null; } /** * Efface tout ce que l'extension a stocké pour un produit. * * ✅ DÉFAUT FERMÉ LE 30/08/2026 — le commentaire précédent disait : « cette méthode * n'est appelée NULLE PART, dans aucune variante ; elle devrait l'être sur * `before_delete_post`, sans quoi les lignes d'un produit supprimé restent orphelines ». * C'était exact, et c'est réparé : l'appelant est posé dans le fichier principal, sur * `before_delete_post`, gardé par `'product' === get_post_type()`. * * ⚠ POURQUOI `before_delete_post` ET NON `deleted_post` : au moment de `deleted_post`, * `get_post_type()` ne rend plus rien — le produit n'existe plus. Le garde ne pourrait * donc pas distinguer un produit d'un article, et l'extension effacerait ses lignes à * chaque suppression de n'importe quel contenu. Le hook « avant » est le seul où * l'information nécessaire à la décision existe encore. * * ⚠ CE QU'IL RESTE À LA CORBEILLE : mettre un produit à la corbeille ne déclenche PAS * ce hook, et c'est voulu — un produit restauré depuis la corbeille doit retrouver ses * données. Seule la suppression définitive efface. * * RE-MESURE : grep -rn 'delete_product_seo' ⇒ doit montrer un appelant. */ public function delete_product_seo( int $product_id ): bool { global $wpdb; // §CQ05 — nettoyage transactionnel des enfants (pas de CASCADE SQL). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre à l'extension ; suppression, rien à mettre en cache. $wpdb->delete( $this->t( 'gsc_signals' ), [ 'product_id' => $product_id ], [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre à l'extension ; suppression, rien à mettre en cache. return (bool) $wpdb->delete( $this->t( 'product_seo' ), [ 'product_id' => $product_id ], [ '%d' ] ); } /* `list_product_seo()` a été SUPPRIMÉE le 28/08/2026 : aucune occurrence de son nom * ailleurs que sa propre définition, dans aucune variante, depuis sa création. Un * lecteur sans lecteur n'a pas de contrat à préserver — contrairement à * `delete_product_seo()` ci-dessus, qui en a un et qu'on garde pour cette raison. */ /** * Nombre de fiches produit qui reçoivent le schema — donc TOUS les produits publiés. * * ⛔ CORRIGÉ LE 28/08/2026 — CE COMPTEUR AFFICHAIT « 0 » POUR TOUJOURS DANS LA GRATUITE, * ET IL ÉTAIT LE SEUL CHIFFRE DU TABLEAU DE BORD. * Il comptait les lignes de `product_seo` portant un `schema_json`. Or cette table n'est * écrite que par `save_product_seo()`, appelé uniquement par le générateur IA — retiré du * build .org. Le tableau de bord annonçait donc « 0 » à un marchand dont les 40 fiches * portaient un schema parfaitement valide. * * ⭐ C'ÉTAIT LA 4ᵉ OCCURRENCE DU MÊME DÉFAUT DANS CETTE EXTENSION, et il a été trouvé en * REGARDANT la bannière wordpress.org — le « 0 » y était le seul chiffre lisible. Les trois * autres : les colonnes de l'onglet Produits (réparées le 26/08 en construisant le schema à * la volée), `Aiforya_Wcs_Content` (retiré du build ce jour), et `Meta::filter_title()` * (repli déterministe ce jour). Même cause à chaque fois : **un affichage qui lit une table * que la variante gratuite ne remplit pas.** * * ⭐ Le remède est celui du 26/08 : mesurer ce qui est VRAI sans rien stocker. Le schema est * construit à la volée dans `wp_head` pour chaque fiche produit affichée — le nombre de * pages concernées est donc, exactement, le nombre de produits publiés. Vrai dans les deux * variantes, et sans dépendre d'un cache. * * ⚠ Ce que ce nombre ne dit PAS : que chaque schema est COMPLET. C'est l'onglet Produits qui * le dit, produit par produit — et lui aussi le construit à la volée. */ public function count_schema(): int { $compte = wp_count_posts( 'product' ); return isset( $compte->publish ) ? (int) $compte->publish : 0; } // ===================================================================== // File d'attente de génération (cron) // ===================================================================== // ===================================================================== // Signaux de ventes ↔ maillage (bridge) // ===================================================================== public function upsert_signal( int $product_id, int $sales_30d, int $sales_total, float $weight ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre à l'extension ; test d'existence avant écriture, il ne doit PAS être servi par un cache. $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE product_id = %d', $this->t( 'gsc_signals' ), $product_id ) ); $row = [ 'product_id' => $product_id, 'sales_30d' => max( 0, $sales_30d ), 'sales_total' => max( 0, $sales_total ), 'weight' => round( max( 0, $weight ), 3 ), 'updated_at' => current_time( 'mysql', true ), ]; $fmt = [ '%d', '%d', '%d', '%f', '%s' ]; if ( $exists ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- écriture dans une table propre à l'extension ; aucune API WordPress ne la couvre. $wpdb->update( $this->t( 'gsc_signals' ), $row, [ 'product_id' => $product_id ], $fmt, [ '%d' ] ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- écriture dans une table propre à l'extension ; aucune API WordPress ne la couvre. $wpdb->insert( $this->t( 'gsc_signals' ), $row, $fmt ); } public function get_weight( int $product_id ): float { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre à l'extension ; lecture d'une seule ligne sur clé unique, servie par le cache de requêtes de MySQL. return (float) $wpdb->get_var( $wpdb->prepare( 'SELECT weight FROM %i WHERE product_id = %d', $this->t( 'gsc_signals' ), $product_id ) ); } /** @return array> */ public function top_weighted( int $limit = 10 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre à l'extension ; classement lu à l'écran d'administration, il doit refléter l'état courant. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT product_id, sales_30d, sales_total, weight FROM %i WHERE weight > 0 ORDER BY weight DESC LIMIT %d', $this->t( 'gsc_signals' ), max( 1, min( 50, $limit ) ) ), ARRAY_A ); return is_array( $rows ) ? $rows : []; } // ===================================================================== // Journal IA + KPIs // ===================================================================== /* Les KPI servis au tableau de bord. `admin.js` les pose génériquement sur les éléments * `[data-kpi]` présents à l'écran : une clé qu'aucun élément ne réclame est calculée puis * jetée. Dans la variante .org, trois des quatre l'étaient — et chacune interrogeait une * table que rien n'y écrit. L'onglet n'affiche que `schema`. */ public function get_dashboard_kpis(): array { return [ 'schema' => $this->count_schema(), ]; } }