Files
robot-proveedores/robot.js
marsalva d5d97f0349 Fix: recarga real de Servicios en curso en Multi + verificación de cierre antes de anular + reapertu
1) La función refrescar() ahora se espera con navegación real (antes se ignoraban los fallos en un catch vacío, dejando una lista incompleta de "Servicios en curso"). 2) Antes de anular un expediente por no aparecer en esa lista, se comprueba su ficha real (campo "Fecha/Hora Cierre") para no anular servicios que en realidad siguen abiertos. 3) Si un expediente anulado automáticamente por el robot vuelve a aparecer activo, se reabre solo (marca interna auto_anulado, nunca afecta a anulaciones hechas por una persona).
2026-09-06 18:50:21 +00:00

735 lines
37 KiB
JavaScript

import { chromium } from 'playwright';
import pg from 'pg';
const { DATABASE_URL } = process.env;
if (!DATABASE_URL) { console.error("❌ Error: No hay DATABASE_URL."); process.exit(1); }
const pool = new pg.Pool({ connectionString: DATABASE_URL, ssl: false });
const HEADLESS = true;
// ==========================================
// 🧠 MOTOR DE CLASIFICACIÓN DE GREMIOS PRO (AHORA DINÁMICO)
// ==========================================
function normalizarTexto(texto) {
if (!texto) return "";
return texto.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
// 🛠️ NUEVO: El "Gira-Nombres" EXCLUSIVO para HomeServe
function arreglarNombre(nombreRaw) {
if (!nombreRaw) return "";
let nombreLimpio = nombreRaw.trim();
// Si detectamos que tiene una coma...
if (nombreLimpio.includes(',')) {
let partes = nombreLimpio.split(',');
let apellidos = partes[0].trim();
let nombre = partes[1].trim();
// Le damos la vuelta y quitamos espacios sobrantes
nombreLimpio = `${nombre} ${apellidos}`.replace(/\s+/g, ' ');
}
return nombreLimpio;
}
// 🛡️ DESCODIFICADOR UNIVERSAL PARA HOMESERVE (Arregla los +, Ó, Ñ, etc.)
function arreglarCodificacion(texto) {
if (!texto) return "";
let limpio = texto.replace(/\+/g, ' '); // Primero cambiamos los + por espacios
try {
// Truco clásico de Javascript para transformar ISO a UTF-8
limpio = decodeURIComponent(escape(limpio));
} catch(e) {
// Si no se puede, lo dejamos con los espacios arreglados
}
return limpio.trim();
}
// NUEVO: La función ahora lee las keywords directamente del JSON de la base de datos
function clasificarGremio(descripcion, gremiosActivosDB) {
if (!descripcion || gremiosActivosDB.length === 0) return null;
const descNormalizada = normalizarTexto(descripcion);
for (const gremio of gremiosActivosDB) {
// Obtenemos las palabras clave guardadas desde el panel de configuración
const keywords = Array.isArray(gremio.ia_keywords) ? gremio.ia_keywords : [];
if (keywords.length === 0) continue; // Si no tiene reglas, pasamos al siguiente
// Comprobamos si alguna palabra clave coincide con la descripción
const coincide = keywords.some(kw => descNormalizada.includes(normalizarTexto(kw)));
if (coincide) {
console.log(` 🧠 Gremio detectado automáticamente: ${gremio.name} (ID: ${gremio.id})`);
return gremio.id;
}
}
return null;
}
// Función auxiliar para reintentar la navegación si hay fallo de red
async function gotoWithRetry(page, url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
// Usamos domcontentloaded para que no se quede colgado esperando imágenes o scripts pesados
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
return; // Si funciona, salimos de la función
} catch (e) {
if (i === retries - 1) throw e; // Si es el último intento, lanza el error
console.log(`⚠️ Fallo de red detectado al ir a ${url}. Reintentando (${i + 1}/${retries})...`);
await page.waitForTimeout(3000); // Esperamos 3 segundos antes de volver a intentar
}
}
}
async function main() {
console.log("🤖 ROBOT MODO: CIRUJANO + ASPIRADORA + CLASIFICADOR PRO");
while (true) {
const client = await pool.connect();
try {
// 1. CARGAMOS LOS GREMIOS ACTUALES (AHORA INCLUYE LAS PALABRAS CLAVE)
const gremiosRes = await client.query("SELECT id, name, ia_keywords FROM guilds");
const gremiosDB = gremiosRes.rows;
await client.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='scraped_services' AND column_name='is_urgent') THEN
ALTER TABLE scraped_services ADD COLUMN is_urgent BOOLEAN DEFAULT FALSE;
END IF;
-- AÑADIDO: Aseguramos que exista la columna ia_keywords en los gremios
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='guilds' AND column_name='ia_keywords') THEN
ALTER TABLE guilds ADD COLUMN ia_keywords JSONB DEFAULT '[]';
END IF;
END $$;
`);
const res = await client.query("SELECT * FROM provider_credentials WHERE status = 'active'");
for (const cred of res.rows) {
let password = Buffer.from(cred.password_hash, 'base64').toString('utf-8');
console.log(`\n🔄 Procesando ${cred.provider.toUpperCase()}...`);
if (cred.provider === 'multiasistencia') {
await runMultiasistencia(cred.owner_id, cred.username, password, gremiosDB);
} else if (cred.provider === 'homeserve') {
await runHomeserve(cred.owner_id, cred.username, password, gremiosDB);
}
await client.query("UPDATE provider_credentials SET last_sync = NOW() WHERE id = $1", [cred.id]);
}
} catch (e) { console.error("❌ Error ciclo:", e.message); }
finally { client.release(); }
console.log("\n💤 Durmiendo 15 minutos...");
await new Promise(r => setTimeout(r, 15 * 60 * 1000));
}
}
// 🔍 NUEVO: Antes de anular un expediente que "ha desaparecido" de la lista de Servicios en
// curso, comprobamos su ficha individual en Multiasistencia. Ahí sí hay un dato inequívoco:
// "Fecha/Hora Cierre". Si pone "(N/D) / (N/D)" es que SIGUE ABIERTO de verdad, aunque no
// salga en esa lista en este momento (esa lista no es el universo completo de lo que sigue
// vivo). Solo si trae una fecha real de cierre confirmamos que se puede anular.
// Devuelve: true = confirmado cerrado de verdad (se puede anular) | false = sigue abierto,
// NO anular | null = no se ha podido comprobar (fail-safe: tampoco anular).
async function verificarCierreRealMulti(page, ref) {
try {
await gotoWithRetry(page, `https://web.multiasistencia.com/w3multi/repasos1.php?reparacion=${ref}`);
await page.waitForTimeout(1200);
const cierre = await page.evaluate(() => {
const cabeceras = Array.from(document.querySelectorAll('.tcab'));
const detalles = Array.from(document.querySelectorAll('.tdet'));
const idx = cabeceras.findIndex(el => el.textContent.trim() === 'Fecha/Hora Cierre');
return idx !== -1 && detalles[idx] ? detalles[idx].textContent.trim() : null;
});
if (!cierre) {
console.log(`⚠️ [Multi] No se encontró el campo "Fecha/Hora Cierre" para ${ref}. No se anula por precaución.`);
return null;
}
return !cierre.includes('N/D');
} catch (e) {
console.error(`⚠️ [Multi] No se pudo verificar el cierre real de ${ref}: ${e.message}`);
return null;
}
}
// ==========================================
// 🏥 MULTIASISTENCIA (CON REINTENTOS ANTI-CAÍDAS)
// ==========================================
async function runMultiasistencia(ownerId, user, pass, gremiosDB) {
const browser = await chromium.launch({
headless: HEADLESS,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
]
});
const context = await browser.newContext();
const page = await context.newPage();
try {
// INICIO DE SESIÓN CON REINTENTOS
await gotoWithRetry(page, 'https://web.multiasistencia.com/w3multi/acceso.php');
await page.fill('input[name="usuario"]', user);
await page.fill('input[type="password"]', pass);
await page.click('input[type="submit"]');
await page.waitForTimeout(4000);
// ENTRAR AL BUZÓN CON REINTENTOS
await gotoWithRetry(page, 'https://web.multiasistencia.com/w3multi/frepasos_new.php?refresh=1');
await page.waitForTimeout(3000);
// 🛡️ FIX CRÍTICO: la página recién cargada (incluso con ?refresh=1 en la URL) puede
// enseñar MENOS expedientes de los que hay en realidad — lo mismo que ves tú si entras
// y la lista sale corta hasta que pulsas "Recargar Datos". Ese botón ejecuta la función
// refrescar(), que reenvía un formulario y provoca una navegación de página completa
// (compruébalo: la URL pierde el "?refresh=1" al pulsarlo). Antes, aquí se llamaba a
// refrescar() con page.evaluate() sin esperar esa navegación, y cualquier fallo se
// tragaba en un catch vacío — así que si la recarga real fallaba, el robot seguía
// adelante con la lista corta y podía anular expedientes que en realidad seguían vivos.
// Ahora esperamos la navegación de verdad y, si no se puede confirmar, abortamos este
// ciclo entero (mejor no tocar nada que anular por una lista incompleta).
console.log("🔄 [Multi] Forzando recarga real de 'Servicios en curso' (equivalente a pulsar Recargar Datos)...");
try {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }),
page.evaluate(() => {
if (typeof refrescar === 'function') refrescar();
else throw new Error('No existe la función refrescar() en esta página (¿ha cambiado la web?).');
})
]);
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(2000);
} catch (e) {
throw new Error(`No se pudo confirmar la recarga real de "Servicios en curso": ${e.message}. Se aborta este ciclo de Multiasistencia para no anular expedientes por una lista incompleta.`);
}
// --- BUCLE DE PAGINACIÓN ---
let todosExpedientes = new Set();
let paginaActual = 1;
while (true) {
console.log(`📄 [Multi] Escaneando página ${paginaActual}...`);
const expedientesPagina = await page.evaluate(() => {
const links = Array.from(document.querySelectorAll('a[href*="reparacion="]'));
return links.map(a => a.href.match(/reparacion=(\d+)/)?.[1]).filter(Boolean);
});
expedientesPagina.forEach(ref => todosExpedientes.add(ref));
// 🛑 NUEVO: Buscamos el botón de siguiente
const hasNextPage = await page.evaluate(() => {
const links = Array.from(document.querySelectorAll('a.lnkheader'));
return links.some(a => a.innerText.trim() === 'Página siguiente');
});
if (hasNextPage) {
// 🛑 NUEVO: Pulsamos usando el motor de Playwright, simulando click humano
await page.locator('a.lnkheader:has-text("Página siguiente")').first().click();
await page.waitForTimeout(3000);
paginaActual++;
if(paginaActual > 15) {
console.log("⚠️ [Multi] Límite de 15 páginas alcanzado por seguridad.");
break;
}
} else {
console.log("🛑 [Multi] No hay más páginas.");
break;
}
}
const expedientes = Array.from(todosExpedientes);
console.log(`✅ [Multi] Total expedientes detectados: ${expedientes.length}`);
if (expedientes.length > 0) {
await syncAndArchive(ownerId, 'multiasistencia', expedientes, {
verificarRealmenteCerrado: (ref) => verificarCierreRealMulti(page, ref)
});
}
for (const ref of expedientes) {
await gotoWithRetry(page, `https://web.multiasistencia.com/w3multi/repasos1.php?reparacion=${ref}`);
await page.waitForTimeout(1500);
let scrapData = null;
for (const frame of page.frames()) {
try {
scrapData = await frame.evaluate(() => {
const clean = (t) => t ? t.replace(/\s+/g, ' ').trim() : "";
const body = document.body?.innerText || "";
if (!body.includes("Nombre Cliente") && !body.includes("Asegurado")) return null;
const cabeceras = Array.from(document.querySelectorAll('.tcab'));
const detalles = Array.from(document.querySelectorAll('.tdet'));
const findByCab = (texto) => {
const idx = cabeceras.findIndex(el => el.innerText.includes(texto));
return idx !== -1 && detalles[idx] ? clean(detalles[idx].innerText) : null;
};
let rawDesc = "";
const descHeader = Array.from(document.querySelectorAll('td.tcab')).find(td => td.innerText.includes("Descripción de la Reparación"));
if (descHeader && descHeader.nextElementSibling) rawDesc = clean(descHeader.nextElementSibling.innerText);
// CORRECCIÓN: Borrado el recorte de fechas destructivo.
// Ahora le pasamos todo el bloque de texto completo a la IA para que no se pierdan palabras clave.
const cleanDesc = rawDesc;
const rawCPField = findByCab("Distrito Postal") || "";
const cpMatch = rawCPField.match(/\b\d{5}\b/);
const cpOnly = cpMatch ? cpMatch[0] : "";
const popOnly = rawCPField.replace(cpOnly, '').replace('-', '').trim();
// 📞 EXTRAER TELÉFONO REAL (Evita coger el de la oficina)
let telefonoReal = "";
const titulosDiv = Array.from(document.querySelectorAll('.subtitulo'));
const divTelefono = titulosDiv.find(div => div.innerText && div.innerText.includes('Teléfono'));
if (divTelefono && divTelefono.nextElementSibling) {
const celdaNum = divTelefono.nextElementSibling.querySelector('.tdet');
if (celdaNum) telefonoReal = celdaNum.innerText.replace(/[^0-9]/g, '');
}
// Fallback por si acaso falla, usamos el que tenías antes
if (!telefonoReal || telefonoReal.length < 9) {
telefonoReal = (document.body.innerText.match(/[6789]\d{8}/) || [])[0] || "";
}
return {
"Expediente": findByCab("Número Reparación") || "",
"Nombre Cliente": findByCab("Nombre Cliente") || "",
"Dirección": findByCab("Dirección") || "",
"Población": popOnly,
"Código Postal": cpOnly,
"Compañía": "MULTI - " + (findByCab("Procedencia") || "MULTIASISTENCIA"),
"Descripción": cleanDesc,
"Teléfono": telefonoReal,
"Estado": findByCab("Estado") || "", // 🛑 AÑADIDO EL ESTADO AQUÍ
"Urgente": findByCab("Urgente") || "No"
};
});
if (scrapData && scrapData['Nombre Cliente']) break;
} catch (e) { continue; }
}
if (scrapData && scrapData['Nombre Cliente']) {
const idGremioDetectado = clasificarGremio(scrapData['Descripción'], gremiosDB);
if (idGremioDetectado) {
scrapData['guild_id'] = idGremioDetectado;
}
await saveServiceToDB(ownerId, 'multiasistencia', ref, scrapData);
}
}
} catch (e) {
console.error("❌ Error Multi:", e.message);
} finally {
await browser.close();
}
}
// ==========================================
// 🧹 HOMESERVE (ROBOT MEJORADO Y DETECTOR DE URGENCIAS)
// ==========================================
async function runHomeserve(ownerId, user, pass, gremiosDB) {
const browser = await chromium.launch({
headless: HEADLESS,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
]
});
const page = await browser.newPage();
try {
console.log("🌍 [HomeServe] Entrando...");
await gotoWithRetry(page, 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=PROF_PASS');
if (await page.isVisible('input[name="CODIGO"]')) {
await page.fill('input[name="CODIGO"]', user);
await page.fill('input[type="password"]', pass);
await page.keyboard.press('Enter');
await page.waitForTimeout(5000);
}
await gotoWithRetry(page, 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=lista_servicios_total');
await page.waitForTimeout(3000);
// 🛑 NUEVO: Ahora extraemos la referencia Y LOS ICONOS de la misma pasada
const listaConIconos = await page.evaluate(() => {
const results = [];
const rows = Array.from(document.querySelectorAll('table[bgcolor="#FCF4D6"] tr'));
rows.forEach(tr => {
const firstTd = tr.querySelector('td');
if (!firstTd) return;
const textMatch = firstTd.innerText.trim().match(/^15\d{6}$/);
const aMatch = firstTd.querySelector('a') ? firstTd.querySelector('a').innerText.trim().match(/^15\d{6}$/) : null;
const ref = textMatch ? textMatch[0] : (aMatch ? aMatch[0] : null);
if (ref) {
const hasLock = firstTd.querySelector('img[src*="candado.gif"]') !== null;
const hasEyes = firstTd.querySelector('img[src*="ojos.gif"]') !== null;
results.push({ ref, hasLock, hasEyes });
}
});
return results;
});
// Sacamos solo los números para la escoba (syncAndArchive)
const refs = [...new Set(listaConIconos.map(item => item.ref))];
if (refs.length > 0) {
await syncAndArchive(ownerId, 'homeserve', refs);
}
console.log(`🔍 [HomeServe] ${refs.length} expedientes detectados.`);
for (const ref of refs) {
await gotoWithRetry(page, `https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=${ref}`);
await page.waitForTimeout(2000);
const scrapData = await page.evaluate(() => {
const d = {};
let isUrgent = "No"; // <-- EMPEZAMOS ASUMIENDO QUE NO ES URGENTE
const rows = Array.from(document.querySelectorAll('tr'));
rows.forEach(r => {
const cells = r.querySelectorAll('td');
if (cells.length >= 2) {
const k = cells[0].innerText.toUpperCase().trim().replace(':', '');
const v = cells[1].innerText.trim();
if (k.includes("COMENTARIOS")) {
const inputEl = cells[1].querySelector('textarea');
const txt = inputEl ? inputEl.value : (cells[1].innerText || "");
// 🔥 DETECTOR DE URGENCIA ANTES DE LIMPIAR 🔥
const txtLower = txt.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (txtLower.includes("atencion presencial urgencias") || txtLower.includes("atencion de la urgencia") || txtLower.includes("urgente")) {
isUrgent = "Sí"; // Si pilla la urgencia, la guarda.
}
const cleanDesc = txt.split('\n').filter(line => {
const l = line.toUpperCase();
// Quitamos los filtros destructivos que borraban el "Cambio de estado"
return !["ESTADO ASIGNADO", "SMS NO ENVIADO", "CONTACTO CON PROF", "0000"].some(b => l.includes(b));
}).join('\n').trim();
// Aplicamos el descodificador a la avería
d['Descripción'] = cleanDesc; // Se limpiará luego abajo
} else if (k.length > 1 && v.length > 0 && !k.includes("MENU")) {
d[k] = v;
}
}
});
const rawPop = d['POBLACION-PROVINCIA'] || "";
const cpMatch = rawPop.match(/\b\d{5}\b/);
d['Código Postal'] = cpMatch ? cpMatch[0] : "";
d['Población'] = rawPop.replace(d['Código Postal'], '').replace('-', '').trim();
d['Compañía'] = "HOME - " + (d['COMPAÑIA'] || "HOMESERVE");
d['Nombre Cliente'] = d['CLIENTE'] || "";
d['Dirección'] = d['DOMICILIO'] || "";
// INYECTAMOS LA URGENCIA AL ESTILO MULTIASISTENCIA
d['Urgente'] = isUrgent;
return d;
});
if (scrapData && scrapData['Nombre Cliente']) {
// 🪄 Arreglamos la codificación alienígena de HomeServe EN TODOS LOS CAMPOS IMPORTANTES
scrapData['Nombre Cliente'] = arreglarCodificacion(scrapData['Nombre Cliente']);
scrapData['Dirección'] = arreglarCodificacion(scrapData['Dirección']);
scrapData['Población'] = arreglarCodificacion(scrapData['Población']);
scrapData['Descripción'] = arreglarCodificacion(scrapData['Descripción']);
// 🪄 Arreglamos el formato de "Apellidos, Nombre" (Solo en HomeServe)
scrapData['Nombre Cliente'] = arreglarNombre(scrapData['Nombre Cliente']);
// 🛑 NUEVO: Le inyectamos los iconos que cazamos antes en la lista
const iconInfo = listaConIconos.find(item => item.ref === ref);
if (iconInfo) {
scrapData['has_lock'] = iconInfo.hasLock;
scrapData['has_eyes'] = iconInfo.hasEyes;
}
const idGremioDetectado = clasificarGremio(scrapData['Descripción'], gremiosDB);
if (idGremioDetectado) {
scrapData['guild_id'] = idGremioDetectado;
}
await saveServiceToDB(ownerId, 'homeserve', ref, scrapData);
}
}
} catch (e) { console.error("❌ [HomeServe] Error:", e.message); }
finally { await browser.close(); }
}
async function syncAndArchive(ownerId, provider, currentWebRefs, opts = {}) {
const { verificarRealmenteCerrado } = opts; // async (ref) => true|false|null (null = no se pudo comprobar)
const client = await pool.connect();
try {
// 1. Buscamos TODOS los expedientes que están vivos (en el buzón o en el panel)
const { rows: dbServices } = await client.query(
`SELECT id, service_ref, assigned_to, raw_data FROM scraped_services
WHERE owner_id = $1 AND provider = $2 AND status IN ('pending', 'imported')`,
[ownerId, provider]
);
// 2. Filtramos cuáles han desaparecido de la web oficial
const missingServices = dbServices.filter(s => !currentWebRefs.includes(s.service_ref));
const refsToArchive = missingServices.map(s => s.service_ref);
if (refsToArchive.length > 0) {
// 3. Los archivamos (los quitamos del buzón)
await client.query(
`UPDATE scraped_services
SET status = 'archived'
WHERE owner_id = $1
AND provider = $2
AND service_ref = ANY($3)`,
[ownerId, provider, refsToArchive]
);
// ========================================================
// 🛡️ EL ESCUDO ANTI-VIAJES EN BALDE (Para la App del Técnico)
// ========================================================
// Buscamos el ID del estado "Anulado" de este propietario
const statusQ = await client.query("SELECT id FROM service_statuses WHERE owner_id = $1 AND name ILIKE '%anulado%' LIMIT 1", [ownerId]);
const idAnulado = statusQ.rowCount > 0 ? statusQ.rows[0].id : null;
if (idAnulado) {
for (const svc of missingServices) {
// Si el expediente estaba asignado a un técnico...
if (svc.assigned_to) {
const raw = svc.raw_data || {};
const currentStatusId = String(raw.status_operativo || "");
// ...y su estado actual no era ya "Anulado"
if (currentStatusId !== String(idAnulado)) {
// Comprobamos si el técnico ya lo había "Finalizado" (para no pisarle el trabajo hecho)
let isFinal = false;
if (currentStatusId) {
const checkStatusQ = await client.query("SELECT is_final FROM service_statuses WHERE id = $1", [currentStatusId]);
isFinal = checkStatusQ.rowCount > 0 ? checkStatusQ.rows[0].is_final : false;
}
// Si NO estaba finalizado, procedemos a ANULARLO automáticamente...
// pero SOLO si podemos confirmarlo. "No sale en esta lista" no es
// prueba suficiente (esa lista no es el universo completo de lo que
// sigue abierto) — por eso, si tenemos forma de verificarlo en la
// ficha real del expediente, lo hacemos antes de anular nada.
if (!isFinal) {
let debeAnular = true;
if (typeof verificarRealmenteCerrado === 'function') {
const verificacion = await verificarRealmenteCerrado(svc.service_ref);
debeAnular = verificacion === true;
if (!debeAnular) {
console.log(`🛡️ [ESCUDO] Expediente ${svc.service_ref} NO se anula: no se ha podido confirmar un cierre real en la web del proveedor (solo estaba ausente de la lista).`);
}
}
if (debeAnular) {
raw.status_operativo = idAnulado;
raw.auto_anulado = true; // Marca: lo anuló el robot solo, así sabemos si luego se puede reabrir automáticamente
await client.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(raw), svc.id]);
// Dejamos huella en la trazabilidad para que sepas por qué se anuló
await client.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[svc.id, "Sistema Robot", "Cancelación Automática", "La compañía ha cancelado/retirado el expediente (verificado). Se pasa a estado Anulado para avisar al técnico."]
);
console.log(`🛡️ [ESCUDO] Expediente ${svc.service_ref} anulado automáticamente (Técnico salvado de viajar en balde).`);
}
}
}
}
}
}
// ========================================================
console.log(`📦 [${provider.toUpperCase()}] Archivados ${refsToArchive.length} expedientes desaparecidos.`);
}
} catch (error) {
console.error(`❌ Error archivando ${provider}:`, error.message);
} finally {
client.release();
}
}
// ==========================================
// 👋 MENSAJE DE BIENVENIDA (WHATSAPP AL CLIENTE, SOLO EXPEDIENTES NUEVOS)
// ==========================================
const { EVOLUTION_BASE_URL, EVOLUTION_API_KEY } = process.env;
function extractValidPhoneRobot(rawPhone) {
if (!rawPhone) return "";
let str = String(rawPhone).replace(/\D/g, "");
if (str.startsWith("0034")) str = str.slice(4);
if (str.startsWith("34") && str.length >= 11) str = str.slice(2);
const match = str.match(/[6789]\d{8}/);
return match ? match[0] : (str.length >= 9 ? str.slice(-9) : str);
}
async function sendWhatsAppWelcomeText(phone, message, instanceName) {
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) {
console.log("⚠️ [Bienvenida] Faltan EVOLUTION_BASE_URL / EVOLUTION_API_KEY en este robot. Se omite el envío.");
return false;
}
try {
const baseUrl = EVOLUTION_BASE_URL.replace(/\/$/, "");
const url = `${baseUrl}/message/sendText/${instanceName}`;
const response = await fetch(url, {
method: 'POST',
headers: { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY.trim() },
body: JSON.stringify({ number: phone, text: message, delay: 0 })
});
if (!response.ok) {
console.error("❌ [Bienvenida] Fallo enviando WhatsApp. Estado Evolution:", response.status);
return false;
}
return true;
} catch (e) {
console.error("❌ [Bienvenida] Error de red con Evolution API:", e.message);
return false;
}
}
const NOMBRE_COMPANIA_POR_PROVEEDOR = { multiasistencia: 'Multiasistencia', homeserve: 'HomeServe' };
// Réplica ligera de la lógica de triggerWhatsAppEvent('wa_evt_welcome') de server.js,
// pero autocontenida aquí porque el robot no pasa por el servidor (escribe directo a la BD).
async function sendWelcomeMessage(ownerId, provider, ref, data, serviceId) {
// 1. ¿Tiene la empresa activado el aviso de bienvenida?
const userQ = await pool.query("SELECT wa_settings FROM users WHERE id=$1", [ownerId]);
const settings = userQ.rows[0]?.wa_settings || {};
if (!settings.wa_evt_welcome) return;
// 2. ¿Existe la plantilla de bienvenida y tiene contenido?
const tplQ = await pool.query("SELECT content FROM message_templates WHERE owner_id=$1 AND type='welcome'", [ownerId]);
if (tplQ.rowCount === 0 || !tplQ.rows[0].content) return;
let text = tplQ.rows[0].content;
// 3. Teléfono válido
const cleanPhone = extractValidPhoneRobot(data['Teléfono']);
if (cleanPhone.length < 9) {
console.log(`⚠️ [Bienvenida] Expediente #${ref}: sin teléfono válido, no se envía.`);
return;
}
const finalPhone = "34" + cleanPhone;
// 4. Enlace al portal del cliente (por si la plantilla lo usa)
let enlace = "";
try {
const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${cleanPhone}%`, ownerId]);
let token;
if (clientQ.rowCount > 0) {
token = clientQ.rows[0].portal_token;
} else {
const insertC = await pool.query(
"INSERT INTO clients (owner_id, full_name, phone, addresses) VALUES ($1, $2, $3, $4) RETURNING portal_token",
[ownerId, data['Nombre Cliente'] || "Asegurado", finalPhone, JSON.stringify([data['Dirección'] || ""])]
);
token = insertC.rows[0].portal_token;
}
enlace = `https://portal.integrarepara.es/?token=${token}&service=${serviceId}`;
} catch (e) {
console.error(`⚠️ [Bienvenida] No se pudo generar el enlace del portal para #${ref}:`, e.message);
}
// 5. Sustituir variables de la plantilla
text = text.replace(/{{NOMBRE}}/g, data['Nombre Cliente'] || "Cliente");
text = text.replace(/{{DIRECCION}}/g, data['Dirección'] || "su domicilio");
text = text.replace(/{{FECHA}}/g, "la fecha acordada");
text = text.replace(/{{HORA}}/g, "la hora acordada");
text = text.replace(/{{COMPANIA}}/g, NOMBRE_COMPANIA_POR_PROVEEDOR[provider] || "su Aseguradora");
text = text.replace(/{{REFERENCIA}}/g, ref);
text = text.replace(/{{ENLACE}}/g, enlace);
// 6. Enviar
const instanceName = `cliente_${ownerId}`;
const ok = await sendWhatsAppWelcomeText(finalPhone, text, instanceName);
if (ok) console.log(`👋 [Bienvenida] WhatsApp enviado a ${finalPhone} para el expediente #${ref}.`);
}
async function saveServiceToDB(ownerId, provider, ref, data) {
console.log(`💾 Guardando/Actualizando ${provider.toUpperCase()} ${ref}...`);
// Aquí está la solución al problema del tilde ("sí" vs "si") que te comenté antes
const isUrgent = (data['Urgente'] && (data['Urgente'].toLowerCase().trim() === 'sí' || data['Urgente'].toLowerCase().trim() === 'si')) ? true : false;
// 👋 Comprobamos ANTES de guardar si este expediente ya existía en el buzón, para saber si
// hay que disparar el WhatsApp de bienvenida (solo la primera vez que aparece), y de paso
// si el robot lo había anulado él solo (auto_anulado) — porque si ha vuelto a aparecer aquí
// es que sigue vivo de verdad, y toca reabrirlo en vez de dejarlo marcado "Anulado" para siempre.
let isNewRecord = false;
let wasAutoAnulado = false;
try {
const preCheck = await pool.query(
"SELECT id, raw_data FROM scraped_services WHERE owner_id=$1 AND provider=$2 AND service_ref=$3",
[ownerId, provider, ref]
);
isNewRecord = preCheck.rowCount === 0;
if (preCheck.rowCount > 0) {
const existingRaw = preCheck.rows[0].raw_data || {};
wasAutoAnulado = existingRaw.auto_anulado === true || existingRaw.auto_anulado === 'true';
}
} catch (e) {
console.error(`⚠️ [Bienvenida] No se pudo comprobar si ${ref} es nuevo:`, e.message);
}
const result = await pool.query(`
INSERT INTO scraped_services (owner_id, provider, service_ref, raw_data, status, is_urgent)
VALUES ($1, $2, $3, $4, 'pending', $5)
ON CONFLICT (owner_id, provider, service_ref)
DO UPDATE SET
-- 🛡️ EL ESCUDO: Fusiona todo, pero si ya había un gremio guardado, lo blinda para que el robot no lo pise.
-- 🔓 REAPERTURA: si el estado "Anulado" lo puso el propio robot (auto_anulado) y el expediente
-- ha vuelto a aparecer como activo en el proveedor, se lo quitamos para que vuelva al buzón normal.
raw_data = CASE
WHEN (scraped_services.raw_data->>'auto_anulado') IN ('true', 'True') THEN
((scraped_services.raw_data || EXCLUDED.raw_data) ||
jsonb_build_object('guild_id', COALESCE(scraped_services.raw_data->'guild_id', EXCLUDED.raw_data->'guild_id')))
- 'status_operativo' - 'auto_anulado'
ELSE
(scraped_services.raw_data || EXCLUDED.raw_data) ||
jsonb_build_object('guild_id', COALESCE(scraped_services.raw_data->'guild_id', EXCLUDED.raw_data->'guild_id'))
END,
is_urgent = EXCLUDED.is_urgent,
status = CASE
-- 🔓 REAPERTURA: si estaba archivado y el proveedor lo vuelve a mostrar activo, sale del archivo.
WHEN scraped_services.status = 'archived' THEN 'pending'
WHEN scraped_services.status = 'imported' THEN 'imported'
ELSE 'pending'
END
RETURNING id
`, [ownerId, provider, ref, JSON.stringify(data), isUrgent]);
// 🔓 Si veníamos de una anulación automática y ha reaparecido, dejamos constancia del porqué
if (wasAutoAnulado && result.rows[0]) {
console.log(`🔓 [REAPERTURA] Expediente ${ref} había sido anulado automáticamente y ha vuelto a aparecer activo en ${provider}. Se reabre.`);
try {
await pool.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
[result.rows[0].id, "Sistema Robot", "Reapertura Automática", "El proveedor ha vuelto a mostrar este expediente como activo. Se retira el estado Anulado automático para que lo revises."]
);
} catch (e) {
console.error(`⚠️ No se pudo registrar la reapertura de ${ref}:`, e.message);
}
}
// 👋 Si es la primera vez que vemos este expediente, disparamos el WhatsApp de
// bienvenida en segundo plano (no bloquea ni puede romper el guardado de arriba).
if (isNewRecord && result.rows[0]) {
const newSvcId = result.rows[0].id;
console.log(`👋 [BIENVENIDA] Expediente #${ref} acaba de nacer. Disparando WhatsApp...`);
sendWelcomeMessage(ownerId, provider, ref, data, newSvcId).catch(e =>
console.error(`❌ [Bienvenida] Error inesperado para #${ref}:`, e.message)
);
}
}
main();