Actualizar index.js
This commit is contained in:
parent
2d5cecb6a5
commit
91e87aa548
804
index.js
804
index.js
|
|
@ -5,8 +5,6 @@ const cors = require('cors');
|
|||
const { chromium } = require('playwright');
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
// -------------------- Firebase --------------------
|
||||
|
||||
function mustEnv(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`Missing env: ${name}`);
|
||||
|
|
@ -14,145 +12,165 @@ function mustEnv(name) {
|
|||
}
|
||||
|
||||
function initFirebase() {
|
||||
if (admin.apps?.length) return admin.firestore();
|
||||
|
||||
// Si NO quieres Firebase, puedes arrancar sin admin.
|
||||
// Pero aquí lo usamos para leer secrets/homeserve.
|
||||
if (!process.env.FIREBASE_PRIVATE_KEY) {
|
||||
throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
||||
// Permitimos arrancar sin Firebase si pones creds por ENV
|
||||
// (pero si tampoco hay creds, fallará al ejecutar).
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
||||
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
||||
}),
|
||||
});
|
||||
|
||||
}
|
||||
return admin.firestore();
|
||||
}
|
||||
|
||||
async function getHomeServeCreds(db) {
|
||||
// Tu caso real: providerCredentials/homeserve con { user, pass }
|
||||
const docPath = process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve';
|
||||
const CONFIG = {
|
||||
// 🔥 IMPORTANTE: por defecto entramos por “clientes CGI” (como tu HTML),
|
||||
// y si quieres lo cambias por ENV o por Firestore (secrets/homeserve.baseUrl).
|
||||
DEFAULT_HS_BASE_URL:
|
||||
process.env.HOMESERVE_BASE_URL ||
|
||||
'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=prof_pass&urgente',
|
||||
|
||||
let fsUser = '';
|
||||
let fsPass = '';
|
||||
HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'secrets/homeserve',
|
||||
|
||||
try {
|
||||
const snap = await db.doc(docPath).get();
|
||||
if (snap.exists) {
|
||||
const d = snap.data() || {};
|
||||
fsUser = String(d.user || '').trim();
|
||||
fsPass = String(d.pass || '').trim();
|
||||
// auth para el endpoint (opcional)
|
||||
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1',
|
||||
AUTH_TOKEN: process.env.AUTH_TOKEN || '',
|
||||
|
||||
// Selectores: mantenemos compatibles con tu robot antiguo
|
||||
SEL: {
|
||||
user: process.env.SEL_USER || 'input[type="text"], input[name*="user" i], input[name*="usuario" i]',
|
||||
pass: process.env.SEL_PASS || 'input[type="password"], input[name*="pass" i], input[name*="clave" i]',
|
||||
submit:
|
||||
process.env.SEL_SUBMIT ||
|
||||
'button[type="submit"], input[type="submit"], button:has-text("Entrar"), button:has-text("Acceder")',
|
||||
|
||||
searchBox:
|
||||
process.env.SEL_SEARCH_BOX ||
|
||||
'input[placeholder*="Buscar"], input[type="search"], input[name*="buscar" i], input[id*="buscar" i]',
|
||||
searchBtn:
|
||||
process.env.SEL_SEARCH_BTN ||
|
||||
'button:has-text("Buscar"), button:has-text("Search"), input[type="submit"][value*="Buscar" i]',
|
||||
|
||||
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||
|
||||
// Estado / observaciones / fecha / checkbox informado
|
||||
statusDropdown:
|
||||
process.env.SEL_STATUS_DROPDOWN ||
|
||||
'select[name*="estado" i], select[id*="estado" i], select[name="ESTADO"], select:has(option)',
|
||||
noteTextarea:
|
||||
process.env.SEL_NOTE_TEXTAREA ||
|
||||
'textarea[name*="nota" i], textarea[name*="observa" i], textarea[id*="nota" i], textarea[id*="observa" i], textarea',
|
||||
dateInput:
|
||||
process.env.SEL_DATE_INPUT ||
|
||||
'input[name*="fecha" i], input[id*="fecha" i], input[placeholder*="dd" i], input[type="date"]',
|
||||
informedCheckbox:
|
||||
process.env.SEL_INFORMED_CHECKBOX ||
|
||||
'input[type="checkbox"][name="INFORMO"], input[type="checkbox"][id*="inform" i], input[type="checkbox"][name*="inform" i]',
|
||||
|
||||
saveBtn:
|
||||
process.env.SEL_SAVE_BTN ||
|
||||
'button:has-text("Guardar"), button:has-text("Actualizar"), button:has-text("Aceptar"), input[type="submit"][value*="Guardar" i], input[type="submit"][value*="Actualizar" i]',
|
||||
},
|
||||
|
||||
// timeouts
|
||||
GOTO_TIMEOUT: parseInt(process.env.GOTO_TIMEOUT || '120000', 10),
|
||||
WAIT_TIMEOUT: parseInt(process.env.WAIT_TIMEOUT || '60000', 10),
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// -------------------------
|
||||
// Helpers
|
||||
// -------------------------
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function pickPort() {
|
||||
// CapRover suele inyectar CAPROVER_PORT
|
||||
// Si no existe, usamos PORT o 3000.
|
||||
return parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10);
|
||||
}
|
||||
|
||||
function ensureAuth(req) {
|
||||
if (!CONFIG.REQUIRE_AUTH) return;
|
||||
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
||||
if (!token || !CONFIG.AUTH_TOKEN || token !== CONFIG.AUTH_TOKEN) {
|
||||
const err = new Error('Unauthorized');
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const envUser = String(process.env.HOMESERVE_USER || '').trim();
|
||||
const envPass = String(process.env.HOMESERVE_PASS || '').trim();
|
||||
let cachedCreds = null;
|
||||
let cachedCredsAt = 0;
|
||||
|
||||
const user = fsUser || envUser;
|
||||
const pass = fsPass || envPass;
|
||||
async function getHomeServeCreds(db) {
|
||||
// 1) ENV manda
|
||||
const envUser = process.env.HOMESERVE_USER;
|
||||
const envPass = process.env.HOMESERVE_PASS;
|
||||
const envBase = process.env.HOMESERVE_BASE_URL;
|
||||
|
||||
if (!user || !pass) {
|
||||
if (envUser && envPass) {
|
||||
return {
|
||||
user: envUser,
|
||||
pass: envPass,
|
||||
baseUrl: envBase || CONFIG.DEFAULT_HS_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Firestore
|
||||
if (!db) {
|
||||
throw new Error(
|
||||
`HomeServe creds missing. Put {user,pass} in Firestore doc "${docPath}" or set env HOMESERVE_USER/HOMESERVE_PASS`
|
||||
`HomeServe creds missing. Set env HOMESERVE_USER/HOMESERVE_PASS or provide Firebase envs + doc "${CONFIG.HS_CRED_DOC_PATH}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { user, pass, source: fsUser ? `firestore:${docPath}` : 'env' };
|
||||
}
|
||||
// cache 30s
|
||||
const now = Date.now();
|
||||
if (cachedCreds && now - cachedCredsAt < 30000) return cachedCreds;
|
||||
|
||||
// -------------------- Config --------------------
|
||||
const snap = await db.doc(CONFIG.HS_CRED_DOC_PATH).get();
|
||||
if (!snap.exists) {
|
||||
throw new Error(`HomeServe creds missing. Create Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" with { user, pass, baseUrl? }`);
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
// Flujo antiguo:
|
||||
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
|
||||
const d = snap.data() || {};
|
||||
const user = d.user || d.email || d.usuario || '';
|
||||
const pass = d.pass || d.password || d.clave || '';
|
||||
|
||||
// Selectores (igual que el robot viejo, pero con extras)
|
||||
SEL: {
|
||||
user: process.env.SEL_USER || 'input[type="text"], input[name="user"], input[name="username"]',
|
||||
pass: process.env.SEL_PASS || 'input[type="password"], input[name="pass"], input[name="password"]',
|
||||
submit: process.env.SEL_SUBMIT || 'button[type="submit"], input[type="submit"], button:has-text("Entrar")',
|
||||
if (!user || !pass) {
|
||||
throw new Error(`HomeServe creds missing in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}". Needs fields "user" and "pass".`);
|
||||
}
|
||||
|
||||
searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]',
|
||||
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")',
|
||||
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||
const baseUrl = d.baseUrl || d.HOMESERVE_BASE_URL || CONFIG.DEFAULT_HS_BASE_URL;
|
||||
|
||||
// Cambio estado
|
||||
statusDropdown:
|
||||
process.env.SEL_STATUS_DROPDOWN ||
|
||||
'select[name*="estado" i], select[id*="estado" i], select:has(option)',
|
||||
|
||||
// Observación/nota
|
||||
noteTextarea:
|
||||
process.env.SEL_NOTE_TEXTAREA ||
|
||||
'textarea[name*="nota" i], textarea[id*="nota" i], textarea',
|
||||
|
||||
// Fecha (si existe)
|
||||
dateInput:
|
||||
process.env.SEL_DATE_INPUT ||
|
||||
'input[name*="fec" i], input[id*="fec" i], input[placeholder*="dd" i], input[placeholder*="fecha" i]',
|
||||
|
||||
// Checkbox “ya informado al cliente”
|
||||
informedCheckbox:
|
||||
process.env.SEL_INFORMED_CHECKBOX ||
|
||||
'input[type="checkbox"][name*="inform" i], input[type="checkbox"][id*="inform" i]',
|
||||
|
||||
// Botón guardar
|
||||
saveBtn:
|
||||
process.env.SEL_SAVE_BTN ||
|
||||
'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar"), input[type="submit"]',
|
||||
},
|
||||
|
||||
HEADLESS: String(process.env.HEADLESS || '1') !== '0',
|
||||
NAV_TIMEOUT_MS: parseInt(process.env.NAV_TIMEOUT_MS || '120000', 10),
|
||||
|
||||
// Auth opcional del endpoint
|
||||
REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1',
|
||||
API_KEY: process.env.API_KEY || '',
|
||||
};
|
||||
|
||||
// Estados por código (los de tu app)
|
||||
const STATUS_CODES = [
|
||||
{ code: '303', title: 'En espera de Cliente por aceptación Presupuesto' },
|
||||
{ code: '307', title: 'En espera de Profesional por fecha de inicio de trabajos' },
|
||||
{ code: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' },
|
||||
{ code: '318', title: 'En espera de Profesional por confirmación del Siniestro' },
|
||||
{ code: '319', title: 'En espera de Profesional por material' },
|
||||
{ code: '320', title: 'En espera de Profesional por espera de otro gremio' },
|
||||
{ code: '321', title: 'En espera de Profesional por presupuesto/valoración' },
|
||||
{ code: '323', title: 'En espera de Profesional por mejora del tiempo' },
|
||||
{ code: '326', title: 'En espera de Cliente por pago de Factura Contado/Franquicia' },
|
||||
{ code: '336', title: 'En espera de Profesional por avería en observación' },
|
||||
{ code: '342', title: 'En espera de Profesional pendiente cobro franquicia' },
|
||||
{ code: '345', title: 'En espera de Profesional en realización pendiente Terminar' },
|
||||
{ code: '348', title: 'En espera de Cliente por indicaciones' },
|
||||
{ code: '352', title: 'En espera de Perjudicado por indicaciones' },
|
||||
];
|
||||
|
||||
const CODE_TO_TITLE = new Map(STATUS_CODES.map(x => [x.code, x.title]));
|
||||
|
||||
// -------------------- Helpers --------------------
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const nowISO = () => new Date().toISOString();
|
||||
const trim = (v) => String(v ?? '').trim();
|
||||
|
||||
function parseBool(v, def = true) {
|
||||
if (v === undefined || v === null || v === '') return def;
|
||||
const s = String(v).toLowerCase().trim();
|
||||
if (['1', 'true', 'yes', 'si', 'sí'].includes(s)) return true;
|
||||
if (['0', 'false', 'no'].includes(s)) return false;
|
||||
return def;
|
||||
cachedCreds = { user, pass, baseUrl };
|
||||
cachedCredsAt = now;
|
||||
return cachedCreds;
|
||||
}
|
||||
|
||||
async function withBrowser(fn) {
|
||||
const browser = await chromium.launch({
|
||||
headless: CONFIG.HEADLESS,
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Un poco de margen
|
||||
page.setDefaultTimeout(CONFIG.WAIT_TIMEOUT);
|
||||
|
||||
try {
|
||||
return await fn(page);
|
||||
} finally {
|
||||
|
|
@ -160,292 +178,300 @@ async function withBrowser(fn) {
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------- Playwright flow (como el robot antiguo) --------------------
|
||||
|
||||
async function login(page, creds) {
|
||||
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||
|
||||
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
|
||||
await page.fill(CONFIG.SEL.user, creds.user);
|
||||
|
||||
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
|
||||
await page.fill(CONFIG.SEL.pass, creds.pass);
|
||||
|
||||
const btn = await page.$(CONFIG.SEL.submit);
|
||||
if (btn) await btn.click();
|
||||
else await page.keyboard.press('Enter');
|
||||
|
||||
// networkidle a veces es traicionero; domcontentloaded + pause suele ir mejor
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
async function openParte(page, parteId) {
|
||||
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
||||
if (hasSearch) {
|
||||
await page.fill(CONFIG.SEL.searchBox, String(parteId));
|
||||
const btn = await page.$(CONFIG.SEL.searchBtn);
|
||||
if (btn) await btn.click();
|
||||
else await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
||||
await page.click(CONFIG.SEL.openRow);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||
await sleep(900);
|
||||
}
|
||||
|
||||
async function checkInformedBox(page) {
|
||||
// 1) intento selector directo
|
||||
const cb = await page.$(CONFIG.SEL.informedCheckbox);
|
||||
if (cb) {
|
||||
const isChecked = await cb.isChecked().catch(() => false);
|
||||
if (!isChecked) await cb.check().catch(() => {});
|
||||
async function fillFirst(page, selectors, value) {
|
||||
for (const sel of selectors) {
|
||||
const el = await page.$(sel).catch(() => null);
|
||||
if (el) {
|
||||
await page.fill(sel, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2) intento por texto de label (tu frase exacta)
|
||||
const ok = await page.evaluate(() => {
|
||||
const text = 'Marque esta casilla, si ya ha informado al Cliente'.toLowerCase();
|
||||
const labels = Array.from(document.querySelectorAll('label'));
|
||||
const hit = labels.find(l => (l.textContent || '').toLowerCase().includes(text));
|
||||
if (!hit) return false;
|
||||
|
||||
const forId = hit.getAttribute('for');
|
||||
if (forId) {
|
||||
const input = document.getElementById(forId);
|
||||
if (input && input.type === 'checkbox') {
|
||||
input.checked = true;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// si no hay "for", buscamos checkbox cercano
|
||||
let el = hit;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) break;
|
||||
const cb2 = parent.querySelector('input[type="checkbox"]');
|
||||
if (cb2) {
|
||||
cb2.checked = true;
|
||||
cb2.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
el = parent;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async function clickFirst(page, selectors) {
|
||||
for (const sel of selectors) {
|
||||
const el = await page.$(sel).catch(() => null);
|
||||
if (el) {
|
||||
await el.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function login(page, { baseUrl, user, pass }) {
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.GOTO_TIMEOUT });
|
||||
|
||||
// user/pass
|
||||
const okUser = await fillFirst(page, [CONFIG.SEL.user], user);
|
||||
const okPass = await fillFirst(page, [CONFIG.SEL.pass], pass);
|
||||
|
||||
if (!okUser || !okPass) {
|
||||
throw new Error('Login form not found (user/pass selectors). Adjust SEL_USER / SEL_PASS.');
|
||||
}
|
||||
|
||||
// submit
|
||||
const clicked = await clickFirst(page, [CONFIG.SEL.submit]);
|
||||
if (!clicked) {
|
||||
// fallback Enter
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
// esperamos navegación
|
||||
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
async function openParte(page, serviceNumber) {
|
||||
// búsqueda
|
||||
const hasSearch = await page.$(CONFIG.SEL.searchBox).catch(() => null);
|
||||
if (hasSearch) {
|
||||
await page.fill(CONFIG.SEL.searchBox, String(serviceNumber));
|
||||
const btn = await page.$(CONFIG.SEL.searchBtn).catch(() => null);
|
||||
if (btn) await btn.click();
|
||||
else await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
// abre primera fila
|
||||
const row = await page.$(CONFIG.SEL.openRow).catch(() => null);
|
||||
if (row) {
|
||||
await row.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
|
||||
await sleep(900);
|
||||
}
|
||||
// Si tu portal entra directo al parte sin tabla, esto simplemente no hace nada.
|
||||
}
|
||||
|
||||
async function trySelectStatus(page, statusValue) {
|
||||
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: CONFIG.WAIT_TIMEOUT });
|
||||
|
||||
// 1) por value (lo que tú usas: 303/307/...)
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(statusValue) });
|
||||
return true;
|
||||
} catch (_) {}
|
||||
|
||||
// 2) por label exacta (por si el portal usa texto)
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label: String(statusValue) });
|
||||
return true;
|
||||
} catch (_) {}
|
||||
|
||||
// 3) fallback DOM (busca option que contenga el código)
|
||||
const ok = await page.evaluate(({ sel, value }) => {
|
||||
const s = document.querySelector(sel);
|
||||
if (!s) return false;
|
||||
const opts = Array.from(s.querySelectorAll('option'));
|
||||
const hit = opts.find(o => (o.value || '').trim() === String(value).trim()
|
||||
|| (o.textContent || '').includes(String(value).trim()));
|
||||
if (!hit) return false;
|
||||
s.value = hit.value;
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}, { sel: CONFIG.SEL.statusDropdown, value: statusValue });
|
||||
|
||||
return !!ok;
|
||||
}
|
||||
|
||||
async function setEstadoByCodeOrLabel(page, newStatusValue) {
|
||||
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
||||
async function setInformoCliente(page, informoCliente) {
|
||||
if (!informoCliente) return;
|
||||
|
||||
// Primero intentamos por VALUE (si el select usa value=303 etc)
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(newStatusValue) });
|
||||
return { method: 'value', picked: String(newStatusValue) };
|
||||
} catch (_) {}
|
||||
|
||||
// Segundo: intentamos por label con el texto del código (si el option pinta "348 - En espera...")
|
||||
const title = CODE_TO_TITLE.get(String(newStatusValue));
|
||||
const candidates = [];
|
||||
|
||||
if (title) {
|
||||
candidates.push(`${newStatusValue} · ${title}`);
|
||||
candidates.push(`${newStatusValue} - ${title}`);
|
||||
candidates.push(`${newStatusValue} ${title}`);
|
||||
candidates.push(title);
|
||||
} else {
|
||||
candidates.push(String(newStatusValue));
|
||||
// 1) selector directo (INFORMO)
|
||||
const cb = await page.$(CONFIG.SEL.informedCheckbox).catch(() => null);
|
||||
if (cb) {
|
||||
const checked = await cb.isChecked().catch(() => false);
|
||||
if (!checked) await cb.check().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const label of candidates) {
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
|
||||
return { method: 'label', picked: label };
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Tercero: buscar en DOM option que contenga el código
|
||||
const ok = await page.evaluate(({ sel, code }) => {
|
||||
const s = document.querySelector(sel);
|
||||
if (!s) return null;
|
||||
const opts = Array.from(s.querySelectorAll('option'));
|
||||
const hit =
|
||||
opts.find(o => String(o.value).trim() === String(code).trim()) ||
|
||||
opts.find(o => (o.textContent || '').includes(String(code)));
|
||||
if (!hit) return null;
|
||||
s.value = hit.value;
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { value: hit.value, text: (hit.textContent || '').trim() };
|
||||
}, { sel: CONFIG.SEL.statusDropdown, code: String(newStatusValue) });
|
||||
|
||||
if (!ok) throw new Error(`No matching status option for code "${newStatusValue}"`);
|
||||
return { method: 'dom', picked: ok.value, text: ok.text };
|
||||
}
|
||||
|
||||
async function fillDateIfExists(page, dateString) {
|
||||
if (!trim(dateString)) return false;
|
||||
const el = await page.$(CONFIG.SEL.dateInput);
|
||||
if (!el) return false;
|
||||
try {
|
||||
await page.fill(CONFIG.SEL.dateInput, String(dateString));
|
||||
// 2) fallback por label con el texto
|
||||
const ok = await page.evaluate(() => {
|
||||
const labels = Array.from(document.querySelectorAll('label'));
|
||||
const target = labels.find(l => (l.textContent || '').toLowerCase().includes('marque esta casilla')
|
||||
&& (l.textContent || '').toLowerCase().includes('informado'));
|
||||
if (!target) return false;
|
||||
const inputId = target.getAttribute('for');
|
||||
if (inputId) {
|
||||
const el = document.getElementById(inputId);
|
||||
if (el && el.type === 'checkbox') {
|
||||
el.checked = true;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
const cb = target.querySelector('input[type="checkbox"]');
|
||||
if (cb) {
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
// no lo hacemos fatal: solo avisamos si quieres forzarlo
|
||||
// throw new Error('Could not find "Informado al cliente" checkbox. Set SEL_INFORMED_CHECKBOX.');
|
||||
}
|
||||
}
|
||||
|
||||
async function fillObservationIfExists(page, observation) {
|
||||
if (!trim(observation)) return false;
|
||||
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
||||
if (!ta) return false;
|
||||
await page.fill(CONFIG.SEL.noteTextarea, String(observation));
|
||||
async function fillOptional(page, selector, value) {
|
||||
if (!value) return false;
|
||||
const el = await page.$(selector).catch(() => null);
|
||||
if (!el) return false;
|
||||
await page.fill(selector, String(value));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function clickSave(page) {
|
||||
const save = await page.$(CONFIG.SEL.saveBtn);
|
||||
if (!save) throw new Error('Save button not found');
|
||||
const save = await page.$(CONFIG.SEL.saveBtn).catch(() => null);
|
||||
if (!save) throw new Error('Save button not found. Adjust SEL_SAVE_BTN.');
|
||||
await save.click();
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: CONFIG.NAV_TIMEOUT_MS });
|
||||
await sleep(1200);
|
||||
await page.waitForLoadState('networkidle', { timeout: CONFIG.GOTO_TIMEOUT }).catch(() => {});
|
||||
await sleep(900);
|
||||
}
|
||||
|
||||
async function changeStatusViaGestor(page, job, creds) {
|
||||
await login(page, creds);
|
||||
await openParte(page, job.serviceNumber);
|
||||
async function changeStatusViaHomeServe({ baseUrl, user, pass }, payload) {
|
||||
const {
|
||||
serviceNumber,
|
||||
newStatusValue,
|
||||
dateString,
|
||||
observation,
|
||||
informoCliente,
|
||||
} = payload;
|
||||
|
||||
const pick = await setEstadoByCodeOrLabel(page, job.newStatusValue);
|
||||
return await withBrowser(async (page) => {
|
||||
await login(page, { baseUrl, user, pass });
|
||||
|
||||
const informed = job.informoCliente ? await checkInformedBox(page) : false;
|
||||
const dateFilled = await fillDateIfExists(page, job.dateString);
|
||||
const obsFilled = await fillObservationIfExists(page, job.observation);
|
||||
// abre parte si hace falta
|
||||
await openParte(page, serviceNumber);
|
||||
|
||||
// cambia estado
|
||||
const selected = await trySelectStatus(page, newStatusValue);
|
||||
if (!selected) {
|
||||
throw new Error(`No matching status option for "${newStatusValue}". Adjust status selector or confirm option values.`);
|
||||
}
|
||||
|
||||
// fecha + observación (si el portal lo tiene)
|
||||
if (dateString) {
|
||||
await fillOptional(page, CONFIG.SEL.dateInput, dateString);
|
||||
}
|
||||
if (observation) {
|
||||
await fillOptional(page, CONFIG.SEL.noteTextarea, observation);
|
||||
}
|
||||
|
||||
// ✅ casilla "ya informado al Cliente"
|
||||
await setInformoCliente(page, !!informoCliente);
|
||||
|
||||
// guardar
|
||||
await clickSave(page);
|
||||
|
||||
const title = await page.title().catch(() => '');
|
||||
const snippet = await page.evaluate(() => (document.body ? document.body.innerText : '')).catch(() => '');
|
||||
return {
|
||||
ok: true,
|
||||
flow: 'gestor',
|
||||
picked: pick,
|
||||
informedChecked: informed,
|
||||
dateFilled,
|
||||
observationFilled: obsFilled,
|
||||
pageTitle: title,
|
||||
snippet: trim(snippet).slice(0, 600),
|
||||
};
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- API server --------------------
|
||||
// -------------------------
|
||||
// Routes
|
||||
// -------------------------
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const db = initFirebase();
|
||||
|
||||
app.get('/', (req, res) => res.status(200).send('ok'));
|
||||
app.get('/', (req, res) => {
|
||||
res.status(200).send('ok');
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
const port = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000);
|
||||
app.get('/health', async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'estados-hs',
|
||||
port,
|
||||
port: pickPort(),
|
||||
requireAuth: CONFIG.REQUIRE_AUTH,
|
||||
hsCredDocPath: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve',
|
||||
baseUrl: CONFIG.HOMESERVE_BASE_URL,
|
||||
ts: nowISO(),
|
||||
hsCredDocPath: CONFIG.HS_CRED_DOC_PATH,
|
||||
defaultBaseUrl: CONFIG.DEFAULT_HS_BASE_URL,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/statuses', (req, res) => {
|
||||
res.json({ ok: true, statuses: STATUS_CODES });
|
||||
});
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
if (!CONFIG.REQUIRE_AUTH) return next();
|
||||
if (!CONFIG.API_KEY) return res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 but API_KEY not set' } });
|
||||
|
||||
const k = trim(req.headers['x-api-key'] || '') || trim(req.query.apiKey || '');
|
||||
if (k !== CONFIG.API_KEY) return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } });
|
||||
next();
|
||||
}
|
||||
|
||||
// HTML de prueba servido desde el propio server (por si te da pereza subir un .html)
|
||||
// HTML de prueba (corregido + checkbox informoCliente)
|
||||
app.get('/test', (req, res) => {
|
||||
res.type('html').send(buildTestHtml());
|
||||
res.type('html').send(TEST_HTML);
|
||||
});
|
||||
|
||||
app.post('/api/homeserve/change-status', authMiddleware, async (req, res) => {
|
||||
const startedAtISO = nowISO();
|
||||
app.post('/api/homeserve/change-status', async (req, res) => {
|
||||
const startedAtISO = new Date().toISOString();
|
||||
|
||||
const job = {
|
||||
serviceNumber: trim(req.body?.serviceNumber),
|
||||
newStatusValue: trim(req.body?.newStatusValue),
|
||||
dateString: trim(req.body?.dateString),
|
||||
observation: trim(req.body?.observation),
|
||||
informoCliente: parseBool(req.body?.informoCliente, true), // por defecto TRUE
|
||||
};
|
||||
try {
|
||||
ensureAuth(req);
|
||||
|
||||
if (!job.serviceNumber || !job.newStatusValue) {
|
||||
const body = req.body || {};
|
||||
const serviceNumber = String(body.serviceNumber || '').trim();
|
||||
const newStatusValue = String(body.newStatusValue || '').trim();
|
||||
|
||||
// dateString opcional (DD/MM/AAAA)
|
||||
const dateString = String(body.dateString || '').trim();
|
||||
const observation = String(body.observation || '').trim();
|
||||
const informoCliente = !!body.informoCliente;
|
||||
|
||||
if (!serviceNumber || !newStatusValue) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
error: { message: 'Missing serviceNumber or newStatusValue' },
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = initFirebase();
|
||||
} catch (e) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
error: { message: String(e?.message || e), stack: String(e?.stack || '') },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const creds = await getHomeServeCreds(db);
|
||||
|
||||
const result = await withBrowser(async (page) => {
|
||||
return await changeStatusViaGestor(page, job, creds);
|
||||
const result = await changeStatusViaHomeServe(creds, {
|
||||
serviceNumber,
|
||||
newStatusValue,
|
||||
dateString: dateString || '',
|
||||
observation: observation || '',
|
||||
informoCliente,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
request: job,
|
||||
finishedAtISO: new Date().toISOString(),
|
||||
request: {
|
||||
serviceNumber,
|
||||
newStatusValue,
|
||||
dateString: dateString || '',
|
||||
observation: observation || '',
|
||||
informoCliente,
|
||||
},
|
||||
result,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
const code = err?.statusCode || 500;
|
||||
return res.status(code).json({
|
||||
ok: false,
|
||||
startedAtISO,
|
||||
finishedAtISO: nowISO(),
|
||||
request: job,
|
||||
error: { message: String(err?.message || err), stack: String(err?.stack || '') },
|
||||
finishedAtISO: new Date().toISOString(),
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: String(err?.stack || ''),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function buildTestHtml() {
|
||||
const options = STATUS_CODES.map(
|
||||
(s) => `<option value="${s.code}">${s.code} · ${escapeHtml(s.title)}</option>`
|
||||
).join('\n');
|
||||
const port = pickPort();
|
||||
app.listen(port, () => {
|
||||
console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`);
|
||||
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`);
|
||||
console.log(`[estados-hs] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`);
|
||||
console.log(`[estados-hs] listening on :${port}`);
|
||||
});
|
||||
|
||||
return `<!doctype html>
|
||||
// -------------------------
|
||||
// Embedded TEST HTML
|
||||
// -------------------------
|
||||
|
||||
const TEST_HTML = `<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
|
@ -453,60 +479,66 @@ function buildTestHtml() {
|
|||
<title>estados-hs · test</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:16px;padding:18px}
|
||||
.card{max-width:760px;margin:0 auto;background:#111a2e;border:1px solid rgba(255,255,255,.08);border-radius:16px;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}
|
||||
textarea{min-height:90px}
|
||||
button{cursor:pointer;font-weight:700}
|
||||
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}
|
||||
.btnrow{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px}
|
||||
.check{display:flex;gap:10px;align-items:center;margin-top:10px}
|
||||
.check input{width:auto}
|
||||
.checkline{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px;border-radius:12px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08)}
|
||||
.checkline input{width:auto;margin-top:2px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 8px">estados-hs · pruebas (flujo gestor)</h2>
|
||||
<div class="muted">Consejo: si /health responde JSON, el server está vivo. Si POST devuelve ok:true, ya estás cambiando estado 🔧</div>
|
||||
<h2 style="margin:0 0 8px">estados-hs · prueba</h2>
|
||||
<div class="muted">POST <code>/api/homeserve/change-status</code></div>
|
||||
|
||||
<label>Base URL (auto)</label>
|
||||
<input id="base" />
|
||||
|
||||
<div class="btnrow">
|
||||
<button id="btnHealth">Probar /health</button>
|
||||
<button id="btnStatuses">Ver /statuses</button>
|
||||
</div>
|
||||
|
||||
<hr style="border:0;border-top:1px solid rgba(255,255,255,.08);margin:14px 0" />
|
||||
|
||||
<label>Service Number</label>
|
||||
<input id="serviceNumber" placeholder="15251178" />
|
||||
<label>Nº de Servicio</label>
|
||||
<input id="serviceNumber" placeholder="Ej: 15251178" />
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Código estado</label>
|
||||
<select id="statusCode">
|
||||
${options}
|
||||
<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) (opcional)</label>
|
||||
<input id="dateString" placeholder="05/01/2026" />
|
||||
<label>Fecha siguiente acción</label>
|
||||
<input id="fecha" type="date" />
|
||||
<div class="muted" id="fechaOut" style="margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Observación (opcional)</label>
|
||||
<textarea id="observation" placeholder="se le envia whatsapp al asegurado"></textarea>
|
||||
<textarea id="observation" placeholder="Ej: se le envía WhatsApp al asegurado"></textarea>
|
||||
|
||||
<div class="check">
|
||||
<input type="checkbox" id="informo" checked />
|
||||
<label for="informo" style="margin:0">Marcar “ya he informado al cliente”</label>
|
||||
<div class="checkline">
|
||||
<input id="informoCliente" type="checkbox" />
|
||||
<div>
|
||||
<div style="font-weight:800">Marcar como informado al cliente</div>
|
||||
<div class="muted">Equivale a: “Marque esta casilla, si ya ha informado al Cliente”</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height:10px"></div>
|
||||
<button id="btnSend">Enviar (POST /api/homeserve/change-status)</button>
|
||||
<div style="height:12px"></div>
|
||||
<button id="btn">Enviar</button>
|
||||
|
||||
<div class="log" id="log">Listo.</div>
|
||||
</div>
|
||||
|
|
@ -514,44 +546,43 @@ function buildTestHtml() {
|
|||
<script>
|
||||
const $ = (id)=>document.getElementById(id);
|
||||
const log = (x)=>{ $('log').textContent = (typeof x==='string'?x:JSON.stringify(x,null,2)); };
|
||||
const baseDefault = window.location.origin;
|
||||
$('base').value = baseDefault;
|
||||
|
||||
async function call(path, opts){
|
||||
const base = $('base').value.trim() || baseDefault;
|
||||
const url = base.replace(/\\/$/, '') + path;
|
||||
const r = await fetch(url, opts);
|
||||
const txt = await r.text();
|
||||
let j = {};
|
||||
try{ j = JSON.parse(txt); } catch(_){ j = { raw: txt }; }
|
||||
return { http: r.status, url, ...j };
|
||||
function toDDMMYYYY(iso){
|
||||
if(!iso) return "";
|
||||
const [y,m,d] = iso.split("-");
|
||||
if(!y||!m||!d) return "";
|
||||
return \`\${d}/\${m}/\${y}\`;
|
||||
}
|
||||
|
||||
$('btnHealth').addEventListener('click', async () => {
|
||||
try{ log('probando /health...'); log(await call('/health')); }
|
||||
catch(e){ log(String(e)); }
|
||||
});
|
||||
function refreshFechaOut(){
|
||||
const iso = $('fecha').value;
|
||||
const dd = toDDMMYYYY(iso);
|
||||
$('fechaOut').textContent = dd ? ('Se enviará como: ' + dd) : '';
|
||||
}
|
||||
$('fecha').addEventListener('change', refreshFechaOut);
|
||||
refreshFechaOut();
|
||||
|
||||
$('btnStatuses').addEventListener('click', async () => {
|
||||
try{ log('cargando /statuses...'); log(await call('/statuses')); }
|
||||
catch(e){ log(String(e)); }
|
||||
});
|
||||
|
||||
$('btnSend').addEventListener('click', async () => {
|
||||
$('btn').addEventListener('click', async () => {
|
||||
try{
|
||||
log('Enviando...');
|
||||
const dateString = toDDMMYYYY($('fecha').value);
|
||||
|
||||
const body = {
|
||||
serviceNumber: $('serviceNumber').value.trim(),
|
||||
newStatusValue: $('statusCode').value.trim(),
|
||||
dateString: $('dateString').value.trim(),
|
||||
dateString,
|
||||
observation: $('observation').value.trim(),
|
||||
informoCliente: $('informo').checked
|
||||
informoCliente: $('informoCliente').checked
|
||||
};
|
||||
log(await call('/api/homeserve/change-status', {
|
||||
|
||||
const r = await fetch('/api/homeserve/change-status', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
}));
|
||||
});
|
||||
|
||||
const j = await r.json().catch(()=>({}));
|
||||
log({ http: r.status, url: location.origin + '/api/homeserve/change-status', ...j });
|
||||
}catch(e){
|
||||
log(String(e));
|
||||
}
|
||||
|
|
@ -559,22 +590,3 @@ function buildTestHtml() {
|
|||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.PORT || process.env.CAPROVER_PORT || 3000);
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[estados-hs] listening on :${PORT}`);
|
||||
console.log(`[estados-hs] HOMESERVE_BASE_URL=${CONFIG.HOMESERVE_BASE_URL}`);
|
||||
console.log(`[estados-hs] HS_CRED_DOC_PATH=${process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve'}`);
|
||||
console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`);
|
||||
console.log(`[estados-hs] HEADLESS=${CONFIG.HEADLESS ? 1 : 0}`);
|
||||
});
|
||||
Loading…
Reference in New Issue