479 lines
17 KiB
PHP
479 lines
17 KiB
PHP
<?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);
|
|
}
|
|
}
|
|
|