// worker-multiasistencia.js (Versión PostgreSQL SaaS + Escáner de Campos Dinámicos) import { chromium } from 'playwright'; import pg from 'pg'; const { Pool } = pg; // --- CONFIGURACIÓN --- const CONFIG = { DATABASE_URL: process.env.DATABASE_URL, MULTI_LOGIN: "https://web.multiasistencia.com/w3multi/acceso.php", MULTI_ACTION_BASE: "https://web.multiasistencia.com/w3multi/fechaccion.php", NAV_TIMEOUT: 60000, POLL_INTERVAL_MS: 5000, DUPLICATE_TIME_MS: 3 * 60 * 1000 // 3 Minutos de memoria para ignorar duplicados }; if (!CONFIG.DATABASE_URL) { console.error("❌ ERROR FATAL: Falta la variable de entorno DATABASE_URL"); process.exit(1); } const pool = new Pool({ connectionString: CONFIG.DATABASE_URL, ssl: false }); // --- MEMORIA ANTI-DUPLICADOS --- const processedServicesCache = new Map(); // --- UTILS --- function timeToMultiValue(timeStr) { if (!timeStr) return ""; const [h, m] = timeStr.split(':').map(Number); return String((h * 3600) + (m * 60)); } function roundToNearest30(timeStr) { if (!timeStr) return null; let [h, m] = timeStr.split(':').map(Number); if (m < 15) { m = 0; } else if (m < 45) { m = 30; } else { m = 0; h = (h + 1) % 24; } return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; } function extractTimeFromText(text) { if (!text) return null; const match = text.match(/(\d{1,2}:\d{2})/); return match ? match[1] : null; } function normalizeDate(dateStr) { if (!dateStr) return ""; if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return dateStr; if (dateStr.includes('/')) { const [day, month, year] = dateStr.split('/'); return `${year}-${month}-${day}`; } return dateStr; } function getCurrentDateTime() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return { dateStr: `${year}-${month}-${day}`, hourStr: String(now.getHours()), minStr: String(now.getMinutes()).padStart(2, '0') }; } async function getMultiCreds(ownerId) { const q = await pool.query( "SELECT username, password_hash FROM provider_credentials WHERE provider = 'multiasistencia' AND status = 'active' AND owner_id = $1 LIMIT 1", [ownerId] ); if (q.rowCount === 0) throw new Error(`Sin credenciales para ID: ${ownerId}`); return { user: q.rows[0].username, pass: Buffer.from(q.rows[0].password_hash, 'base64').toString('utf-8') }; } // --- PLAYWRIGHT SETUP --- async function withBrowser(fn) { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); const context = await browser.newContext(); const page = await context.newPage(); try { return await fn(page); } finally { await browser.close().catch(() => {}); } } async function forceUpdate(elementHandle) { if (elementHandle) { await elementHandle.evaluate(el => { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('blur', { bubbles: true })); }); } } // --- LOGIN MULTIASISTENCIA --- async function loginMulti(page, creds) { console.log(` [1] Login en Multiasistencia (${creds.user})...`); await page.goto(CONFIG.MULTI_LOGIN, { timeout: CONFIG.NAV_TIMEOUT, waitUntil: 'domcontentloaded' }); const userFilled = await page.evaluate((u) => { const el = document.querySelector('input[name="usuario"]'); if (el) { el.value = u; el.dispatchEvent(new Event('input', { bubbles: true })); return true; } return false; }, creds.user); if (!userFilled) await page.fill('input[name="usuario"]', creds.user); await page.fill('input[type="password"]', creds.pass); await page.click('input[type="submit"]'); await page.waitForTimeout(4000); const isStillLogin = await page.locator('input[type="password"]').count(); if (isStillLogin > 0) throw new Error("Credenciales rechazadas por Multiasistencia."); } // --- PROCESO PRINCIPAL --- async function processChangeState(page, creds, jobData) { const serviceNumber = jobData.service_number; const reasonValue = jobData.new_status; const comment = jobData.observation; const dateStr = normalizeDate(jobData.appointment_date); let rawTime = jobData.appointment_time || extractTimeFromText(comment); let timeStr = roundToNearest30(rawTime); console.log(` [DEBUG] Web ID "${reasonValue}" | Fecha: "${dateStr}" | Hora: "${timeStr}"`); if (!reasonValue) throw new Error("No hay código de estado válido."); await loginMulti(page, creds); console.log(` [2] Abriendo servicio ${serviceNumber}...`); const targetUrl = `${CONFIG.MULTI_ACTION_BASE}?reparacion=${serviceNumber}&modo=0&navid=%2Fw3multi%2Ffrepasos_new.php%FDGET%FDrefresh%3D1%FC`; await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT }); await page.waitForSelector('select.answer-select', { timeout: 20000 }); await page.waitForTimeout(1000); // 1. SELECCIONAR MOTIVO console.log(` [3] Seleccionando estado ${reasonValue}...`); const reasonSel = page.locator('select.answer-select').first(); const options = await reasonSel.evaluate(s => Array.from(s.options).map(o => o.value)); if (!options.includes(String(reasonValue))) { throw new Error(`El estado "${reasonValue}" no existe en el desplegable actual del servicio.`); } await reasonSel.selectOption(String(reasonValue)); await forceUpdate(await reasonSel.elementHandle()); // 2. COMENTARIO if (comment) { const commentBox = page.locator('textarea[formcontrolname="comment"]'); await commentBox.fill(comment); await forceUpdate(await commentBox.elementHandle()); } // 3. FECHA SIGUIENTE ACCIÓN (TXTFACCION) - La de la cita real if (dateStr) { const actionBlock = page.locator('encastrables-date-hour-field[label="TXTFACCION"]'); if (await actionBlock.count() > 0) { const dateInput = actionBlock.locator('input[type="date"]'); await dateInput.fill(dateStr); await forceUpdate(await dateInput.elementHandle()); await page.locator('body').click(); if (timeStr) { const seconds = timeToMultiValue(timeStr); const timeSel = actionBlock.locator('select.answer-select'); await timeSel.selectOption(seconds).catch(()=>{}); await forceUpdate(await timeSel.elementHandle()); } } else { // Fallback genérico por si cambia la etiqueta const genDate = page.locator('input[type="date"]').first(); if (await genDate.count() > 0) { await genDate.fill(dateStr); await forceUpdate(await genDate.elementHandle()); } } } // 4. AUTO-RELLENADOR INTELIGENTE (Detecta campos obligatorios dinámicos) const extraDynamicFields = ['TXTFCONTACTO', 'TXTFPRIMERAV']; for (const label of extraDynamicFields) { const block = page.locator(`encastrables-date-hour-field[label="${label}"]`); if (await block.count() > 0 && await block.isVisible()) { console.log(` [INFO] Detectado campo obligatorio "${label}". Auto-rellenando...`); const now = getCurrentDateTime(); // Rellenar Fecha const cDate = block.locator('input[type="date"]'); await cDate.fill(now.dateStr); await forceUpdate(await cDate.elementHandle()); // Rellenar Hora y Minutos (Multiasistencia usa 2 selects separados para esto) const selects = block.locator('select.answer-select-time'); if (await selects.count() >= 2) { await selects.nth(0).selectOption(now.hourStr).catch(()=>{}); await forceUpdate(await selects.nth(0).elementHandle()); await selects.nth(1).selectOption(now.minStr).catch(()=>{}); await forceUpdate(await selects.nth(1).elementHandle()); } } } await page.waitForTimeout(2000); // 5. GUARDAR (CON INTELIGENCIA ANTI-BLOQUEO) const btn = page.locator('button.form-container-button-submit'); if (await btn.isDisabled()) { console.log(' [INFO] Botón bloqueado. Forzando actualización de inputs...'); await page.locator('textarea[formcontrolname="comment"]').click(); await page.keyboard.press('Tab'); await page.waitForTimeout(1000); if (await btn.isDisabled()) throw new Error(`El formulario está bloqueado. Multiasistencia pide más datos de los previstos.`); } console.log(' [4] Guardando cambios en Multiasistencia...'); await btn.click(); // GESTIÓN DE ALERTAS (Popups de confirmación que a veces lanza la web) await page.waitForTimeout(3000); const confirmBtn = page.locator('button.form-container-button-submit-toast').filter({ hasText: 'Sí' }); if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) { await confirmBtn.click(); await page.waitForTimeout(3000); } // RESULTADO const finalResult = await page.evaluate(() => { const successEl = document.querySelector('.form-container-success, .bg-success'); const errorEl = document.querySelector('.form-container-error, .bg-danger'); if (successEl) return { type: 'OK', text: successEl.innerText.trim() }; if (errorEl) return { type: 'ERROR', text: errorEl.innerText.trim() }; const body = document.body.innerText; if (body.includes('correctamente') || body.includes('guardado')) return { type: 'OK', text: "Guardado correctamente." }; return { type: 'UNKNOWN', text: "No se detectó mensaje explícito de éxito." }; }); if (finalResult.type === 'ERROR') { throw new Error(`Rechazado por Multiasistencia: ${finalResult.text}`); } console.log(` >>> Web dice: ${finalResult.text}`); return { success: true }; } // --- EL CEREBRO: LECTURA DE LA COLA EN POSTGRESQL --- async function pollQueue() { try { // 1. Limpieza de memoria caché antigua const now = Date.now(); for (const [key, timestamp] of processedServicesCache.entries()) { if (now - timestamp > CONFIG.DUPLICATE_TIME_MS) { processedServicesCache.delete(key); } } // 2. Extraer un trabajo de Multiasistencia const res = await pool.query(` UPDATE robot_queue SET status = 'RUNNING', updated_at = NOW() WHERE id = ( SELECT id FROM robot_queue WHERE status = 'PENDING' AND provider = 'multiasistencia' ORDER BY created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING *; `); if (res.rowCount > 0) { const job = res.rows[0]; console.log(`\n========================================`); console.log(`🤖 TRABAJO #${job.id} | Empresa ID: ${job.owner_id}`); console.log(`📋 Siniestro: ${job.service_number} (Multi) -> Estado: ${job.new_status}`); console.log(`========================================`); // FILTRO MEMORIA ANTI-DUPLICADOS (3 MINUTOS) const lastTime = processedServicesCache.get(job.service_number); if (lastTime && (now - lastTime < CONFIG.DUPLICATE_TIME_MS)) { console.log(`🚫 DUPLICADO: Servicio ${job.service_number} gestionado hace menos de 3 min. Ignorando.`); await pool.query("UPDATE robot_queue SET status = 'DONE', error_msg = 'Ignorado por duplicado' WHERE id = $1", [job.id]); setTimeout(pollQueue, 1000); return; } try { const creds = await getMultiCreds(job.owner_id); await withBrowser(async (page) => { await processChangeState(page, creds, job); }); // Registrar éxito y añadir a memoria processedServicesCache.set(job.service_number, Date.now()); await pool.query("UPDATE robot_queue SET status = 'DONE', updated_at = NOW() WHERE id = $1", [job.id]); console.log(`✅ TRABAJO #${job.id} COMPLETADO CON ÉXITO.\n`); } catch (err) { console.error(`❌ ERROR EN TRABAJO #${job.id}:`, err.message); await pool.query("UPDATE robot_queue SET status = 'FAILED', error_msg = $1, updated_at = NOW() WHERE id = $2", [err.message, job.id]); } setTimeout(pollQueue, 1000); } else { setTimeout(pollQueue, CONFIG.POLL_INTERVAL_MS); } } catch (e) { console.error("Error crítico en el bucle del robot:", e.message); setTimeout(pollQueue, CONFIG.POLL_INTERVAL_MS); } } // --- INICIO --- console.log("🚀 Robot Multiasistencia (SaaS) Iniciado."); console.log("📡 Conectado a PostgreSQL. Esperando peticiones..."); pollQueue();