From 8f17d5796d92611721b194d96dcba97128f702da Mon Sep 17 00:00:00 2001 From: marsalva Date: Wed, 11 Mar 2026 07:47:47 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20worker-multiasistencia.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worker-multiasistencia.js | 320 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 worker-multiasistencia.js diff --git a/worker-multiasistencia.js b/worker-multiasistencia.js new file mode 100644 index 0000000..56a067d --- /dev/null +++ b/worker-multiasistencia.js @@ -0,0 +1,320 @@ +// 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(); \ No newline at end of file