329 lines
13 KiB
JavaScript
329 lines
13 KiB
JavaScript
// 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(); |