Call Stats

Tableau de bord d'appels en PHP : importe les exports CSV d'un standard téléphonique IP, les convertit en un fichier XML par jour et calcule des statistiques par jour et par semaine, avec graphiques et export PDF.

Présentation

Un standard téléphonique IP exporte ses journaux d'appels en CSV, une ligne par appel. Lu tel quel, ce fichier ne dit pas combien d'appels ont été manqués le matin, qui a décroché, ni si un appelant sans réponse a fini par joindre quelqu'un. J'ai construit Call Stats, un outil interne en PHP sans framework, pour répondre à ces questions. Il convertit chaque export en un fichier XML par journée, puis calcule des tableaux de bord par jour et par semaine, avec des graphiques Chart.js et un export PDF.

Un CSV dont les colonnes changent de nom

Les exports varient : métadonnées en tête, intitulés en français ou en anglais, avec ou sans accents. CsvImporter prend comme en-tête la première ligne d'au moins cinq colonnes dont une cellule contient « date ». Chaque champ passe ensuite par pickValue, qui essaie trois correspondances dans l'ordre :

  • l'intitulé exact, parmi une liste de variantes (« Statut », « Status ») ;
  • l'intitulé normalisé par normalizeKey : minuscules, accents retirés, caractères non alphanumériques supprimés ;
  • un fragment contenu dans l'intitulé, comme « talk ».

Quand la colonne de l'agent manque, je la déduis du sens de l'appel : l'appelant pour un sortant, le destinataire pour un entrant. Un fichier qui couvre plusieurs jours est refusé, car tout l'outil raisonne par journée.

Une journée, un fichier XML

Je n'ai pas utilisé de base de données. XmlGenerator écrit un fichier YYYY-MM-DD_appels.xml par jour, en trois blocs : Meta, Config et Calls. Chaque appel garde sa valeur brute à côté de la valeur interprétée (RingRaw et RingSec), ce qui permet de vérifier une conversion sans rouvrir le CSV.

Le bloc Config embarque la définition des demi-journées (8 h à 12 h 59 et 13 h à 19 h 59 par défaut), le nom de l'agent IA, son seuil et la liste des utilisateurs suivis. Un fichier de journée se suffit donc à lui-même. L'outil gère plusieurs structures, listées dans data/companies.json, et ensureCompanyFolders crée pour chacune ses dossiers csv/, xml/, data/ et un config.json. Si la configuration d'une structure change, la route reload-xml régénère le XML depuis le CSV conservé.

La page d'une structure : un CSV importé par journée, une ligne par jour avec son XML et son CSV rangés dans data/companies/structure-demo, et sur chaque ligne un bouton Recharger XML qui régénère le XML depuis le CSV conservé.

Ce que compte StatsService

computeSummary parcourt les appels en une seule passe. Il compte les entrants (COMPLETED et MISSED), les manqués par demi-journée, les appels dont les mots-clés contiennent PERDU, les appels pris par agent le matin et l'après-midi, la durée moyenne de conversation par agent, la répartition sur 24 tranches horaires et le temps moyen de sonnerie avant décroché. Pour l'agent IA qui prend une partie des appels, il compte aussi les appels aboutis plus courts qu'un seuil configurable, 25 secondes par défaut.

Vue jour du 18/11/2025 : les tranches horaires calculées par computeSummary, en nuage de points Chart.js (aboutis et manqués) et en histogramme des appels aboutis.

Les numéros passent par normalizePhone : chiffres seuls, préfixe 00 retiré, 33 en tête remplacé par 0, puis au plus les dix derniers chiffres. Un appelant compte ainsi une fois, quel que soit le format. Un appel manqué compte comme récupéré si le même numéro obtient un appel abouti plus tard dans la journée.

Indicateurs de la vue jour : 70 numéros distincts pour 91 appels entrants, et 9 appels manqués suivis d'un appel abouti du même numéro plus tard dans la journée.

foreach ($missedCandidates as $missedItem) {
    $num = $missedItem['num'];
    if (!isset($completedTimesByNumber[$num])) {
        continue;
    }
    foreach ($completedTimesByNumber[$num] as $tCompleted) {
        if ($tCompleted > $missedItem['time']) {
            $recoveredMissed++;
            break;
        }
    }
}

Du tableau de bord au PDF, carte par carte

La vue jour aligne des indicateurs et des graphiques Chart.js, dont un nuage de points des appels aboutis et manqués heure par heure. La vue semaine recule au lundi avec modify('monday this week') et lit les sept fichiers. Elle liste aussi les jours absents. Les appelants uniques de la semaine sont l'union des listes quotidiennes, pas la somme des compteurs : quelqu'un qui appelle trois jours de suite compte une fois.

Vue semaine demandée pour le 19/11 : elle part du lundi 17, couvre 5 jours sur 7, liste le samedi et le dimanche absents et compte 221 appelants uniques, contre 346 en additionnant les compteurs quotidiens.

L'export PDF se fait dans le navigateur. La fonction exportPdf passe chaque .card à html2canvas, place l'image sur une page A4 jsPDF et ouvre une nouvelle page quand la carte ne tient plus. Une carte qui tient sur une page n'est jamais coupée en deux.

Les extensions passent par un registre fermé dans loadCompanyExtensions, où chaque identifiant pointe vers une classe qui expose name(), description() et render(). Celle d'annuaire, alimentée par un CSV de contacts, met un nom à côté des numéros qui appellent le plus.

Les restes d'une migration, et ce que je reprendrais

L'outil a commencé en pages PHP à plat dans public/, est passé à un contrôleur frontal, puis a géré plusieurs structures en v1.1. HomeController rend encore des vues déplacées ou supprimées (home/day, home/week, home/month) : la page mois, seule utilisatrice de PeriodAggregator, ne s'affiche plus. deleteDay() ignore la structure envoyée par le formulaire et supprime dans le dossier global. Le nom de l'agent IA reste codé en dur dans StatsService alors qu'il est configurable. La normalisation des numéros existe en trois exemplaires, et des copies StatsService.php.bak traînent dans le dépôt.

Côté sécurité, l'outil a été pensé pour un poste local. Avant de l'ouvrir à plus de monde, il lui faudrait une authentification et une validation stricte de tout ce qui arrive par l'URL. Je fixerais aussi la version des bibliothèques chargées depuis un CDN, puis je centraliserais la normalisation des numéros et testerais CsvImporter sur des exports anonymisés.