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).
This commit is contained in:
2026-09-06 18:50:21 +00:00
parent c15a0fc41b
commit d5d97f0349

145
robot.js
View File

@@ -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) // 🏥 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 gotoWithRetry(page, 'https://web.multiasistencia.com/w3multi/frepasos_new.php?refresh=1');
await page.waitForTimeout(3000); 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 { try {
await page.evaluate(() => { await Promise.all([
if (typeof refrescar === 'function') refrescar(); page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }),
}); page.evaluate(() => {
await page.waitForLoadState('networkidle'); if (typeof refrescar === 'function') refrescar();
await page.waitForTimeout(3000); else throw new Error('No existe la función refrescar() en esta página (¿ha cambiado la web?).');
} catch (e) {} })
]);
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 --- // --- BUCLE DE PAGINACIÓN ---
let todosExpedientes = new Set(); let todosExpedientes = new Set();
@@ -200,7 +244,9 @@ async function runMultiasistencia(ownerId, user, pass, gremiosDB) {
console.log(`✅ [Multi] Total expedientes detectados: ${expedientes.length}`); console.log(`✅ [Multi] Total expedientes detectados: ${expedientes.length}`);
if (expedientes.length > 0) { if (expedientes.length > 0) {
await syncAndArchive(ownerId, 'multiasistencia', expedientes); await syncAndArchive(ownerId, 'multiasistencia', expedientes, {
verificarRealmenteCerrado: (ref) => verificarCierreRealMulti(page, ref)
});
} }
for (const ref of expedientes) { for (const ref of expedientes) {
@@ -419,7 +465,8 @@ async function runHomeserve(ownerId, user, pass, gremiosDB) {
finally { await browser.close(); } 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(); const client = await pool.connect();
try { try {
// 1. Buscamos TODOS los expedientes que están vivos (en el buzón o en el panel) // 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; 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) { if (!isFinal) {
raw.status_operativo = idAnulado; 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).`);
}
}
await client.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(raw), svc.id]); 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
// Dejamos huella en la trazabilidad para que sepas por qué se anuló await client.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(raw), svc.id]);
await client.query(
"INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)", // Dejamos huella en la trazabilidad para que sepas por qué se anuló
[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."] await client.query(
); "INSERT INTO scraped_service_logs (scraped_id, user_name, action, details) VALUES ($1, $2, $3, $4)",
console.log(`🛡️ [ESCUDO] Expediente ${svc.service_ref} anulado automáticamente (Técnico salvado de viajar en balde).`); [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 // 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; 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, // 👋 Comprobamos ANTES de guardar si este expediente ya existía en el buzón, para saber si
// para saber si hay que disparar el WhatsApp de bienvenida (solo la primera vez que aparece). // 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 isNewRecord = false;
let wasAutoAnulado = false;
try { try {
const preCheck = await pool.query( 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] [ownerId, provider, ref]
); );
isNewRecord = preCheck.rowCount === 0; 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) { } catch (e) {
console.error(`⚠️ [Bienvenida] No se pudo comprobar si ${ref} es nuevo:`, e.message); 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) VALUES ($1, $2, $3, $4, 'pending', $5)
ON CONFLICT (owner_id, provider, service_ref) ON CONFLICT (owner_id, provider, service_ref)
DO UPDATE SET DO UPDATE SET
-- 🛡️ EL ESCUDO: Fusiona todo, pero si ya había un gremio guardado, lo blinda para que el robot no lo pise -- 🛡️ 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) || -- 🔓 REAPERTURA: si el estado "Anulado" lo puso el propio robot (auto_anulado) y el expediente
jsonb_build_object('guild_id', COALESCE(scraped_services.raw_data->'guild_id', EXCLUDED.raw_data->'guild_id')), -- 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, is_urgent = EXCLUDED.is_urgent,
status = CASE 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' WHEN scraped_services.status = 'imported' THEN 'imported'
ELSE 'pending' ELSE 'pending'
END END
RETURNING id RETURNING id
`, [ownerId, provider, ref, JSON.stringify(data), isUrgent]); `, [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). // bienvenida en segundo plano (no bloquea ni puede romper el guardado de arriba).
if (isNewRecord && result.rows[0]) { if (isNewRecord && result.rows[0]) {
const newSvcId = result.rows[0].id; const newSvcId = result.rows[0].id;