// worker-multiasistencia.js (Versión PostgreSQL SaaS) 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); // Verificación básica de login (Si sigue estando el input de password, falló) 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; // Asumimos que la API ya le pasa el ID numérico web 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); // 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()); // COMENTARIO if (comment) { const commentBox = page.locator('textarea[formcontrolname="comment"]'); await commentBox.fill(comment); await forceUpdate(await commentBox.elementHandle()); } // FECHA SIGUIENTE ACCIÓN (Solo si existe fecha y es válida) 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(()=>{ console.log(' ⚠️ No se pudo poner la hora exacta') }); await forceUpdate(await timeSel.elementHandle()); } } else { // Fallback const genDate = page.locator('input[type="date"]').first(); await genDate.fill(dateStr); await forceUpdate(await genDate.elementHandle()); } } // FECHA CONTACTO (AUTOMÁTICA - HOY) const contactBlock = page.locator('encastrables-date-hour-field[label="TXTFCONTACTO"]'); if (await contactBlock.count() > 0 && await contactBlock.isVisible()) { console.log(' [INFO] Rellenando fecha de contacto (Auto Hoy)...'); const now = getCurrentDateTime(); const cDate = contactBlock.locator('input[type="date"]'); await cDate.fill(now.dateStr); await forceUpdate(await cDate.elementHandle()); const selects = contactBlock.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); // 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 (falta algún dato obligatorio).`); } console.log(' [4] Guardando cambios en Multiasistencia...'); await btn.click(); // GESTIÓN DE ALERTAS (Popups de confirmación) 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();