diff --git a/robot.js b/robot.js index 36a52bf..b6b8bf8 100644 --- a/robot.js +++ b/robot.js @@ -1,179 +1,303 @@ import { chromium } from 'playwright'; import pg from 'pg'; -// En Coolify las variables se inyectan solas const { DATABASE_URL } = process.env; - -if (!DATABASE_URL) { - console.error("❌ Error: No hay DATABASE_URL definida."); - process.exit(1); -} +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; -// CONFIGURACIÓN -const HEADLESS = true; // En servidor SIEMPRE 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("🤖 INICIANDO ROBOT UNIFICADO (MEJORADO)..."); - + console.log("🤖 ROBOT MODO: CIRUJANO + ASPIRADORA + CLASIFICADOR PRO"); while (true) { const client = await pool.connect(); try { - // 1. Obtener credenciales activas + // 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'"); - const credentials = res.rows; - - console.log(`📋 Procesando ${credentials.length} cuentas de proveedores...`); - - for (const cred of credentials) { - let password = ""; - try { - password = Buffer.from(cred.password_hash, 'base64').toString('utf-8'); - } catch (e) { - console.error(`Error decodificando pass para usuario ${cred.owner_id}`); - continue; + 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); } - console.log(`\n🔄 Sincronizando ${cred.provider.toUpperCase()} para usuario ID ${cred.owner_id}...`); - - if (cred.provider === 'multiasistencia') { - await runMultiasistencia(cred.owner_id, cred.username, password); - } else if (cred.provider === 'homeserve') { - await runHomeserve(cred.owner_id, cred.username, password); - } - - // Actualizar timestamp await client.query("UPDATE provider_credentials SET last_sync = NOW() WHERE id = $1", [cred.id]); } - - } catch (e) { - console.error("❌ Error en ciclo principal:", e); - } finally { - client.release(); - } - - // ESPERA DE 15 MINUTOS + } 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)); } } // ========================================== -// 🛠️ ROBOT MULTIASISTENCIA (CORREGIDO) +// 🏥 MULTIASISTENCIA (CON REINTENTOS ANTI-CAÍDAS) // ========================================== -async function runMultiasistencia(ownerId, user, pass) { - const browser = await chromium.launch({ headless: HEADLESS, args: ['--no-sandbox'] }); +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 { - console.log("🌍 [Multi] Conectando..."); - await page.goto('https://web.multiasistencia.com/w3multi/acceso.php', { timeout: 60000 }); - - // Login - const userInput = await page.$('input[name="usuario"]') || await page.$('input[type="text"]'); - if(userInput) { - await userInput.fill(user); - await page.fill('input[type="password"]', pass); - await page.click('input[type="submit"]'); - await page.waitForTimeout(4000); - } - - // Ir a listado - await page.goto('https://web.multiasistencia.com/w3multi/frepasos_new.php?refresh=1', { waitUntil: 'domcontentloaded' }); + // INICIO DE SESIÓN CON REINTENTOS + await gotoWithRetry(page, 'https://web.multiasistencia.com/w3multi/acceso.php'); - // Sacar expedientes - const expedientes = await page.evaluate(() => { - const links = Array.from(document.querySelectorAll('a[href*="reparacion="]')); - return Array.from(new Set(links.map(a => a.href.match(/reparacion=(\d+)/)?.[1]).filter(Boolean))); - }); + 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); - console.log(`🔍 [Multi] ${expedientes.length} expedientes encontrados.`); + console.log("🔄 [Multi] Forzando recarga segura mediante script interno..."); + try { + await page.evaluate(() => { + if (typeof refrescar === 'function') refrescar(); + }); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + } catch (e) {} - for (const ref of expedientes) { - await page.goto(`https://web.multiasistencia.com/w3multi/repasos1.php?reparacion=${ref}`, { waitUntil: 'domcontentloaded' }); + // --- BUCLE DE PAGINACIÓN --- + let todosExpedientes = new Set(); + let paginaActual = 1; + + while (true) { + console.log(`📄 [Multi] Escaneando página ${paginaActual}...`); - // --- NUEVA LÓGICA DE EXTRACCIÓN POR CELDAS --- - const data = await page.evaluate(() => { - - // Función auxiliar para buscar en celdas vecinas - const findValueByHeader = (keywords) => { - const cells = Array.from(document.querySelectorAll('td, th')); - for (const cell of cells) { - const text = (cell.innerText || "").toUpperCase().trim(); - // Si la celda contiene una de las palabras clave - if (keywords.some(k => text.includes(k.toUpperCase()))) { - // Devolvemos el texto de la celda SIGUIENTE - const nextCell = cell.nextElementSibling; - if (nextCell) return nextCell.innerText.trim(); - } - } - return ""; - }; + 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)); - // Función auxiliar para descripción (busca bloques de texto grandes) - const findDescription = () => { - // Intento 1: Buscar celda "Descripción" - let desc = findValueByHeader(['Descripción', 'Daños', 'Solicitud']); - if(desc) return desc; - - // Intento 2: Buscar en el cuerpo general si falla la tabla - // 🩹 FIX: antes se cortaba en el PRIMER salto de línea que hubiera después - // de "Descripción" ([^\n]+), así que muchas veces el texto quedaba a medias. - // Ahora cogemos todo el texto hasta la siguiente etiqueta de campo conocida - // (o una línea en blanco), con un límite de seguridad por si no encuentra ninguna. - const body = document.body.innerText; - const match = body.match(/Descripción\s*[:\-]?\s*([\s\S]*?)(?:\n\s*\n|Observaciones|Dirección|Domicilio|Población|Localidad|Tel[eé]fono[s]?|Fecha|Reparador|Gremio|C\.?P\.?\b|$)/i); - if (!match) return ""; - return match[1].replace(/[ \t]+/g, ' ').trim().substring(0, 3000); - } - - // Extracción más precisa - let address = findValueByHeader(['Dirección', 'Domicilio', 'Riesgo']); - let pob = findValueByHeader(['Población', 'Localidad']); - let cp = findValueByHeader(['C.P.', 'Postal']); - - // Limpieza de dirección (quitar "Baremo" si se cuela) - if (address.includes("Baremo")) address = address.split("Baremo")[0].trim(); - - // Unir dirección completa - let fullAddress = address; - if (pob) fullAddress += `, ${pob}`; - if (cp) fullAddress += ` (${cp})`; - - return { - clientName: findValueByHeader(['Nombre Cliente', 'Asegurado']) || "Desconocido", - address: fullAddress || "Sin dirección detectada", - phone: (document.body.innerText.match(/[6789]\d{8}/) || [])[0] || "", - description: findDescription() - }; + // 🛑 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 (data.clientName) { - // Solo guardamos si tenemos al menos un nombre - await saveServiceToDB(ownerId, 'multiasistencia', ref, data); + 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; } } - } catch (e) { - console.error("❌ [Multi] Error:", e.message); - } finally { - await browser.close(); + const expedientes = Array.from(todosExpedientes); + console.log(`✅ [Multi] Total expedientes detectados: ${expedientes.length}`); + + if (expedientes.length > 0) { + await syncAndArchive(ownerId, 'multiasistencia', expedientes); + } + + 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(); } } // ========================================== -// 🛠️ ROBOT HOMESERVE (MANTENIDO) +// 🧹 HOMESERVE (ROBOT MEJORADO Y DETECTOR DE URGENCIAS) // ========================================== -async function runHomeserve(ownerId, user, pass) { - const browser = await chromium.launch({ headless: HEADLESS, args: ['--no-sandbox'] }); +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] Conectando..."); - await page.goto('https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=PROF_PASS', { timeout: 60000 }); - + 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); @@ -181,70 +305,219 @@ async function runHomeserve(ownerId, user, pass) { await page.waitForTimeout(5000); } - await page.goto('https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=lista_servicios_total'); - - const refs = await page.evaluate(() => { - const filas = Array.from(document.querySelectorAll('table tr')); - return filas.map(tr => tr.querySelector('td')?.innerText.trim()).filter(t => /^\d{4,}$/.test(t)); + 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; }); - console.log(`🔍 [HomeServe] ${refs.length} expedientes encontrados.`); + // Sacamos solo los números para la escoba (syncAndArchive) + const refs = [...new Set(listaConIconos.map(item => item.ref))]; - for (const ref of refs) { - try { - await page.goto('https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=lista_servicios_total'); - await page.click(`text="${ref}"`, { timeout: 5000 }); - await page.waitForTimeout(1000); - - const data = await page.evaluate(() => { - const rows = Array.from(document.querySelectorAll('tr')); - const d = {}; - rows.forEach(r => { - const cells = r.querySelectorAll('td'); - if(cells.length > 1) { - const k = cells[0].innerText.toUpperCase(); - const v = cells[1].innerText; - if(k.includes("CLIENTE")) d.clientName = v; - if(k.includes("DOMICILIO")) d.address = v; - if(k.includes("POBLACION")) d.address += ", " + v; - if(k.includes("TELEFONOS")) d.phone = v; - if(k.includes("COMENTARIOS") || k.includes("AVERIA")) d.description = v; - } - }); - return d; - }); - - if (data.clientName) { - await saveServiceToDB(ownerId, 'homeserve', ref, data); - } - - } catch (e) { console.error(`Error leyendo ${ref}: ${e.message}`); } + if (refs.length > 0) { + await syncAndArchive(ownerId, 'homeserve', refs); } - } catch (e) { - console.error("❌ [HomeServe] Error:", e.message); + 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) { + 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 + if (!isFinal) { + raw.status_operativo = idAnulado; + + 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. 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 { - await browser.close(); + client.release(); } } -// ========================================== -// 💾 GUARDADO EN BASE DE DATOS -// ========================================== async function saveServiceToDB(ownerId, provider, ref, data) { - // Verificar si ya existe en servicios reales (para no duplicar trabajo) - const exists = await pool.query("SELECT id FROM services WHERE company_ref = $1 AND owner_id = $2", [ref, ownerId]); - if (exists.rowCount > 0) return; - - // Verificar si ya existe en buzón, si existe ACTUALIZAMOS los datos (por si hemos mejorado el scraping) - console.log(`💾 Guardando/Actualizando: ${ref} (${data.address})`); + 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; await pool.query(` - INSERT INTO scraped_services (owner_id, provider, service_ref, raw_data, status) - VALUES ($1, $2, $3, $4, 'pending') + 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 raw_data = EXCLUDED.raw_data, created_at = NOW() - `, [ownerId, provider, ref, JSON.stringify(data)]); + DO UPDATE SET + -- 🛡️ EL ESCUDO: Fusiona todo, pero si ya había un gremio guardado, lo blinda para que el robot no lo pise + raw_data = (scraped_services.raw_data || EXCLUDED.raw_data) || + jsonb_build_object('guild_id', COALESCE(scraped_services.raw_data->'guild_id', EXCLUDED.raw_data->'guild_id')), + + is_urgent = EXCLUDED.is_urgent, + status = CASE + WHEN scraped_services.status = 'archived' THEN 'archived' + WHEN scraped_services.status = 'imported' THEN 'imported' + ELSE 'pending' + END + `, [ownerId, provider, ref, JSON.stringify(data), isUrgent]); } main(); \ No newline at end of file