diff --git a/robot.js b/robot.js index a2a6711..c856878 100644 --- a/robot.js +++ b/robot.js @@ -124,6 +124,34 @@ async function main() { } } +// 🔍 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) // ========================================== @@ -152,14 +180,30 @@ async function runMultiasistencia(ownerId, user, pass, gremiosDB) { await gotoWithRetry(page, 'https://web.multiasistencia.com/w3multi/frepasos_new.php?refresh=1'); await page.waitForTimeout(3000); - console.log("🔄 [Multi] Forzando recarga segura mediante script interno..."); + // 🛡️ 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 page.evaluate(() => { - if (typeof refrescar === 'function') refrescar(); - }); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(3000); - } catch (e) {} + 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(); @@ -200,7 +244,9 @@ async function runMultiasistencia(ownerId, user, pass, gremiosDB) { console.log(`✅ [Multi] Total expedientes detectados: ${expedientes.length}`); if (expedientes.length > 0) { - await syncAndArchive(ownerId, 'multiasistencia', expedientes); + await syncAndArchive(ownerId, 'multiasistencia', expedientes, { + verificarRealmenteCerrado: (ref) => verificarCierreRealMulti(page, ref) + }); } for (const ref of expedientes) { @@ -419,7 +465,8 @@ async function runHomeserve(ownerId, user, pass, gremiosDB) { finally { await browser.close(); } } -async function syncAndArchive(ownerId, provider, currentWebRefs) { +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) @@ -468,18 +515,34 @@ async function syncAndArchive(ownerId, provider, currentWebRefs) { isFinal = checkStatusQ.rowCount > 0 ? checkStatusQ.rows[0].is_final : false; } - // Si NO estaba finalizado, procedemos a ANULARLO automáticamente + // 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) { - 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).`); + 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).`); + } } } } @@ -597,15 +660,22 @@ async function saveServiceToDB(ownerId, provider, ref, data) { // 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; - // 👋 NUEVO: 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). + // 👋 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 FROM scraped_services WHERE owner_id=$1 AND provider=$2 AND service_ref=$3", + "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); } @@ -615,20 +685,43 @@ async function saveServiceToDB(ownerId, provider, ref, data) { 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 - 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')), + -- 🛡️ 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 - WHEN scraped_services.status = 'archived' THEN 'archived' + -- 🔓 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]); - // 👋 NUEVO: Si es la primera vez que vemos este expediente, disparamos el WhatsApp de + // 🔓 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;