feat(cdr): add Call Logs & CDR module with automatic trunk detection and CSV export (v1.6.0)
This commit is contained in:
1 parent
a9d86dd6ac
commit
7c28a237ba
5 files changed
+927
-1
No files matched your search
@@ -0,0 +1,477 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class CdrManager
|
||||
{
|
||||
public const CDR_FILE = '/var/log/asterisk/cdr-csv/Master.csv';
|
||||
|
||||
/**
|
||||
* Récupère la liste analysée et enrichie des derniers appels Asterisk.
|
||||
*
|
||||
* @param PDO $db
|
||||
* @param array $filters [limit => 100, direction => 'all', disposition => 'all', period => 'all', search => '']
|
||||
* @return array [calls => array, stats => array, total_records => int]
|
||||
*/
|
||||
public static function getCalls(PDO $db, array $filters = []): array
|
||||
{
|
||||
$limit = max(10, min(1000, (int)($filters['limit'] ?? 100)));
|
||||
$direction = trim((string)($filters['direction'] ?? 'all'));
|
||||
$disposition = trim((string)($filters['disposition'] ?? 'all'));
|
||||
$period = trim((string)($filters['period'] ?? 'all'));
|
||||
$search = strtolower(trim((string)($filters['search'] ?? '')));
|
||||
|
||||
if (!file_exists(self::CDR_FILE) || !is_readable(self::CDR_FILE)) {
|
||||
return [
|
||||
'calls' => [],
|
||||
'stats' => self::emptyStats(),
|
||||
'total_records' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 1. Dictionnaires en mémoire pour résolution rapide
|
||||
$trunks = $db->query('SELECT id, name, host, username FROM trunks')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$trunkMap = [];
|
||||
foreach ($trunks as $t) {
|
||||
$info = ['id' => (int)$t['id'], 'name' => (string)$t['name']];
|
||||
$name = strtolower(trim((string)$t['name']));
|
||||
$user = strtolower(trim((string)($t['username'] ?? '')));
|
||||
$slug = self::slugify($t['name']);
|
||||
|
||||
$trunkMap[$name] = $info;
|
||||
$trunkMap["trunk-{$name}"] = $info;
|
||||
$trunkMap[$slug] = $info;
|
||||
$trunkMap["trunk-{$slug}"] = $info;
|
||||
if ($user !== '') {
|
||||
$trunkMap[$user] = $info;
|
||||
$trunkMap["trunk-{$user}"] = $info;
|
||||
}
|
||||
}
|
||||
|
||||
$extensions = $db->query('SELECT extension, name FROM extensions')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$extMap = [];
|
||||
foreach ($extensions as $e) {
|
||||
$extMap[(string)$e['extension']] = (string)$e['name'];
|
||||
}
|
||||
|
||||
$groups = $db->query('SELECT extension, name FROM groups')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$groupMap = [];
|
||||
foreach ($groups as $g) {
|
||||
$groupMap[(string)$g['extension']] = (string)$g['name'];
|
||||
}
|
||||
|
||||
$ivrs = $db->query('SELECT id, name FROM ivrs')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$ivrMap = [];
|
||||
foreach ($ivrs as $i) {
|
||||
$ivrMap[(int)$i['id']] = (string)$i['name'];
|
||||
}
|
||||
|
||||
$contactMap = [];
|
||||
try {
|
||||
$contacts = $db->query('SELECT display_name, internal_number, mobile_number, other_number FROM phonebook_contacts')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($contacts as $c) {
|
||||
$cName = trim((string)$c['display_name']);
|
||||
if ($cName === '') continue;
|
||||
if (!empty($c['internal_number'])) $contactMap[trim((string)$c['internal_number'])] = $cName;
|
||||
if (!empty($c['mobile_number'])) $contactMap[trim((string)$c['mobile_number'])] = $cName;
|
||||
if (!empty($c['other_number'])) $contactMap[trim((string)$c['other_number'])] = $cName;
|
||||
}
|
||||
} catch (Throwable) {}
|
||||
|
||||
// 2. Lecture des dernières lignes du fichier CSV (inversées pour avoir les plus récents en premier)
|
||||
$rawLines = self::tailFile(self::CDR_FILE, 2000);
|
||||
$rawLines = array_reverse($rawLines);
|
||||
|
||||
$now = time();
|
||||
$calls = [];
|
||||
$stats = self::emptyStats();
|
||||
$totalParsed = 0;
|
||||
|
||||
foreach ($rawLines as $line) {
|
||||
$row = str_getcsv($line);
|
||||
if (count($row) < 15) continue;
|
||||
|
||||
$accountcode = trim((string)($row[0] ?? ''));
|
||||
$src = trim((string)($row[1] ?? ''));
|
||||
$dst = trim((string)($row[2] ?? ''));
|
||||
$dcontext = trim((string)($row[3] ?? ''));
|
||||
$clid = trim((string)($row[4] ?? ''));
|
||||
$channel = trim((string)($row[5] ?? ''));
|
||||
$dstchannel = trim((string)($row[6] ?? ''));
|
||||
$lastapp = trim((string)($row[7] ?? ''));
|
||||
$lastdata = trim((string)($row[8] ?? ''));
|
||||
$startTime = trim((string)($row[9] ?? ''));
|
||||
$answerTime = trim((string)($row[10] ?? ''));
|
||||
$endTime = trim((string)($row[11] ?? ''));
|
||||
$duration = max(0, (int)($row[12] ?? 0));
|
||||
$billsec = max(0, (int)($row[13] ?? 0));
|
||||
$dispRaw = strtoupper(trim((string)($row[14] ?? 'NO ANSWER')));
|
||||
$uniqueid = trim((string)($row[16] ?? ''));
|
||||
|
||||
$startTs = strtotime($startTime) ?: 0;
|
||||
|
||||
// Filtre par période
|
||||
if ($period !== 'all' && $startTs > 0) {
|
||||
if ($period === 'today') {
|
||||
if (date('Y-m-d', $startTs) !== date('Y-m-d', $now)) continue;
|
||||
} elseif ($period === 'yesterday') {
|
||||
if (date('Y-m-d', $startTs) !== date('Y-m-d', $now - 86400)) continue;
|
||||
} elseif ($period === 'week') {
|
||||
if ($startTs < ($now - 7 * 86400)) continue;
|
||||
} elseif ($period === 'month') {
|
||||
if ($startTs < ($now - 30 * 86400)) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Identification endpoints & direction
|
||||
$srcEp = self::extractEndpoint($channel);
|
||||
$dstEp = self::extractEndpoint($dstchannel);
|
||||
$dialTrunk = self::extractTrunkFromDialData($lastdata);
|
||||
|
||||
$dir = 'internal';
|
||||
$dirLabel = 'Interne';
|
||||
$trunkName = null;
|
||||
$trunkId = null;
|
||||
|
||||
$candidateOut = strtolower($dialTrunk ?: $dstEp);
|
||||
if ($candidateOut !== '' && isset($trunkMap[$candidateOut])) {
|
||||
$dir = 'outbound';
|
||||
$dirLabel = 'Sortant';
|
||||
$trunkName = $trunkMap[$candidateOut]['name'];
|
||||
$trunkId = $trunkMap[$candidateOut]['id'];
|
||||
} elseif (isset($trunkMap[strtolower($srcEp)])) {
|
||||
$dir = 'inbound';
|
||||
$dirLabel = 'Entrant';
|
||||
$trunkName = $trunkMap[strtolower($srcEp)]['name'];
|
||||
$trunkId = $trunkMap[strtolower($srcEp)]['id'];
|
||||
} elseif (in_array($dcontext, ['from-external', 'from-pstn', 'from-trunk', 'from-pstn-toheader'], true)) {
|
||||
$dir = 'inbound';
|
||||
$dirLabel = 'Entrant';
|
||||
$trunkName = 'Trunk Externe';
|
||||
} elseif (str_starts_with($dst, '*') || in_array($lastapp, ['VoiceMailMain', 'Echo', 'WaitExten', 'Playback'], true)) {
|
||||
$dir = 'service';
|
||||
$dirLabel = 'Service';
|
||||
} elseif (isset($groupMap[$dst])) {
|
||||
$dir = 'group';
|
||||
$dirLabel = 'Groupe';
|
||||
} elseif (isset($ivrMap[(int)ltrim($dst, '*80')]) || str_starts_with($dst, '*8')) {
|
||||
$dir = 'ivr';
|
||||
$dirLabel = 'SVI / IVR';
|
||||
}
|
||||
|
||||
// Identification de l'Appelant et du Destinataire
|
||||
$callerNum = $src;
|
||||
$callerName = '';
|
||||
$calleeNum = $dst;
|
||||
$calleeName = '';
|
||||
|
||||
// Appel sortant : le poste physique réel appelant se trouve dans channel
|
||||
if ($dir === 'outbound' && isset($extMap[$srcEp])) {
|
||||
$callerNum = $srcEp;
|
||||
$callerName = $extMap[$srcEp];
|
||||
} elseif (isset($extMap[$src])) {
|
||||
$callerName = $extMap[$src];
|
||||
} elseif (isset($contactMap[$src])) {
|
||||
$callerName = $contactMap[$src];
|
||||
} else {
|
||||
$callerName = self::extractNameFromClid($clid);
|
||||
}
|
||||
|
||||
// Destinataire
|
||||
if (isset($extMap[$dst])) {
|
||||
$calleeName = $extMap[$dst];
|
||||
} elseif (isset($groupMap[$dst])) {
|
||||
$calleeName = "Groupe : {$groupMap[$dst]}";
|
||||
} elseif (isset($contactMap[$dst])) {
|
||||
$calleeName = $contactMap[$dst];
|
||||
} elseif ($dst === '*97' || $dst === '*98' || $lastapp === 'VoiceMailMain') {
|
||||
$calleeName = 'Messagerie Vocale';
|
||||
} elseif ($dst === '*43' || $lastapp === 'Echo') {
|
||||
$calleeName = 'Test Écho Audio';
|
||||
} elseif (str_starts_with($dst, '*80') || str_starts_with($dst, '*8')) {
|
||||
$ivrId = (int)ltrim(substr($dst, 2), '0');
|
||||
$calleeName = isset($ivrMap[$ivrId]) ? "SVI : {$ivrMap[$ivrId]}" : 'Serveur Vocal SVI';
|
||||
}
|
||||
|
||||
// Statistiques globales de la période filtrée
|
||||
$totalParsed++;
|
||||
$stats['total']++;
|
||||
if ($dispRaw === 'ANSWERED') {
|
||||
$stats['answered']++;
|
||||
} else {
|
||||
$stats['missed']++;
|
||||
}
|
||||
if ($dir === 'inbound') $stats['inbound']++;
|
||||
if ($dir === 'outbound') $stats['outbound']++;
|
||||
if ($dir === 'internal') $stats['internal']++;
|
||||
|
||||
// Filtre par direction
|
||||
if ($direction !== 'all' && $dir !== $direction) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtre par statut (disposition)
|
||||
if ($disposition !== 'all') {
|
||||
if ($disposition === 'answered' && $dispRaw !== 'ANSWERED') continue;
|
||||
if ($disposition === 'no_answer' && $dispRaw !== 'NO ANSWER') continue;
|
||||
if ($disposition === 'busy' && $dispRaw !== 'BUSY') continue;
|
||||
if ($disposition === 'failed' && $dispRaw !== 'FAILED') continue;
|
||||
}
|
||||
|
||||
// Filtre de recherche textuelle
|
||||
if ($search !== '') {
|
||||
$haystack = strtolower("{$callerNum} {$callerName} {$calleeNum} {$calleeName} {$trunkName} {$lastapp} {$dcontext}");
|
||||
if (!str_contains($haystack, $search)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Formatage statut
|
||||
$statusLabel = match ($dispRaw) {
|
||||
'ANSWERED' => 'Décroché',
|
||||
'NO ANSWER' => 'Non répondu',
|
||||
'BUSY' => 'Occupé',
|
||||
'FAILED' => 'Échec',
|
||||
default => $dispRaw,
|
||||
};
|
||||
|
||||
$statusBadge = match ($dispRaw) {
|
||||
'ANSWERED' => 'emerald',
|
||||
'NO ANSWER' => 'amber',
|
||||
'BUSY' => 'purple',
|
||||
'FAILED' => 'rose',
|
||||
default => 'slate',
|
||||
};
|
||||
|
||||
$calls[] = [
|
||||
'id' => $uniqueid ?: (string)$totalParsed,
|
||||
'datetime' => $startTime,
|
||||
'datetime_formatted' => self::formatDate($startTime),
|
||||
'direction' => $dir,
|
||||
'direction_label' => $dirLabel,
|
||||
'caller_number' => $callerNum,
|
||||
'caller_name' => $callerName,
|
||||
'callee_number' => $calleeNum,
|
||||
'callee_name' => $calleeName,
|
||||
'trunk_name' => $trunkName,
|
||||
'trunk_id' => $trunkId,
|
||||
'disposition' => $dispRaw,
|
||||
'status_label' => $statusLabel,
|
||||
'status_badge' => $statusBadge,
|
||||
'duration' => $duration,
|
||||
'duration_formatted' => self::formatSeconds($duration),
|
||||
'billsec' => $billsec,
|
||||
'billsec_formatted' => self::formatSeconds($billsec),
|
||||
'app' => $lastapp,
|
||||
'channel' => $channel,
|
||||
'dstchannel' => $dstchannel,
|
||||
];
|
||||
|
||||
if (count($calls) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'calls' => $calls,
|
||||
'stats' => $stats,
|
||||
'total_records' => $totalParsed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un export CSV UTF-8 prêt pour Excel / LibreOffice.
|
||||
*/
|
||||
public static function exportCsv(PDO $db, array $filters = []): string
|
||||
{
|
||||
$filters['limit'] = 5000;
|
||||
$data = self::getCalls($db, $filters);
|
||||
$calls = $data['calls'] ?? [];
|
||||
|
||||
$output = fopen('php://temp', 'r+');
|
||||
// BOM UTF-8 pour Excel
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
|
||||
// En-têtes CSV
|
||||
fputcsv($output, [
|
||||
'Date & Heure',
|
||||
'Direction',
|
||||
'Numéro Appelant',
|
||||
'Nom Appelant',
|
||||
'Numéro Destinataire',
|
||||
'Nom Destinataire',
|
||||
'Trunk Utilisé',
|
||||
'Statut',
|
||||
'Durée Totale (s)',
|
||||
'Durée Parlée (s)',
|
||||
'Durée Formatée',
|
||||
'Application Asterisk',
|
||||
], ';');
|
||||
|
||||
foreach ($calls as $c) {
|
||||
fputcsv($output, [
|
||||
$c['datetime'] ?? '',
|
||||
$c['direction_label'] ?? '',
|
||||
$c['caller_number'] ?? '',
|
||||
$c['caller_name'] ?? '',
|
||||
$c['callee_number'] ?? '',
|
||||
$c['callee_name'] ?? '',
|
||||
$c['trunk_name'] ?: 'Aucun (Direct / Interne)',
|
||||
$c['status_label'] ?? '',
|
||||
$c['duration'] ?? 0,
|
||||
$c['billsec'] ?? 0,
|
||||
$c['billsec_formatted'] ?? '00:00',
|
||||
$c['app'] ?? '',
|
||||
], ';');
|
||||
}
|
||||
|
||||
rewind($output);
|
||||
$csv = stream_get_contents($output);
|
||||
fclose($output);
|
||||
|
||||
return (string)$csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge ou archive le fichier Master.csv
|
||||
*/
|
||||
public static function clearCdr(): bool
|
||||
{
|
||||
if (file_exists(self::CDR_FILE) && is_writable(self::CDR_FILE)) {
|
||||
return (file_put_contents(self::CDR_FILE, '') !== false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Fonctions auxiliaires ---
|
||||
|
||||
private static function emptyStats(): array
|
||||
{
|
||||
return [
|
||||
'total' => 0,
|
||||
'answered' => 0,
|
||||
'missed' => 0,
|
||||
'inbound' => 0,
|
||||
'outbound' => 0,
|
||||
'internal' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public static function extractEndpoint(string $channel): string
|
||||
{
|
||||
$channel = trim($channel);
|
||||
if (preg_match('#^(?:PJSIP|SIP)/([a-zA-Z0-9_\-]+)-[0-9a-fA-F]+#', $channel, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (preg_match('#^(?:PJSIP|SIP)/([a-zA-Z0-9_\-]+)#', $channel, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function extractTrunkFromDialData(string $lastdata): string
|
||||
{
|
||||
if (preg_match('#@([a-zA-Z0-9_\-]+)#', $lastdata, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function extractNameFromClid(string $clid): string
|
||||
{
|
||||
if (preg_match('/"([^"]+)"/', $clid, $m)) {
|
||||
$name = trim($m[1]);
|
||||
// Nettoyage de préfixes comme [Sytelis] ou guillemets résiduels
|
||||
return trim(str_replace(['"', "'"], '', $name));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function formatSeconds(int $seconds): string
|
||||
{
|
||||
$m = floor($seconds / 60);
|
||||
$s = $seconds % 60;
|
||||
if ($m >= 60) {
|
||||
$h = floor($m / 60);
|
||||
$m = $m % 60;
|
||||
return sprintf('%02d:%02d:%02d', $h, $m, $s);
|
||||
}
|
||||
return sprintf('%02d:%02d', $m, $s);
|
||||
}
|
||||
|
||||
public static function formatDate(string $datetime): string
|
||||
{
|
||||
$ts = strtotime($datetime);
|
||||
if (!$ts) return $datetime;
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$yesterday = date('Y-m-d', time() - 86400);
|
||||
$day = date('Y-m-d', $ts);
|
||||
|
||||
if ($day === $today) {
|
||||
return 'Aujourd\'hui à ' . date('H:i:s', $ts);
|
||||
} elseif ($day === $yesterday) {
|
||||
return 'Hier à ' . date('H:i:s', $ts);
|
||||
}
|
||||
return date('d/m/Y H:i', $ts);
|
||||
}
|
||||
|
||||
private static function slugify(string $text): string
|
||||
{
|
||||
$slug = strtolower(trim($text));
|
||||
$slug = preg_replace('/^ivr-/', '', $slug);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
|
||||
return trim($slug, '-') ?: 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit efficacement les N dernières lignes d'un fichier sans saturer la RAM.
|
||||
*/
|
||||
public static function tailFile(string $filepath, int $maxLines = 1000): array
|
||||
{
|
||||
if (!file_exists($filepath) || !is_readable($filepath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$filesize = filesize($filepath);
|
||||
if ($filesize <= 0) return [];
|
||||
|
||||
// Fichiers < 5 Mo : lecture directe optimisée
|
||||
if ($filesize < 5 * 1024 * 1024) {
|
||||
$lines = @file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
return array_slice($lines, -$maxLines);
|
||||
}
|
||||
|
||||
// Fichiers volumineux : parcours inverse par curseur fseek
|
||||
$fp = fopen($filepath, 'rb');
|
||||
if (!$fp) return [];
|
||||
|
||||
$pos = -1;
|
||||
$lines = [];
|
||||
$currentLine = '';
|
||||
|
||||
while (fseek($fp, $pos, SEEK_END) !== -1 && count($lines) < $maxLines) {
|
||||
$char = fgetc($fp);
|
||||
if ($char === "\n") {
|
||||
if ($currentLine !== '') {
|
||||
$lines[] = strrev($currentLine);
|
||||
$currentLine = '';
|
||||
}
|
||||
} else {
|
||||
$currentLine .= $char;
|
||||
}
|
||||
$pos--;
|
||||
}
|
||||
|
||||
if ($currentLine !== '' && count($lines) < $maxLines) {
|
||||
$lines[] = strrev($currentLine);
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
return array_reverse($lines);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user