feat: botón Sincronizar desde ERP en pestaña Números WhatsApp

- Consume el endpoint numeros_dn configurado para la empresa
- Acepta JSON con array raíz o {numeros:[...]}
- Mapea campos wa_number/numero/phone, nombre/name/label, permiso/permission_type/tipo
- Upsert en company_phones: nuevos se insertan, existentes se actualizan
- Reporta conteo de nuevos / actualizados / omitidos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-19 22:45:21 -05:00
co-authored by Claude Sonnet 4.6
parent b84dd01c40
commit ef799a4a0a
2 changed files with 140 additions and 4 deletions
+139 -4
View File
@@ -1045,10 +1045,17 @@ HTML;
$phonesHtml = <<<HTML
{$warnERP}
<div style="display:flex;align-items:center;gap:16px;margin-bottom:14px;flex-wrap:wrap">
<span class="badge badge-blue">Tipo 1 (Solo reporta): {$countT1} / {$limitType1}</span>
<span class="badge badge-teal">Tipo 2+3 (Reciben): {$countT23} / {$limitType23}</span>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap;gap:12px">
<div style="display:flex;gap:12px;flex-wrap:wrap">
<span class="badge badge-blue">Tipo 1 (Solo reporta): {$countT1} / {$limitType1}</span>
<span class="badge badge-teal">Tipo 2+3 (Reciben): {$countT23} / {$limitType23}</span>
</div>
<button class="btn-secondary" onclick="syncPhones({$id})" id="syncBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
Sincronizar desde ERP
</button>
</div>
<div id="syncMsg" style="margin-bottom:10px"></div>
<div class="card" style="margin-bottom:16px">
<table>
<thead><tr><th>Número WA</th><th>Etiqueta</th><th>Permiso</th><th>Estado</th><th></th></tr></thead>
@@ -1056,7 +1063,7 @@ HTML;
</table>
</div>
<div class="card">
<div class="card-h">Agregar número</div>
<div class="card-h">Agregar número manualmente</div>
<div class="card-b">
<div class="form-row">
<div class="form-group">
@@ -1222,6 +1229,29 @@ function showTab(name) {
const initTab = new URLSearchParams(location.search).get('tab') || 'general';
showTab(initTab);
async function syncPhones(cid) {
const btn = document.getElementById('syncBtn');
const msg = document.getElementById('syncMsg');
btn.disabled = true;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:spin 1s linear infinite"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> Sincronizando...';
msg.innerHTML = '';
const fd = new FormData(); fd.append('company_id', cid);
try {
const r = await fetch('/admin/company/phones/sync', {method:'POST', body:fd});
const j = await r.json();
if (j.ok) {
msg.innerHTML = '<div class="toast toast-success">' + j.message + '</div>';
setTimeout(() => location.reload(), 1200);
} else {
msg.innerHTML = '<div class="toast toast-error">' + j.error + (j.detail ? '<br><code style="font-size:11px">' + j.detail + '</code>' : '') + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="toast toast-error">Error de red: ' + e.message + '</div>';
}
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg> Sincronizar desde ERP';
}
async function addPhone(cid) {
const num = document.getElementById('newPhone').value.trim().replace(/\D/g,'');
const lbl = document.getElementById('newPhoneLabel').value.trim();
@@ -1290,6 +1320,111 @@ HTML;
echo Layout::close();
}
// ─── POST /admin/company/phones/sync ─────────────────────────────────────
public static function companyPhonesSync(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
if ($companyId <= 0) {
echo json_encode(['ok' => false, 'error' => 'ID de empresa inválido']);
exit;
}
$company = CompanyRepository::findById($companyId);
if (!$company) {
echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']);
exit;
}
// Buscar endpoint numeros_dn configurado para esta empresa
$epRow = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key='numeros_dn' LIMIT 1");
$epRow->execute([$companyId]);
$ep = $epRow->fetch(\PDO::FETCH_ASSOC);
if (!$ep || empty($ep['url'])) {
echo json_encode(['ok' => false, 'error' => 'El endpoint "Informe de números activos" no está configurado en la pestaña Endpoints API.']);
exit;
}
// Llamar al endpoint
$apiKey = $company['api_key'] ?? '';
$ch = curl_init($ep['url']);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
];
if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey;
if (strtoupper($ep['method'] ?? 'GET') === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = '{}';
$opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json';
}
curl_setopt_array($ch, $opts);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr) {
echo json_encode(['ok' => false, 'error' => 'No se pudo conectar al ERP.', 'detail' => $curlErr]);
exit;
}
if ($httpCode !== 200) {
echo json_encode(['ok' => false, 'error' => "El ERP respondió HTTP {$httpCode}.", 'detail' => substr((string)$resp, 0, 300)]);
exit;
}
$data = json_decode($resp, true);
if (!is_array($data)) {
echo json_encode(['ok' => false, 'error' => 'La respuesta del ERP no es JSON válido.', 'detail' => substr((string)$resp, 0, 300)]);
exit;
}
// Aceptar root array o {numeros:[...]}
$numeros = isset($data['numeros']) ? $data['numeros'] : (array_values($data) && is_array($data[0] ?? null) ? $data : []);
if (empty($numeros)) {
echo json_encode(['ok' => false, 'error' => 'El JSON no contiene números. Espera un array en "numeros" o un array raíz.', 'detail' => substr($resp, 0, 300)]);
exit;
}
$inserted = 0; $updated = 0; $skipped = 0;
$pTypeMap = ['1' => 1, '2' => 2, '3' => 3, 1 => 1, 2 => 2, 3 => 3];
$stmt = db()->prepare("
INSERT INTO company_phones (company_id, wa_number, label, permission_type)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE label=VALUES(label), permission_type=VALUES(permission_type), is_active=1
");
foreach ($numeros as $n) {
$waNumber = preg_replace('/\D/', '', (string)($n['wa_number'] ?? $n['numero'] ?? $n['phone'] ?? ''));
if (strlen($waNumber) < 7) { $skipped++; continue; }
$label = trim((string)($n['nombre'] ?? $n['name'] ?? $n['label'] ?? ''));
$permRaw = $n['permiso'] ?? $n['permission_type'] ?? $n['tipo'] ?? 3;
$permission = $pTypeMap[(int)$permRaw] ?? 3;
try {
$before = db()->prepare("SELECT id FROM company_phones WHERE company_id=? AND wa_number=?");
$before->execute([$companyId, $waNumber]);
$exists = $before->fetch();
$stmt->execute([$companyId, $waNumber, $label, $permission]);
$exists ? $updated++ : $inserted++;
} catch (\PDOException $e) { $skipped++; }
}
echo json_encode([
'ok' => true,
'message' => "Sincronización completa: {$inserted} nuevos, {$updated} actualizados, {$skipped} omitidos.",
]);
exit;
}
// ─── POST /admin/company/phone/save ──────────────────────────────────────
public static function companyPhoneSave(): void