03 — Fleet Services Group
Un référentiel unique à partir de 2 071 CRM isolés
L'entreprise avait plus de deux mille bases clients séparées, impossibles à consulter ensemble. J'en ai fait une seule base interrogeable, mise à jour chaque nuit, et l'application web pour la consulter.
L'entreprise est un prestataire de téléprospection B2B pour l'écosystème informatique et télécom. Chaque campagne commandée par un distributeur ou un intégrateur donne lieu à une instance CRM dédiée. Au fil des années, cela a produit plus de deux mille bases isolées — et un patrimoine commercial de 17,5 millions de lignes que personne ne pouvait interroger.
Le problème
Aucune question transverse n'avait de réponse. « Cette entreprise, l'a-t-on déjà appelée ? Sur quelle campagne, quand, et qu'est-ce qui s'est dit ? Qui est le bon interlocuteur aujourd'hui ? » — pour y répondre, il fallait ouvrir les CRM un par un. À deux mille instances, personne ne le faisait.
Concrètement, l'entreprise rappelait des sociétés qui avaient déjà dit non trois mois plus tôt sur une autre campagne, redémarrait des ciblages à zéro alors que la moitié du travail existait, et ne pouvait pas répondre à un client qui demandait ce qu'on savait de son marché. Le capital le plus précieux de la société était inexploitable parce qu'il était éparpillé.
Les contraintes
- Lecture seule absolue. Les sources sont des bases de production et un espace documentaire d'entreprise. Aucune écriture, jamais, quelles que soient les circonstances.
- Des identifiants qui n'en sont pas. La référence de compte présente dans les CRM n'identifie pas une société : c'est une référence locale au fichier de campagne. Une même valeur couvre plus de 1 300 entreprises différentes.
- Des champs personnalisés opaques. Le CRM stocke les colonnes métier sous des noms génériques numérotés. Mesuré sur 2 091 instances : 81 % de ces colonnes changent de sens d'une instance à l'autre. Le champ « SIRET » se trouve sous 46 numéros différents ; un même numéro porte 279 libellés distincts selon la campagne.
- De la volumétrie sur un poste de travail. Sept sources à réconcilier, dont un référentiel public de 73,5 millions de lignes, 17 181 documents et 2,6 Go de pièces jointes. Le tout doit tourner chaque nuit sur une machine ordinaire.
- Un accès réseau contraint. Les bases CRM n'acceptent de connexion qu'en local, ce qui impose un tunnel ; l'espace documentaire s'authentifie par cookies de session qui expirent.
Ce que j’ai construit
Une chaîne en trois modules, pilotée par une seule commande. Le premier télécharge les sept sources vers un dépôt brut immuable, de façon incrémentale. Le deuxième retraite : normalisation, résolution d'identité, dédoublonnage, enrichissement, contrôles d'intégrité. Le troisième publie vers un dossier de diffusion accompagné d'un manifeste daté — et refuse de publier si les contrôles ont échoué.
Par-dessus, une application web complète écrite en JavaScript natif, sans aucune dépendance : routeur maison, graphiques tracés en SVG à la main, design system clair et sombre. Vue d'ensemble chiffrée, modèle de données auto-documenté, explorateur de tables avec recherche plein texte et facettes, fiche entreprise 360° qui rassemble identité légale, signaux de ciblage, frise de prospection, contacts, établissements et structure capitalistique, et une console SQL bridée aux requêtes de lecture.
Elle se lance en double-cliquant sur un fichier, sans rien installer. C'est la contrainte qui a dicté l'absence de framework — et l'occasion de réécrire à la main ce qu'on délègue d'habitude à trois bibliothèques.
Ce qu’il a fallu écrire à la main
- Un routeur à motifs. Pas un aiguillage de conditions : un tableau de motifs testés dans l'ordre, avec groupes capturants décodés et passés à la vue, séparation du chemin et des paramètres, gestion asynchrone et carte d'erreur en cas d'échec. Conséquence directe : l'état complet d'une table vit dans l'adresse — filtres, recherche, tri, colonnes affichées. Une vue se partage par copier-coller et le bouton Précédent fonctionne.
- Un algorithme d'échelle « nombres ronds ». Pour qu'un axe se gradue sur 0-20-40 plutôt que sur 0-17,3-34,6 : on prend l'ordre de grandeur, puis le premier pas acceptable parmi une courte série. C'est ce que fait toute bibliothèque de graphes ; ici c'est huit lignes.
- Des graphiques en SVG généré. Histogrammes dont chaque barre est un tracé à sommet arrondi et base carrée, courbes multi-séries avec marqueur d'extrémité et anneau de détourage, barres classées, jauges de cellule, tuiles d'indicateur à taille de police adaptative selon la longueur du libellé. Avec un système d'infobulles à détection de débordement sur les deux axes, branché par délégation.
- Une fabrique de tableau générique, pilotée par déclaration. Un seul moteur, configuré par un objet — colonnes, fonctions de rendu, dimensions à facettes, plages numériques, tri par défaut. Les colonnes de faible cardinalité deviennent filtrables automatiquement, avec leurs valeurs et leurs décomptes chargés en différé. Le sélecteur de colonnes réinsère une colonne à sa position d'origine, pas en fin de liste.
- Un export qui respecte exactement la vue. Le lien de téléchargement porte la même chaîne de paramètres que l'écran : filtres, recherche, tri et colonnes affichées sont repris tels quels. Côté serveur, séparateur point-virgule, marqueur d'ordre d'octets pour les accents, et en-têtes remplacés par les libellés métier plutôt que les noms techniques.
- Un thème clair/sombre à trois états. Le clair sur la racine, le sombre redéfini deux fois — sous la préférence système et sous le choix explicite, de sorte que la bascule gagne dans les deux sens. C'est le détail que la plupart des implémentations ratent.
- Un moteur de filtres côté serveur supportant dix opérateurs, avec coercition selon le type SQL réel de la colonne, gestion du marqueur de valeur nulle, et requêtes paramétrées de bout en bout — jamais de concaténation de valeur.
La QA que je me suis imposée
Non-régression sur son propre front
Un script pilote un navigateur sans interface, parcourt automatiquement toutes les routes de l'application — dérivées du catalogue de tables, plus des fiches réelles choisies dynamiquement par appel à l'API —, écoute les erreurs de page et de console, et attend le rendu effectif plutôt que la simple fin des requêtes réseau.
Il remonte les pages en erreur et les pages lentes au-delà de quatre secondes. Deux autres scripts détectent la dérive du schéma et des codes de référence entre deux publications du livrable.
Sur une application interne que personne ne m'obligeait à tester, c'est ce qui fait la différence entre un outil qu'on ose republier trois fois par jour et un outil auquel on ne touche plus.
Décisions structurantes
01Résoudre l’identité avant de fusionner, jamais après
C'est la décision qui rend tout le reste possible. Établi après la fusion, le rattachement au registre officiel n'a plus rien à fusionner : « MAIRIE DE X » et « COMMUNE DE X » sont déjà devenus deux comptes distincts, et le resteront. L'appariement passe donc par un annuaire officiel constitué localement — 73,5 millions de lignes — interrogé en SQL sur un format colonne. Faire transiter 43 millions d'établissements par du code Python n'était pas tenable dans une fenêtre nocturne.
02Résoudre les champs personnalisés instance par instance
Un mapping global des colonnes aurait été plus simple à écrire, et silencieusement faux : le même numéro de colonne désigne un identifiant d'établissement sur une campagne et une date de relance sur une autre. J'extrais donc le dictionnaire de champs propre à chaque instance et je résous le sens colonne par colonne. C'est ce qui rend les 2 071 instances réellement exploitables plutôt que superficiellement empilées.
03Invalider le cache sur le code, pas seulement sur la donnée
Une étape intermédiaire fige le travail de lecture des classeurs — 45 % du temps de traitement. Elle ne reconvertit une campagne que si son fichier a changé ou si le code qui le normalise a changé. Cette seconde condition est la moins évidente et la plus importante : sans elle, une correction des règles de normalisation resterait sans effet sur tout ce qui a déjà été converti, et la base mélangerait deux normalisations sans que rien ne le signale.
04Ne rien perdre à la fusion
Quand deux campagnes portent des valeurs contradictoires pour la même entreprise, la plus récente est retenue — et toutes les autres sont archivées avec leur campagne d'origine et leur période. 5,3 millions de valeurs écartées restent consultables. L'historique commercial est déplié en une ligne par échange daté, chacune portant la campagne, la ligne exacte du fichier source et la référence du compte dans ce fichier.
05Reconstruire le lien contact → entreprise, et afficher son niveau
Les contacts ne portent pas la clé de leur entreprise. Je la rétablis par quatre tentatives successives, de la plus sûre à la plus faible. 96,9 % des contacts sont rattachés — et le niveau utilisé est affiché sur chaque fiche, parce qu'un rattachement fondé sur le seul nom ne vaut pas un rattachement fondé sur un identifiant légal, et que l'utilisateur doit le savoir avant de décrocher son téléphone.
06Une interface sans dépendance, et c’est un choix
L'outil doit démarrer sur un poste sans droits d'installation, sans réseau, et fonctionner encore dans deux ans. Routeur, graphiques et composants sont donc écrits à la main : aucune bibliothèque à mettre à jour, aucune faille tierce à surveiller, aucun build. En contrepartie, tout est à écrire — et c'est précisément ce qui rend l'exercice intéressant.
07Afficher les limites dans le produit
Le consentement e-mail est vide à 99,7 % : aucun ciblage e-mail conforme au RGPD n'est possible sur cette base, et l'interface le dit en toutes lettres. De même pour la profondeur d'historique réellement exploitable et le caractère déclaratif des statuts. Un outil qui ne dit pas ce qu'il ne sait pas fait prendre de mauvaises décisions avec beaucoup d'assurance.
Le piège qui a le plus coûté
Succès partiel silencieux
Le premier parcours de l'espace documentaire, lancé à douze connexions parallèles, n'a rapporté que 3 325 fichiers sur 17 181. Sans une seule erreur dans le journal.
Le serveur ne renvoie pas de code d'erreur quand il limite le débit : il répond « 200 OK » avec une page vide. Le collecteur considérait donc chaque dossier tronqué comme complet. J'ai trouvé le seuil réel — environ six connexions — et ajouté un contrôle de cohérence entre le nombre d'entrées annoncées et le nombre récupéré.
Une erreur franche coûte une heure ; un succès partiel silencieux coûte la confiance dans toutes les données produites depuis.
Résultats
Stack
Traitement
- Python
- DuckDB
- Parquet / PyArrow
- SQL
Sources
- MySQL / MariaDB (lecture seule)
- SharePoint REST
- SIRENE / INSEE
- GLEIF
- Tunnels SSH
Application
- FastAPI
- JavaScript natif
- Routeur maison
- Graphiques SVG
- Design system CSS
Exploitation
- PowerShell
- Tâches planifiées
- Manifeste de publication
- Contrôles d’intégrité bloquants