Files
api/worker-multiasistencia.js

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();