valeur] et attend en retour, pour un Traitement, ce même * type de tableau (valeurs mises à jour) — voir les deux boucles ci-dessous. * - CNS_Moment n'est PAS distingué ici : temps réel (Saisie) abandonné pour * cette version (décision de Jean-Marc, 6 septembre 2026 — priorité à la * robustesse plutôt qu'au retour immédiat à l'écran). Toute consigne * active s'applique donc au moment de l'enregistrement, qu'elle porte * Saisie ou Validation. * - Un seul message d'erreur retourné (le premier test en échec) — pas * d'accumulation de plusieurs erreurs à la fois pour ce premier chantier. * * CNS_Portee : 'Mise à jour' est traité comme un mot-clé signifiant "les deux * moments" (Création et Modification), à côté de valeurs plus précises * ('Création' ou 'Modification' seules seraient possibles plus tard pour une * règle d'immuabilité — voir Réflexions stratégiques). * * CNS_Agent (7 septembre 2026) : nom libre (varchar, pas de clé étrangère * vers ASP_Agents) permettant de rattacher une consigne à un agent — utile * en particulier pour les prochaines consignes de test qui simuleront des * comportements par agent. Champ facultatif, remonté via le paramètre par * référence $agentEchec plutôt qu'en changeant le type de retour de la * fonction (qui reste un simple string|null, pour ne pas casser le contrat * déjà en place) : seul contacts.php lit ce paramètre pour l'ajouter à sa * réponse JSON, le front-end (afficherToast) l'affiche en italique sous le * message quand il est présent. * * Journalisation des Traitements IA (7 septembre 2026, soir) : après le * premier essai réel de consigne_completer_code_postal(), Jean-Marc a demandé * de pouvoir tracer QUAND et POURQUOI l'IA a été sollicitée (bilan de * consommation de tokens, entre autres). Un Traitement à PLUSIEURS champs * (voir ci-dessus) peut donc désormais glisser, dans le tableau qu'il * retourne, une clé réservée "_realisation" — jamais un vrai nom de champ * ASPIC (tous commencent par un préfixe majuscule type "CNT_") donc aucun * risque de collision. Cette clé est retirée par le moteur avant d'écrire le * reste dans $data (voir boucle des Traitements ci-dessous), et collectée * dans le nouveau paramètre par référence $journalisations. Volontairement * PAS d'écriture directe dans ASP_Realisations ici : ce fichier n'a pas * connaissance de CNT_Incr (inexistant tant que l'INSERT n'a pas eu lieu, en * création) — c'est contacts.php qui journalise réellement, une fois * l'identifiant du contact connu (voir ce fichier). * * Badge IA générique côté écran (7 septembre 2026, soir, suite) : le premier * jet de ce badge reposait sur une condition écrite en dur dans * liste_contacts.html (ville renseignée, code postal vide) — Jean-Marc a * fait remarquer à juste titre que c'est exactement la même condition que la * garde en tête de consigne_completer_code_postal(), et que dupliquer cette * condition côté écran allait à l'encontre du chantier des consignes (une * nouvelle consigne IA aurait demandé de retoucher le JS). Nouvelle * convention à la place : une fonction de Traitement porteuse de * FON_VisuelAttenteIA = 'O' (ASP_Fonctions — propriété de la FONCTION, * remarque de Jean-Marc : c'est elle qui interroge une IA, pas la consigne * qui l'utilise) DOIT avoir un jumeau nommé "_seraitDeclenchee" * dans consignes_fonctions.php, signature (array $valeurs): bool — jamais * d'appel réseau, juste la même garde que la vraie fonction (qui l'appelle en * premier, voir consigne_completer_code_postal()) : la condition n'existe * ainsi qu'à un seul endroit. Voir auMoinsUneConsigneIaSeraitDeclenchee() * ci-dessous, utilisée par le nouveau point d'accès consignes_apercu.php — * l'écran l'interroge une fois, juste avant l'enregistrement réel, pour * savoir s'il doit afficher le badge pendant l'attente qui suit. * * Hiérarchisation Traitements de cohérence / Traitements IA (7 septembre * 2026, nuit) : test réel de Jean-Marc (mail invalide + ville sans code * postal) — l'ordre d'origine (TOUS les Traitements, puis TOUS les Tests) * faisait sonner l'IA (coût réel, appel réseau) AVANT que le Test MAIL_VALIDE * échoue et bloque l'enregistrement ; le contact n'étant jamais créé, cet * appel IA n'était même pas journalisable (pas de CNT_Incr) — perdu, coût et * trace. Pire : en resaisissant pour corriger le mail, un DEUXIÈME appel IA * partait pour le même contact. Jean-Marc : "il faut qu'on hiérarchise les * traitements : d'abord les traitements de cohérence, après les traitements * par IA". Nouvel ordre d'exécution, réutilisant FON_VisuelAttenteIA comme * seule frontière (déjà la bonne propriété : "cette fonction interroge un * service externe") : * 1. Traitements de cohérence (FON_VisuelAttenteIA != 'O') — silencieux, * gratuits, locaux, comme avant. * 2. Tests, sur les valeurs déjà mises en cohérence ci-dessus — premier * échec = arrêt immédiat, AVANT tout appel IA. * 3. Traitements IA (FON_VisuelAttenteIA = 'O') — atteints seulement si * tous les Tests sont passés : un enregistrement voué à l'échec ne * déclenche donc plus jamais d'appel IA. * Limite assumée, à revoir le jour où un vrai cas se présentera : un Test qui * porterait sur un champ produit par un Traitement IA (ex. valider le format * d'un code postal déduit par l'IA) verrait la valeur D'AVANT cet appel IA, * puisque les Tests s'exécutent avant l'étape 3 — aucune consigne actuelle * n'est dans ce cas. * * Test à un champ avec accès base (8 septembre 2026) : première consigne de * cette nature (unicité du mail/portable, voir consigne_mail_unique() et * consigne_portable_unique() dans consignes_fonctions.php). Le contrat de * base d'un Test à un champ reste (string $valeur): bool (voir en-tête de * consignes_fonctions.php), mais le moteur appelle désormais TOUJOURS ces * fonctions avec deux arguments supplémentaires, $pdo et $idExclusion — sans * effet sur les fonctions existantes qui ne déclarent qu'un seul paramètre * (PHP ignore silencieusement les arguments en trop), et exploitable par une * nouvelle fonction qui déclare ces deux paramètres en plus. $idExclusion est * l'identifiant (ex. CNT_Incr) de la fiche en cours de modification — null en * Création (rien à exclure), ou en Modification si l'appelant ne le fournit * pas — pour qu'une consigne d'unicité ne se bloque jamais elle-même en * retrouvant la propre valeur déjà enregistrée de la fiche qu'on modifie. * Fourni par l'appelant (voir $idExclusion du paramètre d'appliquerConsignes() * ci-dessous) : ce fichier n'a connaissance d'aucun identifiant métier propre * à un contexte précis, volontairement générique. */ /** * Charge les consignes actives d'un contexte/portée donnés, avec leur * fonction associée — factorisé ici (7 septembre 2026, soir) car utilisé à * la fois par appliquerConsignes() ci-dessous et par * auMoinsUneConsigneIaSeraitDeclenchee() : une seule requête à faire évoluer * si la jointure ASP_Consignes/ASP_Fonctions change un jour. */ function chargerConsignesActives(PDO $pdo, string $contexte, string $portee): array { $sql = "SELECT c.CNS_Champ, c.CNS_Nature, c.CNS_MessageErreur, c.CNS_Agent, f.FON_NomPHP, f.FON_VisuelAttenteIA FROM ASP_Consignes c LEFT JOIN ASP_Fonctions f ON f.FON_Nom = c.CNS_Fonction AND f.FON_Active = 'O' WHERE c.CNS_Contexte = ? AND c.CNS_Active = 'O' AND c.CNS_Champ IS NOT NULL AND (c.CNS_Portee = 'Mise à jour' OR FIND_IN_SET(?, c.CNS_Portee)) ORDER BY c.CNS_Incr"; $stmt = $pdo->prepare($sql); $stmt->execute([$contexte, $portee]); return $stmt->fetchAll(); } /** * Est-ce qu'au moins une consigne de Traitement porteuse de * FON_VisuelAttenteIA = 'O' se déclencherait réellement pour ces données ? * Jamais d'appel réseau ici (voir commentaire d'en-tête) : chaque fonction * concernée est interrogée via son jumeau "_seraitDeclenchee", pas la * fonction réelle. Utilisée par consignes_apercu.php pour piloter le badge * IA générique côté écran — un simple booléen, jamais le détail de quelle * consigne précisément (le badge reste volontairement générique, voir * réflexions avec Jean-Marc : pas de couleur/identité par agent ici). */ function auMoinsUneConsigneIaSeraitDeclenchee(PDO $pdo, string $contexte, string $portee, array $data): bool { $consignes = chargerConsignesActives($pdo, $contexte, $portee); foreach ($consignes as $c) { if ($c['CNS_Nature'] !== 'Traitement') continue; if (($c['FON_VisuelAttenteIA'] ?? 'N') !== 'O') continue; if (!$c['FON_NomPHP']) continue; $nomApercu = $c['FON_NomPHP'] . '_seraitDeclenchee'; if (!function_exists($nomApercu)) continue; $champs = array_map('trim', explode(',', $c['CNS_Champ'])); $valeurs = []; foreach ($champs as $champ) { $valeurs[$champ] = (string)($data[$champ] ?? ''); } if (call_user_func($nomApercu, $valeurs)) { return true; } } return false; } /** * Exécute une liste de consignes de Traitement (déjà filtrée par l'appelant) * sur $data, dans l'ordre reçu. Factorisé (7 septembre 2026, nuit) car * désormais appelé deux fois par appliquerConsignes() — une fois pour les * Traitements de cohérence, une fois pour les Traitements IA — avec * exactement la même mécanique à champ unique/multiple et la même collecte * de "_realisation" dans les deux cas. * * Provenance de l'agent journalisé (7 septembre 2026, nuit — suite) : Jean- * Marc a repéré que consigne_completer_code_postal() inventait elle-même son * agent ("Mat", en dur dans le code, sans lien avec aucune table) et a fait * remarquer que CNS_Agent existe déjà dans ASP_Consignes — exactement ce qui * alimente déjà $agentEchec pour un Test en échec (voir plus bas). Même * principe appliqué ici : l'agent réellement journalisé est TOUJOURS celui * de CNS_Agent quand la consigne en porte un (nom en clair, tronqué à 15 * caractères pour tenir dans REA_CodeUser — voir suggestion SQL associée), * jamais celui que la fonction aurait pu proposer elle-même. Une fonction de * Traitement n'a donc plus aucune raison d'inventer un nom d'agent dans son * "_realisation" — au mieux une valeur de repli si jamais CNS_Agent était * laissé vide par erreur. * * Clé réservée "_avertissement" (7 septembre 2026, nuit — suite) : Jean-Marc, * testant "Paris" (ville sans code postal unique, découpée en * arrondissements), a remarqué qu'un appel IA infructueux ne laissait AUCUNE * trace, ni à l'écran ni dans les réalisations — "il y a un appel fait à * l'IA et pas de trace dans les réalisations, ce qui n'est pas normal". * "_realisation" couvre déjà l'historique (voir consigne_completer_code_postal(), * désormais journalisée aussi en cas d'échec) ; "_avertissement" couvre * l'AUTRE moitié du problème, le retour immédiat à l'écran — sans lui, * l'utilisateur ne voit rien se passer alors que l'IA a bien réfléchi et a * sciemment renoncé. Contrat : une simple chaîne (le message tel quel, * déjà formulé pour l'utilisateur), collectée ici de la même façon que * "_realisation", jamais écrite dans $data. */ function executerTraitements(array $consignesTraitement, array &$data, array &$journalisations, array &$avertissements): void { foreach ($consignesTraitement as $c) { if (!$c['FON_NomPHP'] || !function_exists($c['FON_NomPHP'])) continue; $champs = array_map('trim', explode(',', $c['CNS_Champ'])); if (count($champs) === 1) { // Contrat historique, inchangé : une chaîne en entrée, une // chaîne en sortie. $valeurActuelle = (string)($data[$champs[0]] ?? ''); $data[$champs[0]] = call_user_func($c['FON_NomPHP'], $valeurActuelle); } else { // Consigne à plusieurs champs (7 septembre 2026, voir // commentaire d'en-tête) : un tableau associatif dans les deux // sens, pour que la fonction sache quel champ correspond à // quelle valeur sans dépendre de l'ordre dans CNS_Champ. $valeursActuelles = []; foreach ($champs as $champ) { $valeursActuelles[$champ] = (string)($data[$champ] ?? ''); } $valeursMisesAJour = call_user_func($c['FON_NomPHP'], $valeursActuelles); // Clé réservée "_realisation" (7 septembre 2026, voir commentaire // d'en-tête) : jamais écrite dans $data, juste collectée à part. if (isset($valeursMisesAJour['_realisation'])) { $realisation = $valeursMisesAJour['_realisation']; // L'agent vient de la consigne (CNS_Agent), jamais de la // fonction — voir commentaire ci-dessus. Repli sur ce que la // fonction avait proposé, puis sur 'IA' générique, seulement // si CNS_Agent est vide (ne devrait plus arriver une fois la // consigne correctement renseignée). $agentConsigne = trim((string)($c['CNS_Agent'] ?? '')); $realisation['agent'] = $agentConsigne !== '' ? mb_substr($agentConsigne, 0, 15, 'UTF-8') : ($realisation['agent'] ?? 'IA'); $journalisations[] = $realisation; unset($valeursMisesAJour['_realisation']); } // Clé réservée "_avertissement" (voir commentaire ci-dessus) : // même principe que "_realisation", jamais écrite dans $data. if (isset($valeursMisesAJour['_avertissement'])) { $avertissements[] = (string)$valeursMisesAJour['_avertissement']; unset($valeursMisesAJour['_avertissement']); } foreach ($valeursMisesAJour as $champ => $valeur) { $data[$champ] = $valeur; } } } } /** * @param PDO $pdo Connexion déjà ouverte (voir db.php). * @param string $contexte Ex. 'Contacts' (ASP_Consignes.CNS_Contexte). * @param string $portee 'Création' ou 'Modification'. * @param array $data Données du formulaire, par référence — * les traitements silencieux (ex. * majuscules) modifient directement ce * tableau. * @param string|null $agentEchec Sortie par référence : CNS_Agent de la * consigne dont le test a échoué (ou null * si tout est passé, ou si la consigne en * échec n'a pas d'agent renseigné). * @param array|null $journalisations Sortie par référence : liste des * entrées ['action'=>ACT_Code, * 'agent'=>CNS_Agent de la consigne * (nom en clair, tronqué à 15 caractères * — voir executerTraitements() ci-dessus), * 'commentaire'=>...] glissées par les * Traitements à plusieurs champs qui l'ont * demandé (voir commentaire d'en-tête) — * tableau vide si aucun (jamais null en * sortie, seul le type d'entrée est * nullable — voir note ci-dessous). À * journaliser dans ASP_Realisations par * l'appelant. * @param array|null $avertissements Sortie par référence : messages à * afficher immédiatement à l'écran * (voir "_avertissement" dans * executerTraitements() ci-dessus) — * tableau vide si aucun. À afficher par * l'appelant (ex. un toast par message), * indépendamment de $journalisations qui * couvre l'historique, pas l'écran. * @param int|null $idExclusion Identifiant de la fiche en cours de * modification (ex. CNT_Incr), à exclure * d'une éventuelle vérification * d'unicité — null en Création, ou si * aucune consigne du contexte n'en a * besoin (8 septembre 2026, voir * commentaire d'en-tête). Simplement * retransmis tel quel aux fonctions de * Test à un champ qui le demandent. * @return string|null Message d'erreur du premier test en * échec (déjà le texte à renvoyer tel * quel au front-end), ou null si tout est * passé. */ function appliquerConsignes(PDO $pdo, string $contexte, string $portee, array &$data, ?string &$agentEchec = null, ?array &$journalisations = null, ?array &$avertissements = null, ?int $idExclusion = null): ?string { // Type nullable en entrée (comme $agentEchec ci-dessus) : un appelant qui // passe une variable non initialisée par référence la fait exister à // null, ce qu'un type "array" strict refuserait (TypeError) — repéré à // l'usage le 7 septembre 2026 en testant ce paramètre. Toujours remis à // un tableau (vide ou rempli) avant de sortir de la fonction. $agentEchec = null; $journalisations = []; $avertissements = []; $consignes = chargerConsignesActives($pdo, $contexte, $portee); // Étape 1 : Traitements de cohérence (silencieux, jamais de message, // jamais d'appel réseau) — une consigne de test lue ensuite doit voir la // valeur déjà mise en forme (ex. le nom déjà en majuscules avant de // vérifier qu'il n'est pas vide). $traitementsCoherence = array_filter($consignes, function (array $c): bool { return $c['CNS_Nature'] === 'Traitement' && ($c['FON_VisuelAttenteIA'] ?? 'N') !== 'O'; }); executerTraitements($traitementsCoherence, $data, $journalisations, $avertissements); // Étape 2 : les Tests, sur les valeurs déjà mises en cohérence ci-dessus // (voir commentaire d'en-tête, 7 septembre 2026, nuit) : un premier échec // arrête tout ICI, avant la moindre sollicitation d'IA — c'est tout // l'objet de la réorganisation de ce soir. foreach ($consignes as $c) { if ($c['CNS_Nature'] !== 'Test') continue; if (!$c['FON_NomPHP'] || !function_exists($c['FON_NomPHP'])) continue; $champs = array_map('trim', explode(',', $c['CNS_Champ'])); if (count($champs) === 1) { $valeur = (string)($data[$champs[0]] ?? ''); // $pdo et $idExclusion toujours passés (voir commentaire // d'en-tête, 8 septembre 2026) : sans effet pour une fonction qui // ne déclare que (string $valeur), exploités par une fonction qui // a besoin d'interroger la base (ex. unicité mail/portable). $ok = call_user_func($c['FON_NomPHP'], $valeur, $pdo, $idExclusion); } else { $valeurs = []; foreach ($champs as $champ) { $valeurs[$champ] = (string)($data[$champ] ?? ''); } $ok = call_user_func($c['FON_NomPHP'], $valeurs); } if (!$ok) { $agentEchec = $c['CNS_Agent'] ?: null; return $c['CNS_MessageErreur'] ?: 'Consigne non respectée.'; } } // Étape 3 : Traitements IA — atteints seulement si tous les Tests sont // passés (voir commentaire d'en-tête) : un enregistrement voué à échouer // ne déclenche donc plus jamais d'appel IA facturé pour rien. $traitementsIA = array_filter($consignes, function (array $c): bool { return $c['CNS_Nature'] === 'Traitement' && ($c['FON_VisuelAttenteIA'] ?? 'N') === 'O'; }); executerTraitements($traitementsIA, $data, $journalisations, $avertissements); return null; }