Añadir worker-multiasistencia.js
This commit is contained in:
320
worker-multiasistencia.js
Normal file
320
worker-multiasistencia.js
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user