diff --git a/public/api.php b/public/api.php
index 385e25e..a64f277 100644
--- a/public/api.php
+++ b/public/api.php
@@ -11,6 +11,7 @@ require_once __DIR__ . '/../src/CertificateManager.php';
require_once __DIR__ . '/../src/BackupManager.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
+require_once __DIR__ . '/../src/CdrManager.php';
use App\Database;
use App\Auth;
@@ -22,6 +23,7 @@ use App\CertificateManager;
use App\BackupManager;
use App\FirewallManager;
use App\PhonebookManager;
+use App\CdrManager;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
@@ -2276,6 +2278,49 @@ try {
echo json_encode($res);
break;
+ case 'get_cdr':
+ Auth::requireAuth();
+ $filters = [
+ 'limit' => (int)($_GET['limit'] ?? $payload['limit'] ?? 100),
+ 'direction' => (string)($_GET['direction'] ?? $payload['direction'] ?? 'all'),
+ 'disposition' => (string)($_GET['disposition'] ?? $payload['disposition'] ?? 'all'),
+ 'period' => (string)($_GET['period'] ?? $payload['period'] ?? 'all'),
+ 'search' => (string)($_GET['search'] ?? $payload['search'] ?? ''),
+ ];
+ $cdrData = CdrManager::getCalls($db, $filters);
+ echo json_encode([
+ 'success' => true,
+ 'calls' => $cdrData['calls'],
+ 'stats' => $cdrData['stats'],
+ 'total_records' => $cdrData['total_records'],
+ ]);
+ break;
+
+ case 'export_cdr_csv':
+ Auth::requireAuth();
+ $filters = [
+ 'direction' => (string)($_GET['direction'] ?? 'all'),
+ 'disposition' => (string)($_GET['disposition'] ?? 'all'),
+ 'period' => (string)($_GET['period'] ?? 'all'),
+ 'search' => (string)($_GET['search'] ?? ''),
+ ];
+ $csv = CdrManager::exportCsv($db, $filters);
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename="nexuspbx_journal_appels_' . date('Y-m-d_His') . '.csv"');
+ header('Pragma: no-cache');
+ header('Expires: 0');
+ echo $csv;
+ exit;
+
+ case 'clear_cdr':
+ Auth::requireAuth();
+ $cleared = CdrManager::clearCdr();
+ echo json_encode([
+ 'success' => $cleared,
+ 'message' => $cleared ? "Journal d'appels réinitialisé avec succès." : "Impossible de vider le fichier CDR.",
+ ]);
+ break;
+
case 'check_update':
$appDir = dirname(__DIR__);
clearstatcache(true, "{$appDir}/version.json");
diff --git a/public/assets/app.js b/public/assets/app.js
index 4c94be0..b5f9a09 100644
--- a/public/assets/app.js
+++ b/public/assets/app.js
@@ -3508,6 +3508,7 @@ function renderSettingsAndDiagnostics() {
// --- Navigation Sidebar & Modales ---
const PAGE_TITLES = {
dashboard: 'Tableau de Bord & Supervision Temps Réel',
+ cdr: 'Journal d\'Appels & Historique CDR (Call Detail Records)',
extensions: 'Postes Téléphoniques SIP (PJSIP)',
groups: "Groupes de Sonnerie (Ring Groups)",
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
@@ -3586,6 +3587,11 @@ function switchTab(tabId) {
loadBackups();
}
+ // Si onglet Journal d'appels, charger l'historique CDR
+ if (tabId === 'cdr') {
+ loadCdr();
+ }
+
// Si onglet Pare-feu, charger le statut en direct
if (tabId === 'firewall') {
loadFirewallStatus();
@@ -6073,3 +6079,235 @@ async function executeSystemUpdate() {
}
}
}
+
+// =========================================================================
+// JOURNAL D'APPELS (CDR) & HISTORIQUE DES COMMUNICATIONS
+// =========================================================================
+let cdrDebounceTimer = null;
+
+function debounceCdrLoad() {
+ clearTimeout(cdrDebounceTimer);
+ cdrDebounceTimer = setTimeout(() => {
+ loadCdr();
+ }, 300);
+}
+
+async function loadCdr() {
+ const tbody = document.getElementById('cdr-table-body');
+ const info = document.getElementById('cdr-status-info');
+ const spinner = document.getElementById('spinner-refresh-cdr');
+ if (spinner) spinner.classList.add('animate-spin');
+
+ const search = document.getElementById('cdr-filter-search')?.value || '';
+ const period = document.getElementById('cdr-filter-period')?.value || 'today';
+ const direction = document.getElementById('cdr-filter-direction')?.value || 'all';
+ const disposition = document.getElementById('cdr-filter-disposition')?.value || 'all';
+ const limit = document.getElementById('cdr-filter-limit')?.value || 100;
+
+ try {
+ const query = new URLSearchParams({
+ search,
+ period,
+ direction,
+ disposition,
+ limit
+ });
+
+ const res = await apiCall(`get_cdr&${query.toString()}`);
+ if (!res || !res.success) throw new Error(res?.message || 'Erreur lors de la récupération des CDR');
+
+ renderCdrStats(res.stats || {});
+ renderCdrTable(res.calls || []);
+
+ if (info) {
+ const count = (res.calls || []).length;
+ const total = res.total_records || 0;
+ info.textContent = `Affichage de ${count} appel(s) sur ${total} analysé(s) pour la période sélectionnée.`;
+ }
+ } catch (err) {
+ if (tbody) {
+ tbody.innerHTML = `
+
+ |
+ Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
+ |
+
+ `;
+ }
+ } finally {
+ if (spinner) spinner.classList.remove('animate-spin');
+ }
+}
+
+function renderCdrStats(stats) {
+ const elTotal = document.getElementById('cdr-stat-total');
+ const elAns = document.getElementById('cdr-stat-answered');
+ const elRate = document.getElementById('cdr-stat-rate');
+ const elMiss = document.getElementById('cdr-stat-missed');
+ const elOut = document.getElementById('cdr-stat-outbound');
+ const elIn = document.getElementById('cdr-stat-inbound');
+
+ const total = stats.total || 0;
+ const answered = stats.answered || 0;
+ const missed = stats.missed || 0;
+ const rate = total > 0 ? Math.round((answered / total) * 100) : 0;
+
+ if (elTotal) elTotal.textContent = total;
+ if (elAns) elAns.textContent = answered;
+ if (elRate) elRate.textContent = `${rate}% de réponse`;
+ if (elMiss) elMiss.textContent = missed;
+ if (elOut) elOut.textContent = stats.outbound || 0;
+ if (elIn) elIn.textContent = stats.inbound || 0;
+}
+
+function renderCdrTable(calls) {
+ const tbody = document.getElementById('cdr-table-body');
+ if (!tbody) return;
+
+ if (!calls || calls.length === 0) {
+ tbody.innerHTML = `
+
+ |
+ Aucun appel enregistré pour les filtres sélectionnés.
+ |
+
+ `;
+ return;
+ }
+
+ tbody.innerHTML = calls.map(c => {
+ // Direction badge & icon
+ let dirBadge = '';
+ if (c.direction === 'inbound') {
+ dirBadge = `
+
+ Entrant
+ `;
+ } else if (c.direction === 'outbound') {
+ dirBadge = `
+
+ Sortant
+ `;
+ } else if (c.direction === 'service') {
+ dirBadge = `
+
+ Service
+ `;
+ } else if (c.direction === 'group') {
+ dirBadge = `
+
+ Groupe
+ `;
+ } else if (c.direction === 'ivr') {
+ dirBadge = `
+
+ SVI
+ `;
+ } else {
+ dirBadge = `
+
+ Interne
+ `;
+ }
+
+ // Status badge
+ let statusBadge = '';
+ if (c.disposition === 'ANSWERED') {
+ statusBadge = `
+
+ Décroché
+ `;
+ } else if (c.disposition === 'NO ANSWER') {
+ statusBadge = `
+
+ Non répondu
+ `;
+ } else if (c.disposition === 'BUSY') {
+ statusBadge = `
+
+ Occupé
+ `;
+ } else {
+ statusBadge = `
+
+ Échec
+ `;
+ }
+
+ // Trunk badge
+ let trunkBadge = '';
+ if (c.trunk_name) {
+ trunkBadge = `
+
+ ${escapeHtml(c.trunk_name)}
+ `;
+ } else {
+ trunkBadge = `—`;
+ }
+
+ // Caller display
+ const callerNameHtml = c.caller_name ? `${escapeHtml(c.caller_name)}
` : '';
+ const callerNumHtml = `${escapeHtml(c.caller_number || 'Inconnu')}
`;
+
+ // Callee display
+ const calleeNameHtml = c.callee_name ? `${escapeHtml(c.callee_name)}
` : '';
+ const calleeNumHtml = `${escapeHtml(c.callee_number || '—')}
`;
+
+ // Talked vs Total duration
+ const durationHtml = c.billsec > 0
+ ? `${c.billsec_formatted} (${c.duration_formatted} tot.)
`
+ : `${c.duration_formatted}`;
+
+ return `
+
+ |
+ ${escapeHtml(c.datetime_formatted)}
+ ${escapeHtml(c.datetime)}
+ |
+
+ ${dirBadge}
+ |
+
+ ${callerNameHtml}
+ ${callerNumHtml}
+ |
+
+ ${calleeNameHtml}
+ ${calleeNumHtml}
+ |
+
+ ${trunkBadge}
+ |
+
+ ${durationHtml}
+ |
+
+ ${statusBadge}
+ |
+
+ `;
+ }).join('');
+}
+
+function exportCdrCsv() {
+ const search = encodeURIComponent(document.getElementById('cdr-filter-search')?.value || '');
+ const period = encodeURIComponent(document.getElementById('cdr-filter-period')?.value || 'all');
+ const direction = encodeURIComponent(document.getElementById('cdr-filter-direction')?.value || 'all');
+ const disposition = encodeURIComponent(document.getElementById('cdr-filter-disposition')?.value || 'all');
+
+ window.location.href = `/api.php?action=export_cdr_csv&search=${search}&period=${period}&direction=${direction}&disposition=${disposition}`;
+}
+
+async function confirmClearCdr() {
+ if (!confirm("Êtes-vous sûr de vouloir purger l'historique complet du journal d'appels Asterisk ? Cette action est irréversible.")) {
+ return;
+ }
+ try {
+ const res = await apiCall('clear_cdr', {});
+ showToast(res.message || "Journal d'appels réinitialisé.", 'success');
+ loadCdr();
+ } catch (e) {
+ showToast(e.message || "Erreur lors de la purge du journal.", 'error');
+ }
+}
+
diff --git a/public/index.php b/public/index.php
index 820f517..1e733f7 100644
--- a/public/index.php
+++ b/public/index.php
@@ -258,6 +258,18 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
+
+
+
Téléphonie IP
@@ -980,6 +992,158 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
+
+
+
+
+
+
+
+
+
Journal d'Appels & Historique CDR
+ Asterisk CDR
+
+
Traçabilité complète des flux téléphoniques entrants, sortants et internes avec identification automatique des correspondants et des Trunks SIP.
+
+
+
+
+
+
+
+
+
+
+
+
Total Appels
+
0
+
Sur la période
+
+
+
Décrochés
+
0
+
0% de réponse
+
+
+
Non Répondus / Manqués
+
0
+
Sans réponse / échecs
+
+
+
Appels Sortants
+
0
+
Vers opérateurs / Trunks
+
+
+
Appels Entrants
+
0
+
Reçus via SDA / Trunks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Chargement de l'historique...
+
+ Afficher :
+
+
+
+
+
+
+
+
+
+
+
+ | Date & Heure |
+ Direction |
+ Appelant (Qui a appelé) |
+ Destinataire (Qui a été appelé) |
+ Trunk Utilisé |
+ Durée Parlée |
+ Statut |
+
+
+
+
+ | Chargement du journal d'appels... |
+
+
+
+
+
+
+
diff --git a/src/CdrManager.php b/src/CdrManager.php
new file mode 100644
index 0000000..7c2c981
--- /dev/null
+++ b/src/CdrManager.php
@@ -0,0 +1,477 @@
+ 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);
+ }
+}
diff --git a/version.json b/version.json
index 378fc05..89df209 100644
--- a/version.json
+++ b/version.json
@@ -1,9 +1,11 @@
{
- "version": "1.5.3",
+ "version": "1.6.0",
"codename": "Helios",
"release_date": "2026-11-04",
"min_php_version": "8.2",
"changelog": [
+ "Nouveau module Journal d'Appels & CDR : traçabilité complète des flux entrants, sortants, internes et services avec détection automatique des Trunks SIP et postes associés",
+ "Filtres multicritères CDR en temps réel (période, direction, statut décroché/manqué, recherche textuelle) et export CSV Excel",
"Optimisation TLS SIP PJSIP : négociation universelle TLSv1.2/TLSv1.3 (method=sslv23)",
"Suppression de CertificateRequest en mode TLS standard pour compatibilité totale avec les téléphones IP Yealink, Fanvil et Grandstream",
"Correction de l'émission et du renouvellement Let's Encrypt / Certbot ACME (privilèges sudo et permissions d'accès aux certificats)",