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); } }