Files
ASPIC/moteur_consignes.php

374 lines
22 KiB
PHP

<?php
/**
* ASPIC — moteur_consignes.php
*
* Moteur générique du chantier des consignes (6 septembre 2026, voir
* Réflexions stratégiques). Lit ASP_Consignes/ASP_Fonctions à l'exécution et
* applique ce qu'il y trouve — l'objectif explicite de Jean-Marc étant que
* l'ajout d'une ligne dans ASP_Consignes change le comportement réel de
* l'application sans qu'aucun fichier PHP/JS ne soit retouché, sauf si une
* fonction nouvelle doit être écrite dans consignes_fonctions.php (ou en cas
* de bug). Ce fichier ne doit donc plus jamais contenir la moindre référence
* à un champ ou une consigne précis — tout vient de la base.
*
* Portée de cette première version, volontairement limitée :
* - CNS_Champ NULL (consigne transverse à la fiche entière, ex. futur
* contrôle de doublon) n'est PAS encore géré ; ces lignes sont ignorées
* pour l'instant, à reprendre quand un vrai cas se présentera. En revanche
* CNS_Champ PEUT désormais contenir PLUSIEURS noms de champs séparés par
* une virgule (ex. "CNT_AdrVille,CNT_AdrCP") — extension du 7 septembre
* 2026 (soir), nécessaire pour une consigne dont la réponse dépend de
* plusieurs champs à la fois (ex. déduire le code postal à partir de la
* ville, voir consigne_completer_code_postal() dans
* consignes_fonctions.php). Une consigne à un seul champ garde exactement
* le même contrat qu'avant (fonction appelée avec une simple chaîne) ; une
* consigne à plusieurs champs appelle sa fonction avec un tableau associatif
* [nom_du_champ => 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é "<FON_NomPHP>_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;
}