diff --git a/robot.js b/robot.js index 75ec421..2723e36 100644 --- a/robot.js +++ b/robot.js @@ -1,4 +1,3 @@ -// robot-homeserve/robot.js const { chromium } = require('playwright'); const admin = require('firebase-admin'); @@ -13,136 +12,414 @@ if (process.env.FIREBASE_PRIVATE_KEY) { privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), }), }); - console.log("✅ Firebase inicializado correctamente"); } catch (err) { - console.error("❌ Error inicializando Firebase:", err); + console.error("❌ Error inicializando Firebase:", err.message); process.exit(1); } } else { - console.error("❌ Falta FIREBASE_PRIVATE_KEY en las variables de entorno"); + console.error("⚠️ FALTAN LAS CLAVES DE FIREBASE"); process.exit(1); } const db = admin.firestore(); +const COLLECTION_NAME = "homeserve_pendientes"; +const PROVIDER_DOC = "homeserve"; // providerCredentials/homeserve -// --- VARIABLES DE ENTORNO --- -const HOMESERVE_USER = process.env.HOMESERVE_USER; -const HOMESERVE_PASS = process.env.HOMESERVE_PASS; +// Colecciones “sistema” +const APPOINTMENTS_COL = "appointments"; +const SERVICES_COL = "services"; +const SERVICE_NUMBER_FIELD = "serviceNumber"; -if (!HOMESERVE_USER || !HOMESERVE_PASS) { - console.error("❌ Faltan HOMESERVE_USER o HOMESERVE_PASS en ENV"); - process.exit(1); +async function getProviderCredentials(providerDocId) { + const snap = await db.collection("providerCredentials").doc(providerDocId).get(); + if (!snap.exists) throw new Error(`No existe providerCredentials/${providerDocId} en Firestore`); + const data = snap.data() || {}; + const user = String(data.user || "").trim(); + const pass = String(data.pass || "").trim(); + if (!user || !pass) throw new Error(`providerCredentials/${providerDocId} no tiene user/pass completos`); + return { user, pass }; } -// --- FUNCIONES UTILES --- -async function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); +function normalizeServiceNumber(raw) { + const digits = String(raw || "").trim().replace(/\D/g, ""); + if (!/^\d{4,}$/.test(digits)) return null; + return digits; } -function safeText(str) { - return (str || '').toString().trim(); +function hasMinimumData(detalles) { + const client = String(detalles.clientName || "").trim(); + const addressPart = String(detalles.addressPart || "").trim(); + const cityPart = String(detalles.cityPart || "").trim(); + const phone = String(detalles.phone || "").trim(); + const address = `${addressPart} ${cityPart}`.trim(); + const phoneOk = /^[6789]\d{8}$/.test(phone); + const hasClientAndAddress = client.length >= 3 && address.length >= 8; + return phoneOk || hasClientAndAddress; } -// --- LOGICA PRINCIPAL --- -async function main() { +function chunk(arr, size = 10) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +function isCompletedStatus(raw) { + const s = String(raw || "").trim().toLowerCase(); + return s === "completed" || s === "finalizado" || s === "finished" || s === "done"; +} + +async function preloadAppointmentsInfo(serviceNumbers) { + // Map(sn -> { status, isInboxPending }) + const map = new Map(); + const unique = Array.from(new Set(serviceNumbers)).filter(Boolean); + const parts = chunk(unique, 10); + for (const p of parts) { + const snap = await db.collection(APPOINTMENTS_COL) + .where(SERVICE_NUMBER_FIELD, "in", p) + .get(); + snap.forEach(doc => { + const data = doc.data() || {}; + const sn = normalizeServiceNumber(data[SERVICE_NUMBER_FIELD]); + if (!sn) return; + map.set(sn, { + status: String(data.status || "").trim(), + isInboxPending: !!data.isInboxPending, + docId: doc.id, + }); + }); + } + return map; +} + +async function preloadServicesExistence(serviceNumbers) { + const set = new Set(); + const unique = Array.from(new Set(serviceNumbers)).filter(Boolean); + const parts = chunk(unique, 10); + for (const p of parts) { + const snap = await db.collection(SERVICES_COL) + .where(SERVICE_NUMBER_FIELD, "in", p) + .get(); + snap.forEach(doc => { + const data = doc.data() || {}; + const sn = normalizeServiceNumber(data[SERVICE_NUMBER_FIELD]); + if (sn) set.add(sn); + }); + } + return set; +} + +async function getAllPendingDocIds() { + const snap = await db.collection(COLLECTION_NAME).get(); + return snap.docs.map(d => d.id); +} + +async function runRobot() { + console.log('🤖 [V7.5] Robot HomeServe (no dupes + in_system + archiva solo completed)...'); + // 0) Credenciales + let creds; + try { + creds = await getProviderCredentials(PROVIDER_DOC); + console.log(`🔐 Credenciales OK: provider=${PROVIDER_DOC} user=${creds.user}`); + } catch (e) { + console.error("❌ No se pudieron cargar credenciales:", e.message); + process.exit(1); + } const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); - const context = await browser.newContext(); const page = await context.newPage(); - + const nowISO = new Date().toISOString(); try { - console.log("🌐 Entrando a HomeServe..."); - await page.goto('https://gestor.homeserve.es/', { waitUntil: 'domcontentloaded', timeout: 120000 }); - - // Login - console.log("🔐 Login..."); - await page.waitForSelector('input[type="text"]', { timeout: 60000 }); - await page.fill('input[type="text"]', HOMESERVE_USER); - + // LOGIN + console.log('🔐 Entrando al login...'); + await page.goto('https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=PROF_PASS', { timeout: 60000 }); + const selectorUsuario = 'input[name="CODIGO"]'; const selectorPass = 'input[type="password"]'; - await page.waitForSelector(selectorPass, { timeout: 60000 }); - await page.fill(selectorPass, HOMESERVE_PASS); - - // Botón entrar (puede variar) - const btn = await page.$('button[type="submit"]'); - if (btn) { - await btn.click(); - } else { - // fallback: enter + if (await page.isVisible(selectorUsuario)) { + await page.fill(selectorUsuario, ""); + await page.fill(selectorPass, ""); + await page.type(selectorUsuario, creds.user, { delay: 80 }); + await page.type(selectorPass, creds.pass, { delay: 80 }); + console.log('👆 Pulsando ENTER...'); await page.keyboard.press('Enter'); + await page.waitForTimeout(5000); + } else { + console.log("⚠️ No veo login (quizá ya logueado)."); } - - // Espera a panel - await page.waitForLoadState('networkidle', { timeout: 120000 }); - console.log("✅ Logueado"); - - // Ir a lista de servicios / pendientes (URL/selector depende del portal) - // Aquí se mantiene la lógica robusta con esperas y tolerancia. - await delay(4000); - - // Ejemplo: navegar a "Pendientes" - // (esto puede variar según HomeServe) - const pendientesLink = await page.$('text=Pendientes'); - if (pendientesLink) { - await pendientesLink.click(); - await page.waitForLoadState('networkidle', { timeout: 120000 }); - await delay(2000); - } - - // Extraer tabla/listado - console.log("📥 Extrayendo servicios..."); - const servicios = await page.evaluate(() => { - // Intenta detectar filas en tabla - const rows = - Array.from(document.querySelectorAll('table tbody tr')) || - Array.from(document.querySelectorAll('tbody tr')); - - return rows.map(r => { - const cells = Array.from(r.querySelectorAll('td')).map(td => td.innerText.trim()); - return { cells }; - }).filter(x => x && x.cells && x.cells.length > 0); - }); - - console.log(`🧾 Encontrados ${servicios.length} servicios (filas)`); - - // Guardar en Firestore - const batch = db.batch(); - const col = db.collection('homeserve_pendientes'); - - let saved = 0; - for (const item of servicios) { - // Aquí puedes mapear celdas a campos reales según tu tabla. - // Ejemplo genérico: - const ref = col.doc(); - batch.set(ref, { - raw: item, - createdAt: admin.firestore.FieldValue.serverTimestamp(), - source: 'homeserve', + // LISTA + console.log('📂 Leyendo lista de servicios...'); + await page.goto('https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=lista_servicios_total'); + const referenciasEnWeb = await page.evaluate(() => { + const filas = Array.from(document.querySelectorAll('table tr')); + const refs = []; + filas.forEach(tr => { + const tds = tr.querySelectorAll('td'); + if (tds.length > 0) { + const raw = (tds[0]?.innerText || "").trim(); + const digits = raw.replace(/\D/g, ""); + if (/^\d{4,}$/.test(digits)) refs.push(digits); + } }); - saved++; - // Firestore batch max 500 ops - if (saved % 450 === 0) { - await batch.commit(); + return Array.from(new Set(refs)); + }); + console.log(`🔎 Encontrados ${referenciasEnWeb.length} servicios válidos.`); + const referenciasNormalizadas = referenciasEnWeb + .map(normalizeServiceNumber) + .filter(Boolean); + const webSet = new Set(referenciasNormalizadas); + // ✅ precargar info del sistema + console.log("🧠 Precargando datos del sistema (appointments/services)..."); + const apptInfoMap = await preloadAppointmentsInfo(referenciasNormalizadas); + const servicesSet = await preloadServicesExistence(referenciasNormalizadas); + console.log(`🧾 En appointments: ${apptInfoMap.size} | En services: ${servicesSet.size}`); + // ✅ ids que ya estaban en homeserve_pendientes + console.log("📦 Cargando documentos actuales de homeserve_pendientes..."); + const pendingDocIds = await getAllPendingDocIds(); + const pendingSet = new Set(pendingDocIds); + // --- CONTADORES --- + let actualizados = 0; + let nuevos = 0; + let saltadosBloqueo = 0; + let saltadosSinDatos = 0; + let marcadosInSystem = 0; + let archivadosCompleted = 0; + let marcadosMissingNoArchive = 0; + for (const ref of referenciasEnWeb) { + const normalized = normalizeServiceNumber(ref); + if (!normalized) continue; + const appt = apptInfoMap.get(normalized); + const existsInAppointments = !!appt; + const existsInServices = servicesSet.has(normalized); + const existsInSystem = existsInAppointments || existsInServices; + // ✅ Si ya existe en sistema: + // - SI completed => archived + // - SI NO completed => in_system + // Y NO se archiva por “existir”, solo por completed. + if (existsInSystem && pendingSet.has(normalized)) { + const docRef = db.collection(COLLECTION_NAME).doc(normalized); + if (existsInAppointments && isCompletedStatus(appt.status)) { + await docRef.set({ + status: "archived", + archivedReason: "completed_in_system", + archivedAt: nowISO, + integratedIn: appt.isInboxPending ? "alta" : "calendar", + systemStatus: appt.status, + updatedAt: nowISO, + lastSeenAt: nowISO, + missingFromHomeServe: false + }, { merge: true }); + archivadosCompleted++; + console.log(`✅ ARCHIVED (completed): ${normalized}`); + } else { + await docRef.set({ + status: "in_system", + integratedIn: existsInAppointments ? (appt.isInboxPending ? "alta" : "calendar") : "services", + systemStatus: existsInAppointments ? (appt.status || "") : "", + updatedAt: nowISO, + lastSeenAt: nowISO, + missingFromHomeServe: false + }, { merge: true }); + marcadosInSystem++; + console.log(`🟧 IN_SYSTEM: ${normalized}`); + } + // No hace falta scrapear si ya existe el doc y ya está integrado + continue; + } + // Si existe en sistema pero NO existe en homeserve_pendientes (no lo tenías): + // lo tratamos como antes (scrape) y lo guardamos con status in_system (no archived). + // Si está completed, no creamos doc nuevo (no aporta mucho). + if (existsInSystem && !pendingSet.has(normalized) && existsInAppointments && isCompletedStatus(appt.status)) { + archivadosCompleted++; + console.log(`⏭️ SALTADO (completed y sin doc pendiente): ${normalized}`); + continue; + } + // Navegar a la lista y click + await page.goto('https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=lista_servicios_total'); + try { + await page.click(`text="${normalized}"`, { timeout: 5000 }); + await page.waitForTimeout(1500); + } catch (e) { + saltadosBloqueo++; + console.warn(`⛔ SALTADO (bloqueado/no accesible): ${normalized}`); + continue; + } + const detalles = await page.evaluate(() => { + const d = {}; + const filas = Array.from(document.querySelectorAll('tr')); + filas.forEach(tr => { + const celdas = tr.querySelectorAll('td'); + if (celdas.length >= 2) { + const clave = celdas[0].innerText.toUpperCase().trim(); + const valor = celdas[1].innerText.trim(); + if (clave.includes("TELEFONOS")) { + const match = valor.match(/[6789]\d{8}/); + d.phone = match ? match[0] : ""; + } + if (clave.includes("CLIENTE")) d.clientName = valor; + if (clave.includes("DOMICILIO")) d.addressPart = valor; + if (clave.includes("POBLACION")) d.cityPart = valor; + if (clave.includes("ACTUALMENTE EN")) d.status_homeserve = valor; + if (clave.includes("COMPAÑIA")) d.company = valor; + if (clave.includes("FECHA ASIGNACION")) d.dateString = valor; + if (clave.includes("COMENTARIOS")) d.description = valor; + } + }); + return d; + }); + if (!hasMinimumData(detalles)) { + saltadosSinDatos++; + console.warn(`⛔ SALTADO (sin datos mínimos): ${normalized}`); + continue; + } + const fullAddress = `${detalles.addressPart || ""} ${detalles.cityPart || ""}`.trim(); + let rawCompany = detalles.company || ""; + if (rawCompany && !rawCompany.toUpperCase().includes("HOMESERVE")) { + rawCompany = `HOMESERVE - ${rawCompany}`; + } + const docRef = db.collection(COLLECTION_NAME).doc(normalized); + const docSnapshot = await docRef.get(); + const datosAntiguos = docSnapshot.exists ? docSnapshot.data() : null; + // Estado según sistema (si existe, es in_system; si no, pendiente_validacion) + let status = "pendiente_validacion"; + let integratedIn = ""; + let systemStatus = ""; + if (existsInSystem) { + status = "in_system"; + if (existsInAppointments) { + integratedIn = appt.isInboxPending ? "alta" : "calendar"; + systemStatus = appt.status || ""; + } else { + integratedIn = "services"; + } + } + const servicioFinal = { + serviceNumber: normalized, + clientName: detalles.clientName || "Desconocido", + address: fullAddress, + phone: detalles.phone || "Sin teléfono", + description: detalles.description || "", + homeserveStatus: detalles.status_homeserve || "", + company: rawCompany || "", + dateString: detalles.dateString || "", + status, + integratedIn, + systemStatus, + lastSeenAt: nowISO, + updatedAt: nowISO, + missingFromHomeServe: false, + }; + if (!datosAntiguos) servicioFinal.createdAt = nowISO; + if (!datosAntiguos) { + await docRef.set(servicioFinal); + console.log(`NUEVO: ${normalized} (status=${status})`); + nuevos++; + } else { + const cambioEstado = (datosAntiguos.homeserveStatus || "") !== (servicioFinal.homeserveStatus || ""); + const cambioTelefono = (datosAntiguos.phone || "") !== (servicioFinal.phone || ""); + const cambioAddress = (datosAntiguos.address || "") !== (servicioFinal.address || ""); + const cambioCliente = (datosAntiguos.clientName || "") !== (servicioFinal.clientName || ""); + const cambioCompany = (datosAntiguos.company || "") !== (servicioFinal.company || ""); + const cambioStatus = (datosAntiguos.status || "") !== (servicioFinal.status || ""); + if (cambioEstado || cambioTelefono || cambioAddress || cambioCliente || cambioCompany || cambioStatus) { + console.log(`♻️ ACTUALIZADO: ${normalized}`); + await docRef.set(servicioFinal, { merge: true }); + actualizados++; + } else { + await docRef.set({ lastSeenAt: nowISO, updatedAt: nowISO, missingFromHomeServe: false }, { merge: true }); + } + // Si estaba archivado pero reaparece y NO está completed => lo “desarchivamos” + if ((datosAntiguos.status || "") === "archived") { + const shouldStayArchived = existsInAppointments && isCompletedStatus(appt?.status); + if (!shouldStayArchived) { + await docRef.set({ + status: status, + archivedAt: admin.firestore.FieldValue.delete(), + archivedReason: admin.firestore.FieldValue.delete(), + updatedAt: nowISO + }, { merge: true }); + } + } } } - await batch.commit(); - - console.log(`✅ Guardados ${saved} registros en Firestore (homeserve_pendientes)`); - - } catch (err) { - console.error("❌ Error en robot HomeServe:", err); - try { - await page.screenshot({ path: '/tmp/error-homeserve.png', fullPage: true }); - console.log("📸 Screenshot guardada en /tmp/error-homeserve.png"); - } catch (e) {} - process.exitCode = 1; + // ✅ Gestionar lo que ha desaparecido de HomeServe + // Regla NUEVA: NO archivar por desaparecer; SOLO archivar si completed. + console.log("🗄️ Revisando los que han desaparecido de HomeServe (sin archivar salvo completed)..."); + const missingIds = pendingDocIds + .map(normalizeServiceNumber) + .filter(Boolean) + .filter(sn => !webSet.has(sn)); + // precargamos su estado en sistema para decidir + const apptMissingMap = await preloadAppointmentsInfo(missingIds); + const servicesMissingSet = await preloadServicesExistence(missingIds); + for (const sn of missingIds) { + const ref = db.collection(COLLECTION_NAME).doc(sn); + const snap = await ref.get(); + const data = snap.exists ? (snap.data() || {}) : {}; + const appt = apptMissingMap.get(sn); + const existsInAppointments = !!appt; + const existsInServices = servicesMissingSet.has(sn); + const existsInSystem = existsInAppointments || existsInServices; + if (existsInAppointments && isCompletedStatus(appt.status)) { + // ✅ SOLO AQUÍ archivamos + if ((data.status || "") !== "archived") { + await ref.set({ + status: "archived", + archivedReason: "completed_in_system", + archivedAt: nowISO, + integratedIn: appt.isInboxPending ? "alta" : "calendar", + systemStatus: appt.status, + updatedAt: nowISO, + missingFromHomeServe: true, + missingAt: nowISO + }, { merge: true }); + archivadosCompleted++; + } + continue; + } + if (existsInSystem) { + // Está en tu sistema pero no está completed => en sistema, NO archived + await ref.set({ + status: "in_system", + integratedIn: existsInAppointments ? (appt.isInboxPending ? "alta" : "calendar") : "services", + systemStatus: existsInAppointments ? (appt.status || "") : "", + updatedAt: nowISO, + missingFromHomeServe: true, + missingAt: nowISO + }, { merge: true }); + marcadosInSystem++; + continue; + } + // No está en sistema y ha desaparecido de HomeServe -> NO archivar, solo marcar missing + await ref.set({ + missingFromHomeServe: true, + missingAt: nowISO, + updatedAt: nowISO + }, { merge: true }); + marcadosMissingNoArchive++; + } + console.log( + `🏁 FIN V7.5: ${nuevos} nuevos, ${actualizados} actualizados, ` + + `${saltadosBloqueo} saltados (bloqueados), ${saltadosSinDatos} saltados (sin datos), ` + + `${marcadosInSystem} marcados in_system, ${archivadosCompleted} archivados (completed), ` + + `${marcadosMissingNoArchive} marcados missing (NO archived).` + ); + } catch (error) { + console.error('❌ ERROR:', error.message); + process.exit(1); } finally { - await browser.close(); - console.log("🧹 Browser cerrado"); + if (browser) await browser.close(); + + // --- PAUSA DE 15 MINUTOS ANTES DE REINICIAR --- + const MINUTOS_ESPERA = 15; + console.log(`😴 Pausando ejecución durante ${MINUTOS_ESPERA} minutos...`); + + // 15 minutos * 60 segundos * 1000 milisegundos + await new Promise(resolve => setTimeout(resolve, MINUTOS_ESPERA * 60 * 1000)); + + console.log("👋 Tiempo cumplido. Reiniciando proceso..."); + process.exit(0); } } - -main(); \ No newline at end of file +runRobot(); \ No newline at end of file