Actualizar index.js
This commit is contained in:
parent
be412bf018
commit
7804b1dff2
750
index.js
750
index.js
|
|
@ -1,59 +1,39 @@
|
|||
// worker-homeserve.js
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* estados-hs-direct/index.js
|
||||
* - Endpoint directo: POST /api/homeserve/change-status
|
||||
* - Healthchecks: GET / , GET /health
|
||||
* - Test UI: GET /test
|
||||
*
|
||||
* Credenciales HomeServe:
|
||||
* 1) ENV HOMESERVE_USER/HOMESERVE_PASS (+ HOMESERVE_BASE_URL opcional)
|
||||
* 2) Firestore doc en HS_CRED_DOC_PATH (ej: providerCredentials/homeserve)
|
||||
* 3) Fallbacks: providerCredentials/homeserve -> secrets/homeserve
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { chromium } = require('playwright');
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
// --- CONFIGURACIÓN ---
|
||||
const CONFIG = {
|
||||
// Firestore Collections
|
||||
QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado',
|
||||
RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log',
|
||||
|
||||
// Credenciales: Busca en ENV primero, luego en Firestore
|
||||
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
|
||||
|
||||
// URL Base
|
||||
CLIENTES_CGI_BASE: process.env.CLIENTES_CGI_BASE || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe',
|
||||
|
||||
// --------------------- Utils ---------------------
|
||||
// Timeouts (Ms)
|
||||
NAV_TIMEOUT: 120000,
|
||||
SEL_TIMEOUT: 60000,
|
||||
|
||||
// Worker Settings
|
||||
CLAIM_TTL_MINUTES: 10, // Tiempo antes de robarle la tarea a un worker muerto
|
||||
RESCAN_SECONDS: 60 // Cada cuánto mirar si se nos escapó algo
|
||||
};
|
||||
|
||||
// --- UTILS BÁSICOS ---
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
function nowISO() { return new Date().toISOString(); }
|
||||
function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); }
|
||||
function mustEnv(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`Missing env: ${name}`);
|
||||
if (!v) throw new Error(`Falta variable de entorno: ${name}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function envBool(name, def = false) {
|
||||
const v = (process.env[name] ?? '').toString().trim().toLowerCase();
|
||||
if (!v) return def;
|
||||
return ['1', 'true', 'yes', 'y', 'on'].includes(v);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function nowISO() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function safeStr(x) {
|
||||
return (x === undefined || x === null) ? '' : String(x);
|
||||
}
|
||||
|
||||
function normalizeDocPath(p) {
|
||||
// esperamos "collection/doc"
|
||||
if (!p) return null;
|
||||
const t = String(p).trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!t) return null;
|
||||
const parts = t.split('/').filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return { col: parts[0], doc: parts[1] };
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(...vals) {
|
||||
for (const v of vals) {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
|
||||
|
|
@ -61,7 +41,7 @@ function pickFirstNonEmpty(...vals) {
|
|||
return '';
|
||||
}
|
||||
|
||||
// --------------------- Firebase ---------------------
|
||||
// --- FIREBASE INIT ---
|
||||
function initFirebase() {
|
||||
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
||||
if (!admin.apps.length) {
|
||||
|
|
@ -76,94 +56,33 @@ function initFirebase() {
|
|||
return admin.firestore();
|
||||
}
|
||||
|
||||
// --------------------- Config ---------------------
|
||||
const CFG = {
|
||||
REQUIRE_AUTH: envBool('REQUIRE_AUTH', false),
|
||||
|
||||
// Si quieres, puedes dejarlo vacío y se usará providerCredentials/homeserve por defecto
|
||||
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || process.env.HS_CRED_DOC || process.env.HS_CRED_PATH || '',
|
||||
|
||||
// Puerto
|
||||
PORT: Number(process.env.PORT || process.env.CAPROVER_PORT || 3000),
|
||||
|
||||
// Base “clientes” por defecto (tu enlace es CGI)
|
||||
CLIENTES_CGI_BASE: process.env.CLIENTES_CGI_BASE || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe',
|
||||
|
||||
// Timeouts
|
||||
NAV_TIMEOUT: Number(process.env.NAV_TIMEOUT || 120000),
|
||||
SEL_TIMEOUT: Number(process.env.SEL_TIMEOUT || 60000),
|
||||
};
|
||||
|
||||
function maskCreds(obj) {
|
||||
return {
|
||||
...obj,
|
||||
pass: obj.pass ? '***' : '',
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------- Credenciales ---------------------
|
||||
// --- CREDENCIALES HOMESERVE ---
|
||||
async function getHomeServeCreds(db) {
|
||||
// 1) ENV
|
||||
const envUser = pickFirstNonEmpty(process.env.HOMESERVE_USER, process.env.HS_USER);
|
||||
const envPass = pickFirstNonEmpty(process.env.HOMESERVE_PASS, process.env.HS_PASS);
|
||||
const envBaseUrl = pickFirstNonEmpty(process.env.HOMESERVE_BASE_URL, process.env.HS_BASE_URL);
|
||||
// 1. Intentar ENV
|
||||
const envUser = pickFirstNonEmpty(process.env.HOMESERVE_USER);
|
||||
const envPass = pickFirstNonEmpty(process.env.HOMESERVE_PASS);
|
||||
if (envUser && envPass) return { user: envUser, pass: envPass };
|
||||
|
||||
if (envUser && envPass) {
|
||||
return {
|
||||
user: envUser,
|
||||
pass: envPass,
|
||||
baseUrl: envBaseUrl || CFG.CLIENTES_CGI_BASE,
|
||||
cgiBase: CFG.CLIENTES_CGI_BASE,
|
||||
source: 'env',
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Firestore (intenta varios paths)
|
||||
const candidates = [];
|
||||
const p = normalizeDocPath(CFG.HS_CRED_DOC_PATH);
|
||||
if (p) candidates.push(`${p.col}/${p.doc}`);
|
||||
|
||||
// Fallbacks IMPORTANTES (tu caso)
|
||||
candidates.push('providerCredentials/homeserve');
|
||||
candidates.push('secrets/homeserve');
|
||||
|
||||
const tried = new Set();
|
||||
for (const path of candidates) {
|
||||
if (!path || tried.has(path)) continue;
|
||||
tried.add(path);
|
||||
|
||||
const dp = normalizeDocPath(path);
|
||||
if (!dp) continue;
|
||||
|
||||
const snap = await db.collection(dp.col).doc(dp.doc).get();
|
||||
if (!snap.exists) continue;
|
||||
|
||||
const d = snap.data() || {};
|
||||
|
||||
const user = pickFirstNonEmpty(d.user, d.username, d.usuario);
|
||||
const pass = pickFirstNonEmpty(d.pass, d.password, d.clave);
|
||||
const baseUrl = pickFirstNonEmpty(d.baseUrl, d.url, d.loginUrl, envBaseUrl);
|
||||
|
||||
if (user && pass) {
|
||||
return {
|
||||
user,
|
||||
pass,
|
||||
baseUrl: baseUrl || CFG.CLIENTES_CGI_BASE,
|
||||
cgiBase: CFG.CLIENTES_CGI_BASE,
|
||||
source: `firestore:${path}`,
|
||||
};
|
||||
// 2. Intentar Firestore
|
||||
const path = CONFIG.HS_CRED_DOC_PATH;
|
||||
const parts = path.split('/');
|
||||
if (parts.length === 2) {
|
||||
const snap = await db.collection(parts[0]).doc(parts[1]).get();
|
||||
if (snap.exists) {
|
||||
const d = snap.data();
|
||||
const user = pickFirstNonEmpty(d.user, d.username, d.usuario);
|
||||
const pass = pickFirstNonEmpty(d.pass, d.password, d.clave);
|
||||
if (user && pass) return { user, pass };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'HomeServe creds missing. Busca en ENV (HOMESERVE_USER/HOMESERVE_PASS) o crea/usa el doc Firestore providerCredentials/homeserve con { user, pass, baseUrl? }.'
|
||||
);
|
||||
|
||||
throw new Error('No se encontraron credenciales de HomeServe (ni en ENV ni en Firestore).');
|
||||
}
|
||||
|
||||
// --------------------- Playwright helpers ---------------------
|
||||
// --- PLAYWRIGHT HELPERS (LA MAGIA DEL CÓDIGO B) ---
|
||||
async function withBrowser(fn) {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
headless: true, // Pon false si quieres ver lo que hace en local
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
|
@ -175,12 +94,9 @@ async function withBrowser(fn) {
|
|||
}
|
||||
}
|
||||
|
||||
function allFrames(page) {
|
||||
return page.frames();
|
||||
}
|
||||
|
||||
// Busca en todos los iframes (vital para HomeServe)
|
||||
async function findLocatorInFrames(page, selector) {
|
||||
for (const fr of allFrames(page)) {
|
||||
for (const fr of page.frames()) {
|
||||
const loc = fr.locator(selector);
|
||||
try {
|
||||
if (await loc.count()) return { frame: fr, locator: loc };
|
||||
|
|
@ -189,6 +105,7 @@ async function findLocatorInFrames(page, selector) {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Intenta clickar el primero que encuentre de una lista
|
||||
async function clickFirstThatExists(page, selectors, opts = {}) {
|
||||
for (const sel of selectors) {
|
||||
const hit = await findLocatorInFrames(page, sel);
|
||||
|
|
@ -200,448 +117,269 @@ async function clickFirstThatExists(page, selectors, opts = {}) {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Intenta llenar texto en el primero que encuentre
|
||||
async function fillFirstThatExists(page, selectors, value) {
|
||||
for (const sel of selectors) {
|
||||
const hit = await findLocatorInFrames(page, sel);
|
||||
if (hit) {
|
||||
await hit.locator.first().fill(value);
|
||||
await hit.locator.first().fill(String(value));
|
||||
return sel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lógica inteligente para checkboxes
|
||||
async function checkInformoClienteIfNeeded(page, enabled) {
|
||||
if (!enabled) return false;
|
||||
|
||||
// Intento 1: label explícito
|
||||
const labelTextVariants = [
|
||||
'ya ha informado al Cliente',
|
||||
'ya ha informado al cliente',
|
||||
'informado al Cliente',
|
||||
'informado al cliente',
|
||||
'Marque esta casilla',
|
||||
'marque esta casilla',
|
||||
];
|
||||
|
||||
for (const txt of labelTextVariants) {
|
||||
const sel = `label:has-text("${txt}") >> input[type="checkbox"]`;
|
||||
const hit = await findLocatorInFrames(page, sel);
|
||||
if (hit) {
|
||||
const cb = hit.locator.first();
|
||||
if (!(await cb.isChecked())) await cb.check();
|
||||
return true;
|
||||
if (!enabled) return;
|
||||
const labels = ['informado al cliente', 'informado al Cliente', 'Marque esta casilla'];
|
||||
|
||||
// 1. Buscar por label directa
|
||||
for (const txt of labels) {
|
||||
const hit = await findLocatorInFrames(page, `label:has-text("${txt}") >> input[type="checkbox"]`);
|
||||
if (hit && !(await hit.locator.first().isChecked())) {
|
||||
await hit.locator.first().check();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Intento 2: búsqueda en DOM (checkbox cerca de texto)
|
||||
const done = await page.evaluate((variants) => {
|
||||
const norm = (s) => (s || '').toLowerCase();
|
||||
const labels = Array.from(document.querySelectorAll('label'));
|
||||
for (const l of labels) {
|
||||
const t = norm(l.textContent);
|
||||
if (!variants.some(v => t.includes(norm(v)))) continue;
|
||||
const cb = l.querySelector('input[type="checkbox"]') || document.getElementById(l.getAttribute('for') || '');
|
||||
if (cb && cb.type === 'checkbox') {
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: checkbox con texto al lado (layout antiguo)
|
||||
const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]'));
|
||||
for (const cb of cbs) {
|
||||
const parentText = norm(cb.parentElement ? cb.parentElement.textContent : '');
|
||||
if (variants.some(v => parentText.includes(norm(v)))) {
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, labelTextVariants);
|
||||
|
||||
return !!done;
|
||||
}
|
||||
|
||||
// Lógica inteligente para Dropdowns (Selects)
|
||||
async function selectStatusByCode(page, code) {
|
||||
// preferimos un <select> donde exista una option con value=code o texto que contenga code
|
||||
const ok = await page.evaluate((code) => {
|
||||
const ok = await page.evaluate((codeStr) => {
|
||||
const selects = Array.from(document.querySelectorAll('select'));
|
||||
const norm = (s) => (s || '').toString().trim();
|
||||
|
||||
// 1) option.value === code
|
||||
// 1. Buscar por value exacto
|
||||
for (const s of selects) {
|
||||
const opt = Array.from(s.options || []).find(o => norm(o.value) === norm(code));
|
||||
const opt = Array.from(s.options).find(o => o.value.trim() === codeStr);
|
||||
if (opt) {
|
||||
s.value = opt.value;
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) option.text contiene code
|
||||
// 2. Buscar por texto
|
||||
for (const s of selects) {
|
||||
const opt = Array.from(s.options || []).find(o => norm(o.textContent).includes(norm(code)));
|
||||
const opt = Array.from(s.options).find(o => o.textContent.includes(codeStr));
|
||||
if (opt) {
|
||||
s.value = opt.value;
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, String(code));
|
||||
|
||||
if (!ok) throw new Error(`No encuentro el desplegable de estado o la opción para el código ${code}.`);
|
||||
if (!ok) throw new Error(`No se encontró la opción de estado: ${code} en ningún desplegable.`);
|
||||
}
|
||||
|
||||
async function loginClientesPortal(page, creds) {
|
||||
// Abrimos CGI base
|
||||
await page.goto(creds.baseUrl || CFG.CLIENTES_CGI_BASE, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT });
|
||||
// --- ACCIONES DE NEGOCIO ---
|
||||
|
||||
// Login “genérico”: primer input text + primer password
|
||||
const userSel = [
|
||||
'input[name*="user" i]',
|
||||
'input[name*="usuario" i]',
|
||||
'input[type="text"]',
|
||||
];
|
||||
const passSel = [
|
||||
'input[name*="pass" i]',
|
||||
'input[name*="clave" i]',
|
||||
'input[type="password"]',
|
||||
];
|
||||
const submitSel = [
|
||||
'button[type="submit"]',
|
||||
'input[type="submit"]',
|
||||
'input[type="image"]',
|
||||
'button:has-text("Entrar")',
|
||||
'button:has-text("Acceder")',
|
||||
'button:has-text("Login")',
|
||||
];
|
||||
async function loginAndProcess(page, creds, jobData) {
|
||||
const { serviceNumber, newStatusValue, dateString, observation, informoCliente } = jobData;
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const u = await fillFirstThatExists(page, userSel, creds.user);
|
||||
const p = await fillFirstThatExists(page, passSel, creds.pass);
|
||||
|
||||
if (!u || !p) {
|
||||
// puede que ya esté logueado o sea otra pantalla
|
||||
// no abortamos aquí; dejamos que el siguiente goto confirme si hay sesión
|
||||
return;
|
||||
// 1. LOGIN
|
||||
await page.goto(CONFIG.CLIENTES_CGI_BASE, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT });
|
||||
|
||||
const u = await fillFirstThatExists(page, ['input[name*="user" i]', 'input[type="text"]'], creds.user);
|
||||
const p = await fillFirstThatExists(page, ['input[name*="pass" i]', 'input[type="password"]'], creds.pass);
|
||||
|
||||
if (u && p) {
|
||||
const clicked = await clickFirstThatExists(page, ['button[type="submit"]', 'input[type="submit"]', 'input[type="image"]']);
|
||||
if (!clicked) await page.keyboard.press('Enter');
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const clicked = await clickFirstThatExists(page, submitSel);
|
||||
if (!clicked) {
|
||||
// intentamos Enter
|
||||
await page.keyboard.press('Enter').catch(() => {});
|
||||
// 2. IR AL SERVICIO DIRECTAMENTE
|
||||
const serviceUrl = new URL(CONFIG.CLIENTES_CGI_BASE);
|
||||
serviceUrl.searchParams.set('w3exec', 'ver_servicioencurso');
|
||||
serviceUrl.searchParams.set('Servicio', String(serviceNumber));
|
||||
serviceUrl.searchParams.set('Pag', '1');
|
||||
|
||||
await page.goto(serviceUrl.toString(), { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT });
|
||||
await sleep(1000);
|
||||
|
||||
// 3. CLICK EN "CAMBIAR ESTADO" (REPASO)
|
||||
const changeBtn = await clickFirstThatExists(page, [
|
||||
'input[name="repaso"]',
|
||||
'input[title*="Cambiar el Estado" i]',
|
||||
'input[src*="estado1.gif" i]'
|
||||
]);
|
||||
if (!changeBtn) throw new Error('No se encontró el botón de cambiar estado (repaso). ¿Login fallido?');
|
||||
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await sleep(1000);
|
||||
|
||||
// 4. RELLENAR FORMULARIO
|
||||
// Estado
|
||||
await selectStatusByCode(page, newStatusValue);
|
||||
|
||||
// Fecha (si aplica)
|
||||
if (dateString) {
|
||||
await fillFirstThatExists(page, ['input[name*="fecha" i]', 'input[id*="fecha" i]', 'input[size="10"]'], dateString);
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {});
|
||||
}
|
||||
// Nota
|
||||
if (observation) {
|
||||
await fillFirstThatExists(page, ['textarea[name*="obs" i]', 'textarea[name*="nota" i]', 'textarea'], observation);
|
||||
}
|
||||
|
||||
function buildServiceUrl(serviceNumber) {
|
||||
// Tu URL exacta:
|
||||
// https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=15251178&Pag=1
|
||||
const base = CFG.CLIENTES_CGI_BASE;
|
||||
const u = new URL(base);
|
||||
u.searchParams.set('w3exec', 'ver_servicioencurso');
|
||||
u.searchParams.set('Servicio', String(serviceNumber));
|
||||
u.searchParams.set('Pag', '1');
|
||||
return u.toString();
|
||||
}
|
||||
// Checkbox cliente
|
||||
await checkInformoClienteIfNeeded(page, informoCliente);
|
||||
|
||||
async function clickChangeState(page) {
|
||||
// Tu botón: <input type="IMAGE" name="repaso" ... title="Cambiar el Estado del Servicio">
|
||||
const selectors = [
|
||||
'input[name="repaso"]',
|
||||
'input[title*="Cambiar el Estado" i]',
|
||||
'input[src*="estado1.gif" i]',
|
||||
'input[type="image"][name*="repaso" i]',
|
||||
];
|
||||
|
||||
const clicked = await clickFirstThatExists(page, selectors, { timeout: CFG.SEL_TIMEOUT }).catch(() => null);
|
||||
if (!clicked) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).');
|
||||
}
|
||||
|
||||
async function submitChange(page) {
|
||||
const selectors = [
|
||||
// 5. GUARDAR
|
||||
const saveBtn = await clickFirstThatExists(page, [
|
||||
'input[type="submit"][value*="Enviar" i]',
|
||||
'input[type="submit"][value*="Guardar" i]',
|
||||
'button:has-text("Enviar")',
|
||||
'button:has-text("Guardar")',
|
||||
'button:has-text("Aceptar")',
|
||||
'input[type="image"][title*="Enviar" i]',
|
||||
];
|
||||
const clicked = await clickFirstThatExists(page, selectors, { timeout: CFG.SEL_TIMEOUT }).catch(() => null);
|
||||
if (!clicked) throw new Error('No encuentro el botón para guardar/enviar el cambio de estado.');
|
||||
'button:has-text("Guardar")'
|
||||
]);
|
||||
|
||||
if (!saveBtn) throw new Error('No se encontró el botón de Guardar.');
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
|
||||
|
||||
return { success: true, serviceUrl: serviceUrl.toString() };
|
||||
}
|
||||
|
||||
async function changeStatusViaClientesPortal(db, reqBody) {
|
||||
const creds = await getHomeServeCreds(db);
|
||||
// --- GESTIÓN DE COLAS (EL CEREBRO DEL CÓDIGO A) ---
|
||||
|
||||
const serviceNumber = safeStr(reqBody.serviceNumber).trim();
|
||||
const newStatusValue = safeStr(reqBody.newStatusValue).trim();
|
||||
const dateString = safeStr(reqBody.dateString).trim(); // DD/MM/AAAA
|
||||
const observation = safeStr(reqBody.observation).trim();
|
||||
const informoCliente = !!reqBody.informoCliente;
|
||||
async function claimJobById(db, jobId) {
|
||||
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
|
||||
return await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(ref);
|
||||
if (!snap.exists) return null;
|
||||
|
||||
if (!serviceNumber) throw new Error('Missing serviceNumber');
|
||||
if (!newStatusValue) throw new Error('Missing newStatusValue');
|
||||
const d = snap.data();
|
||||
const st = d.status || 'PENDING';
|
||||
const claimedAt = d.claimedAt ? d.claimedAt.toMillis() : 0;
|
||||
const now = Date.now();
|
||||
const isStale = (st === 'RUNNING') && ((now - claimedAt) > (CONFIG.CLAIM_TTL_MINUTES * 60 * 1000));
|
||||
|
||||
const startedAtISO = nowISO();
|
||||
// Solo cogemos PENDING o RUNNING caducados
|
||||
if (st !== 'PENDING' && !isStale) return null;
|
||||
|
||||
const result = await withBrowser(async (page) => {
|
||||
// Login
|
||||
await loginClientesPortal(page, creds);
|
||||
tx.set(ref, {
|
||||
status: 'RUNNING',
|
||||
claimedAt: toServerTimestamp(),
|
||||
lastSeenAt: toServerTimestamp(),
|
||||
workerId: process.env.HOSTNAME || 'worker-local'
|
||||
}, { merge: true });
|
||||
|
||||
// Abrir servicio
|
||||
const serviceUrl = buildServiceUrl(serviceNumber);
|
||||
await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT });
|
||||
await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Click “cambio de estado”
|
||||
await clickChangeState(page);
|
||||
|
||||
// Esperar que cargue formulario
|
||||
await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Seleccionar estado por CÓDIGO (307, 348, etc.)
|
||||
await selectStatusByCode(page, newStatusValue);
|
||||
|
||||
// Fecha (si existe un input razonable)
|
||||
if (dateString) {
|
||||
const dateSelectors = [
|
||||
'input[name*="fecha" i]',
|
||||
'input[id*="fecha" i]',
|
||||
'input[placeholder*="dd" i]',
|
||||
'input[type="text"][size="10"]',
|
||||
'input[type="text"]',
|
||||
];
|
||||
// llenamos el primero que exista, pero intentando no pisar user/pass: ya estamos dentro
|
||||
await fillFirstThatExists(page, dateSelectors, dateString).catch(() => {});
|
||||
}
|
||||
|
||||
// Observación
|
||||
if (observation) {
|
||||
const obsSelectors = [
|
||||
'textarea[name*="obs" i]',
|
||||
'textarea[name*="nota" i]',
|
||||
'textarea[id*="obs" i]',
|
||||
'textarea',
|
||||
];
|
||||
await fillFirstThatExists(page, obsSelectors, observation).catch(() => {});
|
||||
}
|
||||
|
||||
// Checkbox “ya he informado al Cliente”
|
||||
await checkInformoClienteIfNeeded(page, informoCliente).catch(() => false);
|
||||
|
||||
// Guardar
|
||||
await submitChange(page);
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Verificación “blanda”: volvemos al servicio y comprobamos que la página carga
|
||||
await page.goto(serviceUrl, { waitUntil: 'domcontentloaded', timeout: CFG.NAV_TIMEOUT });
|
||||
await page.waitForLoadState('networkidle', { timeout: CFG.NAV_TIMEOUT }).catch(() => {});
|
||||
const html = await page.content().catch(() => '');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
usedCreds: maskCreds({ ...creds }),
|
||||
serviceUrl,
|
||||
// dejamos un “hint” por si quieres inspeccionar rápido
|
||||
pageContainsStatusCode: html.includes(String(newStatusValue)),
|
||||
};
|
||||
return { id: jobId, ...d };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --------------------- Routes ---------------------
|
||||
const db = initFirebase();
|
||||
async function markJobDone(db, jobId, result) {
|
||||
await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({
|
||||
status: 'DONE',
|
||||
finishedAt: toServerTimestamp(),
|
||||
result: result
|
||||
}, { merge: true });
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.status(200).send('ok');
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
await db.collection(CONFIG.RESULT_COLLECTION).add({
|
||||
jobId,
|
||||
ok: true,
|
||||
service: 'estados-hs',
|
||||
port: CFG.PORT,
|
||||
requireAuth: CFG.REQUIRE_AUTH,
|
||||
hsCredDocPath: CFG.HS_CRED_DOC_PATH || '(auto)',
|
||||
ts: nowISO(),
|
||||
...result,
|
||||
createdAt: toServerTimestamp()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// UI simple de pruebas dentro del propio servicio
|
||||
app.get('/test', (req, res) => {
|
||||
res.type('html').send(TEST_HTML);
|
||||
});
|
||||
async function markJobFailed(db, jobId, error) {
|
||||
const errData = {
|
||||
message: String(error?.message || error),
|
||||
stack: String(error?.stack || '')
|
||||
};
|
||||
|
||||
await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({
|
||||
status: 'FAILED',
|
||||
finishedAt: toServerTimestamp(),
|
||||
error: errData
|
||||
}, { merge: true });
|
||||
|
||||
app.post('/api/homeserve/change-status', async (req, res) => {
|
||||
const startedAtISO = nowISO();
|
||||
try {
|
||||
const out = await changeStatusViaClientesPortal(db, req.body || {});
|
||||
res.status(200).json({ ...out, startedAtISO });
|
||||
} catch (e) {
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
error: {
|
||||
message: String(e?.message || e),
|
||||
stack: String(e?.stack || ''),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await db.collection(CONFIG.RESULT_COLLECTION).add({
|
||||
jobId,
|
||||
ok: false,
|
||||
error: errData,
|
||||
createdAt: toServerTimestamp()
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------- Start ---------------------
|
||||
app.listen(CFG.PORT, '0.0.0.0', () => {
|
||||
console.log(`[estados-hs] listening on :${CFG.PORT}`);
|
||||
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CFG.HS_CRED_DOC_PATH || '(auto providerCredentials/homeserve)'}`);
|
||||
console.log(`[estados-hs] REQUIRE_AUTH=${CFG.REQUIRE_AUTH ? 1 : 0}`);
|
||||
console.log(`[estados-hs] CLIENTES_CGI_BASE=${CFG.CLIENTES_CGI_BASE}`);
|
||||
});
|
||||
// --- BUCLE PRINCIPAL ---
|
||||
|
||||
// --------------------- Embedded test HTML ---------------------
|
||||
const TEST_HTML = `<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>estados-hs · prueba</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;padding:24px;background:#0b1220;color:#e8eefc}
|
||||
.card{max-width:820px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:18px;padding:18px}
|
||||
label{display:block;margin:12px 0 6px;font-size:13px;opacity:.85}
|
||||
input,select,textarea,button{width:100%;padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,.12);background:#0b1220;color:#e8eefc;box-sizing:border-box}
|
||||
textarea{min-height:110px}
|
||||
button{cursor:pointer;font-weight:800}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.log{white-space:pre-wrap;background:#0b1220;border-radius:12px;border:1px solid rgba(255,255,255,.12);padding:12px;margin-top:12px;font-size:12px}
|
||||
.muted{opacity:.7;font-size:12px}
|
||||
.actions{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
||||
.check{display:flex;gap:10px;align-items:center;margin-top:12px}
|
||||
.check input{width:auto}
|
||||
.chip{display:inline-block;padding:6px 10px;border-radius:999px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.12);font-size:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 8px">estados-hs · prueba</h2>
|
||||
<div class="muted">POST <span class="chip">/api/homeserve/change-status</span></div>
|
||||
|
||||
<label>Base URL (por si pruebas otro dominio)</label>
|
||||
<input id="base" value="" placeholder="https://estados-hs.ms.marsalva.org" />
|
||||
|
||||
<div class="actions">
|
||||
<button id="btnRoot">Probar /</button>
|
||||
<button id="btnHealth">Probar /health</button>
|
||||
</div>
|
||||
|
||||
<label>Nº de Servicio</label>
|
||||
<input id="serviceNumber" placeholder="15251178" />
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Código estado</label>
|
||||
<select id="statusCode">
|
||||
<option value="303">303 · En espera de Cliente por aceptación Presupuesto</option>
|
||||
<option value="307">307 · En espera de Profesional por fecha de inicio de trabajos</option>
|
||||
<option value="313">313 · En espera de Profesional por secado de cala, pintura o parquet</option>
|
||||
<option value="318">318 · En espera de Profesional por confirmación del Siniestro</option>
|
||||
<option value="319">319 · En espera de Profesional por material</option>
|
||||
<option value="320">320 · En espera de Profesional por espera de otro gremio</option>
|
||||
<option value="321">321 · En espera de Profesional por presupuesto/valoración</option>
|
||||
<option value="323">323 · En espera de Profesional por mejora del tiempo</option>
|
||||
<option value="326">326 · En espera de Cliente por pago de Factura Contado/Franquicia</option>
|
||||
<option value="336">336 · En espera de Profesional por avería en observación</option>
|
||||
<option value="342">342 · En espera de Profesional pendiente cobro franquicia</option>
|
||||
<option value="345">345 · En espera de Profesional en realización pendiente Terminar</option>
|
||||
<option value="348" selected>348 · En espera de Cliente por indicaciones</option>
|
||||
<option value="352">352 · En espera de Perjudicado por indicaciones</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fecha (DD/MM/AAAA)</label>
|
||||
<input id="dateString" placeholder="05/01/2026" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="check">
|
||||
<input type="checkbox" id="informoCliente" />
|
||||
<label for="informoCliente" style="margin:0">Marcar “ya he informado al Cliente”</label>
|
||||
</div>
|
||||
|
||||
<label>Observación (opcional)</label>
|
||||
<textarea id="observation" placeholder="Ej: el asegurado prefiere que vaya el lunes."></textarea>
|
||||
|
||||
<div style="height:10px"></div>
|
||||
<button id="btnSend">Enviar cambio de estado</button>
|
||||
|
||||
<div class="log" id="log">Listo.</div>
|
||||
<div class="muted" style="margin-top:10px">
|
||||
Consejo rápido: si ves "ok" en <span class="chip">/</span> y JSON en <span class="chip">/health</span>, el servicio está vivo. Si el cambio falla, el JSON de error te dirá en qué paso se atranca.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id)=>document.getElementById(id);
|
||||
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
|
||||
const base = ()=> {
|
||||
const b = $('base').value.trim();
|
||||
if (b) return b.replace(/\\/$/, '');
|
||||
return location.origin;
|
||||
async function processJob(db, job) {
|
||||
console.log(`>>> Procesando Job: ${job.id}`);
|
||||
|
||||
// Mapeo de campos flexibles (para que acepte inputs variados)
|
||||
const jobData = {
|
||||
serviceNumber: job.parteId || job.serviceNumber || job.codigo,
|
||||
newStatusValue: job.nuevoEstado || job.newStatusValue || job.statusCode,
|
||||
dateString: job.fecha || job.dateString || '',
|
||||
observation: job.nota || job.observation || '',
|
||||
informoCliente: job.informoCliente || false
|
||||
};
|
||||
|
||||
async function probe(path){
|
||||
const url = base() + path;
|
||||
const r = await fetch(url, { method:'GET' });
|
||||
const text = await r.text();
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(text); } catch(_){}
|
||||
return { http: r.status, url, response: parsed ?? text };
|
||||
if (!jobData.serviceNumber || !jobData.newStatusValue) {
|
||||
await markJobFailed(db, job.id, new Error('Faltan datos obligatorios: serviceNumber o newStatusValue'));
|
||||
return;
|
||||
}
|
||||
|
||||
$('btnRoot').addEventListener('click', async () => {
|
||||
log(await probe('/'));
|
||||
});
|
||||
try {
|
||||
const creds = await getHomeServeCreds(db);
|
||||
|
||||
await withBrowser(async (page) => {
|
||||
const res = await loginAndProcess(page, creds, jobData);
|
||||
await markJobDone(db, job.id, res);
|
||||
console.log(`✅ Job ${job.id} completado.`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`❌ Job ${job.id} falló:`, err.message);
|
||||
await markJobFailed(db, job.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
$('btnHealth').addEventListener('click', async () => {
|
||||
log(await probe('/health'));
|
||||
});
|
||||
// Listener Reactivo
|
||||
function startWorker(db) {
|
||||
const queue = [];
|
||||
let isProcessing = false;
|
||||
|
||||
$('btnSend').addEventListener('click', async () => {
|
||||
try{
|
||||
log('Enviando...');
|
||||
const url = base() + '/api/homeserve/change-status';
|
||||
const body = {
|
||||
serviceNumber: $('serviceNumber').value.trim(),
|
||||
newStatusValue: $('statusCode').value.trim(),
|
||||
dateString: $('dateString').value.trim(),
|
||||
observation: $('observation').value.trim(),
|
||||
informoCliente: $('informoCliente').checked
|
||||
};
|
||||
const r = await fetch(url, {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
log({ http: r.status, url, request: body, response: j });
|
||||
}catch(e){
|
||||
log(String(e));
|
||||
const processQueue = async () => {
|
||||
if (isProcessing) return;
|
||||
isProcessing = true;
|
||||
while (queue.length > 0) {
|
||||
const jobId = queue.shift();
|
||||
const job = await claimJobById(db, jobId);
|
||||
if (job) await processJob(db, job);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
isProcessing = false;
|
||||
};
|
||||
|
||||
const enqueue = (id) => {
|
||||
if (!queue.includes(id)) {
|
||||
queue.push(id);
|
||||
processQueue();
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Escuchar nuevos
|
||||
db.collection(CONFIG.QUEUE_COLLECTION)
|
||||
.where('status', '==', 'PENDING')
|
||||
.onSnapshot(snap => {
|
||||
snap.docChanges().forEach(change => {
|
||||
if (change.type === 'added') enqueue(change.doc.id);
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Rescaneo de seguridad (polling)
|
||||
setInterval(async () => {
|
||||
const snap = await db.collection(CONFIG.QUEUE_COLLECTION)
|
||||
.where('status', '==', 'PENDING')
|
||||
.limit(10).get();
|
||||
snap.forEach(doc => enqueue(doc.id));
|
||||
}, CONFIG.RESCAN_SECONDS * 1000);
|
||||
|
||||
console.log('🚀 Worker HomeServe iniciado. Esperando trabajos en Firestore...');
|
||||
}
|
||||
|
||||
// Start
|
||||
const db = initFirebase();
|
||||
startWorker(db);
|
||||
Loading…
Reference in New Issue