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
@@ -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");
|
||||
|
||||
@@ -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 = `
|
||||
<tr>
|
||||
<td colspan="7" class="py-8 text-center text-rose-400">
|
||||
Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
} 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 = `
|
||||
<tr>
|
||||
<td colspan="7" class="py-8 text-center text-slate-400">
|
||||
Aucun appel enregistré pour les filtres sélectionnés.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = calls.map(c => {
|
||||
// Direction badge & icon
|
||||
let dirBadge = '';
|
||||
if (c.direction === 'inbound') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-teal-500/15 text-teal-300 border border-teal-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
|
||||
<span>Entrant</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'outbound') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
|
||||
<span>Sortant</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'service') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span>Service</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'group') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span>Groupe</span>
|
||||
</span>`;
|
||||
} else if (c.direction === 'ivr') {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-pink-500/15 text-pink-300 border border-pink-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
|
||||
<span>SVI</span>
|
||||
</span>`;
|
||||
} else {
|
||||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
|
||||
<span>Interne</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Status badge
|
||||
let statusBadge = '';
|
||||
if (c.disposition === 'ANSWERED') {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||||
<span>Décroché</span>
|
||||
</span>`;
|
||||
} else if (c.disposition === 'NO ANSWER') {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
<span>Non répondu</span>
|
||||
</span>`;
|
||||
} else if (c.disposition === 'BUSY') {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||
<span>Occupé</span>
|
||||
</span>`;
|
||||
} else {
|
||||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
|
||||
<span>Échec</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Trunk badge
|
||||
let trunkBadge = '';
|
||||
if (c.trunk_name) {
|
||||
trunkBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold bg-emerald-950/60 text-emerald-300 border border-emerald-500/40 shadow-sm">
|
||||
<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||||
<span>${escapeHtml(c.trunk_name)}</span>
|
||||
</span>`;
|
||||
} else {
|
||||
trunkBadge = `<span class="text-slate-500 font-mono text-xs">—</span>`;
|
||||
}
|
||||
|
||||
// Caller display
|
||||
const callerNameHtml = c.caller_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.caller_name)}</div>` : '';
|
||||
const callerNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.caller_number || 'Inconnu')}</div>`;
|
||||
|
||||
// Callee display
|
||||
const calleeNameHtml = c.callee_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.callee_name)}</div>` : '';
|
||||
const calleeNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.callee_number || '—')}</div>`;
|
||||
|
||||
// Talked vs Total duration
|
||||
const durationHtml = c.billsec > 0
|
||||
? `<div><span class="font-bold text-emerald-300 font-mono text-xs">${c.billsec_formatted}</span> <span class="text-[10px] text-slate-500 font-sans">(${c.duration_formatted} tot.)</span></div>`
|
||||
: `<span class="font-mono text-slate-400 text-xs">${c.duration_formatted}</span>`;
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-anthracite-750/50 transition">
|
||||
<td class="py-3 px-4 whitespace-nowrap text-slate-300 font-mono text-xs">
|
||||
<div class="font-semibold text-slate-200">${escapeHtml(c.datetime_formatted)}</div>
|
||||
<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(c.datetime)}</div>
|
||||
</td>
|
||||
<td class="py-3 px-3 whitespace-nowrap">
|
||||
${dirBadge}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
${callerNameHtml}
|
||||
${callerNumHtml}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
${calleeNameHtml}
|
||||
${calleeNumHtml}
|
||||
</td>
|
||||
<td class="py-3 px-4 whitespace-nowrap">
|
||||
${trunkBadge}
|
||||
</td>
|
||||
<td class="py-3 px-3 whitespace-nowrap">
|
||||
${durationHtml}
|
||||
</td>
|
||||
<td class="py-3 px-3 whitespace-nowrap">
|
||||
${statusBadge}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,18 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-300 animate-pulse"></span>
|
||||
</button>
|
||||
|
||||
<!-- Journal d'appels (CDR) -->
|
||||
<button type="button" data-tab="cdr" onclick="switchTab('cdr')"
|
||||
class="tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition">
|
||||
<span class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>Journal d'appels (CDR)</span>
|
||||
</span>
|
||||
<span id="nav-badge-cdr" class="px-2 py-0.5 text-xs rounded-full bg-anthracite-800 text-sky-300 border border-anthracite-700 font-bold">CDR</span>
|
||||
</button>
|
||||
|
||||
<div class="pt-3 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-slate-400">Téléphonie IP</div>
|
||||
|
||||
<!-- Postes SIP -->
|
||||
@@ -980,6 +992,158 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- ONGLET : JOURNAL D'APPELS (CDR) -->
|
||||
<!-- ========================================================= -->
|
||||
<section id="tab-cdr" class="tab-panel hidden space-y-4">
|
||||
<!-- En-tête avec Actions -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-bold text-white">Journal d'Appels & Historique CDR</h2>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30">Asterisk CDR</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-300">Traçabilité complète des flux téléphoniques entrants, sortants et internes avec identification automatique des correspondants et des Trunks SIP.</p>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<button type="button" onclick="loadCdr()" id="btn-refresh-cdr"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-anthracite-800 hover:bg-anthracite-750 text-slate-200 border border-anthracite-700 transition" title="Recharger les derniers appels">
|
||||
<svg id="spinner-refresh-cdr" class="w-4 h-4 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
<button type="button" onclick="exportCdrCsv()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/40 shadow-sm transition" title="Exporter au format CSV compatible Excel">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span>Exporter CSV</span>
|
||||
</button>
|
||||
<button type="button" onclick="confirmClearCdr()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-rose-600/10 hover:bg-rose-600/20 text-rose-300 border border-rose-500/30 transition" title="Purger le journal d'appels">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
<span>Purger</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartes KPIs Statistiques -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-slate-400">Total Appels</div>
|
||||
<div id="cdr-stat-total" class="text-2xl font-bold text-white mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Sur la période</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-emerald-400">Décrochés</div>
|
||||
<div id="cdr-stat-answered" class="text-2xl font-bold text-emerald-400 mt-1">0</div>
|
||||
<div id="cdr-stat-rate" class="text-[11px] text-emerald-300 font-semibold mt-0.5">0% de réponse</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-amber-400">Non Répondus / Manqués</div>
|
||||
<div id="cdr-stat-missed" class="text-2xl font-bold text-amber-400 mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Sans réponse / échecs</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-cyan-400">Appels Sortants</div>
|
||||
<div id="cdr-stat-outbound" class="text-2xl font-bold text-cyan-400 mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Vers opérateurs / Trunks</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-teal-400">Appels Entrants</div>
|
||||
<div id="cdr-stat-inbound" class="text-2xl font-bold text-teal-400 mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Reçus via SDA / Trunks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de Recherche & Filtres Multicritères -->
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<!-- Recherche textuelle -->
|
||||
<div class="relative lg:col-span-2">
|
||||
<span class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</span>
|
||||
<input type="text" id="cdr-filter-search" oninput="debounceCdrLoad()" placeholder="Rechercher par appelant, destinataire, trunk..."
|
||||
class="w-full pl-10 pr-4 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500 transition">
|
||||
</div>
|
||||
|
||||
<!-- Filtre Période -->
|
||||
<div>
|
||||
<select id="cdr-filter-period" onchange="loadCdr()"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
|
||||
<option value="all">Toute la période</option>
|
||||
<option value="today" selected>Aujourd'hui</option>
|
||||
<option value="yesterday">Hier</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filtre Direction -->
|
||||
<div>
|
||||
<select id="cdr-filter-direction" onchange="loadCdr()"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
|
||||
<option value="all">Toutes les directions</option>
|
||||
<option value="inbound">Entrants (Trunk → Poste)</option>
|
||||
<option value="outbound">Sortants (Poste → Trunk)</option>
|
||||
<option value="internal">Internes (Poste à Poste)</option>
|
||||
<option value="service">Services (*98, SVI, etc.)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filtre Statut / Disposition -->
|
||||
<div>
|
||||
<select id="cdr-filter-disposition" onchange="loadCdr()"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
|
||||
<option value="all">Tous les statuts</option>
|
||||
<option value="answered">Décrochés (Répondus)</option>
|
||||
<option value="no_answer">Non répondus (Manqués)</option>
|
||||
<option value="busy">Occupés</option>
|
||||
<option value="failed">Échecs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne d'info & Limite d'affichage -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-anthracite-700/60 text-xs text-slate-400">
|
||||
<div id="cdr-status-info">Chargement de l'historique...</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Afficher :</span>
|
||||
<select id="cdr-filter-limit" onchange="loadCdr()"
|
||||
class="px-2 py-1 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau du Journal d'Appels -->
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl overflow-hidden shadow-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-anthracite-950/80 text-[11px] font-semibold uppercase text-slate-400 border-b border-anthracite-700">
|
||||
<th class="py-3 px-4">Date & Heure</th>
|
||||
<th class="py-3 px-3">Direction</th>
|
||||
<th class="py-3 px-4">Appelant (Qui a appelé)</th>
|
||||
<th class="py-3 px-4">Destinataire (Qui a été appelé)</th>
|
||||
<th class="py-3 px-4">Trunk Utilisé</th>
|
||||
<th class="py-3 px-3">Durée Parlée</th>
|
||||
<th class="py-3 px-3">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cdr-table-body" class="divide-y divide-anthracite-700/60 text-xs">
|
||||
<tr>
|
||||
<td colspan="7" class="py-8 text-center text-slate-400">Chargement du journal d'appels...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- ONGLET 1 : EXTENSIONS SIP -->
|
||||
<!-- ========================================================= -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user