1# Preuves — famille 4 « Le travail, les entrées, les traces », carte étages 1/2/5, deux points de « Ce qui tient » 2 3Vérification phrase par phrase de `audit-v3.html`, avec le **nom des fonctions** à faire figurer dans le texte publié. 4 5## Conventions de lecture 6 7| Sigle | Chemin | Nature | 8|---|---|---| 9| **D1** | `code/flexylead-crm/backend/` (`888cf02`) | copie **déployée**, 73 fonctions, état de capture d'origine | 10| **D2** | `code/flexylead-crm-0908/backend/` (`730a5e7`) | mêmes 73 captures **inchangées** + 10 ajouts (`SNAPSHOT-ADDITIONS-20260908.json:3`) | 11| **S** | `code/flexylead-crm-0908/functions/` | **sources de travail**, postérieures, correctifs non déployés | 12| **P** | `code/apteva-platform/` | plateforme Apteva (couche `tables`) | 13 14Sauf mention contraire, **D1 et D2 sont identiques** sur chaque point cité : vérifié par `diff` fichier à fichier. 15Racine de tous les chemins : `/Users/gregorydelaloeuf/Documents/scalewayxflexylead/`. 16 17**Avertissement de sens.** Pour chaque ligne marquée « correctif en source », le sens est : *le défaut est dans ce qui tourne, la réparation est écrite et non mise en service*. Aucune de ces lignes ne décrit un défaut des sources. 18 19--- 20 21## 1. « Un client jamais appelé est enregistré comme injoignable » 22 23| Phrase exacte du document | Verdict | Fonction(s) à nommer | Preuve `fichier:ligne` | Texte de remplacement | 24|---|---|---|---|---| 25| « Un client jamais appelé est enregistré comme injoignable » (titre) | **VRAI, à restreindre** | `expireStaleAIReservations` | S `flexylead-route-work-items.mjs:1077-1103` ; D1 `flexylead-route-work-items.mjs:1040-1066` — identique | « Un rendez-vous jamais appelé est enregistré comme injoignable » | 26| « Un lead ou un rendez-vous qui n'est pas distribué dans la journée » | **FAUX pour le lead** | `expireStaleAIReservations` | La requête filtre `COALESCE(NULLIF(reservation_type,''),'ai_booking') = 'ai_booking'` — D1 `flexylead-route-work-items.mjs:1046`. Ni les leads (`prospects`), ni les rappels planifiés (`commercial_callback`), ni les appels manqués ne sont touchés. | « Un rendez-vous pris par l'agent vocal et non distribué dans la journée » | 27| « le système lui écrit « absent au rendez-vous » et « injoignable » » | **VRAI** | `expireStaleAIReservations` | D1 `flexylead-route-work-items.mjs:1059-1060` : `status:"no_show"`, `commercial_outcome:"unreachable"` | inchangé | 28| « qui n'est pas distribué dans la journée » | **VRAI** | `expireStaleAIReservations` · `zonedDayStart` | Le seuil est le **début du jour ouvré** de la politique : D1 `:1042-1044` (`zonedDayStart(now, policy.timezone)`) | inchangé | 29| « Il sort donc des statistiques comme un échec de contact… Personne, en lisant les chiffres, ne peut faire la différence. » | **VRAI — et la preuve est plus forte que le texte** | `expireStaleAIReservations` **et** `buildTransition` | Les deux écrivent **la même valeur** `commercial_outcome = "unreachable"` : D1 `flexylead-route-work-items.mjs:1060` (expiration automatique) et D1 `flexylead-record-work-result.mjs:150` (NRP réellement constaté par un commercial). Aucune colonne ne les distingue. Consommation : D1 `flexylead-app-dashboard-query.mjs:87` exclut `'unreachable'` du décompte ; D1 `flexylead-app-commercial-ai-operations-query.mjs:1585` l'affiche tel quel. | « Deux fonctions écrivent la même mention « injoignable » : celle qui enregistre un vrai appel sans réponse — `buildTransition` — et celle qui périme un rendez-vous jamais distribué — `expireStaleAIReservations`. Rien, dans la base, ne les distingue. » | 30| Condition d'exécution non dite dans le document | **à ajouter** | `routingSweepPlan` | D1 `flexylead-route-work-items.mjs:47-49` : l'expiration ne tourne que dans un **passage de maintenance** sans ressource ciblée. | à préciser si le document veut être opposable | 31 32**Recherche établissant l'absence** (aucun autre écrivain de ces deux valeurs) : 33`grep -rn "no_show\|unreachable" code/flexylead-crm-0908/backend | grep -v SELECTS` → seuls `flexylead-route-work-items.mjs` et `flexylead-record-work-result.mjs` écrivent ; les autres occurrences sont des listes de colonnes ou des exclusions SQL. 34 35--- 36 37## 2. « La relance d'un numéro qui n'a pas répondu est morte » 38 39| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 40|---|---|---|---|---| 41| « Quand un rappel se termine, le système est censé libérer la fiche » | **VRAI, à préciser** | `buildTransition` | Le moment exact est l'enregistrement d'un résultat **NRP**, pas la fin du rappel : S `flexylead-record-work-result.mjs:137` (`if (result === "nrp")`) | « Quand un commercial enregistre « n'a pas répondu », le système est censé libérer la fiche » | 42| « La production ne la libère pas » | **VRAI** | `buildTransition` | S `flexylead-record-work-result.mjs:166-168` ajoute `followup_appel_id: null` avec le commentaire *« this slot is reserved for an active attempt, not the previous NRP »*. `grep -n "followup_appel_id: null" code/flexylead-crm/backend/flexylead-record-work-result.mjs code/flexylead-crm-0908/backend/flexylead-record-work-result.mjs` → **0 occurrence**. Le `diff` complet des deux fichiers ne contient que ces 3 lignes. | inchangé | 43| « la fonction qui déclenche le rappel suivant refuse alors, avec le message « un rappel existe déjà pour cet appel » » | **VRAI** | `assertActiveMissedCallClaim` (dans `flexylead-start-callback`) | D1 `flexylead-start-callback.mjs:619` : `if (clean(parentCall.followup_appel_id) || …) throw new Error("Un rappel existe déjà pour cet appel.")` — sans exception. S `flexylead-start-callback.mjs:620-634` ajoute la dérogation `nrpWaiting`. `grep -c "nrpWaiting"` → S : 3 ; D1 : 0 ; D2 : 0. | inchangé | 44| « Deux fonctions, un seul défaut » | **À BORNER — il y en a trois** | `buildTransition` · `assertActiveMissedCallClaim` · `recoverFailedCallbackPointer` | La troisième moitié est aussi source-seule : S `flexylead-claim-missed-call.mjs:845-870`, appelée en S `:24-26`. `grep -rn "recoverFailedCallbackPointer" code/flexylead-crm code/flexylead-crm-0908/backend` → **0**. Elle ne rattrape toutefois qu'un rappel **techniquement en échec**, pas le cas général. | « Trois fonctions, un seul défaut : `buildTransition` ne libère pas la fiche, `assertActiveMissedCallClaim` refuse alors le rappel suivant, et le rattrapage `recoverFailedCallbackPointer` n'existe que dans les sources. » | 45| « le cycle des quatre tentatives ne peut pas se dérouler » | **VRAI** | `buildTransition` · `MAX_NRP` | `MAX_NRP = 4` : D1 `flexylead-record-work-result.mjs:3`. Enchaînement : `flexylead-start-callback` pose `followup_appel_id` au 1er rappel → `buildTransition` le laisse en place → `assertActiveMissedCallClaim` refuse le 2ᵉ. | inchangé | 46| « Les deux moitiés du correctif sont écrites dans les sources, et aucune n'est en service. » | **VRAI sur le fond, faux sur le compte** | idem | idem ci-dessus | « Les trois moitiés du correctif sont écrites dans les sources, et aucune n'est en service. » | 47 48--- 49 50## 3. « Un appel pris par le serveur vocal est compté comme un appel pris par un commercial » 51 52| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 53|---|---|---|---|---| 54| Titre : « … est compté comme un appel pris par un commercial » | **VRAI** | `confirmedBusinessAnswerSql` | Les deux compteurs de production ne filtrent pas l'IVR : D1 `flexylead-app-dashboard-query.mjs:139-148` et D1 `flexylead-app-acquisition-query.mjs:10-18`. `grep -c "'ivr'" ` sur ces deux fichiers déployés → **0**. | inchangé | 55| **« Rien, en production, ne distingue les deux. »** | **FAUX** | `isUnattributedIVRAnswer` | La porte d'entrée **marque déjà** l'appel en production : D1 `ringover-call-events.go:611` (définition) et `:2912-2918` (`row["handled_by"]="ivr"`, `issue="ivr_connected_unattributed"`). Le marquage existe ; **c'est la lecture qui manque**. | « La marque existe en production — `isUnattributedIVRAnswer` écrit « serveur vocal » sur l'appel. Aucun compteur ne la lit : `confirmedBusinessAnswerSql`, la règle qui décide ce qu'est « un appel pris », ignore ce champ. » | 56| « Le prestataire a écrit la correction dans **cinq** fichiers différents » | **À BORNER — au moins huit** | — | Recomptage : `for f in functions/*.mjs; do diff backend/$(basename $f) $f | grep -c "ivr\|handled_by"; done` → 7 fichiers `.mjs` + `ringover-call-events/main.go`. Les huit : `flexylead-app-dashboard-query`, `flexylead-app-acquisition-query`, `flexylead-route-work-items`, `flexylead-claim-missed-call`, `flexylead-project-telephony-event`, `flexylead-reconcile-telephony-handoffs`, `flexylead-ringover-smart-routing`, `ringover-call-events`. S'y ajoute un module neuf `functions/shared/ai-callback-obligation.mjs`. | « Le prestataire a écrit la correction dans huit fichiers, plus un module neuf. Aucun n'est déployé. » | 57| « le tableau de bord, l'acquisition, la répartition du travail, la reprise des manqués, et la porte d'entrée des appels » | **VRAI** (liste partielle mais exacte) | `confirmedBusinessAnswerSql` (× 2, une copie par fonction) · `isPendingMissedCall`/filtres IVR de `flexylead-route-work-items` · `hasAnswerEvidence` (dans `flexylead-claim-missed-call`) · `isUnattributedIVRAnswer` | S `flexylead-app-dashboard-query.mjs:142` ; S `flexylead-app-acquisition-query.mjs:13` ; S `flexylead-route-work-items.mjs:150,159,1220` ; S `flexylead-claim-missed-call.mjs:548` ; S `ringover-call-events/main.go:3030-3042` | ajouter les trois autres | 58| « Aucun des cinq n'est déployé. » | **VRAI pour les cinq cités** | idem | `grep -c "'ivr'\|\"ivr\"" ` sur D1 et D2 → 0 pour les quatre `.mjs` ; pour le Go, D1 `:2912` n'a **pas** les gardes `handled_by != "ai"` ni la remontée `handled_by="human"` de S `:3038-3043` | inchangé | 59| « C'est sa réponse aux appels mal attribués trouvés le 7 septembre. » | **HORS CODE** | — | Aucune trace datée dans les trois dépôts : le dépôt a été créé le 7/09 et ne porte aucun historique. Cette phrase repose sur le fil WhatsApp, pas sur le code. | garder, mais sourcer sur le message, pas sur le code | 60| « Un dispositif cohérent en cinq morceaux, déjà écrit. Il suffit de le mettre en service, et de vérifier qu'il atteint tous les exemplaires. » | **À BORNER** | — | « cinq » → huit. Le reste tient : `confirmedBusinessAnswerSql` est **dupliqué** à l'identique dans deux fonctions distinctes, il faudra corriger les deux. | remplacer « cinq » par « huit » | 61 62--- 63 64## 4. « Deux portes d'entrée n'authentifient rien » 65 66| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 67|---|---|---|---|---| 68| « Trois adresses web reçoivent les messages de l'opérateur téléphonique. » | **À BORNER** | `verifyRingoverSignature` · `Handle` (Go) · `parseEnvelope` | Porte 1 prouvée : `flexylead-ringover-smart-routing` est déclarée **sans** `disable_function_url` — `scripts/application-api-manifest.mjs:71`. Porte 2 prouvée : la structure `urlEvent` (`Method`, `Headers`, `RawBody`) de S `ringover-call-events/main.go:29-35`. Porte 3 **non prouvée** : `flexylead-project-telephony-event` est **absente du manifeste**, donc son exposition publique n'est pas déclarée dans le dépôt — elle accepte cependant une enveloppe HTTP (D1 `flexylead-project-telephony-event.mjs:686-692`, `event?.body`). | « Trois fonctions reçoivent les événements des opérateurs téléphoniques. Deux sont publiquement joignables de façon établie ; pour la troisième, le dépôt ne dit pas si son adresse est ouverte. » | 69| **« Une seule vérifie qui l'appelle — et elle le fait très bien. »** | **VRAI** | `verifyRingoverSignature` | Appelée avant tout traitement : D1 `flexylead-ringover-smart-routing.mjs:30`. Définition D1 `:2862-2894` : HMAC-SHA256 V3 avec horodatage, ou JWT HS512, `timingSafeEqual`, contrôle de l'URL signée, comparaison du corps signé au corps reçu, et fraîcheur. | inchangé | 70| **« le champ prévu pour ça est déclaré une seule fois sur 4 565 lignes, à sa déclaration »** | **VRAI — mais le chiffre est celui de la source** | `urlEvent` (structure) · `Handle` | `Headers map[string]any` déclaré S `ringover-call-events/main.go:31`. `grep -c "\.Headers"` → **0** dans S **et** dans D1/D2. `wc -l` : **S = 4 565**, **D1/D2 = 4 440**. Le chiffre publié désigne donc la **source de travail** ; la version en service fait 4 440 lignes et ne lit pas davantage l'en-tête. | « le champ prévu pour ça est déclaré une seule fois — à sa déclaration — et n'est relu nulle part, ni dans les 4 440 lignes en service ni dans les 4 565 lignes de la source » | 71| « Qui connaît l'adresse peut **créer un appel** » | **VRAI** | `upsertAppel` | D1 `ringover-call-events.go:2772` (déf.), `:2950` (`upsertOne(ctx,"appels",…)`) | inchangé | 72| « …**créer ou modifier un prospect** » | **VRAI** | `upsertProspect` · `establishAnsweredProspectAffinity` | D1 `ringover-call-events.go:1798`/`:1864` (création) ; `:1262`/`:1285-1293` (modification, y compris `centre_lock_*`) ; aussi D1 `flexylead-project-telephony-event.mjs:532-533` | inchangé | 73| « …**en supprimer un** » | **VRAI** | `consolidateConcurrentRingoverProspects` | D1 `ringover-call-events.go:1378` (déf.), `:1456` : `rows_delete` sur `prospects` | inchangé | 74| « …ou **écrire une décision d'aiguillage** » | **VRAI** | `confirmRoutingDecisionDelivery` | D1 `ringover-call-events.go:623` (déf.), `:659` : `rows_update` sur `call_routing_decisions` | inchangé | 75| **« rien de ce qu'elles écrivent ne détourne un appel : l'aiguillage décide sur un autre champ, bien gardé »** | **VRAI** | `presenceFieldsForCallEvent` vs `buildCentreAffinityLookup` / `selectRoutingRule` | La porte non authentifiée écrit **`presence_status`** : S `ringover-call-events/main.go:3269-3310`. L'aiguillage lit **`availability_status`** : D1 `flexylead-ringover-smart-routing.mjs:118`, `:2087`, `:2599`. `grep -n "presence_status" code/flexylead-crm/backend/flexylead-ringover-smart-routing.mjs` → **0**. | « la porte non authentifiée écrit un état de présence — `presenceFieldsForCallEvent` — que l'aiguillage ne lit jamais : il décide sur `availability_status`, écrit par la seule fonction authentifiée `setAvailability`. » | 76| « Pour la troisième, écrite dans le même langage, c'est une recopie. » | **VRAI** | `parseEnvelope` (dans `flexylead-project-telephony-event`) | `grep -cniE "authenticate\|signature\|hmac\|api_key\|secret\|token"` sur D1 **et** S de `flexylead-project-telephony-event.mjs` → **0** dans les deux | inchangé | 77| « Pour la deuxième… elle ne lit aujourd'hui aucun réglage d'environnement. » | **VRAI** | `Handle` (Go) | `grep -c "os.Getenv\|os.LookupEnv"` sur S et D1 de `ringover-call-events` → **0** | inchangé | 78| « Et refuser le mode non chiffré, qui fait circuler ce secret en clair. » | **NON PROUVABLE ICI** | — | Aucun « mode non chiffré » dans le code : `verifyRingoverSignature` n'accepte que HMAC-SHA256 V3 ou JWT HS512 et lève 401 sinon (D1 `:2878`, `:2881`, `:2885`). L'option relève de la **console Ringover**, hors des trois dépôts. | soit retirer, soit dire explicitement « côté configuration Ringover, hors du code remis » | 79 80--- 81 82## 5. « Le classement énergie ou télécom est une devinette, et elle penche toujours du même côté » 83 84| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 85|---|---|---|---|---| 86| « Quand le fournisseur n'indique pas la campagne, le système cherche des mots dans le formulaire. » | **VRAI** | `inferFlatDeliveryCategory` | D1 `leadbyte-event-inspector.mjs:360-373`. Le foin balayé comprend **toutes** les clés et valeurs non standard du formulaire (`:364-368`). | inchangé | 87| **« Il teste l'énergie en premier : l'erreur va donc toujours du télécom vers l'énergie. »** | **VRAI** | `inferFlatDeliveryCategory` · `inferDispatchCategory` | Ordre des tests : énergie D1 `:369`, télécom D1 `:370`. Idem dans le second classeur : D1 `:1041` puis `:1042`. Un formulaire télécom contenant « fournisseur », « compteur » ou « électrique » bascule en énergie avant que « fibre » ou « box » ne soit testé. | inchangé | 88| « Un lead mal classé est refusé au fournisseur. » | **À BORNER** | `canonicalCampaignReference` · `recordStatus` | Le classement vide donne la référence `LEADBYTE-UNCLASSIFIED` (D1 `:375-379`), pas un refus direct. Le refus vient d'ailleurs : `recordStatus` (D1 `:931-942`) rend « Reject » sur `duplicate_contact`, `processing_error` ou action inconnue. **Je n'ai pas trouvé de chemin qui refuse un lead au seul motif du classement.** | « Un lead mal classé part dans la mauvaise file d'attribution » — ou apporter la preuve du refus | 89| « en production, une vente d'une offre télécom précise n'est classée ni en télécom ni en énergie » | **FAUX en l'état / NON REPRODUIT** | `salesFamilySql` · `saleFamily` · `querySaleAggregates` | Ces trois classeurs retiennent `%free%`, `% box%`, `%mobile%`, `%fibre%` : D1 `flexylead-app-commercial-bootstrap-query.mjs:1484-1493` et D1 `flexylead-app-dashboard-query.mjs:1130-1132`. Une vente `free_box_pop` **est** classée « téléphonie ». **Le fait réel voisin, lui, est établi** : les offres Free précises n'existent pas au catalogue déployé (D1 `flexylead-sync-local-google.mjs:485-486` : seulement `free_box` et `free_mobile`, **sans `type_offre`**), si bien qu'une règle de prime visant `free_box_pop` est **écartée** du calcul (test S `flexylead-manage-production-economics.test.mjs:144-153`). Le correctif source est `preciseTelecomOfferId` (S `flexylead-sync-local-google.mjs:2921-2940`, appelée `:3136`, absente de D1/D2). | « Et en production, les offres télécom précises n'existent pas au catalogue : une prime attachée à l'une d'elles est écartée du calcul. Le correctif — `preciseTelecomOfferId` — est dans les sources, pas en service. » | 90| « Un second classeur, mieux écrit, existe déjà dans le même fichier. » | **VRAI** | `inferDispatchCategory` | D1 `leadbyte-event-inspector.mjs:1026-1044`, même fichier que `inferFlatDeliveryCategory`. Il borne ses mots (`\b…\b`) et ne balaie pas les clés arbitraires. | inchangé | 91| « créer le bouton de reclassement manuel, qui laisse une trace, et qui n'existe pas aujourd'hui » | **VRAI, à borner** | — | `grep -rniE "dispatch_category" code/flexylead-crm-0908/src` → seulement des **filtres de lecture** (`src/dashboard/lib/dispatch.ts:3`, `:83`, `src/main.tsx:811`, `:2893`). Aucune écriture depuis l'interface. La seule écriture serveur est l'éditeur de tables générique `flexylead-admin-data-mutation` (S `:8`, table `campaigns`), qui reclasse **une campagne**, pas un lead — et le prospect n'a **aucune** colonne de catégorie (`functions/test-support/table-schema.json`, table `prospects`). | ajouter : « le seul reclassement possible aujourd'hui porte sur la campagne, par l'éditeur d'administration, et pas sur le lead » | 92 93--- 94 95## 6. « Le consentement du client n'est rangé nulle part » 96 97| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 98|---|---|---|---|---| 99| « Le client donne trois accords : courriel, téléphone, SMS. » | **HORS CODE** | — | Aucune trace de ces trois accords dans les trois dépôts. C'est une donnée métier (formulaire LeadByte), pas un fait de code. | conserver, en le présentant comme un fait métier | 100| **« Les trois traversent le système sans jamais entrer dans une case qu'on puisse relire »** | **VRAI** | — | Recherche d'absence : `grep -rniE "consent\|optin\|opt_in\|opt-in" code/flexylead-crm-0908 code/flexylead-crm --exclude-dir=.git --exclude-dir=dist --exclude-dir=prebuilt-dist` → **aucune colonne, aucun champ** ; les seules occurrences sont du texte de consigne pour l'IA (`process-appel-db-only.mjs:1875`, `:1909`) et deux titres de grilles Excel. La table `prospects` (34 colonnes, `functions/test-support/table-schema.json`) n'en comporte aucune. | inchangé | 101| « au mieux ils finissent noyés dans un champ de notes en texte libre, tronqué » | **VRAI, et le constat est plus dur** | `deliveryCustomFields` · `isSensitiveKey` · `leadByteNotes` · `compactText` | Chaîne : `deliveryCustomFields` (D1 `leadbyte-event-inspector.mjs:343-358`) collecte au plus **40** champs non standard → `leadByteNotes` (D1 `:1011-1024`) les sérialise en JSON dans `prospects.notes` → `compactText` (D1 `:1085-1087`) tronque à **1 000 caractères**. **Et surtout** : `isSensitiveKey` (D1 `:1108-1110`) rejette toute clé contenant `email`, `phone`, `tel` ou `mobile` — un champ nommé `optin_email` ou `consent_phone` est donc **purement et simplement supprimé** avant d'atteindre les notes. | « au mieux ils finissent noyés dans un champ de notes en texte libre, tronqué à mille caractères — et l'accord courriel comme l'accord téléphonique n'y arrivent même pas : `isSensitiveKey` supprime tout champ dont le nom contient « email » ou « phone ». Seul l'accord SMS peut survivre. » | 102| « On ne peut donc pas prouver, dossier par dossier, qu'on avait le droit d'appeler. » | **VRAI** | idem | découle des deux lignes ci-dessus | inchangé | 103| « Compter d'abord combien de fiches existantes n'ont aucun accord enregistré — cela se fait en lecture seule. » | **À CORRIGER** | — | Sans colonne, le décompte est **100 %** par construction : il n'y a rien à compter. La mesure utile est autre : combien de fiches portent un fragment d'accord dans `notes`. | « Il n'y a rien à compter : aucune fiche n'a d'accord enregistré. La mesure utile est de savoir combien en portent une trace dans le champ de notes. » | 104 105--- 106 107## 7. « Des enregistrements finissent par être introuvables » 108 109| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 110|---|---|---|---|---| 111| « Deux filets rattrapent les enregistrements que le chemin principal a manqués. » | **VRAI** | `ringover-audio-mirror` (`handler`, `selectAudioMirrorCandidates`) · `ringover-audio-reconcile` (`handler`, `loadCandidates`) | D1 `ringover-audio-mirror.mjs:4-14` ; D1 `ringover-audio-reconcile.mjs:8-30` | inchangé | 112| « Le second est bien réglé » | **VRAI** | `loadCandidates` | D1 `ringover-audio-reconcile.mjs:261` : `ORDER BY a.ended_at ASC, a.id ASC` — du plus ancien au plus récent, fenêtre 72 h, temporisation 10 min, reprise pilotée par `retry_after` (`:255-259`) | inchangé | 113| **« le premier n'est déclenché par rien, nulle part »** | **VRAI** | `ringover-audio-mirror` | Recherche d'absence : `grep -rn "ringover-audio-mirror" code/flexylead-crm-0908 --exclude-dir=.git --exclude-dir=dist --exclude-dir=prebuilt-dist` → **une seule occurrence**, son propre fichier de test (`functions/ringover-audio-mirror.test.mjs:3`). Absente de `scripts/application-api-manifest.mjs`, de `config/jobs/` (6 tâches, aucune) et de `workflows/` (5 flux, aucun). Le second, lui, a sa tâche : `config/jobs/ringover-audio-reconcile.json` (`every_seconds: 300`, `status: "active"`). | inchangé | 114| « quand un rattrapage échoue, il marque lui-même l'enregistrement « indisponible » » | **VRAI** | `markRecordingUnavailable` | D1 `ringover-audio-reconcile.mjs:456-478`, champ `storage_status: 'unavailable'`, `storage_error: 'recording_url_404'` | inchangé | 115| **« ce qui l'exclut définitivement des deux filets »** | **VRAI** | `isPendingRingoverAudio` (filet 1) · `reconcileCandidate` (filet 2) | Filet 1 : D1 `ringover-audio-mirror.mjs:65` — `['mirrored','unavailable','not_found'].includes(storage_status)` → `false`. Filet 2 : D1 `ringover-audio-reconcile.mjs:299` — `continue`. **Aucune fonction ne remet ce statut à zéro** : `grep -rn "storage_status" code/flexylead-crm-0908/backend` → 6 écritures, toutes vers `mirrored` ou `unavailable`, jamais vers `null`. | inchangé | 116| « Une vente dont l'enregistrement n'a pas été copié n'est notée que s'il subsiste une transcription. » | **NON VÉRIFIÉ ICI** | — | Relève de la famille 3 (`process-appel-db-only`), hors du périmètre confié. Non contrôlé. | à faire confirmer par l'analyse qualité | 117| « lui faire choisir les appels en attente du plus ancien au plus récent, comme le second le fait déjà » | **VRAI** | `handler` de `ringover-audio-mirror` | D1 `ringover-audio-mirror.mjs:11` : `order_by: 'id desc'`, `limit: MAX_SCAN` (250) — du plus **récent** au plus ancien, sur une fenêtre bornée. | inchangé | 118 119--- 120 121## 8. « Rien ne relie les tables entre elles » 122 123| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 124|---|---|---|---|---| 125| « Ici aucun lien de ce genre n'est déclaré, et le format employé ne permet pas d'en déclarer un. » | **VRAI** | `buildCreateTableSQL` (couche `tables` de la plateforme Apteva) | P `apps/mcp/tables/tables.go:186-212` : le `CREATE TABLE` produit ne contient que `id`, `created_at`, `updated_at`, `_revision` puis les colonnes typées, avec au plus `NOT NULL`. **Aucune clause `FOREIGN KEY`, `UNIQUE` ou `CHECK` n'est émise, et l'appelant n'a aucun moyen d'en demander une.** Recherche d'absence : `grep -rniE "foreign key\|references \|on delete" code/apteva-platform/apps/mcp/tables/*.go | grep -v _test` → 2 lignes, toutes deux sur le schéma **interne** de la plateforme (`tables.go:647`), aucune sur les tables applicatives. | inchangé | 126| **« treize fonctions modifient la structure de la base en pleine requête »** | **VRAI pour la production** | `ensureSchema`, `ensureColumns`, `ensureTable` (les trois noms se répètent d'une fonction à l'autre) | Recomptage : `grep -rlE "tables_alter\|tables_create" code/flexylead-crm-0908/backend` → **13** fichiers (6 avec `tables_alter`, 12 avec `tables_create`, union 13). Dans les **sources**, le compte monte à **15** (`grep -rlE … functions | grep -v test` → 15). Le chiffre publié est donc celui du **déployé**, et il est exact. | inchangé — préciser « treize fonctions en service, quinze dans les sources » | 127| « faute de procédure prévue » | **VRAI** | `ensureColumns` | Le motif se répète à l'identique : lire `tables_describe`, comparer, puis `tables_alter` en avalant l'erreur « already exists ». D1 `flexylead-update-sale.mjs:157-175` ; D1 `lead-qualification-update.mjs:477-490` ; D1 `flexylead-record-work-result.mjs:273-281` ; D1 `flexylead-manage-facturation.mjs:68-84`. | inchangé | 128| **« l'une d'elles crée elle-même une colonne dont l'absence avait provoqué un incident du mois d'août »** | **FAUX** | `ensureSchema` / `ensure-local-partners-schema` | La colonne de l'incident du 28/08 est `centre.effective_call_min_talk_seconds` (`analyse/notes-visuelles.md:773`). Elle n'est créée par **aucune** des treize fonctions : `grep -rn "effective_call_min_talk_seconds" code/flexylead-crm-0908 | grep -iE "alter\|create\|ensure"` → une seule occurrence, dans un **script de maintenance local**, `scripts/ensure-local-partners-schema.mjs:10`, qui refuse de tourner ailleurs qu'en local. Les fonctions ne font que **lire** cette colonne (D1 `flexylead-app-dashboard-query.mjs:949-951`). | « et la colonne dont l'absence avait provoqué un incident du mois d'août n'est créée par aucune de ces treize fonctions : elle l'est par un script de maintenance qui refuse de tourner ailleurs qu'en local. » | 129| « avec l'interdiction d'écrire dans plusieurs tables d'un seul geste, qui oblige aujourd'hui le code à tenir un journal de rattrapage à la main » | **VRAI** | `guardedRoutingWrites` | Les écritures multi-tables sont simulées à la main : contrôles préalables puis mutations séquentielles, avec conflit rendu au client. D1 `flexylead-claim-ai-reservation.mjs:25-36` (deux tables, `reservations` et `work_routing_assignments`) ; implémentation D1 `:190-305`, avec un verrou de bail (`owner_token`, `expires_at_ms`) tenu dans une table. | inchangé | 130 131--- 132 133## 9. « La supervision est tracée d'un côté et pas de l'autre » 134 135| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 136|---|---|---|---|---| 137| « Consulter le portefeuille d'un commercial par la fonction prévue laisse une trace complète : jeton signé, quinze minutes maximum, lecture seule, ligne d'audit. » | **VRAI** | `createSession` · `verifiedSession` (dans `commercial-supervision-session`) | Jeton signé et audit : S `commercial-supervision-session.mjs:33-95`, table `commercial_supervision_sessions`. Durée : `MAX_TTL_SECONDS = 15 * 60` (S `:8`). Lecture seule imposée en aval par `assertNotSupervisionMutation` dans sept fonctions de mutation (test S `supervision-read-only.test.mjs:11-19`). | inchangé | 138| **« Une seconde porte donne exactement le même accès sur simple présence d'un droit — sans jeton, sans durée, sans écrire une ligne. »** | **VRAI** | `resolveCommercial` (dans `flexylead-app-commercial-resource-query`, déployée sous ~20 noms dont `flexylead-app-commercial-bootstrap-query`) | D1 `flexylead-app-commercial-bootstrap-query.mjs:1723-1724` : `canSeeAll = permissions.has('commercial:all_centres')` ou `canSupervise = permissions.has('commercial:team:supervise')` **et même centre** → la fonction rend le profil demandé. Appel : S `flexylead-app-commercial-resource-query.mjs:53`, `resolveCommercial(context, actor, request.commercial_id)`. Aucun jeton, aucune expiration, aucune écriture : `grep -n "commercial_supervision_sessions\|rows_insert" code/flexylead-crm/backend/flexylead-app-commercial-bootstrap-query.mjs` → **0**. | « Une seconde porte — `resolveCommercial` — donne le même accès sur simple présence d'un droit : sans jeton, sans durée, sans écrire une ligne. » | 139| « Une réponse à « qui a consulté le portefeuille de X ? » sera donc incomplète, et rien ne l'indiquera. » | **VRAI** | idem | découle de la ligne précédente | inchangé | 140| **« prendre une opportunité n'est journalisé nulle part ; la rendre, si »** | **VRAI** | `flexylead-claim-ai-reservation` (`handler`) · `releaseClaimLock` · `flexylead-unassign-lead` (`handler`) | Prendre un rendez-vous IA : D1 `flexylead-claim-ai-reservation.mjs:1-42` — deux `rows_update`, **aucune** écriture d'événement. Prendre un appel manqué : une ligne est bien insérée dans `commercial_missed_call_claims` (D1 `flexylead-claim-missed-call.mjs:584-593`) mais c'est un **verrou d'arbitrage**, supprimé par `releaseClaimLock` (D1 `:620-624`, `rows_delete`) — il n'en reste rien. Rendre : `flexylead-unassign-lead` insère dans `work_routing_audit` avec `action: "lead.unassigned"`, l'auteur et l'état précédent (D1 `flexylead-unassign-lead.mjs:8`, `:55-74`). | inchangé | 141| **« un rendez-vous pris par l'agent vocal est détenu sans limite de temps, alors qu'un appel manqué repart au bout de dix minutes »** | **VRAI** | `flexylead-claim-ai-reservation` (`handler`) vs `flexylead-claim-missed-call` · `releaseExpiredAssignments` | Rendez-vous IA : D1 `flexylead-claim-ai-reservation.mjs:31-33` écrit `claimed_by`/`claimed_at` **sans `claim_expires_at`** ; et le routeur ne pose d'expiration que pour les appels manqués — `temporaryClaim` vaut vrai **uniquement** si `resourceType === "missed_call"` (D1 `flexylead-route-work-items.mjs:573`), sinon `claim_expires_at: null` (`:595-597`). `releaseExpiredAssignments` ne reprend que les lignes dont `claim_expires_at <= maintenant` (D1 `:874-877`) : une valeur nulle n'y entre jamais. Appel manqué : `DEFAULT_CLAIM_TTL_MS = 10 * 60 * 1000` (D1 `flexylead-claim-missed-call.mjs:2`), `MISSED_CLAIM_TTL_MS = 10 * 60 * 1000` (D1 `flexylead-start-callback.mjs:1`). | inchangé | 142| Gravité « Mineure » | **CONTESTABLE** | — | Le point le plus lourd de cette entrée — un second chemin de lecture du portefeuille d'un autre commercial, non tracé — n'est pas mineur au regard de la traçabilité. Jugement, pas fait. | à arbitrer | 143 144--- 145 146## 10. « Une vente annulée garde son verrou de trois mois, et personne ne peut l'enlever » 147 148| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 149|---|---|---|---|---| 150| « Une vente conclue réserve le client à ce centre pour trois mois. » | **VRAI, à préciser** | `saleLockUntil` (dans `flexylead-create-sales`) | D1 `flexylead-create-sales.mjs:597-602` : `soldAt + 90 * 24 * 60 * 60 * 1000`. **Quatre-vingt-dix jours**, comptés depuis la **date de vente la plus ancienne** du lot, pas depuis la date d'enregistrement. Écriture : D1 `:438-440` (`centre_lock_id`, `centre_lock_until`, `centre_lock_source: "sale"`). | « pour quatre-vingt-dix jours à compter de la date de vente » | 151| **« Si elle est annulée, le verrou reste. »** | **VRAI** | `flexylead-update-sale` (`handler`) | Recherche d'absence : `grep -n "centre_lock" code/flexylead-crm/backend/flexylead-update-sale.mjs` → **0**. Aucune des fonctions de vente ne touche au verrou lors d'un changement d'état. | inchangé | 152| **« Le programme qui libère les verrous ne connaît que deux origines, et pas celle-là. »** | **VRAI** | `buildProspectPatch` (dans `lead-qualification-update`) | D1 `lead-qualification-update.mjs:909-932` : la libération n'a lieu que si `centre_lock_source` vaut `answered_call` ou `commercial_comment` (`:910-911`), et seulement sur un événement `ko`. Or les origines écrites en production sont **cinq** : `sale` (D1 `flexylead-create-sales.mjs:440`, `flexylead-reconcile-telephony-handoffs.mjs:622`, `:647`, `flexylead-start-callback.mjs:1182`), `answered_call` (D1 `ringover-call-events.go:1293`, `:1423`, `:1591`), `commercial_comment` (D1 `lead-qualification-update.mjs:806`), `work_assignment` (D1 `flexylead-reconcile-telephony-handoffs.mjs:652`) et `released_ko`. **`sale` et `work_assignment` ne sont jamais libérés.** | « ne connaît que deux des cinq origines de verrou, et pas celle de la vente » | 153| **« Aucune fonction du logiciel ne peut rendre ce client aux autres centres. »** | **VRAI, avec une borne** | `buildProspectPatch` · `commercialCommentLockPatch` · `flexylead-admin-data-mutation` | Recherche d'absence : `grep -rn "centre_lock_source:\|\"centre_lock_source\"" code/flexylead-crm/backend` → 11 écritures, **une seule** remet le verrou à zéro (`lead-qualification-update.mjs:929`), et pas pour l'origine `sale`. La voie d'administration est fermée : la politique d'écriture de `flexylead-admin-data-mutation` n'autorise sur `prospects` que `assigned_commercial_id`, `assigned_at`, `assignment_source`, `assignment_error`, `dispatch_due_at`, `updated_at_iso` (D1 `flexylead-admin-data-mutation.mjs:9`) — **`centre_lock_*` n'y figure pas**. `flexylead-unassign-lead` ne vide que l'attribution, avec un commentaire qui l'assume (D1 `:40-53`). `commercialCommentLockPatch` (D1 `lead-qualification-update.mjs:774-808`) ne fait que **refuser** l'accès à un autre centre. **Borne** : le verrou n'est pas éternel — il **expire seul** à `centre_lock_until`, l'aiguillage exigeant `centre_lock_until > maintenant` pour les verrous de vente (D1 `flexylead-ringover-smart-routing.mjs:2169`). | « Aucune fonction du logiciel ne peut l'enlever : il faut attendre les quatre-vingt-dix jours. » | 154 155--- 156 157## Carte — étage 1 · « Ce qui entre » 158 159| Cellule / phrase | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 160|---|---|---|---|---| 161| « Un lead acheté — Un vendeur de listes envoie une fiche : quelqu'un a rempli un formulaire sur internet. » | **VRAI** | `looksLikeLeadByte` · `syncLeadByteEvent` | D1 `leadbyte-event-inspector.mjs:394` (reconnaissance), `:174` (traitement) | inchangé | 162| Défaut : « Le système devine « énergie » ou « télécom » en cherchant des mots. Il teste l'énergie en premier : l'erreur va toujours dans le même sens. » | **VRAI** | `inferFlatDeliveryCategory` · `inferDispatchCategory` | D1 `leadbyte-event-inspector.mjs:369-370` et `:1041-1042` | inchangé | 163| « Un appel entrant — Le client compose le numéro. L'opérateur téléphonique demande aussitôt au CRM à qui le donner. » | **VRAI** | `assertRequest` · `resolveAndPersist` | D1 `flexylead-ringover-smart-routing.mjs:2896-2905` (l'événement attendu est `event:"routing"`, `resource:"call"`, `direction:"inbound"`), `:56` | inchangé | 164| Solide : **« Le CRM répond en moins de deux secondes »** | **À BORNER — c'est un budget, pas une mesure** | `responseBudgetExceeded` · `abandonForResponseBudget` | `DEFAULT_RESPONSE_BUDGET_MS = 1_000` (D1 `flexylead-ringover-smart-routing.mjs:6`), plafonné à 2 500 ms (`:534`, `:797`) ; au-delà, la fonction abandonne et rend une réponse de secours (`:831`). **Aucun journal d'exécution n'a été remis : la latence réelle n'est pas mesurée.** | « Le CRM s'impose un budget de réponse d'une seconde, deux et demie au maximum, et abandonne proprement au-delà. » | 165| Solide : « et enregistre pourquoi il a choisi cette personne » | **VRAI** | `selectRoutingRule` · `commitRepeatedDecision` | Le motif est écrit dans `call_routing_decisions.decision_reason` : D1 `flexylead-ringover-smart-routing.mjs:1471`, `:2685` ; colonne déclarée dans `scripts/ensure-local-partners-schema.mjs` (schéma `call_routing_decisions`) | inchangé | 166| « Un rendez-vous pris par l'agent vocal — … Le CRM le range avec le reste à rappeler. » | **VRAI** | `loadReservationCandidates` · `candidateFromReservation` | D1 `flexylead-route-work-items.mjs:1131-1139` : la source `ai_appointment` lit `reservations` de type `ai_booking` et rejoint la même file que les manqués, rappels et leads | inchangé | 167| « Les coûts publicitaires — … récupéré automatiquement. » | **VRAI** | `flexylead-sync-google-ads-costs` (`handler`) · `upsertCostBatches` | D1 `flexylead-sync-google-ads-costs.mjs:191-199` ; tâche `config/jobs/google-ads-cost-sync.json` (`status: "active"`) | inchangé | 168| Défaut : **« Une tâche automatique réécrit jusqu'à trois mois de coûts passés, toutes les dix minutes. »** | **À BORNER** | `findCoverageGap` · `upsertCostBatches` | Cadence exacte : `every_seconds: 600` = dix minutes (`config/jobs/google-ads-cost-sync.json:5-6`). Portée : chaque passage réécrit les **7 derniers jours** (`lookback_days: 7`, D1 `:4`, `:293`) **plus au plus une fenêtre de 7 jours** trouvée par `findCoverageGap` dans un horizon de **90 jours** (D1 `:151-160`, commentaire : *« at most one seven-day window per invocation »*). Ce n'est donc pas trois mois réécrits toutes les dix minutes, mais une fenêtre glissante de quatorze jours au plus, cherchée dans les trois derniers mois. | « Toutes les dix minutes, une tâche automatique réécrit la semaine écoulée et rattrape au plus une semaine supplémentaire, choisie n'importe où dans les trois derniers mois. » | 169 170--- 171 172## Carte — étage 2 · « Ce qui décide à qui ça va » 173 174| Cellule / phrase | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 175|---|---|---|---|---| 176| « Deux mécanismes séparés, qui ne fonctionnent pas du tout de la même manière. » | **VRAI** | `flexylead-ringover-smart-routing` vs `flexylead-route-work-items` | Deux fonctions distinctes, deux jeux de tables (`call_routing_*` d'un côté, `work_routing_*` de l'autre), deux modes d'appel (URL signée en direct vs tâche `config/jobs/work-routing.json`) | inchangé | 177| **« Cherche l'historique du numéro à sept endroits »** | **VRAI** | `buildCentreAffinityLookup` | D1 `flexylead-ringover-smart-routing.mjs:2121-2270` : exactement sept sources unies et priorisées — `recent_sale` (`:2131`), `prospect_centre_lock` (`:2161`), `released_ko` (`:2180`), `assigned_prospect` (`:2192`), `delivered_routing` (`:2208`), `inbound_call` (`:2222`), `planned_routing` (`:2240`) ; `ORDER BY source_priority ASC … LIMIT 1` (`:2258-2260`) | inchangé | 178| « vérifie les quotas et qui est disponible, puis fait sonner un ou plusieurs commerciaux » | **VRAI** | `selectRoutingRule` · `routingChainAgents` · `groupAgents` | D1 `flexylead-ringover-smart-routing.mjs:855`, `:932-960`, avec filtres `ruleIsRoutable` et `availability_status` (`:118`, `:2087`) | inchangé | 179| Défaut : **« Il crée une ligne par personne chez qui ça sonne, et refuse volontairement de les regrouper. »** | **VRAI — le refus est commenté dans le code** | `findExistingAppel` · `providerCallKey` | D1 `ringover-call-events.go:2001-2005`, commentaire : *« A Ringover call can fan out to several user channels. When a channel is present, falling back to raw_call_id would merge a new sibling into the first row and overwrite its provider identity. »* puis `return nil, nil` — c'est-à-dire : crée une nouvelle ligne. La clé d'unicité est `call_id_channel_id` (`providerCallKey`, D1 `:3485-3493`), donc une ligne par canal. | inchangé | 180| « Un seul appel laisse donc plusieurs traces. » | **VRAI** | `upsertAppel` | D1 `ringover-call-events.go:2950` : `upsertOne(ctx,"appels",["provider","provider_call_id"],row)` — l'unicité porte sur la clé par canal, pas sur l'appel | inchangé | 181| « La répartition du travail — en file d'attente. Leads achetés, appels manqués, rendez-vous et rappels attendent au même endroit. » | **VRAI** | `loadCandidates` | D1 `flexylead-route-work-items.mjs:1093-1108` : quatre sources — `missed_call`, `ai_appointment`, `planned_callback`, `assigned_lead` — fusionnées et triées ensemble | inchangé | 182| « Chacun est confié à une personne, ou proposé à un groupe entier. » | **VRAI** | `assignment_mode` dans `flexylead-route-work-items` | D1 `flexylead-route-work-items.mjs:592` (`assignment_mode: selection.assignmentMode`) | inchangé | 183| Défaut : **« Un élément qui n'est pas distribué le jour même cesse définitivement de l'être. »** | **VRAI pour deux sources sur quatre** | `candidateLoadCutoffForSource` | D1 `flexylead-route-work-items.mjs:1114-1126` : pour `missed_call` et `assigned_lead`, le seuil est `max(effectiveFrom, début du jour ouvré)` — un élément de la veille n'est plus jamais chargé. Pour `ai_appointment` et `planned_callback`, le seuil est `earliestEffectiveFromForSource`, sans borne de journée (`:1116`). | « Un appel manqué ou un lead acheté qui n'est pas distribué le jour même cesse définitivement de l'être. » | 184 185--- 186 187## Carte — étage 5 · « Ce qui repart vers l'extérieur » 188 189| Cellule / phrase | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 190|---|---|---|---|---| 191| « À la seconde où la fiche arrive, le CRM répond au fournisseur : accepté, ou refusé. » | **VRAI** | `syncLeadByteEvent` · `recordStatus` · `attachSynchronousRouting` | D1 `leadbyte-event-inspector.mjs:174` ; verdict par enregistrement `:931-942` ; agrégats `accepted_count` / `declined_count` `:229-230`. L'attribution est faite **en ligne** avant de répondre : `:646-647`, commentaire : *« Route the leads we just accepted, in-line, so the answer we hand LeadByte can… »*. | inchangé | 192| Défaut : **« Un envoi rejoué répond « accepté » sans rien créer. »** | **VRAI** | `recordStatus` · `replayVerdict` | D1 `leadbyte-event-inspector.mjs:934` : `skipped_duplicate` → `{ status: "Approved", reason: "duplicate_event_replay" }` ; D1 `:944-951` : `replayVerdict` rend « Approved » sauf si le prospect est déjà en statut « refuse » avec un motif de refus explicite. | inchangé | 193| Défaut : **« Une panne technique du CRM répond « refusé » »** | **VRAI** | `syncLeadByteEvent` | D1 `leadbyte-event-inspector.mjs:210-212` : le `catch` de la boucle par enregistrement pousse `{ ok:false, status:"Reject", reason:"processing_error" }`. Toute exception — base indisponible, contrainte, délai — devient un refus rendu au fournisseur. | inchangé | 194| « — le lead est perdu, et payé. » | **À BORNER (moitié hors code)** | — | « perdu » est prouvé : aucune reprise n'est prévue, `grep -rn "processing_error" code/flexylead-crm/backend` → une seule occurrence, celle de l'écriture. « payé » est une conséquence contractuelle avec LeadByte, absente du code. | « — le lead est perdu. Selon le contrat de fourniture, il peut néanmoins être facturé. » | 195| « Les statistiques et le coût d'acquisition — Ce que la direction regarde pour décider où mettre l'argent » | **VRAI** | `flexylead-app-acquisition-query` (`handler`) · `flexylead-app-dashboard-query` (`handler`) | D1, routes `POST /v1/operations/acquisition/summary` et `/v1/operations/dashboard/summary` (`scripts/application-api-manifest.mjs`) | inchangé | 196| Défaut : **« Ces chiffres viennent des compteurs de l'étage 3, ceux qui ne se recoupent pas. »** | **VRAI** | `confirmedBusinessAnswerSql` (deux copies) · `querySaleAggregates` | La même fonction est **recopiée** dans les deux moteurs : D1 `flexylead-app-dashboard-query.mjs:139` et D1 `flexylead-app-acquisition-query.mjs:10` — définitions distinctes, à maintenir en double. Et le mot « manqué » n'y a pas le même sens : le tableau de bord teste `issue`/`motif`/`status` (D1 `flexylead-app-dashboard-query.mjs:224`), l'acquisition teste `period_answered = 0` (D1 `flexylead-app-acquisition-query.mjs:174`). Le correctif source unifie les deux par une fonction commune `missedCallSql` (S `flexylead-app-dashboard-query.mjs:151-156`), non déployée. | inchangé — on peut ajouter : « la règle qui décide ce qu'est « un appel pris » est recopiée dans les deux moteurs, et celle qui décide ce qu'est « un appel manqué » n'y est pas la même » | 197 198--- 199 200## « Ce qui tient » — les deux points confiés 201 202| Phrase exacte | Verdict | Fonction(s) à nommer | Preuve | Remplacement | 203|---|---|---|---|---| 204| **« L'exclusivité client de trois mois est réellement appliquée. »** | **VRAI** | `loadExistingCentreAffinity` · `buildCentreAffinityLookup` | D1 `flexylead-ringover-smart-routing.mjs:2111-2119` et `:2121-2270`. Fenêtre : `now - 90 jours` (`:2125`). Contrainte descendante : `allowedCentreId` (`:248`, `:277`, `:340`, `:482`). | inchangé — mais dire « quatre-vingt-dix jours » | 205| **« Le système croise sept traces avant de décider »** | **VRAI** | `buildCentreAffinityLookup` | Sept `SELECT` unis et priorisés, cf. étage 2 ci-dessus | inchangé | 206| **« et le réglage qui la gouverne ne peut pas envoyer un client chez un concurrent »** | **VRAI** | `selectRoutingRule` · `routingChainAgents` · `emergencyRoutingResponse` | Le réglage en question est `sticky_repeat` (D1 `flexylead-ringover-smart-routing.mjs:313`, `:1605`). Qu'il soit vrai ou faux, l'exclusivité est appliquée **ailleurs** : les trois sélecteurs filtrent les règles par `!allowedCentre || sameCentre(rule.destinationCentreId, allowedCentre)` — D1 `:709` (`emergencyRoutingResponse`), `:946` et `:950` (`routingChainAgents`). Et la voie de secours refuse explicitement de choisir un centre arbitraire : D1 `:47-51`, commentaire *« An unexpected error must never discard a caller's ownership constraint. »* | « Le réglage qu'on soupçonnait — la répétition collante — ne commande pas l'exclusivité : elle est appliquée à part, dans le choix des règles comme dans la voie de secours. » | 207| **« La disponibilité d'un commercial est la partie la mieux écrite du logiciel. »** | **VRAI** | `setAvailability` | D1 `flexylead-commercial-availability.mjs:131-232` | inchangé | 208| « Le serveur exige de savoir quel état l'écran croyait avoir » | **VRAI** | `setAvailability` | D1 `flexylead-commercial-availability.mjs:152-155` : `expected_stored_status` obligatoire, comparé à l'état réel, **409** en cas d'écart avec le message « Votre disponibilité a changé dans un autre onglet ». S'y ajoute `client_instance_id` obligatoire (`:140`). | inchangé | 209| « refuse un même changement envoyé deux fois » | **VRAI** | `setAvailability` · `availabilityRequestEventId` | D1 `flexylead-commercial-availability.mjs:141-150` : recherche du `provider_event_id` déterministe dans `telephony_events` ; s'il existe, retour `{ changed: false, duplicate: true }` sans écriture. | inchangé | 210| « et écrit une trace horodatée avec son auteur » | **VRAI** | `setAvailability` | D1 `flexylead-commercial-availability.mjs:183-212` : insertion d'un `commercial_availability_changed` dans `telephony_events`, avec `previous_status`, `status`, `updated_by`, `actor_email`, `admin_override`, `request_id` et `received_at`/`processed_at`. Colonnes miroir sur la fiche : `availability_updated_at`, `availability_updated_by` (`:176-180`). | inchangé | 211 212--- 213 214## Ce que je n'ai pas pu établir 215 2161. **L'exposition publique de `flexylead-project-telephony-event`.** Elle n'authentifie rien — cela est établi. Mais elle est **absente** de `scripts/application-api-manifest.mjs`, seul endroit du dépôt qui déclare `disable_function_url`. Le dépôt ne permet donc pas d'affirmer qu'une adresse web l'atteint. 2172. **Le « mode non chiffré » de Ringover.** Aucune trace dans les trois dépôts. `verifyRingoverSignature` n'accepte que deux modes signés. Le point relève de la console Ringover. 2183. **« Un lead mal classé est refusé au fournisseur. »** Je n'ai trouvé aucun chemin de refus dont la cause soit le classement. 2194. **« Une vente d'une offre télécom précise n'est classée ni en télécom ni en énergie. »** Non reproduit : les trois classeurs de vente retiennent `%free%` et `% box%`. Le fait voisin — l'absence des offres précises au catalogue déployé, et l'écartement de leurs primes — est établi. 2205. **La latence réelle de l'aiguillage.** Aucun journal d'exécution remis ; seul le budget est lisible. 2216. **« Une vente dont l'enregistrement n'a pas été copié n'est notée que s'il subsiste une transcription. »** Hors périmètre confié, non contrôlée. 222 223## Contre-lecture 224 225- La règle qui coûte le plus cher au document est la n° 3 : **le marquage du serveur vocal existe déjà en production**. Écrire « rien ne distingue les deux » est faux et donne prise à une réfutation en une ligne. Le vrai constat — la marque existe, aucun compteur ne la lit — est plus court et plus solide. 226- La règle n° 8 contient une affirmation invérifiable en l'état (la colonne de l'incident d'août), au milieu d'un constat par ailleurs entièrement établi. C'est le genre de détail qui fait tomber un constat juste. 227- Trois chiffres publiés sont ceux de la **source de travail** et non du déployé : les 4 565 lignes (le déployé en fait 4 440) ; les « cinq fichiers » du dispositif IVR (au moins huit) ; les « deux moitiés » du correctif de relance (trois). Le chiffre « treize fonctions », lui, est exactement celui du déployé et doit être conservé tel quel. 228